Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions src/browser/components/Settings/sections/ProvidersSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,119 @@ export function ProvidersSection() {
setCodexOauthError(null);
};

// --- Anthropic OAuth (Claude Max/Pro subscription) ---
type AnthropicOauthFlowStatus = "idle" | "starting" | "waiting_for_code" | "submitting" | "error";
const [anthropicOauthStatus, setAnthropicOauthStatus] =
useState<AnthropicOauthFlowStatus>("idle");
const [anthropicOauthError, setAnthropicOauthError] = useState<string | null>(null);
const [anthropicOauthFlowId, setAnthropicOauthFlowId] = useState<string | null>(null);
const [anthropicOauthCodeInput, setAnthropicOauthCodeInput] = useState("");
const anthropicOauthAttemptRef = useRef(0);

const anthropicOauthIsConnected = config?.anthropic?.anthropicOauthSet === true;
const anthropicOauthInProgress =
anthropicOauthStatus === "starting" ||
anthropicOauthStatus === "waiting_for_code" ||
anthropicOauthStatus === "submitting";

const startAnthropicOauthConnect = async () => {
if (!api) return;
const attempt = ++anthropicOauthAttemptRef.current;

if (anthropicOauthFlowId) {
void api.anthropicOauth.cancelFlow({ flowId: anthropicOauthFlowId });
}

setAnthropicOauthStatus("starting");
setAnthropicOauthError(null);
setAnthropicOauthCodeInput("");

try {
const result = await api.anthropicOauth.startFlow();
if (attempt !== anthropicOauthAttemptRef.current) return;

if (!result.success) {
setAnthropicOauthStatus("error");
setAnthropicOauthError(result.error);
return;
}

setAnthropicOauthFlowId(result.data.flowId);
setAnthropicOauthStatus("waiting_for_code");
window.open(result.data.authorizeUrl, "_blank");
} catch (err) {
if (attempt !== anthropicOauthAttemptRef.current) return;
setAnthropicOauthStatus("error");
setAnthropicOauthError(err instanceof Error ? err.message : String(err));
}
};

const submitAnthropicOauthCode = async () => {
if (!api || !anthropicOauthFlowId || !anthropicOauthCodeInput.trim()) return;
const attempt = ++anthropicOauthAttemptRef.current;

setAnthropicOauthStatus("submitting");

try {
const result = await api.anthropicOauth.submitCode({
flowId: anthropicOauthFlowId,
code: anthropicOauthCodeInput.trim(),
});
if (attempt !== anthropicOauthAttemptRef.current) return;

if (!result.success) {
setAnthropicOauthStatus("error");
setAnthropicOauthError(result.error);
return;
}

updateOptimistically("anthropic", { anthropicOauthSet: true });
setAnthropicOauthStatus("idle");
setAnthropicOauthCodeInput("");
setAnthropicOauthFlowId(null);
await refresh();
} catch (err) {
if (attempt !== anthropicOauthAttemptRef.current) return;
setAnthropicOauthStatus("error");
setAnthropicOauthError(err instanceof Error ? err.message : String(err));
}
};

const disconnectAnthropicOauth = async () => {
if (!api) return;
const attempt = ++anthropicOauthAttemptRef.current;

try {
const result = await api.anthropicOauth.disconnect();
if (attempt !== anthropicOauthAttemptRef.current) return;

if (!result.success) {
setAnthropicOauthStatus("error");
setAnthropicOauthError(result.error);
return;
}

updateOptimistically("anthropic", { anthropicOauthSet: false });
setAnthropicOauthStatus("idle");
await refresh();
} catch (err) {
if (attempt !== anthropicOauthAttemptRef.current) return;
setAnthropicOauthStatus("error");
setAnthropicOauthError(err instanceof Error ? err.message : String(err));
}
};

const cancelAnthropicOauth = () => {
anthropicOauthAttemptRef.current++;
if (anthropicOauthFlowId && api) {
void api.anthropicOauth.cancelFlow({ flowId: anthropicOauthFlowId });
}
setAnthropicOauthStatus("idle");
setAnthropicOauthFlowId(null);
setAnthropicOauthCodeInput("");
setAnthropicOauthError(null);
};

const [muxGatewayLoginError, setMuxGatewayLoginError] = useState<string | null>(null);

const muxGatewayApplyDefaultModelsOnSuccessRef = useRef(false);
Expand Down Expand Up @@ -1415,6 +1528,99 @@ export function ProvidersSection() {
);
})}

{/* Anthropic: Claude Max/Pro OAuth */}
{provider === "anthropic" && (
<div className="border-border-light space-y-3 border-t pt-3">
<div>
<label className="text-foreground block text-xs font-medium">
Claude Max/Pro OAuth
</label>
<span className="text-muted text-xs">
{anthropicOauthStatus === "starting"
? "Starting..."
: anthropicOauthStatus === "waiting_for_code"
? "Waiting for authorization code..."
: anthropicOauthStatus === "submitting"
? "Submitting..."
: anthropicOauthIsConnected
? "Connected"
: "Not connected"}
</span>
</div>

<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
onClick={() => {
void startAnthropicOauthConnect();
}}
disabled={!api || anthropicOauthInProgress}
>
{anthropicOauthIsConnected ? "Reconnect" : "Connect with Claude"}
</Button>

{anthropicOauthInProgress && (
<Button variant="secondary" size="sm" onClick={cancelAnthropicOauth}>
Cancel
</Button>
)}

{anthropicOauthIsConnected && (
<Button
size="sm"
variant="ghost"
onClick={() => {
void disconnectAnthropicOauth();
}}
disabled={!api || anthropicOauthInProgress}
>
Disconnect
</Button>
)}
</div>

{anthropicOauthStatus === "waiting_for_code" && (
<div className="bg-background-tertiary space-y-2 rounded-md p-3">
<p className="text-muted text-xs">
Authorize in the browser, then paste the code below:
</p>
<div className="flex items-center gap-2">
<input
type="text"
value={anthropicOauthCodeInput}
onChange={(e) => setAnthropicOauthCodeInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
void submitAnthropicOauthCode();
}
}}
placeholder="Paste authorization code here"
className="border-border bg-background text-foreground flex-1 rounded border px-2 py-1 text-sm"
autoFocus
/>
<Button
size="sm"
onClick={() => {
void submitAnthropicOauthCode();
}}
disabled={!anthropicOauthCodeInput.trim()}
>
Submit
</Button>
</div>
</div>
)}

{anthropicOauthStatus === "error" && anthropicOauthError && (
<p className="text-destructive text-xs">{anthropicOauthError}</p>
)}

<p className="text-muted text-xs">
Claude Max/Pro OAuth uses subscription billing (costs included).
</p>
</div>
)}

{/* OpenAI: ChatGPT OAuth + service tier */}
{provider === "openai" && (
<div className="border-border-light space-y-3 border-t pt-3">
Expand Down
1 change: 1 addition & 0 deletions src/cli/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ async function createTestServer(authToken?: string): Promise<TestServerHandle> {
muxGatewayOauthService: services.muxGatewayOauthService,
muxGovernorOauthService: services.muxGovernorOauthService,
codexOauthService: services.codexOauthService,
anthropicOauthService: services.anthropicOauthService,
copilotOauthService: services.copilotOauthService,
taskService: services.taskService,
providerService: services.providerService,
Expand Down
4 changes: 4 additions & 0 deletions src/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Config } from "@/node/config";
import { DisposableTempDir } from "@/node/services/tempDir";
import { AgentSession, type AgentSessionChatEvent } from "@/node/services/agentSession";
import { CodexOauthService } from "@/node/services/codexOauthService";
import { AnthropicOauthService } from "@/node/services/anthropicOauthService";
import { createCoreServices } from "@/node/services/coreServices";
import {
isCaughtUpMessage,
Expand Down Expand Up @@ -448,6 +449,9 @@ async function main(): Promise<number> {
const codexOauthService = new CodexOauthService(config, providerService);
aiService.setCodexOauthService(codexOauthService);

const anthropicOauthService = new AnthropicOauthService(config, providerService);
aiService.setAnthropicOauthService(anthropicOauthService);

// CLI-only exit code control: allows agent to set the process exit code
// Useful for CI workflows where the agent should block merge on failure
let agentExitCode: number | undefined;
Expand Down
1 change: 1 addition & 0 deletions src/cli/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ async function createTestServer(): Promise<TestServerHandle> {
muxGatewayOauthService: services.muxGatewayOauthService,
muxGovernorOauthService: services.muxGovernorOauthService,
codexOauthService: services.codexOauthService,
anthropicOauthService: services.anthropicOauthService,
copilotOauthService: services.copilotOauthService,
taskService: services.taskService,
providerService: services.providerService,
Expand Down
81 changes: 81 additions & 0 deletions src/common/constants/anthropicOAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Anthropic OAuth constants and helpers.
*
* Anthropic (Claude Max/Pro subscription) authentication uses OAuth tokens
* rather than a standard Anthropic API key.
*
* This module is intentionally shared (common/) so both the backend and
* UI can reference the same endpoints.
*/

// Public OAuth client id for Claude Max/Pro flows.
export const ANTHROPIC_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";

export const ANTHROPIC_OAUTH_AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
export const ANTHROPIC_OAUTH_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token";

// Redirect URI -- Anthropic's code-paste flow uses this fixed value.
// The server displays the auth code on this page for the user to copy.
export const ANTHROPIC_OAUTH_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback";

// Scopes needed for inference via subscription.
export const ANTHROPIC_OAUTH_SCOPE = "org:create_api_key user:profile user:inference";

// Beta header value required for OAuth-authed API requests.
export const ANTHROPIC_OAUTH_BETA_HEADER = "oauth-2025-04-20";

// Additional beta for interleaved thinking support.
export const ANTHROPIC_OAUTH_THINKING_BETA = "interleaved-thinking-2025-05-14";

// User-agent string to send with OAuth-authed requests.
export const ANTHROPIC_OAUTH_USER_AGENT = "claude-cli/2.1.2 (external, cli)";

// Tool name prefix required by Anthropic's OAuth API.
export const ANTHROPIC_OAUTH_TOOL_PREFIX = "mcp_";

// System prompt prefix required by Anthropic's OAuth API.
// The server validates that Claude Code OAuth requests include this identity
// prefix in the system prompt; without it the credential is rejected.
export const ANTHROPIC_OAUTH_SYSTEM_PREFIX =
"You are Claude Code, Anthropic's official CLI for Claude.";

export function buildAnthropicAuthorizeUrl(input: {
state: string;
codeChallenge: string;
}): string {
const url = new URL(ANTHROPIC_OAUTH_AUTHORIZE_URL);
// code=true tells the server to display a code for the user to copy/paste
// instead of performing a redirect to localhost.
url.searchParams.set("code", "true");
url.searchParams.set("client_id", ANTHROPIC_OAUTH_CLIENT_ID);
url.searchParams.set("response_type", "code");
url.searchParams.set("redirect_uri", ANTHROPIC_OAUTH_REDIRECT_URI);
url.searchParams.set("scope", ANTHROPIC_OAUTH_SCOPE);
url.searchParams.set("code_challenge", input.codeChallenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("state", input.state);
return url.toString();
}

export function buildAnthropicTokenExchangeBody(input: {
code: string;
state: string;
codeVerifier: string;
}): string {
return JSON.stringify({
code: input.code,
state: input.state,
grant_type: "authorization_code",
client_id: ANTHROPIC_OAUTH_CLIENT_ID,
redirect_uri: ANTHROPIC_OAUTH_REDIRECT_URI,
code_verifier: input.codeVerifier,
});
}

export function buildAnthropicRefreshBody(input: { refreshToken: string }): string {
return JSON.stringify({
grant_type: "refresh_token",
refresh_token: input.refreshToken,
client_id: ANTHROPIC_OAUTH_CLIENT_ID,
});
}
1 change: 1 addition & 0 deletions src/common/orpc/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ export {
copilotOauth,
muxGovernorOauth,
codexOauth,
anthropicOauth,
policy,
providers,
ProvidersConfigMapSchema,
Expand Down
23 changes: 23 additions & 0 deletions src/common/orpc/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ export const ProviderConfigInfoSchema = z.object({
codexOauthDefaultAuth: z.enum(["oauth", "apiKey"]).optional(),
/** AWS-specific fields (only present for bedrock provider) */
aws: AWSCredentialStatusSchema.optional(),
/** Anthropic-only: whether Anthropic OAuth tokens are present in providers.jsonc */
anthropicOauthSet: z.boolean().optional(),
/** Mux Gateway-specific fields */
couponCodeSet: z.boolean().optional(),
});
Expand Down Expand Up @@ -326,6 +328,27 @@ export const codexOauth = {
output: ResultSchema(z.void(), z.string()),
},
};

// Anthropic OAuth (Claude Max/Pro subscription auth)
export const anthropicOauth = {
startFlow: {
input: z.void(),
output: ResultSchema(z.object({ flowId: z.string(), authorizeUrl: z.string() }), z.string()),
},
submitCode: {
input: z.object({ flowId: z.string(), code: z.string() }).strict(),
output: ResultSchema(z.void(), z.string()),
},
cancelFlow: {
input: z.object({ flowId: z.string() }).strict(),
output: z.void(),
},
disconnect: {
input: z.void(),
output: ResultSchema(z.void(), z.string()),
},
};

// Mux Gateway
export const muxGateway = {
getAccountStatus: {
Expand Down
2 changes: 2 additions & 0 deletions src/node/orpc/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { WorkspaceService } from "@/node/services/workspaceService";
import type { MuxGatewayOauthService } from "@/node/services/muxGatewayOauthService";
import type { MuxGovernorOauthService } from "@/node/services/muxGovernorOauthService";
import type { CodexOauthService } from "@/node/services/codexOauthService";
import type { AnthropicOauthService } from "@/node/services/anthropicOauthService";
import type { CopilotOauthService } from "@/node/services/copilotOauthService";
import type { ProviderService } from "@/node/services/providerService";
import type { TerminalService } from "@/node/services/terminalService";
Expand Down Expand Up @@ -40,6 +41,7 @@ export interface ORPCContext {
muxGatewayOauthService: MuxGatewayOauthService;
muxGovernorOauthService: MuxGovernorOauthService;
codexOauthService: CodexOauthService;
anthropicOauthService: AnthropicOauthService;
copilotOauthService: CopilotOauthService;
terminalService: TerminalService;
editorService: EditorService;
Expand Down
Loading