diff --git a/src/browser/components/Settings/sections/ProvidersSection.tsx b/src/browser/components/Settings/sections/ProvidersSection.tsx index 46e557f203..8722e9558d 100644 --- a/src/browser/components/Settings/sections/ProvidersSection.tsx +++ b/src/browser/components/Settings/sections/ProvidersSection.tsx @@ -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("idle"); + const [anthropicOauthError, setAnthropicOauthError] = useState(null); + const [anthropicOauthFlowId, setAnthropicOauthFlowId] = useState(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(null); const muxGatewayApplyDefaultModelsOnSuccessRef = useRef(false); @@ -1415,6 +1528,99 @@ export function ProvidersSection() { ); })} + {/* Anthropic: Claude Max/Pro OAuth */} + {provider === "anthropic" && ( +
+
+ + + {anthropicOauthStatus === "starting" + ? "Starting..." + : anthropicOauthStatus === "waiting_for_code" + ? "Waiting for authorization code..." + : anthropicOauthStatus === "submitting" + ? "Submitting..." + : anthropicOauthIsConnected + ? "Connected" + : "Not connected"} + +
+ +
+ + + {anthropicOauthInProgress && ( + + )} + + {anthropicOauthIsConnected && ( + + )} +
+ + {anthropicOauthStatus === "waiting_for_code" && ( +
+

+ Authorize in the browser, then paste the code below: +

+
+ 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 + /> + +
+
+ )} + + {anthropicOauthStatus === "error" && anthropicOauthError && ( +

{anthropicOauthError}

+ )} + +

+ Claude Max/Pro OAuth uses subscription billing (costs included). +

+
+ )} + {/* OpenAI: ChatGPT OAuth + service tier */} {provider === "openai" && (
diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index cf06d17140..acd4310076 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -64,6 +64,7 @@ async function createTestServer(authToken?: string): Promise { muxGatewayOauthService: services.muxGatewayOauthService, muxGovernorOauthService: services.muxGovernorOauthService, codexOauthService: services.codexOauthService, + anthropicOauthService: services.anthropicOauthService, copilotOauthService: services.copilotOauthService, taskService: services.taskService, providerService: services.providerService, diff --git a/src/cli/run.ts b/src/cli/run.ts index 90b72ae874..380d13c482 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -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, @@ -448,6 +449,9 @@ async function main(): Promise { 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; diff --git a/src/cli/server.test.ts b/src/cli/server.test.ts index 4c2fa39845..1f6f8aa306 100644 --- a/src/cli/server.test.ts +++ b/src/cli/server.test.ts @@ -67,6 +67,7 @@ async function createTestServer(): Promise { muxGatewayOauthService: services.muxGatewayOauthService, muxGovernorOauthService: services.muxGovernorOauthService, codexOauthService: services.codexOauthService, + anthropicOauthService: services.anthropicOauthService, copilotOauthService: services.copilotOauthService, taskService: services.taskService, providerService: services.providerService, diff --git a/src/common/constants/anthropicOAuth.ts b/src/common/constants/anthropicOAuth.ts new file mode 100644 index 0000000000..af6f9f11a3 --- /dev/null +++ b/src/common/constants/anthropicOAuth.ts @@ -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, + }); +} diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index 223f4cd32d..8ecdda0f40 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -190,6 +190,7 @@ export { copilotOauth, muxGovernorOauth, codexOauth, + anthropicOauth, policy, providers, ProvidersConfigMapSchema, diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index b6a384e505..f9e31ad5e2 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -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(), }); @@ -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: { diff --git a/src/node/orpc/context.ts b/src/node/orpc/context.ts index 61eee9391d..7e36efbca3 100644 --- a/src/node/orpc/context.ts +++ b/src/node/orpc/context.ts @@ -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"; @@ -40,6 +41,7 @@ export interface ORPCContext { muxGatewayOauthService: MuxGatewayOauthService; muxGovernorOauthService: MuxGovernorOauthService; codexOauthService: CodexOauthService; + anthropicOauthService: AnthropicOauthService; copilotOauthService: CopilotOauthService; terminalService: TerminalService; editorService: EditorService; diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 05fc5b9733..a11205daf1 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -1242,6 +1242,32 @@ export const router = (authToken?: string) => { return context.codexOauthService.disconnect(); }), }, + anthropicOauth: { + startFlow: t + .input(schemas.anthropicOauth.startFlow.input) + .output(schemas.anthropicOauth.startFlow.output) + .handler(({ context }) => { + return context.anthropicOauthService.startFlow(); + }), + submitCode: t + .input(schemas.anthropicOauth.submitCode.input) + .output(schemas.anthropicOauth.submitCode.output) + .handler(({ context, input }) => { + return context.anthropicOauthService.submitCode(input); + }), + cancelFlow: t + .input(schemas.anthropicOauth.cancelFlow.input) + .output(schemas.anthropicOauth.cancelFlow.output) + .handler(({ context, input }) => { + context.anthropicOauthService.cancelFlow(input.flowId); + }), + disconnect: t + .input(schemas.anthropicOauth.disconnect.input) + .output(schemas.anthropicOauth.disconnect.output) + .handler(({ context }) => { + return context.anthropicOauthService.disconnect(); + }), + }, general: { listDirectory: t .input(schemas.general.listDirectory.input) diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 39c91008b7..621be2fa4a 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -26,6 +26,7 @@ import type { MuxProviderOptions } from "@/common/types/providerOptions"; import type { PolicyService } from "@/node/services/policyService"; import type { ProviderService } from "@/node/services/providerService"; import type { CodexOauthService } from "@/node/services/codexOauthService"; +import type { AnthropicOauthService } from "@/node/services/anthropicOauthService"; import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; import type { FileState, EditedFileAttachment } from "@/node/services/agentSession"; import { log } from "./log"; @@ -172,6 +173,9 @@ export class AIService extends EventEmitter { setCodexOauthService(service: CodexOauthService): void { this.providerModelFactory.codexOauthService = service; } + setAnthropicOauthService(service: AnthropicOauthService): void { + this.providerModelFactory.anthropicOauthService = service; + } setMCPServerManager(manager: MCPServerManager): void { this.mcpServerManager = manager; this.streamManager.setMCPServerManager(manager); diff --git a/src/node/services/anthropicOauthService.ts b/src/node/services/anthropicOauthService.ts new file mode 100644 index 0000000000..4afc0552d3 --- /dev/null +++ b/src/node/services/anthropicOauthService.ts @@ -0,0 +1,366 @@ +/** + * Anthropic OAuth service for Claude Max/Pro subscription authentication. + * + * Uses a code-paste flow: user opens an auth URL, authorizes, and pastes + * back the displayed code. Simpler than the Codex OAuth flow (no local + * HTTP server, no device flow). + */ + +import * as crypto from "crypto"; +import type { Result } from "@/common/types/result"; +import { Err, Ok } from "@/common/types/result"; +import { + buildAnthropicAuthorizeUrl, + buildAnthropicTokenExchangeBody, + buildAnthropicRefreshBody, + ANTHROPIC_OAUTH_TOKEN_URL, +} from "@/common/constants/anthropicOAuth"; +import type { Config } from "@/node/config"; +import type { ProviderService } from "@/node/services/providerService"; +import type { WindowService } from "@/node/services/windowService"; +import { log } from "@/node/services/log"; +import { AsyncMutex } from "@/node/utils/concurrency/asyncMutex"; +import { + isAnthropicOauthAuthExpired, + parseAnthropicOauthAuth, + type AnthropicOauthAuth, +} from "@/node/utils/anthropicOauthAuth"; + +const DEFAULT_FLOW_TIMEOUT_MS = 5 * 60 * 1000; + +interface PendingCodePasteFlow { + flowId: string; + /** Random state for CSRF validation. */ + state: string; + /** PKCE code verifier. */ + codeVerifier: string; + timeout: ReturnType; +} + +function sha256Base64Url(value: string): string { + return crypto.createHash("sha256").update(value).digest().toString("base64url"); +} + +function randomBase64Url(bytes = 32): string { + return crypto.randomBytes(bytes).toString("base64url"); +} + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function parseOptionalNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const n = Number(value); + return Number.isFinite(n) ? n : null; + } + return null; +} + +function isInvalidGrantError(errorText: string): boolean { + const trimmed = errorText.trim(); + if (trimmed.length === 0) return false; + + try { + const json = JSON.parse(trimmed) as unknown; + if (isPlainObject(json) && json.error === "invalid_grant") { + return true; + } + } catch { + // Fall through to substring check. + } + + const lower = trimmed.toLowerCase(); + return lower.includes("invalid_grant") || lower.includes("revoked"); +} + +export class AnthropicOauthService { + private pendingFlow: PendingCodePasteFlow | null = null; + private readonly refreshMutex = new AsyncMutex(); + + // In-memory cache so getValidAuth() skips disk reads when tokens are valid. + // Invalidated on every write (exchange, refresh, disconnect). + private cachedAuth: AnthropicOauthAuth | null = null; + + constructor( + private readonly config: Config, + private readonly providerService: ProviderService, + private readonly windowService?: WindowService + ) {} + + /** + * Start the OAuth code-paste flow: generate PKCE, build authorize URL. + * The frontend opens the URL in the browser and shows a code input field. + */ + startFlow(): Result<{ flowId: string; authorizeUrl: string }, string> { + // Cancel any existing pending flow. + if (this.pendingFlow) { + clearTimeout(this.pendingFlow.timeout); + this.pendingFlow = null; + } + + const flowId = randomBase64Url(); + const state = randomBase64Url(); + const codeVerifier = randomBase64Url(); + const codeChallenge = sha256Base64Url(codeVerifier); + + const authorizeUrl = buildAnthropicAuthorizeUrl({ state, codeChallenge }); + + const timeout = setTimeout(() => { + if (this.pendingFlow?.flowId === flowId) { + log.debug(`[Anthropic OAuth] Flow timed out (flowId=${flowId})`); + this.pendingFlow = null; + } + }, DEFAULT_FLOW_TIMEOUT_MS); + + this.pendingFlow = { flowId, state, codeVerifier, timeout }; + + log.debug(`[Anthropic OAuth] Flow started (flowId=${flowId})`); + return Ok({ flowId, authorizeUrl }); + } + + /** + * Submit the pasted authorization code from the user. + * Expected format: "code#state" (as displayed by Anthropic's callback page). + */ + async submitCode(input: { flowId: string; code: string }): Promise> { + const flow = this.pendingFlow; + if (!flow || flow.flowId !== input.flowId) { + return Err("No pending OAuth flow with that ID"); + } + + // Parse "code#state" format + const hashIndex = input.code.indexOf("#"); + if (hashIndex === -1) { + return Err("Invalid authorization code format (expected code#state)"); + } + + const code = input.code.slice(0, hashIndex); + const state = input.code.slice(hashIndex + 1); + + if (!code) { + return Err("Authorization code is empty"); + } + + // Clear the pending flow before exchange (one-shot). + clearTimeout(flow.timeout); + this.pendingFlow = null; + + const exchangeResult = await this.exchangeCodeForTokens({ + code, + state, + codeVerifier: flow.codeVerifier, + }); + + if (!exchangeResult.success) { + return Err(exchangeResult.error); + } + + const persistResult = this.persistAuth(exchangeResult.data); + if (!persistResult.success) { + return Err(persistResult.error); + } + + log.debug("[Anthropic OAuth] Successfully connected"); + + // Focus the main window so the user sees the updated settings. + this.windowService?.focusMainWindow(); + + return Ok(undefined); + } + + cancelFlow(flowId: string): void { + if (this.pendingFlow?.flowId === flowId) { + clearTimeout(this.pendingFlow.timeout); + this.pendingFlow = null; + log.debug(`[Anthropic OAuth] Flow cancelled (flowId=${flowId})`); + } + } + + disconnect(): Result { + this.cachedAuth = null; + return this.providerService.setConfigValue("anthropic", ["anthropicOauth"], undefined); + } + + async getValidAuth(): Promise> { + const stored = this.readStoredAuth(); + if (!stored) { + return Err("Anthropic OAuth is not configured"); + } + + if (!isAnthropicOauthAuthExpired(stored)) { + return Ok(stored); + } + + await using _lock = await this.refreshMutex.acquire(); + + // Re-read after acquiring lock in case another caller refreshed first. + const latest = this.readStoredAuth(); + if (!latest) { + return Err("Anthropic OAuth is not configured"); + } + + if (!isAnthropicOauthAuthExpired(latest)) { + return Ok(latest); + } + + const refreshed = await this.refreshTokens(latest); + if (!refreshed.success) { + return Err(refreshed.error); + } + + return Ok(refreshed.data); + } + + dispose(): void { + if (this.pendingFlow) { + clearTimeout(this.pendingFlow.timeout); + this.pendingFlow = null; + } + } + + // --------------------------------------------------------------------------- + // Private + // --------------------------------------------------------------------------- + + private readStoredAuth(): AnthropicOauthAuth | null { + if (this.cachedAuth) { + return this.cachedAuth; + } + const providersConfig = this.config.loadProvidersConfig() ?? {}; + const anthropicConfig = providersConfig.anthropic as Record | undefined; + const auth = parseAnthropicOauthAuth(anthropicConfig?.anthropicOauth); + this.cachedAuth = auth; + return auth; + } + + private persistAuth(auth: AnthropicOauthAuth): Result { + const result = this.providerService.setConfigValue("anthropic", ["anthropicOauth"], auth); + // Invalidate cache so the next read picks up the persisted value from disk. + this.cachedAuth = null; + return result; + } + + private async exchangeCodeForTokens(input: { + code: string; + state: string; + codeVerifier: string; + }): Promise> { + try { + const response = await fetch(ANTHROPIC_OAUTH_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: buildAnthropicTokenExchangeBody({ + code: input.code, + state: input.state, + codeVerifier: input.codeVerifier, + }), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + const prefix = `Anthropic OAuth exchange failed (${response.status})`; + return Err(errorText ? `${prefix}: ${errorText}` : prefix); + } + + const json = (await response.json()) as unknown; + if (!isPlainObject(json)) { + return Err("Anthropic OAuth exchange returned an invalid JSON payload"); + } + + const accessToken = typeof json.access_token === "string" ? json.access_token : null; + const refreshToken = typeof json.refresh_token === "string" ? json.refresh_token : null; + const expiresIn = parseOptionalNumber(json.expires_in); + + if (!accessToken) { + return Err("Anthropic OAuth exchange response missing access_token"); + } + + if (!refreshToken) { + return Err("Anthropic OAuth exchange response missing refresh_token"); + } + + if (expiresIn === null) { + return Err("Anthropic OAuth exchange response missing expires_in"); + } + + return Ok({ + type: "oauth", + access: accessToken, + refresh: refreshToken, + expires: Date.now() + Math.max(0, Math.floor(expiresIn * 1000)), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Err(`Anthropic OAuth exchange failed: ${message}`); + } + } + + private async refreshTokens( + current: AnthropicOauthAuth + ): Promise> { + try { + const response = await fetch(ANTHROPIC_OAUTH_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: buildAnthropicRefreshBody({ refreshToken: current.refresh }), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + + // When the refresh token is invalid/revoked, clear persisted auth so + // subsequent requests fall back to "not connected" behavior. + if (isInvalidGrantError(errorText)) { + log.debug("[Anthropic OAuth] Refresh token rejected; clearing stored auth"); + const disconnectResult = this.disconnect(); + if (!disconnectResult.success) { + log.warn( + `[Anthropic OAuth] Failed to clear stored auth after refresh failure: ${disconnectResult.error}` + ); + } + } + + const prefix = `Anthropic OAuth refresh failed (${response.status})`; + return Err(errorText ? `${prefix}: ${errorText}` : prefix); + } + + const json = (await response.json()) as unknown; + if (!isPlainObject(json)) { + return Err("Anthropic OAuth refresh returned an invalid JSON payload"); + } + + const accessToken = typeof json.access_token === "string" ? json.access_token : null; + const refreshToken = typeof json.refresh_token === "string" ? json.refresh_token : null; + const expiresIn = parseOptionalNumber(json.expires_in); + + if (!accessToken) { + return Err("Anthropic OAuth refresh response missing access_token"); + } + + if (expiresIn === null) { + return Err("Anthropic OAuth refresh response missing expires_in"); + } + + const next: AnthropicOauthAuth = { + type: "oauth", + access: accessToken, + refresh: refreshToken ?? current.refresh, + expires: Date.now() + Math.max(0, Math.floor(expiresIn * 1000)), + }; + + const persistResult = this.persistAuth(next); + if (!persistResult.success) { + return Err(persistResult.error); + } + + return Ok(next); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Err(`Anthropic OAuth refresh failed: ${message}`); + } + } +} diff --git a/src/node/services/providerModelFactory.ts b/src/node/services/providerModelFactory.ts index 4d0c001b0f..0174e35533 100644 --- a/src/node/services/providerModelFactory.ts +++ b/src/node/services/providerModelFactory.ts @@ -17,6 +17,15 @@ import { isCodexOauthRequiredModelId, } from "@/common/constants/codexOAuth"; import { parseCodexOauthAuth } from "@/node/utils/codexOauthAuth"; +import { parseAnthropicOauthAuth } from "@/node/utils/anthropicOauthAuth"; +import type { AnthropicOauthService } from "@/node/services/anthropicOauthService"; +import { + ANTHROPIC_OAUTH_BETA_HEADER, + ANTHROPIC_OAUTH_THINKING_BETA, + ANTHROPIC_OAUTH_USER_AGENT, + ANTHROPIC_OAUTH_TOOL_PREFIX, + ANTHROPIC_OAUTH_SYSTEM_PREFIX, +} from "@/common/constants/anthropicOAuth"; import type { Config, ProviderConfig } from "@/node/config"; import type { MuxProviderOptions } from "@/common/types/providerOptions"; import type { PolicyService } from "@/node/services/policyService"; @@ -86,6 +95,86 @@ if (typeof globalFetchWithExtras.certificate === "function") { globalFetchWithExtras.certificate.bind(globalFetchWithExtras); } +// --------------------------------------------------------------------------- +// Anthropic OAuth tool prefix stripping +// --------------------------------------------------------------------------- + +/** + * Strip the mcp_ prefix from tool names in Anthropic streaming responses. + * Anthropic's OAuth API requires tool names to be prefixed with "mcp_" in + * requests; we strip them from responses so the SDK sees the original names. + * + * Uses a carry-over buffer to handle chunk boundaries: if a chunk ends with + * a partial match (e.g. `"name":"mcp_` split across two reads), the trailing + * fragment is held back and prepended to the next chunk before applying the + * regex, preventing missed replacements. + */ +function stripAnthropicOauthToolPrefix(response: Response): Response { + const body = response.body; + if (!body) return response; + + const reader = body.getReader(); + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + + // Maximum number of bytes a partial match can span. The longest pattern + // we need to match is `"name" : "mcp_"`. A conservative upper + // bound for the non-toolname portion is ~20 chars (`"name" : "mcp_`). + const CARRY_LIMIT = 30; + let carry = ""; + + const stream = new ReadableStream({ + async pull(controller) { + const { done, value } = await reader.read(); + if (done) { + // Flush any remaining carry buffer on stream end. + if (carry.length > 0) { + controller.enqueue(encoder.encode(carry)); + carry = ""; + } + controller.close(); + return; + } + + const chunk = carry + decoder.decode(value, { stream: true }); + carry = ""; + + // Strip mcp_ prefix from tool name fields in SSE JSON payloads. + const replaced = chunk.replace(/"name"\s*:\s*"mcp_([^"]+)"/g, '"name":"$1"'); + + // Hold back a trailing fragment that could be a partial match split + // across chunks. We only carry if the tail looks like it could be the + // start of a `"name"` JSON key (i.e. contains an unmatched `"`). + const lastQuote = replaced.lastIndexOf('"'); + if (lastQuote >= 0 && replaced.length - lastQuote <= CARRY_LIMIT) { + // Check if there's an unclosed string at the tail by counting quotes + // after the last newline (SSE is line-delimited, so newlines are safe + // flush points). + const lastNewline = replaced.lastIndexOf("\n"); + const tail = lastNewline >= 0 ? replaced.slice(lastNewline + 1) : replaced; + const quoteCount = (tail.match(/"/g) || []).length; + if (quoteCount % 2 !== 0) { + // Odd number of quotes means an unclosed string -- carry the tail. + const emit = lastNewline >= 0 ? replaced.slice(0, lastNewline + 1) : ""; + carry = lastNewline >= 0 ? replaced.slice(lastNewline + 1) : replaced; + if (emit.length > 0) { + controller.enqueue(encoder.encode(emit)); + } + return; + } + } + + controller.enqueue(encoder.encode(replaced)); + }, + }); + + return new Response(stream, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); +} + // --------------------------------------------------------------------------- // Fetch wrappers // --------------------------------------------------------------------------- @@ -366,6 +455,7 @@ export class ProviderModelFactory { private readonly providerService: ProviderService; private readonly policyService?: PolicyService; codexOauthService?: CodexOauthService; + anthropicOauthService?: AnthropicOauthService; constructor( config: Config, @@ -459,16 +549,33 @@ export class ProviderModelFactory { // Handle Anthropic provider if (providerName === "anthropic") { - // Resolve credentials from config + env (single source of truth) const creds = resolveProviderCredentials("anthropic", providerConfig); - if (!creds.isConfigured) { + + // Check for stored Anthropic OAuth tokens (Claude Max/Pro subscription). + const storedAnthropicOauth = parseAnthropicOauthAuth( + (providerConfig as { anthropicOauth?: unknown }).anthropicOauth + ); + + // Route through OAuth when connected and no API key is set. + // When both are available, prefer the API key (user explicitly configured it). + const shouldRouteThroughAnthropicOauth = (() => { + if (!storedAnthropicOauth) return false; + if (!creds.isConfigured) return true; + return false; + })(); + + if (!shouldRouteThroughAnthropicOauth && !creds.isConfigured) { return Err({ type: "api_key_not_found", provider: providerName }); } - // Build config with resolved credentials - const configWithApiKey = creds.apiKey - ? { ...providerConfig, apiKey: creds.apiKey } - : providerConfig; + // Build config with resolved credentials. + // When using OAuth, pass a placeholder API key so the SDK never reads env vars. + const configWithApiKey = { + ...providerConfig, + apiKey: shouldRouteThroughAnthropicOauth + ? (creds.apiKey ?? "anthropic-oauth") + : creds.apiKey, + }; // Normalize base URL to ensure /v1 suffix (SDK expects it) const effectiveBaseURL = configWithApiKey.baseURL ?? creds.baseUrl?.trim(); @@ -477,7 +584,6 @@ export class ProviderModelFactory { : configWithApiKey; // Add 1M context beta header if requested and model supports it. - // Check both per-model list (use1MContextModels) and legacy global flag (use1MContext). const fullModelId = `anthropic:${modelId}`; const is1MEnabled = ((muxProviderOptions?.anthropic?.use1MContextModels?.includes(fullModelId) ?? false) || @@ -485,19 +591,199 @@ export class ProviderModelFactory { supports1MContext(fullModelId); const headers = buildAnthropicHeaders(normalizedConfig.headers, is1MEnabled); - // Lazy-load Anthropic provider to reduce startup time const { createAnthropic } = await PROVIDER_REGISTRY.anthropic(); - // Wrap fetch to inject cache_control on tools and messages - // (SDK doesn't translate providerOptions to cache_control for these) - // Use getProviderFetch to preserve any user-configured custom fetch (e.g., proxies) const baseFetch = getProviderFetch(providerConfig); - const fetchWithCacheControl = wrapFetchWithAnthropicCacheControl(baseFetch); + const anthropicOauthService = this.anthropicOauthService; + + let effectiveFetch: typeof fetch; + + if (shouldRouteThroughAnthropicOauth) { + // Wrap fetch for OAuth: inject Bearer token, beta headers, tool prefixing. + effectiveFetch = Object.assign( + async ( + input: Parameters[0], + init?: Parameters[1] + ): Promise => { + if (!anthropicOauthService) { + throw new Error("Anthropic OAuth service not initialized"); + } + + const authResult = await anthropicOauthService.getValidAuth(); + if (!authResult.success) { + throw new Error(authResult.error); + } + + const urlString = (() => { + if (typeof input === "string") return input; + if (input instanceof URL) return input.toString(); + if (typeof input === "object" && input !== null && "url" in input) { + const possibleUrl = (input as { url?: unknown }).url; + if (typeof possibleUrl === "string") return possibleUrl; + } + return ""; + })(); + + const method = (init?.method ?? "GET").toUpperCase(); + const isMessagesEndpoint = /\/v1\/messages(\?|$)/.test(urlString); + + let nextInput: Parameters[0] = input; + let nextInit: Parameters[1] | undefined = init; + + // Transform request body for Anthropic OAuth API compatibility. + if (isMessagesEndpoint && method === "POST" && typeof init?.body === "string") { + try { + const json = JSON.parse(init.body) as Record; + + // Prepend the Claude Code identity prefix as a SEPARATE text block. + // The server validates that the first system text block is EXACTLY + // the Claude Code identity string. Concatenating it with other text + // causes the check to fail. It must be its own standalone block. + if (Array.isArray(json.system)) { + const systemArr = json.system as Array>; + systemArr.unshift({ type: "text", text: ANTHROPIC_OAUTH_SYSTEM_PREFIX }); + } else if (typeof json.system === "string") { + json.system = [ + { type: "text", text: ANTHROPIC_OAUTH_SYSTEM_PREFIX }, + { type: "text", text: json.system }, + ]; + } else if (!json.system) { + json.system = [{ type: "text", text: ANTHROPIC_OAUTH_SYSTEM_PREFIX }]; + } + + // Prefix custom tool definitions only. Anthropic's built-in + // server-side tools (web_search, code_execution, text_editor, etc.) + // have a versioned type like "web_search_20250305" and their names + // must remain unchanged. Custom tools have type "custom" or no type. + if (Array.isArray(json.tools)) { + for (const tool of json.tools as Array>) { + const isCustomTool = !tool.type || tool.type === "custom"; + if ( + isCustomTool && + typeof tool.name === "string" && + !tool.name.startsWith(ANTHROPIC_OAUTH_TOOL_PREFIX) + ) { + tool.name = `${ANTHROPIC_OAUTH_TOOL_PREFIX}${tool.name}`; + } + } + } + + // Collect built-in tool names so we skip them in messages too. + const builtInToolNames = new Set(); + if (Array.isArray(json.tools)) { + for (const tool of json.tools as Array>) { + if (tool.type && tool.type !== "custom" && typeof tool.name === "string") { + builtInToolNames.add(tool.name); + } + } + } + + // Prefix tool_use blocks in messages (skip built-in tools). + if (Array.isArray(json.messages)) { + for (const msg of json.messages as Array>) { + if (Array.isArray(msg.content)) { + for (const block of msg.content as Array>) { + if ( + block.type === "tool_use" && + typeof block.name === "string" && + !block.name.startsWith(ANTHROPIC_OAUTH_TOOL_PREFIX) && + !builtInToolNames.has(block.name) + ) { + block.name = `${ANTHROPIC_OAUTH_TOOL_PREFIX}${block.name}`; + } + } + } + } + } + + // Prefix tool_choice.name when a specific tool is forced. + // The SDK sends { type: "tool", name: "bash" } but the tool + // definition is now mcp_bash, so the name must match. + const tc = json.tool_choice as Record | undefined; + if ( + tc?.type === "tool" && + typeof tc.name === "string" && + !tc.name.startsWith(ANTHROPIC_OAUTH_TOOL_PREFIX) && + !builtInToolNames.has(tc.name) + ) { + tc.name = `${ANTHROPIC_OAUTH_TOOL_PREFIX}${tc.name}`; + } + + const newBody = JSON.stringify(json); + const newHeaders = new Headers(init?.headers); + newHeaders.delete("content-length"); + nextInit = { ...init, headers: newHeaders, body: newBody }; + } catch { + // If body parsing fails, proceed without modification. + } + } + + // Rewrite URL: add ?beta=true for OAuth-authed messages requests. + if (isMessagesEndpoint) { + try { + const url = new URL(urlString); + if (!url.searchParams.has("beta")) { + url.searchParams.set("beta", "true"); + } + nextInput = url.toString(); + } catch { + // If URL parsing fails, proceed with original. + } + } + + // Set auth headers for Claude Code OAuth impersonation. + const reqHeaders = new Headers(nextInit?.headers); + reqHeaders.set("Authorization", `Bearer ${authResult.data.access}`); + reqHeaders.delete("x-api-key"); + + // Remove app attribution headers that identify this as a non-Claude-Code + // client. The server checks these and rejects OAuth credentials when + // they come from an unrecognized application. + reqHeaders.delete("http-referer"); + reqHeaders.delete("x-title"); + + // Merge anthropic-beta: ensure required OAuth betas are present + // while preserving SDK betas (e.g. structured-outputs, effort). + const existingBeta = reqHeaders.get("anthropic-beta") ?? ""; + const existingValues = existingBeta.split(",").map((v) => v.trim()).filter(Boolean); + const oauthBetas = [ANTHROPIC_OAUTH_BETA_HEADER, ANTHROPIC_OAUTH_THINKING_BETA]; + const merged = [...new Set([...oauthBetas, ...existingValues])].join(","); + reqHeaders.set("anthropic-beta", merged); + + reqHeaders.set("user-agent", ANTHROPIC_OAUTH_USER_AGENT); + + nextInit = { ...(nextInit ?? {}), headers: reqHeaders }; + + const response = await baseFetch(nextInput, nextInit); + + // Strip mcp_ prefix from tool names in the streaming response. + if (isMessagesEndpoint && response.body) { + return stripAnthropicOauthToolPrefix(response); + } + + return response; + }, + "preconnect" in baseFetch && typeof baseFetch.preconnect === "function" + ? { preconnect: baseFetch.preconnect.bind(baseFetch) } + : {} + ) as typeof fetch; + + // Apply cache control wrapping on top of the OAuth fetch. + effectiveFetch = wrapFetchWithAnthropicCacheControl(effectiveFetch); + } else { + effectiveFetch = wrapFetchWithAnthropicCacheControl(baseFetch); + } + const provider = createAnthropic({ ...normalizedConfig, headers, - fetch: fetchWithCacheControl, + fetch: effectiveFetch, }); - return Ok(provider(modelId)); + + const model = provider(modelId); + if (shouldRouteThroughAnthropicOauth) { + markModelCostsIncluded(model); + } + return Ok(model); } // Handle OpenAI provider (using Responses API) diff --git a/src/node/services/providerService.ts b/src/node/services/providerService.ts index 33538f35af..ad9bd2eb5c 100644 --- a/src/node/services/providerService.ts +++ b/src/node/services/providerService.ts @@ -10,6 +10,7 @@ import type { import { log } from "@/node/services/log"; import { checkProviderConfigured } from "@/node/utils/providerRequirements"; import { parseCodexOauthAuth } from "@/node/utils/codexOauthAuth"; +import { parseAnthropicOauthAuth } from "@/node/utils/anthropicOauthAuth"; import type { PolicyService } from "@/node/services/policyService"; // Re-export types for backward compatibility @@ -80,6 +81,8 @@ export class ProviderService { secretAccessKey?: string; /** OpenAI-only: stored Codex OAuth tokens (never sent to frontend). */ codexOauth?: unknown; + /** Anthropic-only: stored Anthropic OAuth tokens (never sent to frontend). */ + anthropicOauth?: unknown; }; const forcedBaseUrl = this.policyService?.isEnforced() @@ -99,6 +102,9 @@ export class ProviderService { const codexOauthSet = provider === "openai" && parseCodexOauthAuth(config.codexOauth) !== null; + const anthropicOauthSet = + provider === "anthropic" && parseAnthropicOauthAuth(config.anthropicOauth) !== null; + const providerInfo: ProviderConfigInfo = { apiKeySet: !!config.apiKey, isConfigured: false, // computed below @@ -126,6 +132,11 @@ export class ProviderService { providerInfo.codexOauthDefaultAuth = codexOauthDefaultAuth; } } + // Anthropic-specific fields + if (provider === "anthropic") { + providerInfo.anthropicOauthSet = anthropicOauthSet; + } + // AWS/Bedrock-specific fields if (provider === "bedrock") { providerInfo.aws = { @@ -150,6 +161,10 @@ export class ProviderService { providerInfo.isConfigured = true; } + if (provider === "anthropic" && anthropicOauthSet) { + providerInfo.isConfigured = true; + } + result[provider] = providerInfo; } diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index bb41baeab7..900217e54d 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -17,6 +17,7 @@ import { ProjectService } from "@/node/services/projectService"; import { MuxGatewayOauthService } from "@/node/services/muxGatewayOauthService"; import { MuxGovernorOauthService } from "@/node/services/muxGovernorOauthService"; import { CodexOauthService } from "@/node/services/codexOauthService"; +import { AnthropicOauthService } from "@/node/services/anthropicOauthService"; import { CopilotOauthService } from "@/node/services/copilotOauthService"; import { TerminalService } from "@/node/services/terminalService"; import { EditorService } from "@/node/services/editorService"; @@ -94,6 +95,7 @@ export class ServiceContainer { public readonly muxGatewayOauthService: MuxGatewayOauthService; public readonly muxGovernorOauthService: MuxGovernorOauthService; public readonly codexOauthService: CodexOauthService; + public readonly anthropicOauthService: AnthropicOauthService; public readonly copilotOauthService: CopilotOauthService; public readonly terminalService: TerminalService; public readonly editorService: EditorService; @@ -187,6 +189,12 @@ export class ServiceContainer { this.windowService ); this.aiService.setCodexOauthService(this.codexOauthService); + this.anthropicOauthService = new AnthropicOauthService( + config, + this.providerService, + this.windowService + ); + this.aiService.setAnthropicOauthService(this.anthropicOauthService); this.copilotOauthService = new CopilotOauthService(this.providerService, this.windowService); // Terminal services - PTYService is cross-platform this.ptyService = new PTYService(); @@ -414,6 +422,7 @@ export class ServiceContainer { muxGatewayOauthService: this.muxGatewayOauthService, muxGovernorOauthService: this.muxGovernorOauthService, codexOauthService: this.codexOauthService, + anthropicOauthService: this.anthropicOauthService, copilotOauthService: this.copilotOauthService, terminalService: this.terminalService, editorService: this.editorService, @@ -465,6 +474,7 @@ export class ServiceContainer { await this.muxGatewayOauthService.dispose(); await this.muxGovernorOauthService.dispose(); await this.codexOauthService.dispose(); + this.anthropicOauthService.dispose(); this.copilotOauthService.dispose(); await this.backgroundProcessManager.terminateAll(); diff --git a/src/node/utils/anthropicOauthAuth.ts b/src/node/utils/anthropicOauthAuth.ts new file mode 100644 index 0000000000..254e671936 --- /dev/null +++ b/src/node/utils/anthropicOauthAuth.ts @@ -0,0 +1,41 @@ +/** + * Anthropic OAuth token parsing and validation. + */ + +export interface AnthropicOauthAuth { + type: "oauth"; + /** OAuth access token. */ + access: string; + /** OAuth refresh token. */ + refresh: string; + /** Unix epoch milliseconds when the access token expires. */ + expires: number; +} + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function parseAnthropicOauthAuth(value: unknown): AnthropicOauthAuth | null { + if (!isPlainObject(value)) { + return null; + } + + const { type, access, refresh, expires } = value; + + if (type !== "oauth") return null; + if (typeof access !== "string" || !access) return null; + if (typeof refresh !== "string" || !refresh) return null; + if (typeof expires !== "number" || !Number.isFinite(expires)) return null; + + return { type: "oauth", access, refresh, expires }; +} + +export function isAnthropicOauthAuthExpired( + auth: AnthropicOauthAuth, + opts?: { nowMs?: number; skewMs?: number } +): boolean { + const now = opts?.nowMs ?? Date.now(); + const skew = opts?.skewMs ?? 30_000; + return now + skew >= auth.expires; +} diff --git a/tests/ipc/setup.ts b/tests/ipc/setup.ts index 31c5ae7dd7..bc7ba8720b 100644 --- a/tests/ipc/setup.ts +++ b/tests/ipc/setup.ts @@ -89,6 +89,7 @@ export async function createTestEnvironment(): Promise { muxGatewayOauthService: services.muxGatewayOauthService, muxGovernorOauthService: services.muxGovernorOauthService, codexOauthService: services.codexOauthService, + anthropicOauthService: services.anthropicOauthService, copilotOauthService: services.copilotOauthService, taskService: services.taskService, providerService: services.providerService,