From 92882dc4ccb02b7d5686c6d7c3c50a54df2f7b25 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 13 Mar 2026 22:26:28 +0100 Subject: [PATCH] fix(ai): improve anthropic oauth flow fixes #2119 --- package-lock.json | 40 +- packages/agent/test/agent-loop.test.ts | 32 ++ packages/ai/src/providers/anthropic.ts | 2 +- packages/ai/src/utils/oauth/anthropic.ts | 491 +++++++++++++++++++---- 4 files changed, 460 insertions(+), 105 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1303449e..2b0b9acb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,26 +63,6 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.73.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.73.0.tgz", - "integrity": "sha512-URURVzhxXGJDGUGFunIOtBlSl7KWvZiAAKY/ttTkZAkXT9bTPqdk2eK0b8qqSxXpikh3QKPnPYpiyX98zf5ebw==", - "license": "MIT", - "dependencies": { - "json-schema-to-ts": "^3.1.1" - }, - "bin": { - "anthropic-ai-sdk": "bin/cli" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } - } - }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", @@ -8581,6 +8561,26 @@ "node": ">=20.0.0" } }, + "packages/ai/node_modules/@anthropic-ai/sdk": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.73.0.tgz", + "integrity": "sha512-URURVzhxXGJDGUGFunIOtBlSl7KWvZiAAKY/ttTkZAkXT9bTPqdk2eK0b8qqSxXpikh3QKPnPYpiyX98zf5ebw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "packages/ai/node_modules/@types/node": { "version": "24.12.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index 21290f16..50bfb16b 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -128,6 +128,38 @@ describe("agentLoop with AgentMessage", () => { expect(eventTypes).toContain("agent_end"); }); + it("should terminate the stream when the async loop throws before agent_end", async () => { + const context: AgentContext = { + systemPrompt: "You are helpful.", + messages: [], + tools: [], + }; + + const userPrompt: AgentMessage = createUserMessage("Hello"); + const config: AgentLoopConfig = { + model: createModel(), + convertToLlm: identityConverter, + }; + + const events: AgentEvent[] = []; + const stream = agentLoop([userPrompt], context, config, undefined, async () => { + throw new Error("boom"); + }); + + const result = await Promise.race([ + (async () => { + for await (const event of stream) { + events.push(event); + } + return "completed" as const; + })(), + new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 50)), + ]); + + expect(result).toBe("timeout"); + expect(events.map((e) => e.type)).not.toContain("agent_end"); + }); + it("should handle custom message types via convertToLlm", async () => { // Create a custom message type interface CustomNotification { diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index ba59f147..4ad2e89e 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -62,7 +62,7 @@ function getCacheControl( } // Stealth mode: Mimic Claude Code's tool naming exactly -const claudeCodeVersion = "2.1.62"; +const claudeCodeVersion = "2.1.75"; // Claude Code 2.x tool names (canonical casing) // Source: https://cchistory.mariozechner.at/data/prompts-2.1.11.md diff --git a/packages/ai/src/utils/oauth/anthropic.ts b/packages/ai/src/utils/oauth/anthropic.ts index 5355df0d..f7f2c97f 100644 --- a/packages/ai/src/utils/oauth/anthropic.ts +++ b/packages/ai/src/utils/oauth/anthropic.ts @@ -1,114 +1,434 @@ /** * Anthropic OAuth flow (Claude Pro/Max) + * + * NOTE: This module uses Node.js http.createServer and child_process for the OAuth callback + * and token exchange. It is only intended for CLI use, not browser environments. */ +import type { Server } from "node:http"; import { generatePKCE } from "./pkce.js"; -import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.js"; +import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types.js"; + +type CallbackServerInfo = { + server: Server; + redirectUri: string; + cancelWait: () => void; + waitForCode: () => Promise<{ code: string; state: string } | null>; +}; + +type NodeApis = { + createServer: typeof import("node:http").createServer; + execFile: typeof import("node:child_process").execFile; +}; + +let nodeApis: NodeApis | null = null; +let nodeApisPromise: Promise | null = null; const decode = (s: string) => atob(s); const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl"); const AUTHORIZE_URL = "https://claude.ai/oauth/authorize"; -const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"; -const REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"; -const SCOPES = "org:create_api_key user:profile user:inference"; +const TOKEN_URL = "https://platform.claude.com/v1/oauth/token"; +const MANUAL_REDIRECT_URI = "https://platform.claude.com/oauth/code/callback"; +const CALLBACK_HOST = "127.0.0.1"; +const CALLBACK_PORT = 53692; +const CALLBACK_PATH = "/callback"; +const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`; +const SCOPES = + "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"; +const SUCCESS_HTML = ` + + + + + Authentication successful + + +

Authentication successful. Return to your terminal to continue.

+ +`; -/** - * Login with Anthropic OAuth (device code flow) - * - * @param onAuthUrl - Callback to handle the authorization URL (e.g., open browser) - * @param onPromptCode - Callback to prompt user for the authorization code - */ -export async function loginAnthropic( - onAuthUrl: (url: string) => void, - onPromptCode: () => Promise, -): Promise { - const { verifier, challenge } = await generatePKCE(); +async function getNodeApis(): Promise { + if (nodeApis) return nodeApis; + if (!nodeApisPromise) { + if (typeof process === "undefined" || (!process.versions?.node && !process.versions?.bun)) { + throw new Error("Anthropic OAuth is only available in Node.js environments"); + } + nodeApisPromise = Promise.all([import("node:http"), import("node:child_process")]).then( + ([httpModule, childProcessModule]) => ({ + createServer: httpModule.createServer, + execFile: childProcessModule.execFile, + }), + ); + } + nodeApis = await nodeApisPromise; + return nodeApis; +} - // Build authorization URL - const authParams = new URLSearchParams({ - code: "true", - client_id: CLIENT_ID, - response_type: "code", - redirect_uri: REDIRECT_URI, - scope: SCOPES, - code_challenge: challenge, - code_challenge_method: "S256", - state: verifier, - }); +function parseAuthorizationInput(input: string): { code?: string; state?: string } { + const value = input.trim(); + if (!value) return {}; - const authUrl = `${AUTHORIZE_URL}?${authParams.toString()}`; - - // Notify caller with URL to open - onAuthUrl(authUrl); - - // Wait for user to paste authorization code (format: code#state) - const authCode = await onPromptCode(); - const splits = authCode.split("#"); - const code = splits[0]; - const state = splits[1]; - - // Exchange code for tokens - const tokenResponse = await fetch(TOKEN_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - grant_type: "authorization_code", - client_id: CLIENT_ID, - code: code, - state: state, - redirect_uri: REDIRECT_URI, - code_verifier: verifier, - }), - }); - - if (!tokenResponse.ok) { - const error = await tokenResponse.text(); - throw new Error(`Token exchange failed: ${error}`); + try { + const url = new URL(value); + return { + code: url.searchParams.get("code") ?? undefined, + state: url.searchParams.get("state") ?? undefined, + }; + } catch { + // not a URL } - const tokenData = (await tokenResponse.json()) as { - access_token: string; - refresh_token: string; - expires_in: number; - }; + if (value.includes("#")) { + const [code, state] = value.split("#", 2); + return { code, state }; + } - // Calculate expiry time (current time + expires_in seconds - 5 min buffer) - const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000; + if (value.includes("code=")) { + const params = new URLSearchParams(value); + return { + code: params.get("code") ?? undefined, + state: params.get("state") ?? undefined, + }; + } + + return { code: value }; +} + +function formatErrorDetails(error: unknown): string { + if (error instanceof Error) { + const details: string[] = [`${error.name}: ${error.message}`]; + const errorWithCode = error as Error & { code?: string; errno?: number | string; cause?: unknown }; + if (errorWithCode.code) details.push(`code=${errorWithCode.code}`); + if (typeof errorWithCode.errno !== "undefined") details.push(`errno=${String(errorWithCode.errno)}`); + if (typeof error.cause !== "undefined") { + details.push(`cause=${formatErrorDetails(error.cause)}`); + } + if (error.stack) { + details.push(`stack=${error.stack}`); + } + return details.join("; "); + } + return String(error); +} + +async function startCallbackServer(expectedState: string): Promise { + const { createServer } = await getNodeApis(); + + return new Promise((resolve, reject) => { + let result: { code: string; state: string } | null = null; + let cancelled = false; + + const server = createServer((req, res) => { + try { + const url = new URL(req.url || "", "http://localhost"); + if (url.pathname !== CALLBACK_PATH) { + res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("Not found"); + return; + } + + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const error = url.searchParams.get("error"); + + if (error) { + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + res.end(`

Authentication Failed

Error: ${error}

`); + return; + } + + if (!code || !state) { + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + res.end( + `

Authentication Failed

Missing code or state parameter.

`, + ); + return; + } + + if (state !== expectedState) { + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + res.end(`

Authentication Failed

State mismatch.

`); + return; + } + + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(SUCCESS_HTML); + result = { code, state }; + } catch { + res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("Internal error"); + } + }); + + server.on("error", (err) => { + reject(err); + }); + + server.listen(CALLBACK_PORT, CALLBACK_HOST, () => { + resolve({ + server, + redirectUri: REDIRECT_URI, + cancelWait: () => { + cancelled = true; + }, + waitForCode: async () => { + const sleep = () => new Promise((r) => setTimeout(r, 100)); + while (!result && !cancelled) { + await sleep(); + } + return result; + }, + }); + }); + }); +} + +async function postJsonWithCurl(url: string, body: Record): Promise { + const { execFile } = await getNodeApis(); + const payload = JSON.stringify(body); + const marker = "__PI_CURL_HTTP_STATUS__:"; + + const result = await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { + execFile( + "curl", + [ + "-sS", + "--connect-timeout", + "15", + "--max-time", + "30", + "-X", + "POST", + url, + "-H", + "Content-Type: application/json", + "-H", + "Accept: application/json", + "--data-binary", + payload, + "-w", + `\n${marker}%{http_code}`, + ], + (error, stdout, stderr) => { + if (error) { + reject( + new Error(`curl request failed. url=${url}; details=${formatErrorDetails(error)}; stderr=${stderr}`), + ); + return; + } + resolve({ stdout, stderr }); + }, + ); + }); + + const markerIndex = result.stdout.lastIndexOf(`\n${marker}`); + if (markerIndex === -1) { + throw new Error( + `curl response missing status marker. url=${url}; stdout=${result.stdout}; stderr=${result.stderr}`, + ); + } + + const responseBody = result.stdout.slice(0, markerIndex); + const statusText = result.stdout.slice(markerIndex + `\n${marker}`.length).trim(); + const status = Number.parseInt(statusText, 10); + if (!Number.isFinite(status)) { + throw new Error(`curl returned invalid status. url=${url}; status=${statusText}; body=${responseBody}`); + } + if (status < 200 || status >= 300) { + throw new Error(`HTTP request failed. status=${status}; url=${url}; body=${responseBody}`); + } + + return responseBody; +} + +async function exchangeAuthorizationCode( + code: string, + state: string, + verifier: string, + redirectUri: string, +): Promise { + let responseBody: string; + try { + responseBody = await postJsonWithCurl(TOKEN_URL, { + grant_type: "authorization_code", + client_id: CLIENT_ID, + code, + state, + redirect_uri: redirectUri, + code_verifier: verifier, + }); + } catch (error) { + throw new Error( + `Token exchange request failed. url=${TOKEN_URL}; redirect_uri=${redirectUri}; response_type=authorization_code; details=${formatErrorDetails(error)}`, + ); + } + + let tokenData: { access_token: string; refresh_token: string; expires_in: number }; + try { + tokenData = JSON.parse(responseBody) as { access_token: string; refresh_token: string; expires_in: number }; + } catch (error) { + throw new Error( + `Token exchange returned invalid JSON. url=${TOKEN_URL}; body=${responseBody}; details=${formatErrorDetails(error)}`, + ); + } - // Save credentials return { refresh: tokenData.refresh_token, access: tokenData.access_token, - expires: expiresAt, + expires: Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000, }; } +/** + * Login with Anthropic OAuth (authorization code + PKCE) + */ +export async function loginAnthropic(options: { + onAuth: (info: { url: string; instructions?: string }) => void; + onPrompt: (prompt: OAuthPrompt) => Promise; + onProgress?: (message: string) => void; + onManualCodeInput?: () => Promise; +}): Promise { + const { verifier, challenge } = await generatePKCE(); + const server = await startCallbackServer(verifier); + + let code: string | undefined; + let state: string | undefined; + let redirectUriForExchange = REDIRECT_URI; + + try { + const authParams = new URLSearchParams({ + code: "true", + client_id: CLIENT_ID, + response_type: "code", + redirect_uri: REDIRECT_URI, + scope: SCOPES, + code_challenge: challenge, + code_challenge_method: "S256", + state: verifier, + }); + + options.onAuth({ + url: `${AUTHORIZE_URL}?${authParams.toString()}`, + instructions: + "Complete login in your browser. If the browser is on another machine, paste the final redirect URL here.", + }); + + if (options.onManualCodeInput) { + let manualInput: string | undefined; + let manualError: Error | undefined; + const manualPromise = options + .onManualCodeInput() + .then((input) => { + manualInput = input; + server.cancelWait(); + }) + .catch((err) => { + manualError = err instanceof Error ? err : new Error(String(err)); + server.cancelWait(); + }); + + const result = await server.waitForCode(); + + if (manualError) { + throw manualError; + } + + if (result?.code) { + code = result.code; + state = result.state; + redirectUriForExchange = REDIRECT_URI; + } else if (manualInput) { + const parsed = parseAuthorizationInput(manualInput); + if (parsed.state && parsed.state !== verifier) { + throw new Error("OAuth state mismatch"); + } + code = parsed.code; + state = parsed.state ?? verifier; + redirectUriForExchange = MANUAL_REDIRECT_URI; + } + + if (!code) { + await manualPromise; + if (manualError) { + throw manualError; + } + if (manualInput) { + const parsed = parseAuthorizationInput(manualInput); + if (parsed.state && parsed.state !== verifier) { + throw new Error("OAuth state mismatch"); + } + code = parsed.code; + state = parsed.state ?? verifier; + redirectUriForExchange = MANUAL_REDIRECT_URI; + } + } + } else { + const result = await server.waitForCode(); + if (result?.code) { + code = result.code; + state = result.state; + redirectUriForExchange = REDIRECT_URI; + } + } + + if (!code) { + const input = await options.onPrompt({ + message: "Paste the authorization code or full redirect URL:", + placeholder: MANUAL_REDIRECT_URI, + }); + const parsed = parseAuthorizationInput(input); + if (parsed.state && parsed.state !== verifier) { + throw new Error("OAuth state mismatch"); + } + code = parsed.code; + state = parsed.state ?? verifier; + redirectUriForExchange = MANUAL_REDIRECT_URI; + } + + if (!code) { + throw new Error("Missing authorization code"); + } + + if (!state) { + throw new Error("Missing OAuth state"); + } + + options.onProgress?.("Exchanging authorization code for tokens..."); + return exchangeAuthorizationCode(code, state, verifier, redirectUriForExchange); + } finally { + server.server.close(); + } +} + /** * Refresh Anthropic OAuth token */ export async function refreshAnthropicToken(refreshToken: string): Promise { - const response = await fetch(TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ + let responseBody: string; + try { + responseBody = await postJsonWithCurl(TOKEN_URL, { grant_type: "refresh_token", client_id: CLIENT_ID, refresh_token: refreshToken, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Anthropic token refresh failed: ${error}`); + scope: SCOPES, + }); + } catch (error) { + throw new Error(`Anthropic token refresh request failed. url=${TOKEN_URL}; details=${formatErrorDetails(error)}`); } - const data = (await response.json()) as { - access_token: string; - refresh_token: string; - expires_in: number; - }; + let data: { access_token: string; refresh_token: string; expires_in: number; scope?: string }; + try { + data = JSON.parse(responseBody) as { + access_token: string; + refresh_token: string; + expires_in: number; + scope?: string; + }; + } catch (error) { + throw new Error( + `Anthropic token refresh returned invalid JSON. url=${TOKEN_URL}; body=${responseBody}; details=${formatErrorDetails(error)}`, + ); + } return { refresh: data.refresh_token, @@ -120,12 +440,15 @@ export async function refreshAnthropicToken(refreshToken: string): Promise { - return loginAnthropic( - (url) => callbacks.onAuth({ url }), - () => callbacks.onPrompt({ message: "Paste the authorization code:" }), - ); + return loginAnthropic({ + onAuth: callbacks.onAuth, + onPrompt: callbacks.onPrompt, + onProgress: callbacks.onProgress, + onManualCodeInput: callbacks.onManualCodeInput, + }); }, async refreshToken(credentials: OAuthCredentials): Promise {