diff --git a/CLAUDE.md b/CLAUDE.md index 4a3a770e..4ad13237 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,7 @@ This codebase follows `docs/superpowers/specs/2026-05-25-architecture-v4.md` (su | Agent loop | `specs/2026-05-25-agent-loop.md` | | Multi-provider API | `specs/2026-05-28-multi-provider-api-design.md` | | Web-session provider | `specs/2026-08-29-gemini-web-provider.md` — incl. the measurements behind `supportsTools: false` | +| Anthropic OAuth (Pro/Max) | `specs/2026-09-05-anthropic-oauth-provider.md` — subscription auth mode on the `anthropic` entry. **Phases 0–2 built**: `providers/anthropic-oauth.ts` + `auth-store.ts` (import Claude Code's login, refresh, fetch rewrite, identity block) and `providers/anthropic-oauth-login.ts` + `cli/commands/auth.ts` (`freecode auth login|status|logout` — PKCE, localhost callback, paste fallback). Opt-in via `freecode auth login anthropic`, `providers.anthropic.authMode: "oauth"`, or `FREECODE_ANTHROPIC_AUTH=oauth`; login pins the mode, logout un-pins it. `state` **is** the PKCE verifier, so login is single-process (no `--code` flag). Phase 2: an "OAuth not allowed for this organization" 403 is detected **at the fetch** (both SDK paths surface it differently), latches for the process, and falls back to the API key — which also drops the identity block, keeping the §0.1 invariant. **Cost is stamped on the call, not read at fold time**: `model.response` carries `authMode`, `priceUsd(provider, model, usage, authMode?)` takes it as an argument, and `pricing.ts` does not import `config.ts` — reading live config there repriced historical sessions and made pricing machine-dependent. The OAuth system param leads with **two** blocks: Claude Code's billing-attribution line (`x-anthropic-billing-header: cc_version=…` — a system block, not an HTTP header; `cc_version` tracks the spoofed User-Agent) then the identity block. Tool-name mapping stays unbuilt: jcode forwards unmapped tools under their own names, so it is a tool-use-quality tweak, **not** an access or billing requirement — §9 Q1 waits on a real turn. Eval integration (§8) is built: `SuiteReport.authMode` is recorded and `baselineFor` refuses to compare across an auth-mode switch, so a subscription run never becomes the bar an API-key run is measured against. Read §0.1 (ToS risk) before extending | | Memory + sessions | `specs/2026-06-02-memory-session-design.md` | | Memory graph | `specs/2026-07-26-memory-knowledge-graph.md` | | Memory write path | `specs/2026-08-09-memory-write-path.md` | diff --git a/apps/core/src/agent/loop.ts b/apps/core/src/agent/loop.ts index 0b79392d..06c8ec9e 100644 --- a/apps/core/src/agent/loop.ts +++ b/apps/core/src/agent/loop.ts @@ -29,6 +29,7 @@ import type { AgentMode, } from "./types.js"; import type { SystemBlock, ExecuteUsage } from "../providers/types.js"; +import { subscriptionAuth } from "../providers/config.js"; import type { PermissionRequestResult } from "../hooks/PermissionRequest.js"; import { evaluatePermission } from "../permission/evaluate.js"; import { isReadOnlyMode } from "../permission/mode-policy.js"; @@ -2061,6 +2062,7 @@ export class AgentLoop { cacheWriteTokens: usage?.cacheWriteInputTokens ?? usage?.cacheCreationInputTokens, reasoningTokens: usage?.reasoningTokens, + authMode: subscriptionAuth(provider), toolCalls: (toolCalls ?? []).map((t) => t.name), textChars: content.length, thinkingChars: thinking.length, @@ -2100,6 +2102,7 @@ export class AgentLoop { result.usage?.cacheWriteInputTokens ?? result.usage?.cacheCreationInputTokens, reasoningTokens: result.usage?.reasoningTokens, + authMode: subscriptionAuth(provider), toolCalls: (result.toolCalls ?? []).map((t) => t.name), textChars: result.content.length, thinkingChars: result.thinking?.length ?? 0, diff --git a/apps/core/src/cli/commands/auth.ts b/apps/core/src/cli/commands/auth.ts new file mode 100644 index 00000000..7463a5c7 --- /dev/null +++ b/apps/core/src/cli/commands/auth.ts @@ -0,0 +1,238 @@ +// ============================================================================= +// `freecode auth login|status|logout` — Phase 1 of the Anthropic OAuth spec +// (`docs/superpowers/specs/2026-09-05-anthropic-oauth-provider.md`). +// +// Presentation only: the protocol lives in `providers/anthropic-oauth*.ts`. +// Login prints the §0.1 disclosure once, per that spec — this feature +// impersonates Claude Code against the user's own account and says so. +// ============================================================================= + +import type { CommandModule } from "yargs"; +import * as readline from "readline"; +import { spawn } from "child_process"; +import { + anthropicAuthMode, + setAnthropicAuthMode, +} from "../../providers/config.js"; +import { + deleteAnthropicOAuth, + hasImportableClaudeCodeLogin, + readAnthropicOAuth, +} from "../../providers/auth-store.js"; +import { + buildAuthorizeUrl, + exchangeAnthropicCode, + generatePkce, + redirectUriForInput, + startCallbackServer, + ANTHROPIC_OAUTH_LOGIN, +} from "../../providers/anthropic-oauth-login.js"; + +const CALLBACK_TIMEOUT_MS = 120_000; + +const DISCLOSURE = ` +This logs in with your Claude Pro/Max subscription instead of an API key. + +To reach subscription inference, freecode sends Claude Code's OAuth client id, +its User-Agent and beta headers, and its identity line as the first system +block — it presents itself to Anthropic as Claude Code. Anthropic reserves +subscription inference for its official surfaces, so this is against the spirit +(and arguably the letter) of the terms, and they have blocked tools doing it. +The account at risk is yours. + +Your API-key setup is untouched: run \`freecode auth logout anthropic\` to go back. +`; + +function prompt(question: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stderr, + }); + return new Promise((resolve) => + rl.question(question, (answer) => { + rl.close(); + resolve(answer); + }), + ); +} + +/** Best-effort; a machine with no browser just uses the printed URL. */ +function openBrowser(url: string): boolean { + const cmd = + process.platform === "darwin" + ? "open" + : process.platform === "win32" + ? "start" + : "xdg-open"; + try { + const child = spawn(cmd, [url], { stdio: "ignore", detached: true }); + child.unref(); + return true; + } catch { + return false; + } +} + +function assertAnthropic(provider: string): void { + if (provider !== "anthropic") { + throw new Error( + `Only "anthropic" supports OAuth login today (got "${provider}").`, + ); + } +} + +interface LoginArgs { + provider: string; + browser: boolean; +} + +const loginCommand: CommandModule = { + command: "login [provider]", + describe: "log in to a provider with your subscription (OAuth)", + builder: (yargs) => + yargs + .positional("provider", { + type: "string", + default: "anthropic", + describe: "provider to log in to", + }) + .option("browser", { + type: "boolean", + default: true, + describe: "open the authorize URL in a browser (--no-browser to skip)", + }) as never, + handler: async (argv) => { + assertAnthropic(argv.provider); + console.error(DISCLOSURE); + + const { verifier, challenge } = generatePkce(); + + const server = await startCallbackServer(); + const redirectUri = server?.redirectUri ?? ANTHROPIC_OAUTH_LOGIN.manualRedirectUri; + const authUrl = buildAuthorizeUrl(redirectUri, challenge, verifier); + const manualUrl = buildAuthorizeUrl( + ANTHROPIC_OAUTH_LOGIN.manualRedirectUri, + challenge, + verifier, + ); + + console.error("Open this URL to authorize freecode:\n"); + console.error(` ${authUrl}\n`); + if (server && argv.browser) openBrowser(authUrl); + + try { + if (server && argv.browser) { + console.error( + `Waiting up to ${CALLBACK_TIMEOUT_MS / 1000}s for the callback on ${redirectUri} ...`, + ); + try { + const code = await server.waitForCode(verifier, CALLBACK_TIMEOUT_MS); + const tokens = await exchangeAnthropicCode({ + verifier, + input: code, + redirectUri, + }); + finishLogin(tokens.expires_at); + return; + } catch (e) { + console.error( + `${e instanceof Error ? e.message : String(e)} Falling back to pasting the code.\n`, + ); + } + } + + if (!server || !argv.browser) { + console.error( + "No local callback listener — finish in a browser (this or another " + + "device) using the URL above, then paste the result here.\n", + ); + if (!server) console.error(` (manual URL: ${manualUrl})\n`); + } + + const input = ( + await prompt("Paste the callback URL or authorization code: ") + ).trim(); + if (!input) throw new Error("No authorization code entered."); + const tokens = await exchangeAnthropicCode({ + verifier, + input, + redirectUri: redirectUriForInput(input, redirectUri), + }); + finishLogin(tokens.expires_at); + } finally { + server?.close(); + } + }, +}; + +function finishLogin(expiresAt: number): void { + // An explicit login is an explicit opt-in (spec §0.1), so pin the mode + // rather than leaving it to the "no API key configured" fallback. + setAnthropicAuthMode("oauth"); + console.error( + `\nLogged in. anthropic now uses your subscription; the token expires ${new Date( + expiresAt, + ).toLocaleString()} and refreshes automatically.`, + ); +} + +const statusCommand: CommandModule = { + command: "status", + describe: "show how each provider authenticates", + handler: () => { + const mode = anthropicAuthMode(); + const stored = readAnthropicOAuth(); + console.log(`anthropic auth mode: ${mode}`); + if (stored) { + const expires = new Date(stored.expires_at); + const state = stored.expires_at > Date.now() ? "valid until" : "expired"; + console.log(` oauth token: ${state} ${expires.toLocaleString()}`); + console.log( + ` scopes: ${stored.scopes.length ? stored.scopes.join(" ") : "(none reported)"}`, + ); + } else { + console.log(" oauth token: none stored"); + if (hasImportableClaudeCodeLogin()) { + console.log( + " an official Claude Code login is importable on this machine", + ); + } + } + if (mode === "oauth" && !stored) { + console.log(" run `freecode auth login anthropic`"); + } + }, +}; + +const logoutCommand: CommandModule = { + command: "logout [provider]", + describe: "forget stored OAuth credentials and revert to API-key auth", + builder: (yargs) => + yargs.positional("provider", { + type: "string", + default: "anthropic", + describe: "provider to log out of", + }) as never, + handler: (argv) => { + assertAnthropic(argv.provider); + const removed = deleteAnthropicOAuth(); + setAnthropicAuthMode(undefined); + console.log( + removed + ? "Logged out of anthropic; auth mode reverts to your API key." + : "No stored anthropic OAuth credentials.", + ); + }, +}; + +export const authCommand: CommandModule = { + command: "auth", + describe: "manage provider authentication", + builder: (yargs) => + yargs + .command(loginCommand as CommandModule) + .command(statusCommand) + .command(logoutCommand as CommandModule) + .demandCommand(1, "Specify a subcommand"), + handler: () => {}, +}; diff --git a/apps/core/src/cli/create-cli.ts b/apps/core/src/cli/create-cli.ts index 797cce69..07ad502e 100644 --- a/apps/core/src/cli/create-cli.ts +++ b/apps/core/src/cli/create-cli.ts @@ -24,6 +24,7 @@ import { runCommand } from "./commands/run.js"; import { traceCommand } from "./commands/trace.js"; import { evalCommand } from "./commands/eval.js"; import { uninstallCommand } from "./commands/uninstall.js"; +import { authCommand } from "./commands/auth.js"; // ANSI color codes const yellowBright = "\x1b[93m"; @@ -95,6 +96,7 @@ export function createCli(extraCommands: CommandModule[] = []) { .command(runCommand) .command(traceCommand as CommandModule) .command(evalCommand as CommandModule) + .command(authCommand) .command(uninstallCommand) .strict(); diff --git a/apps/core/src/eval/report.test.ts b/apps/core/src/eval/report.test.ts index fea1ddc2..33387ddc 100644 --- a/apps/core/src/eval/report.test.ts +++ b/apps/core/src/eval/report.test.ts @@ -84,6 +84,35 @@ test("a baseline from a different model is refused", () => { assert.equal(baselineFor("trajectory", "openai/gpt-4o")?.passed, 2); }); +test("a baseline from the other auth mode is refused (OAuth spec §8)", () => { + // The subscription endpoint sends a different beta set and an extra system + // block, so an OAuth run is a different instrument — its numbers must not + // become the bar an API-key run is measured against, in either direction. + writeReport(report({ authMode: "oauth", passed: 2 })); + assert.equal(baselineFor("trajectory", "anthropic/claude-sonnet-4-5"), null); + assert.equal( + baselineFor("trajectory", "anthropic/claude-sonnet-4-5", "oauth")?.passed, + 2, + ); + + writeReport(report({ passed: 1, total: 2 })); + assert.equal( + baselineFor("trajectory", "anthropic/claude-sonnet-4-5", "oauth")?.passed, + 2, + "an API-key run must not overwrite the OAuth baseline", + ); +}); + +test("an untracked auth mode still matches an api-key run", () => { + // Every baseline written before §8 landed has no authMode. Treating that as + // a mismatch would throw away all of them. + writeReport(report({ passed: 2 })); + assert.equal( + baselineFor("trajectory", "anthropic/claude-sonnet-4-5", "api-key")?.passed, + 2, + ); +}); + test("the newest run on the SAME model wins over a newer one on another", () => { writeReport(report({ model: "anthropic/claude-sonnet-4-5", passed: 2 })); writeReport(report({ model: "openai/gpt-4o", passed: 0, total: 2 })); diff --git a/apps/core/src/eval/report.ts b/apps/core/src/eval/report.ts index f63c7644..42e4a964 100644 --- a/apps/core/src/eval/report.ts +++ b/apps/core/src/eval/report.ts @@ -72,6 +72,10 @@ export interface Baseline { * run meant a regression became its own baseline and was forgiven on the next * attempt — 18/20 → 14/20 closes the gate, re-run at 14/20 and it opens. * + * Skipping a different auth mode is the same argument (OAuth spec §8): the + * subscription endpoint carries a different beta set and an extra system + * block, so its numbers are not comparable to an API-key run's. + * * Skipping other models is the other half. Comparing a cheap local run against * a CI baseline from a different model reads as a regression with no way to see * why, and the spec is explicit that a repriced baseline is worse than none @@ -79,12 +83,21 @@ export interface Baseline { * before the model was tracked — compared anyway rather than discarded, since * refusing would throw away every baseline written before this change. */ -export function baselineFor(suite: string, model?: string): Baseline | null { +export function baselineFor( + suite: string, + model?: string, + authMode?: "oauth" | "api-key", +): Baseline | null { const history = readHistory(suite); + // An absent mode on either side means "api-key or not tracked yet", which is + // the same instrument — only a recorded "oauth" on one side and not the + // other is a switch. + const normalize = (m?: string) => (m === "oauth" ? "oauth" : "api-key"); for (let i = history.length - 1; i >= 0; i--) { const run = history[i]; if (run.gateBlocked) continue; if (model && run.model && run.model !== model) continue; + if (normalize(run.authMode) !== normalize(authMode)) continue; return { passed: run.passed, total: run.total, diff --git a/apps/core/src/eval/suite.ts b/apps/core/src/eval/suite.ts index ad88c1d4..42a8a447 100644 --- a/apps/core/src/eval/suite.ts +++ b/apps/core/src/eval/suite.ts @@ -5,6 +5,7 @@ import { loadSuite } from "./dataset.js"; import { evaluateGate, summarise, type Verdict } from "./gate.js"; import { baselineFor, writeReport } from "./report.js"; +import { subscriptionAuth } from "../providers/config.js"; import { resolveJudge } from "./judge-config.js"; import { loadQuarantine } from "./quarantine.js"; import { initRunner, runTrial } from "./runner.js"; @@ -97,6 +98,9 @@ export async function runSuite( // through a gateway route, so the resolved judge is recorded on every // report for a reader to check. ...(config.judge ? { judge: config.judge } : {}), + // Spec §8: a mode switch changes the instrument, so it is recorded and + // `baselineFor` refuses to compare across it. + ...(subscriptionAuth(config.provider) ? { authMode: "oauth" as const } : {}), ...(judgeSkipped ? { judgeSkipped } : {}), // Same reason as `judge` above: what we asked for is already recorded, and // what was actually served is the thing a stable id cannot tell you. @@ -105,7 +109,7 @@ export async function runSuite( // Read the baseline BEFORE writing, or this run becomes its own baseline // and the gate compares the report to itself. - const baseline = baselineFor(options.suite, report.model); + const baseline = baselineFor(options.suite, report.model, report.authMode); const verdict = evaluateGate(report, baseline); // A blocked run is recorded but MUST NOT become the baseline. Writing it diff --git a/apps/core/src/eval/types.ts b/apps/core/src/eval/types.ts index 1a7f1b65..af6f124e 100644 --- a/apps/core/src/eval/types.ts +++ b/apps/core/src/eval/types.ts @@ -291,6 +291,17 @@ export interface SuiteReport { * report lets a reader catch what the comparison cannot. */ judge?: { provider: string; model?: string }; + /** + * How the provider under test authenticated (OAuth spec §8). Recorded, and + * treated by `baselineFor` like a model switch: the Anthropic subscription + * endpoint sends a different beta set and a Claude Code identity block, so + * an OAuth run is not the same instrument as an API-key run and its numbers + * must not become the baseline for one. + * + * Absent on runs recorded before this was tracked, and on providers with a + * single auth mode — compared permissively for the same reason `model` is. + */ + authMode?: "oauth" | "api-key"; /** * Every distinct model id the provider echoed back across the run * (`model-echo.ts`). Recorded for the same reason `judge` is: `model` above diff --git a/apps/core/src/memory/graph/secret-filter.test.ts b/apps/core/src/memory/graph/secret-filter.test.ts index e2f21a96..95910b78 100644 --- a/apps/core/src/memory/graph/secret-filter.test.ts +++ b/apps/core/src/memory/graph/secret-filter.test.ts @@ -30,3 +30,17 @@ test("does not flag ordinary memory prose", () => { assert.ok(!containsSecret(c), `should not flag: ${c}`); } }); + +test("Anthropic OAuth tokens are treated as secrets (OAuth spec §3.5)", () => { + // `~/.freecode/auth.json` holds sk-ant-oat01-/sk-ant-ort01- tokens; spec §3.5 + // asks that these shapes be covered here, so nothing derived from a memory + // quoting one is ever embedded. Synthetic values — no real credential. + assert.equal(containsSecret("sk-ant-oat01-" + "A".repeat(40)), true); + assert.equal(containsSecret("sk-ant-ort01-" + "B".repeat(40)), true); + assert.equal( + containsSecret( + JSON.stringify({ access_token: "sk-ant-oat01-" + "C".repeat(40) }), + ), + true, + ); +}); diff --git a/apps/core/src/providers/anthropic-oauth-login.test.ts b/apps/core/src/providers/anthropic-oauth-login.test.ts new file mode 100644 index 00000000..67a5bef4 --- /dev/null +++ b/apps/core/src/providers/anthropic-oauth-login.test.ts @@ -0,0 +1,249 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import * as crypto from "crypto"; +import { + ANTHROPIC_OAUTH_LOGIN, + buildAuthorizeUrl, + exchangeAnthropicCode, + generatePkce, + parseAuthCodeInput, + redirectUriForInput, + startCallbackServer, +} from "./anthropic-oauth-login.js"; +import { ANTHROPIC_OAUTH } from "./anthropic-oauth.js"; +import { readAnthropicOAuth } from "./auth-store.js"; + +function tmpAuthFile(): string { + return path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "freecode-oauth-login-")), + "auth.json", + ); +} + +/** A fetch stub standing in for the token endpoint. */ +function tokenEndpoint( + respond: (body: Record) => { + status: number; + body: unknown; + }, +): { fetchImpl: typeof fetch; calls: Record[] } { + const calls: Record[] = []; + const fetchImpl = (async (_url: unknown, init: RequestInit) => { + const body = JSON.parse(String(init.body)) as Record; + calls.push(body); + const { status, body: out } = respond(body); + const text = typeof out === "string" ? out : JSON.stringify(out); + return new Response(text, { + status, + headers: { "content-type": "application/json" }, + }); + }) as unknown as typeof fetch; + return { fetchImpl, calls }; +} + +const OK_TOKENS = { + access_token: "at-new", + refresh_token: "rt-new", + expires_in: 3600, + scope: "user:profile user:inference", +}; + +test("PKCE verifier is 64 alphanumerics and the challenge is its sha256", () => { + const { verifier, challenge } = generatePkce(); + assert.equal(verifier.length, 64); + assert.match(verifier, /^[A-Za-z0-9]{64}$/); + assert.equal( + challenge, + crypto.createHash("sha256").update(verifier).digest("base64url"), + ); + assert.ok(!challenge.includes("="), "challenge must be unpadded base64url"); + assert.notEqual(generatePkce().verifier, verifier); +}); + +test("authorize URL targets the claude.ai surface with state bound to the verifier", () => { + const { verifier, challenge } = generatePkce(); + const url = new URL( + buildAuthorizeUrl("http://localhost:9999/callback", challenge, verifier), + ); + assert.equal( + `${url.origin}${url.pathname}`, + ANTHROPIC_OAUTH_LOGIN.authorizeUrl, + // The console surface mints tokens the inference API refuses (spec §3.1). + ); + assert.equal(url.searchParams.get("client_id"), ANTHROPIC_OAUTH.clientId); + assert.equal(url.searchParams.get("code_challenge"), challenge); + assert.equal(url.searchParams.get("code_challenge_method"), "S256"); + assert.equal(url.searchParams.get("state"), verifier); + assert.equal(url.searchParams.get("code"), "true"); + assert.equal( + url.searchParams.get("redirect_uri"), + "http://localhost:9999/callback", + ); + assert.ok(url.searchParams.get("scope")?.includes("user:inference")); +}); + +test("parseAuthCodeInput accepts a bare code, a callback URL, a query string, and code#state", () => { + assert.deepEqual(parseAuthCodeInput(" abc123 "), { + code: "abc123", + state: undefined, + }); + assert.deepEqual( + parseAuthCodeInput("https://platform.claude.com/oauth/code/callback?code=abc&state=xyz"), + { code: "abc", state: "xyz" }, + ); + assert.deepEqual(parseAuthCodeInput("code=abc&state=xyz"), { + code: "abc", + state: "xyz", + }); + assert.deepEqual(parseAuthCodeInput("abc#xyz"), { code: "abc", state: "xyz" }); + assert.throws(() => parseAuthCodeInput(" "), /No authorization code/); +}); + +test("redirectUriForInput picks the manual URI only for a manual callback paste", () => { + const local = "http://localhost:1234/callback"; + assert.equal( + redirectUriForInput( + "https://platform.claude.com/oauth/code/callback?code=abc", + local, + ), + ANTHROPIC_OAUTH_LOGIN.manualRedirectUri, + ); + assert.equal( + redirectUriForInput( + "https://console.anthropic.com/oauth/code/callback?code=abc", + local, + ), + ANTHROPIC_OAUTH_LOGIN.manualRedirectUri, + ); + assert.equal(redirectUriForInput("plain-code", local), local); + assert.equal( + redirectUriForInput("http://localhost:1234/callback?code=abc", local), + local, + ); +}); + +test("exchange posts JSON with the verifier and persists the tokens", async () => { + const authFile = tmpAuthFile(); + const { fetchImpl, calls } = tokenEndpoint(() => ({ + status: 200, + body: OK_TOKENS, + })); + + const tokens = await exchangeAnthropicCode({ + verifier: "v".repeat(64), + input: "the-code", + redirectUri: "http://localhost:1234/callback", + authFile, + fetchImpl, + }); + + assert.deepEqual(calls[0], { + grant_type: "authorization_code", + code: "the-code", + redirect_uri: "http://localhost:1234/callback", + client_id: ANTHROPIC_OAUTH.clientId, + code_verifier: "v".repeat(64), + state: "v".repeat(64), + }); + assert.equal(tokens.access_token, "at-new"); + assert.deepEqual(readAnthropicOAuth(authFile), tokens); + assert.ok(tokens.expires_at > Date.now()); +}); + +test("exchange aborts on a state that is not the verifier, without calling the endpoint", async () => { + const authFile = tmpAuthFile(); + const { fetchImpl, calls } = tokenEndpoint(() => ({ + status: 200, + body: OK_TOKENS, + })); + await assert.rejects( + exchangeAnthropicCode({ + verifier: "v".repeat(64), + input: "the-code#someone-elses-state", + redirectUri: "http://localhost:1234/callback", + authFile, + fetchImpl, + }), + /state mismatch/, + ); + assert.equal(calls.length, 0); + assert.equal(readAnthropicOAuth(authFile), undefined); +}); + +test("exchange refuses a token without an inference scope and stores nothing", async () => { + const authFile = tmpAuthFile(); + const { fetchImpl } = tokenEndpoint(() => ({ + status: 200, + body: { ...OK_TOKENS, scope: "user:profile org:create_api_key" }, + })); + await assert.rejects( + exchangeAnthropicCode({ + verifier: "v".repeat(64), + input: "the-code", + redirectUri: "http://localhost:1234/callback", + authFile, + fetchImpl, + }), + /without an inference scope/, + ); + assert.equal(readAnthropicOAuth(authFile), undefined); +}); + +test("a Cloudflare 403 is diagnosed rather than reported as a raw HTTP error", async () => { + const { fetchImpl } = tokenEndpoint(() => ({ + status: 403, + body: "Just a moment.../cdn-cgi/challenge-platform", + })); + await assert.rejects( + exchangeAnthropicCode({ + verifier: "v".repeat(64), + input: "the-code", + redirectUri: "http://localhost:1234/callback", + authFile: tmpAuthFile(), + fetchImpl, + }), + /blocked by Cloudflare/, + ); +}); + +test("callback server returns the code only for the expected state", async () => { + const server = await startCallbackServer(); + assert.ok(server, "callback server should bind an ephemeral port"); + try { + const pending = server.waitForCode("expected-state", 5_000); + + // A mismatched state is rejected and the listener keeps waiting. + const bad = await fetch( + `http://localhost:${server.port}/callback?code=nope&state=wrong`, + ); + assert.equal(bad.status, 400); + + const good = await fetch( + `http://localhost:${server.port}/callback?code=good-code&state=expected-state`, + ); + assert.equal(good.status, 200); + assert.equal(await pending, "good-code"); + } finally { + server.close(); + } +}); + +test("callback server surfaces a denied authorization", async () => { + const server = await startCallbackServer(); + assert.ok(server); + try { + // Assert on the rejection before triggering it: the callback rejects + // synchronously with the request, and an unattached rejection is a crash. + const rejects = assert.rejects( + server.waitForCode("expected-state", 5_000), + /access_denied/, + ); + await fetch(`http://localhost:${server.port}/callback?error=access_denied`); + await rejects; + } finally { + server.close(); + } +}); diff --git a/apps/core/src/providers/anthropic-oauth-login.ts b/apps/core/src/providers/anthropic-oauth-login.ts new file mode 100644 index 00000000..780ba01c --- /dev/null +++ b/apps/core/src/providers/anthropic-oauth-login.ts @@ -0,0 +1,294 @@ +// ============================================================================= +// Anthropic OAuth login (PKCE) — Phase 1 of +// `docs/superpowers/specs/2026-09-05-anthropic-oauth-provider.md`. +// +// Phase 0 borrowed the official Claude Code CLI's login; this module mints our +// own, so freecode works on a machine that never ran Claude Code. Protocol only +// — no printing, no prompting: the CLI (`cli/commands/auth.ts`) owns the user +// interaction, this file owns the wire format. +// +// Read §0.1 before touching it: the whole flow impersonates Claude Code, and +// that impersonation stays quarantined to the OAuth path. +// ============================================================================= + +import * as crypto from "crypto"; +import * as http from "http"; +import { AddressInfo } from "net"; +import { + ANTHROPIC_OAUTH, + CLOUDFLARE_CHALLENGE_MESSAGE, + ensureInferenceScope, + looksLikeCloudflareChallenge, + type AnthropicOAuthOptions, +} from "./anthropic-oauth.js"; +import { + AUTH_FILE, + type StoredAnthropicOAuth, + saveAnthropicOAuth, +} from "./auth-store.js"; + +export const ANTHROPIC_OAUTH_LOGIN = { + /** + * The claude.ai surface — NOT `platform.claude.com/oauth/authorize`. The + * console endpoint mints tokens that refresh fine but are refused at + * inference time (spec §3.1); jcode learned this the hard way. + */ + authorizeUrl: "https://claude.com/cai/oauth/authorize", + manualRedirectUri: "https://platform.claude.com/oauth/code/callback", + /** Older Claude Code builds redirected here; still accepted on paste. */ + legacyRedirectUri: "https://console.anthropic.com/oauth/code/callback", + scopes: + "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload", +} as const; + +const PKCE_CHARSET = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + +export interface Pkce { + verifier: string; + challenge: string; +} + +/** 64 alphanumeric chars; challenge is unpadded base64url(sha256(verifier)). */ +export function generatePkce(): Pkce { + const bytes = crypto.randomBytes(64); + let verifier = ""; + for (const byte of bytes) verifier += PKCE_CHARSET[byte % PKCE_CHARSET.length]; + const challenge = crypto + .createHash("sha256") + .update(verifier) + .digest("base64url"); + return { verifier, challenge }; +} + +/** + * `state` is the verifier itself (jcode's convention): Anthropic's token + * endpoint requires a `state`, and reusing the verifier binds it to the PKCE + * secret without a second piece of state to carry around. + */ +export function buildAuthorizeUrl( + redirectUri: string, + challenge: string, + state: string, +): string { + const q = new URLSearchParams({ + code: "true", + client_id: ANTHROPIC_OAUTH.clientId, + response_type: "code", + redirect_uri: redirectUri, + scope: ANTHROPIC_OAUTH_LOGIN.scopes, + code_challenge: challenge, + code_challenge_method: "S256", + state, + }); + return `${ANTHROPIC_OAUTH_LOGIN.authorizeUrl}?${q.toString()}`; +} + +export interface ParsedAuthCode { + code: string; + state?: string; +} + +/** + * Accepts what a user can plausibly paste: a bare code, a full callback URL or + * query string carrying `code=`, or OpenCode-style `code#state`. + */ +export function parseAuthCodeInput(input: string): ParsedAuthCode { + const trimmed = input.trim(); + if (!trimmed) throw new Error("No authorization code provided."); + + let raw = trimmed; + let state: string | undefined; + if (trimmed.includes("code=")) { + let url: URL; + try { + url = new URL(trimmed); + } catch { + url = new URL(`https://example.com?${trimmed.replace(/^\?/, "")}`); + } + const code = url.searchParams.get("code"); + if (!code) throw new Error("No authorization code found in that URL."); + raw = code; + state = url.searchParams.get("state") ?? undefined; + } + + // A `code#state` pair wins over any `state` query param — it is the fragment + // the authorize page actually handed the user. + const hash = raw.indexOf("#"); + if (hash !== -1) { + state = raw.slice(hash + 1); + raw = raw.slice(0, hash); + } + if (!raw.trim()) throw new Error("No authorization code provided."); + return { code: raw.trim(), state: state?.trim() || undefined }; +} + +/** + * Which redirect_uri the exchange must claim. The token endpoint matches it + * against the one the code was minted for: a pasted manual-callback URL means + * the manual URI, anything else means the localhost callback we served. + */ +export function redirectUriForInput(input: string, fallback: string): string { + let url: URL; + try { + url = new URL(input.trim()); + } catch { + return fallback; + } + const manual = [ + ANTHROPIC_OAUTH_LOGIN.manualRedirectUri, + ANTHROPIC_OAUTH_LOGIN.legacyRedirectUri, + ].some((candidate) => { + const expected = new URL(candidate); + return url.origin === expected.origin && url.pathname === expected.pathname; + }); + return manual ? ANTHROPIC_OAUTH_LOGIN.manualRedirectUri : fallback; +} + +export interface ExchangeOptions extends AnthropicOAuthOptions { + verifier: string; + /** Raw user/callback input: code, callback URL, or `code#state`. */ + input: string; + redirectUri: string; +} + +/** + * Trade an authorization code for tokens and persist them (spec §3.2 step 4). + * JSON body, not form-encoded — Anthropic's endpoint wants JSON. + */ +export async function exchangeAnthropicCode( + opts: ExchangeOptions, +): Promise { + const { code, state: callbackState } = parseAuthCodeInput(opts.input); + if (callbackState && callbackState !== opts.verifier) { + throw new Error( + "OAuth state mismatch. Start the login again and use the newest callback URL or code.", + ); + } + + const fetchImpl = opts.fetchImpl ?? fetch; + const resp = await fetchImpl(opts.tokenUrl ?? ANTHROPIC_OAUTH.tokenUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + grant_type: "authorization_code", + code, + redirect_uri: opts.redirectUri, + client_id: ANTHROPIC_OAUTH.clientId, + code_verifier: opts.verifier, + state: opts.verifier, + }), + }); + + if (!resp.ok) { + const text = await resp.text(); + if (resp.status === 403 && looksLikeCloudflareChallenge(text)) { + throw new Error(CLOUDFLARE_CHALLENGE_MESSAGE); + } + throw new Error(`Token exchange failed (HTTP ${resp.status}): ${text}`); + } + + const body = (await resp.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + scope?: string; + }; + const scopes = body.scope ? body.scope.split(/\s+/).filter(Boolean) : []; + ensureInferenceScope(scopes, "token exchange"); + + const tokens: StoredAnthropicOAuth = { + type: "oauth", + access_token: body.access_token, + refresh_token: body.refresh_token, + expires_at: Date.now() + body.expires_in * 1000, + scopes, + }; + saveAnthropicOAuth(tokens, opts.authFile ?? AUTH_FILE); + return tokens; +} + +export interface CallbackServer { + port: number; + redirectUri: string; + /** Resolves with the raw code once the browser hits /callback. */ + waitForCode(expectedState: string, timeoutMs: number): Promise; + close(): void; +} + +function respond(res: http.ServerResponse, status: number, body: string): void { + res.writeHead(status, { + "content-type": "text/html", + "content-length": Buffer.byteLength(body), + connection: "close", + }); + res.end(body); +} + +/** + * Localhost callback listener on an ephemeral port. Returns undefined when the + * port cannot be bound (locked-down machine, container) — the caller then falls + * back to the manual paste flow rather than failing the login. + */ +export async function startCallbackServer(): Promise { + const server = http.createServer(); + const bound = await new Promise((resolve) => { + server.once("error", () => resolve(false)); + server.listen(0, "127.0.0.1", () => resolve(true)); + }); + if (!bound) return undefined; + + const port = (server.address() as AddressInfo).port; + return { + port, + redirectUri: `http://localhost:${port}/callback`, + close: () => server.close(), + waitForCode(expectedState, timeoutMs) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + server.removeListener("request", onRequest); + reject(new Error("Timed out waiting for the OAuth callback.")); + }, timeoutMs); + + function finish(err: Error | undefined, code?: string): void { + clearTimeout(timer); + server.removeListener("request", onRequest); + if (err) reject(err); + else resolve(code!); + } + + function onRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + ): void { + const url = new URL(req.url ?? "/", `http://localhost:${port}`); + const error = url.searchParams.get("error"); + if (error) { + respond(res, 400, "

Login cancelled

"); + finish(new Error(`Anthropic returned an OAuth error: ${error}`)); + return; + } + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + if (!code || !state) { + // Favicon and stray probes land here; keep listening. + respond(res, 400, "

Missing code or state

"); + return; + } + if (state !== expectedState) { + respond(res, 400, "

OAuth state mismatch

"); + return; + } + respond( + res, + 200, + "

Signed in

You can close this window and return to freecode.

", + ); + finish(undefined, code); + } + + server.on("request", onRequest); + }); + }, + }; +} diff --git a/apps/core/src/providers/anthropic-oauth.test.ts b/apps/core/src/providers/anthropic-oauth.test.ts new file mode 100644 index 00000000..e6d3586f --- /dev/null +++ b/apps/core/src/providers/anthropic-oauth.test.ts @@ -0,0 +1,565 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import * as http from "http"; +import { + ANTHROPIC_OAUTH, + CLAUDE_CODE_BILLING_BLOCK, + CLAUDE_CODE_IDENTITY, + anthropicOAuthForbidden, + isOAuthForbiddenBody, + markAnthropicOAuthForbidden, + createAnthropicOAuthFetch, + importClaudeCodeCredentials, + refreshAnthropicTokens, + getAnthropicAccessToken, + resetAnthropicOAuthState, + withClaudeCodeIdentity, +} from "./anthropic-oauth.js"; +import { readAnthropicOAuth } from "./auth-store.js"; +import { buildGenerateOptions } from "./generic-provider.js"; +import { resolveCatalogue } from "./catalogue.js"; +import { priceUsd, totalUsd } from "./pricing.js"; + +function tmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "freecode-oauth-test-")); +} + +function writeClaudeCreds( + dir: string, + overrides: Record = {}, +): string { + const file = path.join(dir, ".credentials.json"); + fs.writeFileSync( + file, + JSON.stringify({ + claudeAiOauth: { + accessToken: "at-imported", + refreshToken: "rt-imported", + expiresAt: Date.now() + 3_600_000, + scopes: ["user:profile", "user:inference"], + subscriptionType: "max", + ...overrides, + }, + }), + ); + return file; +} + +/** + * A one-endpoint token server. `respond` sees each parsed request body and + * returns { status, body }; every call is recorded for assertions. + */ +async function tokenServer( + respond: ( + body: Record, + callIndex: number, + ) => { status: number; body: unknown }, +): Promise<{ + url: string; + calls: Array>; + close: () => Promise; +}> { + const calls: Array> = []; + const server = http.createServer((req, res) => { + let data = ""; + req.on("data", (chunk) => (data += chunk)); + req.on("end", () => { + const body = JSON.parse(data) as Record; + calls.push(body); + const { status, body: responseBody } = respond(body, calls.length - 1); + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(responseBody)); + }); + }); + await new Promise((resolve) => server.listen(0, resolve)); + const addr = server.address() as { port: number }; + return { + url: `http://127.0.0.1:${addr.port}/v1/oauth/token`, + calls, + close: () => + new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())), + ), + }; +} + +const freshTokenResponse = { + access_token: "at-fresh", + refresh_token: "rt-rotated", + expires_in: 3600, + scope: "user:profile user:inference", +}; + +// --------------------------------------------------------------------------- +// Import +// --------------------------------------------------------------------------- + +test("import: Claude Code credentials land in the auth store, file at 0600", () => { + const dir = tmpDir(); + const authFile = path.join(dir, "auth.json"); + const stored = importClaudeCodeCredentials(writeClaudeCreds(dir), authFile); + + assert.equal(stored?.access_token, "at-imported"); + assert.equal(stored?.refresh_token, "rt-imported"); + assert.deepEqual(readAnthropicOAuth(authFile), stored); + if (process.platform !== "win32") { + assert.equal(fs.statSync(authFile).mode & 0o777, 0o600); + } +}); + +test("import: absent file is undefined, malformed file is a clear error", () => { + const dir = tmpDir(); + const authFile = path.join(dir, "auth.json"); + assert.equal( + importClaudeCodeCredentials(path.join(dir, "nope.json"), authFile), + undefined, + ); + + const malformed = path.join(dir, "malformed.json"); + fs.writeFileSync(malformed, JSON.stringify({ claudeAiOauth: { nope: 1 } })); + assert.throws( + () => importClaudeCodeCredentials(malformed, authFile), + /no usable claudeAiOauth entry/, + ); +}); + +test("import: a token without an inference scope is refused, not stored", () => { + const dir = tmpDir(); + const authFile = path.join(dir, "auth.json"); + const creds = writeClaudeCreds(dir, { scopes: ["user:profile"] }); + assert.throws( + () => importClaudeCodeCredentials(creds, authFile), + /without an inference scope/, + ); + assert.equal(readAnthropicOAuth(authFile), undefined); +}); + +// --------------------------------------------------------------------------- +// Refresh +// --------------------------------------------------------------------------- + +test("refresh: rotates the refresh token and persists it", async () => { + resetAnthropicOAuthState(); + const dir = tmpDir(); + const authFile = path.join(dir, "auth.json"); + importClaudeCodeCredentials(writeClaudeCreds(dir), authFile); + + const server = await tokenServer(() => ({ + status: 200, + body: freshTokenResponse, + })); + try { + const next = await refreshAnthropicTokens("rt-imported", { + tokenUrl: server.url, + authFile, + }); + assert.equal(next.access_token, "at-fresh"); + assert.equal(next.refresh_token, "rt-rotated"); + assert.equal(readAnthropicOAuth(authFile)?.refresh_token, "rt-rotated"); + + // Request shape: JSON body with Claude Code's client id and scopes. + assert.equal(server.calls.length, 1); + assert.equal(server.calls[0].grant_type, "refresh_token"); + assert.equal(server.calls[0].client_id, ANTHROPIC_OAUTH.clientId); + assert.equal(server.calls[0].scope, ANTHROPIC_OAUTH.refreshScopes); + } finally { + await server.close(); + } +}); + +test("refresh: concurrent callers share one network call", async () => { + resetAnthropicOAuthState(); + const dir = tmpDir(); + const authFile = path.join(dir, "auth.json"); + importClaudeCodeCredentials(writeClaudeCreds(dir), authFile); + + const server = await tokenServer(() => ({ + status: 200, + body: freshTokenResponse, + })); + try { + const [a, b] = await Promise.all([ + refreshAnthropicTokens("rt-imported", { tokenUrl: server.url, authFile }), + refreshAnthropicTokens("rt-imported", { tokenUrl: server.url, authFile }), + ]); + assert.equal(server.calls.length, 1); + assert.equal(a.access_token, b.access_token); + } finally { + await server.close(); + } +}); + +test("refresh: a fresher stored token wins without a network call (rotation guard)", async () => { + resetAnthropicOAuthState(); + const dir = tmpDir(); + const authFile = path.join(dir, "auth.json"); + importClaudeCodeCredentials(writeClaudeCreds(dir), authFile); + + const server = await tokenServer(() => { + throw new Error("must not be called"); + }); + try { + // Caller observed a stale token; the store already has a fresh one. + const result = await refreshAnthropicTokens("rt-stale-observation", { + tokenUrl: server.url, + authFile, + }); + assert.equal(result.refresh_token, "rt-imported"); + assert.equal(server.calls.length, 0); + } finally { + await server.close(); + } +}); + +test("refresh: invalid_scope falls back to a scopeless retry", async () => { + resetAnthropicOAuthState(); + const dir = tmpDir(); + const authFile = path.join(dir, "auth.json"); + importClaudeCodeCredentials(writeClaudeCreds(dir), authFile); + + const server = await tokenServer((_body, i) => + i === 0 + ? { status: 400, body: { error: "invalid_scope" } } + : { status: 200, body: freshTokenResponse }, + ); + try { + const next = await refreshAnthropicTokens("rt-imported", { + tokenUrl: server.url, + authFile, + }); + assert.equal(next.access_token, "at-fresh"); + assert.equal(server.calls.length, 2); + assert.equal("scope" in server.calls[1], false); + } finally { + await server.close(); + } +}); + +test("refresh: a permanent rejection is terminal — no second round-trip", async () => { + resetAnthropicOAuthState(); + const dir = tmpDir(); + const authFile = path.join(dir, "auth.json"); + importClaudeCodeCredentials(writeClaudeCreds(dir), authFile); + + const server = await tokenServer(() => ({ + status: 401, + body: { error: "invalid_grant" }, + })); + try { + await assert.rejects( + refreshAnthropicTokens("rt-imported", { tokenUrl: server.url, authFile }), + /refresh failed \(HTTP 401\)/, + ); + await assert.rejects( + refreshAnthropicTokens("rt-imported", { tokenUrl: server.url, authFile }), + /refresh failed \(HTTP 401\)/, + ); + assert.equal(server.calls.length, 1); + } finally { + await server.close(); + } +}); + +test("refresh: a token that comes back without an inference scope is refused", async () => { + resetAnthropicOAuthState(); + const dir = tmpDir(); + const authFile = path.join(dir, "auth.json"); + importClaudeCodeCredentials(writeClaudeCreds(dir), authFile); + + const server = await tokenServer(() => ({ + status: 200, + body: { ...freshTokenResponse, scope: "user:profile" }, + })); + try { + await assert.rejects( + refreshAnthropicTokens("rt-imported", { tokenUrl: server.url, authFile }), + /without an inference scope/, + ); + } finally { + await server.close(); + } +}); + +test("getAnthropicAccessToken: fresh token is returned as-is, near-expiry token is refreshed", async () => { + resetAnthropicOAuthState(); + const dir = tmpDir(); + const authFile = path.join(dir, "auth.json"); + importClaudeCodeCredentials(writeClaudeCreds(dir), authFile); + assert.equal(await getAnthropicAccessToken({ authFile }), "at-imported"); + + // Second store: expires inside the refresh margin. + const dir2 = tmpDir(); + const authFile2 = path.join(dir2, "auth.json"); + importClaudeCodeCredentials( + writeClaudeCreds(dir2, { expiresAt: Date.now() + 60_000 }), + authFile2, + ); + const server = await tokenServer(() => ({ + status: 200, + body: freshTokenResponse, + })); + try { + assert.equal( + await getAnthropicAccessToken({ tokenUrl: server.url, authFile: authFile2 }), + "at-fresh", + ); + } finally { + await server.close(); + } +}); + +// --------------------------------------------------------------------------- +// Request rewriting +// --------------------------------------------------------------------------- + +test("oauth fetch: bearer replaces x-api-key, betas merge, Claude Code UA set", async () => { + let seen: Headers | undefined; + const oauthFetch = createAnthropicOAuthFetch( + async (_input, init) => { + seen = new Headers(init?.headers); + return new Response("{}"); + }, + async () => "at-test", + ); + + await oauthFetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "x-api-key": "oauth-subscription", + "anthropic-beta": "prompt-caching-2024-07-31,oauth-2025-04-20", + }, + }); + + assert.ok(seen); + assert.equal(seen.get("x-api-key"), null); + assert.equal(seen.get("authorization"), "Bearer at-test"); + assert.equal(seen.get("user-agent"), ANTHROPIC_OAUTH.userAgent); + // Ours lead, the SDK's own betas survive, duplicates collapse. + assert.equal( + seen.get("anthropic-beta"), + "claude-code-20250219,oauth-2025-04-20,prompt-caching-2024-07-31", + ); +}); + +// --------------------------------------------------------------------------- +// Identity block — the §0.1 invariant +// --------------------------------------------------------------------------- + +test("withClaudeCodeIdentity: identity leads, caller's blocks and cache flags survive", () => { + const lead = [ + { text: CLAUDE_CODE_BILLING_BLOCK }, + { text: CLAUDE_CODE_IDENTITY }, + ]; + assert.deepEqual(withClaudeCodeIdentity(undefined), lead); + assert.deepEqual(withClaudeCodeIdentity("be helpful"), [ + ...lead, + { text: "be helpful" }, + ]); + assert.deepEqual(withClaudeCodeIdentity([{ text: "be helpful", cache: true }]), [ + ...lead, + { text: "be helpful", cache: true }, + ]); +}); + +const anthropicEntry = resolveCatalogue().find((e) => e.id === "anthropic")!; + +test("OAuth mode: the system param leads with the identity block, even with no system prompt", () => { + process.env.FREECODE_ANTHROPIC_AUTH = "oauth"; + try { + const opts = buildGenerateOptions(anthropicEntry, {}, { + system: "be helpful", + prompt: "hi", + }); + assert.equal(opts.system[0].content, CLAUDE_CODE_BILLING_BLOCK); + assert.equal(opts.system[1].content, CLAUDE_CODE_IDENTITY); + assert.equal(opts.system[2].content, "be helpful"); + + const bare = buildGenerateOptions(anthropicEntry, {}, { prompt: "hi" }); + assert.equal(bare.system[0].content, CLAUDE_CODE_BILLING_BLOCK); + assert.equal(bare.system[1].content, CLAUDE_CODE_IDENTITY); + } finally { + delete process.env.FREECODE_ANTHROPIC_AUTH; + } +}); + +test("API-key mode: the request never carries the identity block (spec §0.1)", () => { + process.env.FREECODE_ANTHROPIC_AUTH = "api-key"; + try { + const opts = buildGenerateOptions(anthropicEntry, {}, { + system: "be helpful", + prompt: "hi", + }); + const wire = JSON.stringify(opts); + assert.equal(wire.includes(CLAUDE_CODE_IDENTITY), false); + // The billing attribution is impersonation too: a metered API-key request + // must not claim to be Claude Code usage. + assert.equal(wire.includes("x-anthropic-billing-header"), false); + } finally { + delete process.env.FREECODE_ANTHROPIC_AUTH; + } +}); + +// --------------------------------------------------------------------------- +// Cost +// --------------------------------------------------------------------------- + +test("a subscription call prices as undefined, never $0 (spec §5)", () => { + const usage = { inputTokens: 1_000_000, outputTokens: 1000 }; + assert.equal( + priceUsd("anthropic", "claude-sonnet-4-5", usage, "oauth"), + undefined, + ); + assert.notEqual( + priceUsd("anthropic", "claude-sonnet-4-5", usage, "api-key"), + undefined, + ); + // The stamp is per-call, so it is provider-agnostic by design; nothing + // stamps a metered provider, and an unstamped call prices normally. + assert.notEqual(priceUsd("openai", "gpt-4o", usage), undefined); +}); + +test("cost is a property of the recorded call, not of the reader's config", () => { + // Pricing used to read the live auth mode, so logging in repriced every + // historical API-key session as "subscription" — and the price of a span + // depended on which machine folded the log. + const usage = { inputTokens: 1_000_000, outputTokens: 1000 }; + for (const mode of ["oauth", "api-key"]) { + process.env.FREECODE_ANTHROPIC_AUTH = mode; + try { + assert.notEqual( + priceUsd("anthropic", "claude-sonnet-4-5", usage), + undefined, + `an unstamped call must price normally regardless of config (${mode})`, + ); + } finally { + delete process.env.FREECODE_ANTHROPIC_AUTH; + } + } +}); + +test("a mixed session totals only the API-key calls, and says it is partial", () => { + const usage = { inputTokens: 1_000_000, outputTokens: 1000 }; + const total = totalUsd([ + { provider: "anthropic", model: "claude-sonnet-4-5", ...usage }, + { + provider: "anthropic", + model: "claude-sonnet-4-5", + authMode: "oauth" as const, + ...usage, + }, + ]); + assert.ok(total); + assert.equal(total.partial, true); + assert.equal( + total.usd, + priceUsd("anthropic", "claude-sonnet-4-5", usage, "api-key"), + ); +}); + +// --------------------------------------------------------------------------- +// Phase 2 — hardening: Cloudflare diagnosis and the org-forbidden fallback +// --------------------------------------------------------------------------- + +test("refresh: a Cloudflare challenge is diagnosed and NOT marked terminal", async () => { + resetAnthropicOAuthState(); + const dir = tmpDir(); + const authFile = path.join(dir, "auth.json"); + importClaudeCodeCredentials(writeClaudeCreds(dir), authFile); + + const server = await tokenServer((_body, callIndex) => + callIndex === 0 + ? { + status: 403, + body: "Just a moment.../cdn-cgi/challenge-platform", + } + : { status: 200, body: freshTokenResponse }, + ); + try { + await assert.rejects( + refreshAnthropicTokens("rt-imported", { tokenUrl: server.url, authFile }), + /blocked by Cloudflare/, + ); + // The token is fine — the network was not. A retry must still go out, + // which is exactly what the terminal-rejection latch would have prevented. + const tokens = await refreshAnthropicTokens("rt-imported", { + tokenUrl: server.url, + authFile, + }); + assert.equal(tokens.access_token, "at-fresh"); + assert.equal(server.calls.length, 2); + } finally { + await server.close(); + } +}); + +test("forbidden: only a 403 carrying Anthropic's OAuth refusal matches", () => { + assert.equal( + isOAuthForbiddenBody( + 403, + '{"type":"error","error":{"type":"permission_error","message":"OAuth authentication is currently not allowed for this organization."}}', + ), + true, + ); + assert.equal(isOAuthForbiddenBody(403, '{"error":{"type":"permission_error"}}'), true); + assert.equal(isOAuthForbiddenBody(429, "rate limited"), false); + assert.equal(isOAuthForbiddenBody(403, '{"error":{"type":"not_found_error"}}'), false); + assert.equal(isOAuthForbiddenBody(200, "permission_error"), false); +}); + +test("forbidden: the oauth fetch latches the 403 and leaves the body readable", async () => { + resetAnthropicOAuthState(); + const forbidden = + '{"type":"error","error":{"type":"permission_error","message":"OAuth authentication is currently not allowed for this organization."}}'; + const oauthFetch = createAnthropicOAuthFetch( + async () => new Response(forbidden, { status: 403 }), + async () => "at-test", + ); + + assert.equal(anthropicOAuthForbidden(), undefined); + const resp = await oauthFetch("https://api.anthropic.com/v1/messages"); + // The SDK still has to be able to read the body it was handed. + assert.equal(await resp.text(), forbidden); + assert.match(anthropicOAuthForbidden() ?? "", /not allowed for this organization/); + resetAnthropicOAuthState(); +}); + +test("billing attribution leads the OAuth system param, before the identity", () => { + // The official CLI's order (jcode's `build_system_param_split`): billing + // attribution, identity, then the real prompt. + const blocks = withClaudeCodeIdentity([{ text: "real prompt", cache: true }]); + assert.equal(blocks[0].text, CLAUDE_CODE_BILLING_BLOCK); + assert.equal(blocks[1].text, CLAUDE_CODE_IDENTITY); + assert.equal(blocks[2].text, "real prompt"); + // Neither prepended block may carry a cache marker: Anthropic caches up to + // the marked block, so a marker here would cut the caller's prefix short. + assert.equal(blocks[0].cache, undefined); + assert.equal(blocks[1].cache, undefined); + assert.equal(blocks[2].cache, true); + assert.equal(ANTHROPIC_OAUTH.userAgent.includes("2.1.257"), true); + assert.equal(CLAUDE_CODE_BILLING_BLOCK.includes("cc_version=2.1.257"), true); +}); + +test("forbidden: a latched 403 drops the identity block, keeping the §0.1 invariant", () => { + resetAnthropicOAuthState(); + process.env.FREECODE_ANTHROPIC_AUTH = "oauth"; + try { + const before = buildGenerateOptions(anthropicEntry, {}, { prompt: "hi" }); + assert.equal(before.system[1].content, CLAUDE_CODE_IDENTITY); + + markAnthropicOAuthForbidden("org refuses OAuth"); + + // The fallback request goes out on the API key, so it must carry none of + // the Claude Code impersonation. + const after = JSON.stringify( + buildGenerateOptions(anthropicEntry, {}, { prompt: "hi" }), + ); + assert.equal(after.includes(CLAUDE_CODE_IDENTITY), false); + assert.equal(after.includes("x-anthropic-billing-header"), false); + } finally { + delete process.env.FREECODE_ANTHROPIC_AUTH; + resetAnthropicOAuthState(); + } +}); diff --git a/apps/core/src/providers/anthropic-oauth.ts b/apps/core/src/providers/anthropic-oauth.ts new file mode 100644 index 00000000..f3b87046 --- /dev/null +++ b/apps/core/src/providers/anthropic-oauth.ts @@ -0,0 +1,451 @@ +// ============================================================================= +// Anthropic OAuth (Claude Pro/Max subscription) — Phase 0. +// +// Spec: `docs/superpowers/specs/2026-09-05-anthropic-oauth-provider.md`. +// Read §0.1 before touching this file: the subscription endpoint only answers +// requests that look like Claude Code, so this module impersonates it — Claude +// Code's OAuth client id, its User-Agent, its beta headers, and its identity +// string as the first system block. That impersonation is quarantined to the +// OAuth path; a request authenticated with a real API key must never carry any +// of it (tested in `anthropic-oauth.test.ts`). +// +// Phase 0 ships no login flow. Credentials come from importing the official +// Claude Code CLI's own login (`~/.claude/.credentials.json`) and are kept +// fresh by the refresh path below. Phase 1 adds `freecode auth login`. +// ============================================================================= + +import * as fs from "fs"; +import type { SystemBlock } from "./types.js"; +import { + AUTH_FILE, + CLAUDE_CODE_CREDENTIALS_FILE, + type StoredAnthropicOAuth, + readAnthropicOAuth, + saveAnthropicOAuth, +} from "./auth-store.js"; + +/** + * Claude Code's OAuth surface. Constants copied from jcode + * (`crates/jcode-base/src/auth/oauth.rs`), which copied them from Claude Code. + * The version strings rot as Claude Code ships (spec §9 Q2) — if OAuth + * requests start failing, check these first. + */ +export const ANTHROPIC_OAUTH = { + clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e", + tokenUrl: "https://platform.claude.com/v1/oauth/token", + refreshScopes: + "user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload", + /** + * Start minimal (spec §3.4): just the two betas the endpoint requires. + * Extend only on observed rejection — jcode's full current list lives in + * its `jcode-provider-core/src/anthropic.rs` if that day comes. + */ + betas: "claude-code-20250219,oauth-2025-04-20", + userAgent: "claude-cli/2.1.257 (external, sdk-cli)", +} as const; + +export const CLAUDE_CODE_IDENTITY = + "You are Claude Code, Anthropic's official CLI for Claude."; + +/** + * Claude Code's billing-attribution line, which the official CLI carries as + * the FIRST system block on the subscription path (jcode: + * `jcode-provider-anthropic/src/lib.rs`, `OAUTH_BILLING_HEADER`, "observed in + * the official CLI's system prompt blocks"). Despite the `x-anthropic-` name + * it is a system block, not an HTTP header. + * + * `cc_version` matches `ANTHROPIC_OAUTH.userAgent` and rots with it — bump + * both together (spec §9 Q2). `cch` is an opaque Claude Code build hash, + * copied verbatim. + */ +export const CLAUDE_CODE_BILLING_BLOCK = + "x-anthropic-billing-header: cc_version=2.1.257; cc_entrypoint=sdk-cli; cch=33f85;"; + +/** + * Scopes the inference API accepts, per jcode's `claude_scopes_have_inference`. + * The console authorize surface mints tokens that refresh fine but carry none + * of these — storing one would 403 at request time, so exchange and refresh + * both fail loudly instead (spec §3.1). + */ +const INFERENCE_SCOPES = new Set([ + "user:inference", + "user:ccr_inference", + "user:voice", + "org:service_key_inference", + "workspace:developer", + "workspace:inference", +]); + +export function ensureInferenceScope(scopes: string[], action: string): void { + // Empty means the endpoint reported nothing, not that inference is missing. + if (scopes.length === 0 || scopes.some((s) => INFERENCE_SCOPES.has(s))) { + return; + } + throw new Error( + `Anthropic OAuth ${action} returned a token without an inference scope ` + + `(scopes: ${scopes.join(" ")}). Run \`freecode auth login anthropic\` to ` + + `mint a token from the claude.ai surface — the console surface issues ` + + `tokens the inference API refuses.`, + ); +} + +export interface AnthropicOAuthOptions { + tokenUrl?: string; + authFile?: string; + fetchImpl?: typeof fetch; +} + +/** + * Phase 0 credential source: the official Claude Code CLI's own login. + * + * Parses a file owned by another program, so it schema-guards every field and + * fails with a "re-login in Claude Code" message rather than a crash when the + * format drifts (spec §9 Q3). Returns undefined only when the file is absent — + * a present-but-unreadable file is an error worth surfacing. + */ +export function importClaudeCodeCredentials( + credentialsFile: string = CLAUDE_CODE_CREDENTIALS_FILE, + authFile: string = AUTH_FILE, +): StoredAnthropicOAuth | undefined { + if (!fs.existsSync(credentialsFile)) return undefined; + + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(credentialsFile, "utf-8")); + } catch { + throw new Error( + `${credentialsFile} is not valid JSON. Re-login in the official Claude Code CLI, then retry.`, + ); + } + const oauth = (parsed as { claudeAiOauth?: Record }) + .claudeAiOauth; + if ( + !oauth || + typeof oauth.accessToken !== "string" || + typeof oauth.refreshToken !== "string" || + typeof oauth.expiresAt !== "number" + ) { + throw new Error( + `${credentialsFile} has no usable claudeAiOauth entry (the format may have ` + + `changed). Re-login in the official Claude Code CLI, then retry.`, + ); + } + const scopes = Array.isArray(oauth.scopes) + ? oauth.scopes.filter((s): s is string => typeof s === "string") + : []; + ensureInferenceScope(scopes, "import"); + + const stored: StoredAnthropicOAuth = { + type: "oauth", + access_token: oauth.accessToken, + refresh_token: oauth.refreshToken, + expires_at: oauth.expiresAt, + scopes, + }; + saveAnthropicOAuth(stored, authFile); + return stored; +} + +// ----------------------------------------------------------------------------- +// Refresh. Anthropic ROTATES refresh tokens: two concurrent refreshes can +// persist a dead token and permanently break the login (spec §3.3). Hence: +// - single-flight per auth file — concurrent callers share one network call; +// - rotation guard — re-read the store first, and if someone else already +// refreshed (stored token differs, expiry fresh), use theirs, no network; +// - terminal rejections — a token the endpoint permanently rejected is +// remembered and never retried in this process. +// ----------------------------------------------------------------------------- + +const refreshFlights = new Map>(); +const terminalRejections = new Map(); + +/** Fresh enough that another process's refresh clearly just happened. */ +function expiryIsFresh(expiresAt: number): boolean { + return expiresAt - Date.now() > 60_000; +} + +/** + * Anthropic's token endpoint sits behind Cloudflare, which challenges some + * networks and IPs before the request ever reaches Anthropic. jcode carries a + * dedicated message for this because the raw 403 reads like a rejected login + * and sends users to re-authenticate forever. Scar tissue from enforcement, + * not theory (spec §0.1). + */ +export function looksLikeCloudflareChallenge(text: string): boolean { + const lower = text.toLowerCase(); + return ( + lower.includes("cf-challenge") || + lower.includes("cloudflare") || + lower.includes("just a moment") || + lower.includes("/cdn-cgi/challenge-platform") + ); +} + +export const CLOUDFLARE_CHALLENGE_MESSAGE = + "Anthropic's token endpoint was blocked by Cloudflare before it answered. " + + "This network or IP is being challenged, not your login — switch network " + + "(or VPN exit) and retry `freecode auth login anthropic --no-browser`, " + + "pasting the callback URL."; + +function isInvalidScopeError(body: string): boolean { + const lower = body.toLowerCase(); + return lower.includes("invalid_scope") || lower.includes("scope is invalid"); +} + +async function postRefresh( + refreshToken: string, + scope: string | undefined, + opts: AnthropicOAuthOptions, +): Promise { + const fetchImpl = opts.fetchImpl ?? fetch; + return fetchImpl(opts.tokenUrl ?? ANTHROPIC_OAUTH.tokenUrl, { + method: "POST", + // JSON, not form-encoded — Anthropic's token endpoint wants JSON. + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: ANTHROPIC_OAUTH.clientId, + ...(scope && { scope }), + }), + }); +} + +async function doRefresh( + observedRefreshToken: string, + opts: AnthropicOAuthOptions, +): Promise { + const authFile = opts.authFile ?? AUTH_FILE; + + // Rotation guard: prefer the newest stored refresh token over the caller's + // possibly stale observation, and skip the network entirely when the store + // shows a refresh already happened. + const stored = readAnthropicOAuth(authFile); + if ( + stored && + stored.refresh_token !== observedRefreshToken && + expiryIsFresh(stored.expires_at) + ) { + return stored; + } + const refreshToken = stored?.refresh_token || observedRefreshToken; + + const terminal = terminalRejections.get(refreshToken); + if (terminal) throw new Error(terminal); + + let resp = await postRefresh(refreshToken, ANTHROPIC_OAUTH.refreshScopes, opts); + if (!resp.ok) { + const text = await resp.text(); + if (isInvalidScopeError(text)) { + // Legacy tokens reject Claude Code's scope list; retry without one. + resp = await postRefresh(refreshToken, undefined, opts); + if (!resp.ok) { + const fallbackText = await resp.text(); + throw new Error( + `Anthropic OAuth token refresh failed with scopes (${text}) and without (${fallbackText}).`, + ); + } + } else if (resp.status === 403 && looksLikeCloudflareChallenge(text)) { + // Not a credential problem, so deliberately NOT marked terminal: the + // same token works from another network. + throw new Error(CLOUDFLARE_CHALLENGE_MESSAGE); + } else { + const message = + `Anthropic OAuth token refresh failed (HTTP ${resp.status}): ${text}. ` + + `Run \`freecode auth login anthropic\` to log in again.`; + if ([400, 401, 403].includes(resp.status)) { + // A permanently rejected token cannot start working again; remember it + // so every subsequent turn fails fast instead of re-paying a doomed + // round-trip. + terminalRejections.set(refreshToken, message); + } + throw new Error(message); + } + } + + const body = (await resp.json()) as { + access_token: string; + refresh_token?: string; + expires_in: number; + scope?: string; + }; + const scopes = body.scope + ? body.scope.split(/\s+/).filter(Boolean) + : (stored?.scopes ?? []); + ensureInferenceScope(scopes, "refresh"); + + const next: StoredAnthropicOAuth = { + type: "oauth", + access_token: body.access_token, + // The response may omit the rotated token; the old one stays valid then. + refresh_token: body.refresh_token || refreshToken, + expires_at: Date.now() + body.expires_in * 1000, + scopes, + }; + saveAnthropicOAuth(next, authFile); + return next; +} + +export function refreshAnthropicTokens( + observedRefreshToken: string, + opts: AnthropicOAuthOptions = {}, +): Promise { + const authFile = opts.authFile ?? AUTH_FILE; + const inFlight = refreshFlights.get(authFile); + if (inFlight) return inFlight; + const flight = doRefresh(observedRefreshToken, opts).finally(() => + refreshFlights.delete(authFile), + ); + refreshFlights.set(authFile, flight); + return flight; +} + +/** Refresh this far before expiry, so a token never dies mid-request. */ +const REFRESH_MARGIN_MS = 5 * 60_000; + +/** + * A valid access token: stored → imported from Claude Code → refreshed. + * The per-request entry point; `createAnthropicOAuthFetch` calls this. + */ +export async function getAnthropicAccessToken( + opts: AnthropicOAuthOptions = {}, +): Promise { + const authFile = opts.authFile ?? AUTH_FILE; + let stored = readAnthropicOAuth(authFile); + if (!stored) stored = importClaudeCodeCredentials(undefined, authFile); + if (!stored) { + throw new Error( + "No Anthropic OAuth credentials found. Run `freecode auth login " + + "anthropic` (or log in with the official Claude Code CLI, whose " + + "~/.claude/.credentials.json freecode imports), or set an API key and " + + 'providers.anthropic.authMode: "api-key".', + ); + } + if (stored.expires_at - Date.now() > REFRESH_MARGIN_MS) { + return stored.access_token; + } + const refreshed = await refreshAnthropicTokens(stored.refresh_token, opts); + return refreshed.access_token; +} + +function mergedBetas(existing: string | null): string { + if (!existing) return ANTHROPIC_OAUTH.betas; + const seen = new Set(); + const merged: string[] = []; + for (const beta of [ + ...ANTHROPIC_OAUTH.betas.split(","), + ...existing.split(","), + ]) { + const trimmed = beta.trim(); + if (trimmed && !seen.has(trimmed)) { + seen.add(trimmed); + merged.push(trimmed); + } + } + return merged.join(","); +} + +// ----------------------------------------------------------------------------- +// "OAuth authentication is currently not allowed for this organization." +// +// Some orgs (and some accounts Anthropic has actioned) accept the token but +// refuse it at `/v1/messages` with a 403. Retrying is pointless and the raw +// error reads like a bad login, so the first one latches for the rest of the +// process and `generic-provider.ts` falls back to the API key when one is +// configured (spec §6 Phase 2, jcode's `is_anthropic_oauth_forbidden`). +// +// Detection lives here, at the fetch, because this is the one place that sees +// the raw response body: by the time the AI SDK has wrapped it, the shape +// differs between the generateText and streamText paths. +// ----------------------------------------------------------------------------- + +let forbiddenReason: string | undefined; + +export function isOAuthForbiddenBody(status: number, body: string): boolean { + if (status !== 403) return false; + return ( + body.includes("OAuth authentication is currently not allowed") || + body.includes("permission_error") + ); +} + +export function markAnthropicOAuthForbidden(reason: string): void { + forbiddenReason ??= reason; +} + +/** The reason string once a 403 has latched, else undefined. */ +export function anthropicOAuthForbidden(): string | undefined { + return forbiddenReason; +} + +/** + * The OAuth request seam (spec §4): composed onto the timeout fetch instead of + * touching the SDK's `apiKey`, because per-request is where token refresh has + * to live anyway. Rewrites each request to what the subscription endpoint + * expects — bearer auth (the SDK's placeholder `x-api-key` deleted), the OAuth + * betas merged with whatever betas the SDK already set, Claude Code's + * User-Agent. + */ +export function createAnthropicOAuthFetch( + baseFetch: typeof fetch, + getToken: () => Promise = () => getAnthropicAccessToken(), +): typeof fetch { + return async function oauthFetch( + input: Parameters[0], + init?: Parameters[1], + ): Promise { + const token = await getToken(); + const headers = new Headers(init?.headers); + headers.delete("x-api-key"); + headers.set("authorization", `Bearer ${token}`); + headers.set("anthropic-beta", mergedBetas(headers.get("anthropic-beta"))); + headers.set("user-agent", ANTHROPIC_OAUTH.userAgent); + const resp = await baseFetch(input, { ...init, headers }); + if (resp.status === 403) { + // Clone so the caller still gets an unread body. Only on 403, so the + // happy path pays nothing. + const body = await resp.clone().text().catch(() => ""); + if (isOAuthForbiddenBody(resp.status, body)) { + markAnthropicOAuthForbidden( + "Anthropic refused OAuth for this account or organization: " + body, + ); + } + } + return resp; + }; +} + +/** + * System blocks with the Claude Code identity leading (spec §3.4). The real + * system prompt follows unchanged, cache flags intact — the identity block + * itself carries no cache marker because Anthropic caches everything up to a + * marked block, so it rides inside whatever prefix the caller already caches. + * + * OAuth path ONLY. The API-key path must never call this (spec §0.1). + */ +export function withClaudeCodeIdentity( + system: string | SystemBlock[] | undefined, +): SystemBlock[] { + const blocks: SystemBlock[] = + system === undefined + ? [] + : typeof system === "string" + ? [{ text: system }] + : system; + // Order matters and mirrors the official CLI: billing attribution, then + // identity, then the caller's real prompt. Neither prepended block carries a + // cache marker — Anthropic caches everything up to a marked block, so they + // ride inside whatever prefix the caller already caches. + return [ + { text: CLAUDE_CODE_BILLING_BLOCK }, + { text: CLAUDE_CODE_IDENTITY }, + ...blocks, + ]; +} + +/** Test-only: forget single-flight and terminal-rejection state. */ +export function resetAnthropicOAuthState(): void { + refreshFlights.clear(); + terminalRejections.clear(); + forbiddenReason = undefined; +} diff --git a/apps/core/src/providers/auth-store.ts b/apps/core/src/providers/auth-store.ts new file mode 100644 index 00000000..ef26db53 --- /dev/null +++ b/apps/core/src/providers/auth-store.ts @@ -0,0 +1,104 @@ +// ============================================================================= +// OAuth token storage — `~/.freecode/auth.json`. +// +// A separate file from `config.json` on purpose (spec +// `2026-09-05-anthropic-oauth-provider.md` §3.5): config is user-edited and +// sometimes committed to dotfiles; this file holds machine-written secrets and +// is kept at mode 0600. Nothing in here talks to the network — the protocol +// lives in `anthropic-oauth.ts`, and both `config.ts` and the OAuth module +// import this so neither has to import the other. +// ============================================================================= + +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +export const AUTH_FILE = path.join(os.homedir(), ".freecode", "auth.json"); + +/** The official Claude Code CLI's own login, importable as Phase 0 auth. */ +export const CLAUDE_CODE_CREDENTIALS_FILE = path.join( + os.homedir(), + ".claude", + ".credentials.json", +); + +export interface StoredAnthropicOAuth { + type: "oauth"; + access_token: string; + refresh_token: string; + /** ms since epoch. */ + expires_at: number; + scopes: string[]; +} + +function readAuthFile(file: string): Record { + if (!fs.existsSync(file)) return {}; + try { + return JSON.parse(fs.readFileSync(file, "utf-8")) as Record< + string, + unknown + >; + } catch { + // A corrupt auth file must not brick every provider call; treat it as + // empty and let the next save rewrite it whole. + return {}; + } +} + +export function readAnthropicOAuth( + file: string = AUTH_FILE, +): StoredAnthropicOAuth | undefined { + const entry = readAuthFile(file)["anthropic"] as + | Partial + | undefined; + if ( + entry && + entry.type === "oauth" && + typeof entry.access_token === "string" && + typeof entry.refresh_token === "string" && + typeof entry.expires_at === "number" + ) { + return { + type: "oauth", + access_token: entry.access_token, + refresh_token: entry.refresh_token, + expires_at: entry.expires_at, + scopes: Array.isArray(entry.scopes) ? entry.scopes : [], + }; + } + return undefined; +} + +/** Merges under the `anthropic` key so future providers can share the file. */ +export function saveAnthropicOAuth( + tokens: StoredAnthropicOAuth, + file: string = AUTH_FILE, +): void { + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const all = readAuthFile(file); + all["anthropic"] = tokens; + fs.writeFileSync(file, JSON.stringify(all, null, 2), { mode: 0o600 }); + // `mode` only applies at creation; an existing file keeps its old bits. + fs.chmodSync(file, 0o600); +} + +export function hasStoredAnthropicOAuth(file: string = AUTH_FILE): boolean { + return readAnthropicOAuth(file) !== undefined; +} + +export function hasImportableClaudeCodeLogin( + file: string = CLAUDE_CODE_CREDENTIALS_FILE, +): boolean { + return fs.existsSync(file); +} + +/** Drops the anthropic entry (`freecode auth logout`), leaving other providers. */ +export function deleteAnthropicOAuth(file: string = AUTH_FILE): boolean { + const all = readAuthFile(file); + if (!("anthropic" in all)) return false; + delete all["anthropic"]; + fs.writeFileSync(file, JSON.stringify(all, null, 2), { mode: 0o600 }); + fs.chmodSync(file, 0o600); + return true; +} diff --git a/apps/core/src/providers/config.ts b/apps/core/src/providers/config.ts index 23a0f821..e5765aa9 100644 --- a/apps/core/src/providers/config.ts +++ b/apps/core/src/providers/config.ts @@ -3,6 +3,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { envKeysFor as catalogueEnvKeys } from "./catalogue.js"; +import { hasStoredAnthropicOAuth } from "./auth-store.js"; export const CONFIG_DIR = path.join(os.homedir(), ".freecode"); export const CONFIG_FILE = path.join(CONFIG_DIR, "config.json"); @@ -10,6 +11,12 @@ export const CONFIG_FILE = path.join(CONFIG_DIR, "config.json"); export interface ProviderCredentials { apiKey: string; model?: string; + /** + * Anthropic only: "oauth" authenticates with a stored Claude Pro/Max + * subscription login instead of a metered key. Opt-in, never the default — + * see spec `2026-09-05-anthropic-oauth-provider.md` §0.1 for why. + */ + authMode?: "oauth" | "api-key"; } /** @@ -150,12 +157,60 @@ export function setLastAgentMode(mode: string): void { } export function hasApiKey(providerId: string): boolean { + // OAuth stands in for a key: provider listing must show anthropic as + // configured when a subscription login is on file, or the UI would demand a + // key the request path will never read. + if ( + providerId === "anthropic" && + anthropicAuthMode() === "oauth" && + hasStoredAnthropicOAuth() + ) { + return true; + } + return hasConfiguredKey(providerId); +} + +/** + * A real API key, ignoring OAuth. `hasApiKey` deliberately answers true for an + * anthropic subscription login; the OAuth→API-key fallback needs the narrower + * question, "is there a key to fall back TO?". + */ +export function hasConfiguredKey(providerId: string): boolean { const config = readConfig(); if (config.providers?.[providerId]?.apiKey) return true; const baseProvider = providerId.replace(/-coding-plan$/, ""); return envKeysFor(baseProvider).some((key) => Boolean(process.env[key])); } +export type AnthropicAuthMode = "oauth" | "api-key"; + +function normalizeAuthMode(value: string | undefined): AnthropicAuthMode | undefined { + if (value === "oauth") return "oauth"; + if (value === "api-key" || value === "apiKey") return "api-key"; + return undefined; +} + +/** + * How the `anthropic` provider authenticates. Resolution: + * `FREECODE_ANTHROPIC_AUTH` env pin → `providers.anthropic.authMode` in + * config → default. The default is API-key whenever one exists — a machine + * with a key never silently switches to the subscription — and falls back to + * OAuth only when no key is configured but a login is already stored in + * `~/.freecode/auth.json` (mirrors jcode's resolution, keeps zero-config + * working after a login). Note import from Claude Code does NOT count here: + * OAuth without an explicit opt-in requires freecode's own stored login. + */ +export function anthropicAuthMode(): AnthropicAuthMode { + const pinned = normalizeAuthMode(process.env.FREECODE_ANTHROPIC_AUTH); + if (pinned) return pinned; + const configured = normalizeAuthMode( + readConfig().providers?.["anthropic"]?.authMode, + ); + if (configured) return configured; + if (hasConfiguredKey("anthropic")) return "api-key"; + return hasStoredAnthropicOAuth() ? "oauth" : "api-key"; +} + export function setApiKey( providerId: string, apiKey: string, @@ -210,3 +265,29 @@ export function setWebCredential( config.web[providerId] = { ...config.web[providerId], ...credential }; writeConfig(config); } + +/** + * Pin (or, with undefined, un-pin) how `anthropic` authenticates. Written by + * `freecode auth login/logout` — an explicit login is one of the two opt-ins + * §0.1 of the OAuth spec allows, and an explicit logout takes it back. + */ +export function setAnthropicAuthMode(mode: AnthropicAuthMode | undefined): void { + const config = readConfig(); + if (!config.providers) config.providers = {}; + const entry = config.providers["anthropic"] ?? {}; + if (mode) entry.authMode = mode; + else delete entry.authMode; + config.providers["anthropic"] = entry; + writeConfig(config); +} + +/** + * "oauth" when a call to this provider right now is billed to a subscription + * rather than a key — stamped onto `model.response` so the cost of a recorded + * call never depends on how the machine reading the log is configured. + */ +export function subscriptionAuth(providerId: string): "oauth" | undefined { + return providerId === "anthropic" && anthropicAuthMode() === "oauth" + ? "oauth" + : undefined; +} diff --git a/apps/core/src/providers/generic-provider.test.ts b/apps/core/src/providers/generic-provider.test.ts index 997f8249..e16da578 100644 --- a/apps/core/src/providers/generic-provider.test.ts +++ b/apps/core/src/providers/generic-provider.test.ts @@ -1,6 +1,11 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { buildGenerateOptions } from "./generic-provider.js"; + +// These assertions describe the API-key request shape; pin the auth mode so +// they don't flip on a machine whose config resolves anthropic to OAuth. +// The OAuth shape has its own tests in anthropic-oauth.test.ts. +process.env.FREECODE_ANTHROPIC_AUTH = "api-key"; import { resolveCatalogue } from "./catalogue.js"; import type { ProviderCatalogueEntry } from "./catalogue.js"; diff --git a/apps/core/src/providers/generic-provider.ts b/apps/core/src/providers/generic-provider.ts index 7917b4d0..f242ea65 100644 --- a/apps/core/src/providers/generic-provider.ts +++ b/apps/core/src/providers/generic-provider.ts @@ -8,8 +8,13 @@ import { ProviderChunk, ProviderInfo, } from "./types.js"; -import { getApiKey } from "./config.js"; +import { getApiKey, anthropicAuthMode, hasConfiguredKey } from "./config.js"; import { createTimeoutFetch } from "./fetch-timeout.js"; +import { + anthropicOAuthForbidden, + createAnthropicOAuthFetch, + withClaudeCodeIdentity, +} from "./anthropic-oauth.js"; import { convertToCoreMessages, buildAnthropicSystemParam, @@ -38,6 +43,22 @@ function requestShape(npm: string): "anthropic" | "openai" { return npm === "@ai-sdk/anthropic" ? "anthropic" : "openai"; } +/** + * Whether this entry authenticates with a Claude subscription login rather + * than a key. Keyed on the provider id, not the SDK package: `minimax` and + * `zai` share `@ai-sdk/anthropic` but have nothing to do with Anthropic's + * OAuth surface. + */ +function usesAnthropicOAuth(entry: ProviderCatalogueEntry): boolean { + if (entry.id !== "anthropic" || anthropicAuthMode() !== "oauth") return false; + // A latched "OAuth not allowed for this organization" 403 takes the OAuth + // path out of service for the rest of the process (spec §6 Phase 2). Read + // here rather than in the fallback alone so BOTH the SDK construction and + // the system param agree: retrying with a key while still prepending the + // Claude Code identity block would break the §0.1 invariant. + return anthropicOAuthForbidden() === undefined; +} + /** * Assembles the AI SDK request options for one call, branching on the SDK * package rather than the provider id: @@ -72,7 +93,15 @@ export function buildGenerateOptions( }; if (requestShape(entry.npm) === "anthropic") { - if (opts.system) { + if (usesAnthropicOAuth(entry)) { + // The subscription endpoint only answers requests whose system param + // leads with the Claude Code identity block — even when the caller sent + // no system prompt at all. OAuth path only: an API-key request must + // never carry it (spec §0.1, tested). + generateOptions.system = buildAnthropicSystemParam( + withClaudeCodeIdentity(opts.system), + ); + } else if (opts.system) { generateOptions.system = buildAnthropicSystemParam(opts.system); } if (opts.messages) { @@ -135,18 +164,55 @@ export function createGenericProvider(entry: ProviderCatalogueEntry): AIProvider // provider must not require having its credential; // - nothing should pay to load an SDK for a provider it never calls. let sdkPromise: Promise | undefined; + let fallbackAnnounced = false; + + /** + * One retry after Anthropic refuses OAuth for the account/organization. + * + * The trigger is the latch set by the OAuth fetch wrapper, not the shape of + * the thrown error: `generateText` and `streamText` surface a 403 quite + * differently (throw vs. an error chunk), while the fetch sees the same raw + * body on both paths. Falling back means rebuilding the SDK with the API key + * — which also drops the identity block, since `usesAnthropicOAuth` now + * answers false. + */ + function canFallBackToApiKey(usedOAuth: boolean): boolean { + if (!usedOAuth || anthropicOAuthForbidden() === undefined) return false; + if (!hasConfiguredKey(entry.id)) return false; + sdkPromise = undefined; + if (!fallbackAnnounced) { + fallbackAnnounced = true; + console.warn( + `[anthropic] ${anthropicOAuthForbidden()}\n[anthropic] Falling back to ` + + `the API key for the rest of this process. Run \`freecode auth status\` ` + + `to see how anthropic is authenticating.`, + ); + } + return true; + } + function getSdk(): Promise { if (!sdkPromise) { sdkPromise = loadSdkFactory(entry.npm) - .then((factory) => - factory({ - apiKey: getApiKey(entry.id, entry.envKeys), + .then((factory) => { + // Auth mode is read once, when the SDK is first built — flipping + // authMode mid-process needs a restart, same as changing a key. + const oauth = usesAnthropicOAuth(entry); + return factory({ + // On the OAuth path the placeholder only satisfies the SDK + // constructor; the fetch wrapper deletes its x-api-key header and + // substitutes bearer auth on every request. + apiKey: oauth + ? "oauth-subscription" + : getApiKey(entry.id, entry.envKeys), baseURL: entry.baseURL, - fetch: createTimeoutFetch(), + fetch: oauth + ? createAnthropicOAuthFetch(createTimeoutFetch()) + : createTimeoutFetch(), // Only @ai-sdk/openai-compatible requires this; the rest ignore it. name: entry.id, - }), - ) + }); + }) .catch((err) => { // Not memoized on failure: a missing key set after the first attempt // should work on the next one, without restarting the process. @@ -173,12 +239,21 @@ export function createGenericProvider(entry: ProviderCatalogueEntry): AIProvider entry.defaultModel, !opts.quietModelFallback, ); - const generateOptions = buildGenerateOptions( - entry, - await modelHandle(model), - opts, - ); - const result = await generateText(generateOptions); + async function call(): Promise>> { + const usedOAuth = usesAnthropicOAuth(entry); + const generateOptions = buildGenerateOptions( + entry, + await modelHandle(model), + opts, + ); + try { + return await generateText(generateOptions); + } catch (err) { + if (!canFallBackToApiKey(usedOAuth)) throw err; + return call(); + } + } + const result = await call(); const toolCalls = result.toolCalls?.map( (tc): { name: string; args: Record; id: string } => { @@ -220,10 +295,42 @@ export function createGenericProvider(entry: ProviderCatalogueEntry): AIProvider await modelHandle(model), opts, ); - const result = streamText({ ...generateOptions, onError: silenceStreamErrors }); - yield* normalizeAiSdkStream( - result.fullStream as unknown as AsyncIterable<{ type: string } & Record>, - ); + for (;;) { + const usedOAuth = usesAnthropicOAuth(entry); + const generateOptions = buildGenerateOptions( + entry, + await modelHandle(model), + opts, + ); + const result = streamText({ + ...generateOptions, + onError: silenceStreamErrors, + }); + const chunks = normalizeAiSdkStream( + result.fullStream as unknown as AsyncIterable< + { type: string } & Record + >, + )[Symbol.asyncIterator](); + + // A refused-OAuth 403 fails the request before any content exists, so + // the first chunk is the error chunk. Once anything has been yielded the + // turn is committed and a retry would duplicate output — hence the + // fallback is only ever considered on that first chunk. + const first = await chunks.next(); + if ( + !first.done && + first.value.type === "error" && + canFallBackToApiKey(usedOAuth) + ) { + continue; + } + if (first.done) return; + yield first.value; + for (let next = await chunks.next(); !next.done; next = await chunks.next()) { + yield next.value; + } + return; + } } return { info, execute, stream }; diff --git a/apps/core/src/providers/pricing.ts b/apps/core/src/providers/pricing.ts index 4f7e65f7..e98a455f 100644 --- a/apps/core/src/providers/pricing.ts +++ b/apps/core/src/providers/pricing.ts @@ -205,6 +205,12 @@ export interface TokenCounts { cacheWriteTokens?: number; } +/** + * How a recorded call was authenticated. Only "oauth" changes pricing, and it + * is stamped on the event when the call is made (`rollout/types.ts`). + */ +export type AuthModeAtCall = "oauth" | "api-key"; + /** * Cost of one call in USD, or `undefined` if the model is unpriced. * @@ -219,7 +225,18 @@ export function priceUsd( provider: string, model: string, usage: TokenCounts, + authMode?: AuthModeAtCall, ): number | undefined { + // Subscription inference has no per-token dollar price. `undefined`, not + // $0 — a zero would poison cost rollups with fake savings, and nothing + // downstream could tell it from a real free call (OAuth spec §5). + // + // Passed in, never read from config here: the caller pricing a trace is + // pricing calls that already happened. Reading the CURRENT auth mode meant + // logging in once repriced every historical API-key session as + // "subscription" — and made the price of a span depend on the machine + // reading it. + if (authMode === "oauth") return undefined; const price = priceFor(provider, model); if (!price) return undefined; @@ -246,12 +263,14 @@ export function priceUsd( * quietly omits a model. */ export function totalUsd( - calls: Array<{ provider: string; model: string } & TokenCounts>, + calls: Array< + { provider: string; model: string; authMode?: AuthModeAtCall } & TokenCounts + >, ): { usd: number; partial: boolean } | undefined { let usd = 0; let priced = 0; for (const call of calls) { - const cost = priceUsd(call.provider, call.model, call); + const cost = priceUsd(call.provider, call.model, call, call.authMode); if (cost === undefined) continue; usd += cost; priced++; diff --git a/apps/core/src/rollout/otlp.test.ts b/apps/core/src/rollout/otlp.test.ts index 277ad6ae..a6051749 100644 --- a/apps/core/src/rollout/otlp.test.ts +++ b/apps/core/src/rollout/otlp.test.ts @@ -98,6 +98,22 @@ test("cost is a double, not a rounded integer", () => { assert.deepEqual(attr(chat, "gen_ai.usage.cost"), { doubleValue: 3 }); }); +test("a subscription call emits no cost attribute, even on a priced model", () => { + // Anthropic Sonnet has a price; the call still cost no dollars because it + // was billed to a Claude subscription (OAuth spec §5). The stamp comes off + // the recorded event, so a collector sees the same thing no matter how the + // machine reading the log authenticates today. + const spans = spansOf( + trace({ + modelSpans: [ + modelSpan({ inputTokens: 1_000_000, authMode: "oauth" as const }), + ], + }), + ); + const chat = spans.find((s) => s.name.startsWith("chat"))!; + assert.equal(attr(chat, "gen_ai.usage.cost"), undefined); +}); + test("an unpriced model emits no cost attribute at all", () => { // A collector cannot tell a real zero from a missing price, so it must not // be shown one. diff --git a/apps/core/src/rollout/otlp.ts b/apps/core/src/rollout/otlp.ts index 5bd257bf..cb9111aa 100644 --- a/apps/core/src/rollout/otlp.ts +++ b/apps/core/src/rollout/otlp.ts @@ -106,7 +106,7 @@ function modelSpanToOtlp( // An estimate from a table with a vintage, not a bill — `pricing.ts`. // Absent, rather than 0, when the model is unpriced: a collector cannot // tell a real zero from a missing price, so it must not see one. - "gen_ai.usage.cost": priceUsd(span.provider, span.model, span), + "gen_ai.usage.cost": priceUsd(span.provider, span.model, span, span.authMode), // Not part of the convention, but the fields that actually explain a // slow or hung call — which is the whole point of exporting this. "freecode.turn_id": span.turnId, diff --git a/apps/core/src/rollout/trace-render.ts b/apps/core/src/rollout/trace-render.ts index ed6935d7..2b933258 100644 --- a/apps/core/src/rollout/trace-render.ts +++ b/apps/core/src/rollout/trace-render.ts @@ -162,12 +162,23 @@ export function renderTrace(trace: Trace, opts: RenderOptions = {}): string { // Silent when nothing in the session is priced — a "cost $0.00" line for an // unpriced model is worse than no line, because it reads as free. const cost = totalUsd(trace.modelSpans); + // Subscription calls have no per-token price at all (OAuth spec §5), so they + // are what the missing dollars ARE — say "subscription" rather than leaving + // the reader to read an unexplained `*`, or no line, as free. + const subscription = trace.modelSpans.some((s) => s.authMode === "oauth"); if (cost) { + const why = cost.partial + ? subscription + ? "; * = subscription calls, no per-token price" + : "; * = some models unpriced" + : ""; out.push( dim( - ` cost ${formatUsd(cost)} ${dim(`(est., prices as of ${pricesAsOf()}${cost.partial ? "; * = some models unpriced" : ""})`)}`, + ` cost ${formatUsd(cost)} ${dim(`(est., prices as of ${pricesAsOf()}${why})`)}`, ), ); + } else if (subscription) { + out.push(dim(` cost subscription ${dim("(no per-token price)")}`)); } // Only when something happened: a line reading "redirects 0" on every // healthy session is noise, and the feature is off by default. diff --git a/apps/core/src/rollout/trace.ts b/apps/core/src/rollout/trace.ts index 06a9ecb8..fd27d275 100644 --- a/apps/core/src/rollout/trace.ts +++ b/apps/core/src/rollout/trace.ts @@ -55,6 +55,8 @@ export interface ModelSpan { * fold change, not an instrumentation change. */ cacheWriteTokens?: number; + /** "oauth" when the call was billed to a subscription rather than a key. */ + authMode?: "oauth"; toolCalls: string[]; errorKind?: "stall" | "abort" | "provider"; error?: string; @@ -204,6 +206,7 @@ export function buildTrace( span.outputTokens = event.outputTokens; span.cacheReadTokens = event.cacheReadTokens; span.cacheWriteTokens = event.cacheWriteTokens; + span.authMode = event.authMode; span.toolCalls = event.toolCalls; break; } diff --git a/apps/core/src/rollout/types.ts b/apps/core/src/rollout/types.ts index 98cc38c4..9a8fa842 100644 --- a/apps/core/src/rollout/types.ts +++ b/apps/core/src/rollout/types.ts @@ -227,6 +227,12 @@ export interface ModelResponseEvent extends BaseEvent { cacheWriteTokens?: number; /** Subset of `outputTokens` spent on hidden reasoning. */ reasoningTokens?: number; + /** + * How the call authenticated, stamped when it was made. Only recorded for + * the Anthropic subscription path, and only so cost stays a property of the + * call rather than of whoever later reads the log (OAuth spec §5). + */ + authMode?: "oauth"; /** Names only — the args are already captured by function.call. */ toolCalls: string[]; textChars: number; diff --git a/docs/superpowers/specs/2026-09-05-anthropic-oauth-provider.md b/docs/superpowers/specs/2026-09-05-anthropic-oauth-provider.md new file mode 100644 index 00000000..87ba0308 --- /dev/null +++ b/docs/superpowers/specs/2026-09-05-anthropic-oauth-provider.md @@ -0,0 +1,350 @@ +# Anthropic OAuth (Claude Pro/Max subscription) as an auth mode for the `anthropic` provider + +**Status:** Phases 0–2 built (2026-09-05). Phase 0 — `providers/anthropic-oauth.ts`, +`providers/auth-store.ts`, OAuth branches in `generic-provider.ts` / +`config.ts` / `pricing.ts`, tests in `anthropic-oauth.test.ts`. Phase 1 — +`providers/anthropic-oauth-login.ts` (PKCE, authorize URL, code parsing, +exchange, localhost callback server) + `cli/commands/auth.ts` +(`freecode auth login|status|logout`), tests in +`anthropic-oauth-login.test.ts`. Phase 2 — Cloudflare diagnosis on both token +paths, the org-forbidden 403 latch + API-key fallback, and cost stamped on the +call rather than read from live config (see §5). **Tool-name mapping is +deliberately NOT built** — §9 Q1 is still open, and Phase 2 conditions that +work on Q1 resolving against us. Every phase's §6 exit criterion is still +unverified — no real turn and no real login have been run, so treat the +endpoint details as jcode-derived, not observed. +**Date:** 2026-09-05 +**Prior art:** jcode (`~/Projects/githubProjects/agents/jcode`), read in full for this spec +on 2026-09-05 — `crates/jcode-base/src/auth/oauth.rs` (PKCE login, exchange, refresh, +single-flight coordinator), `crates/jcode-base/src/sidecar.rs` (identity block rules, +API-key/OAuth split), `crates/jcode-provider-core/src/anthropic.rs` (beta headers), +`crates/jcode-provider-anthropic/src/lib.rs` (OAuth tool-name mapping + curated +schemas), `crates/jcode-app-core/src/external_auth.rs` (importing the official Claude +Code login). OpenCode implements the same surface. +**Extends:** `2026-09-02-dynamic-provider-catalogue-design.md` — this is an *auth mode* +on the existing `anthropic` catalogue entry, not a new provider file. The +one-generic-driver rule stands. +**Related specs:** `2026-05-28-multi-provider-api-design.md`, +`2026-08-29-gemini-web-provider.md` (the other "use my subscription, not an API key" +provider — read its §0 for how we framed the same risk there), +`2026-08-10-agent-observability.md` (cost accounting constraint, §Cost). + +--- + +## 0. Read this first (plain language) + +FreeCode talks to Anthropic with an API key, billed per token. The user already pays +for a Claude Pro/Max subscription, which includes a large inference allowance — but +that allowance is only reachable through Anthropic's OAuth surface, and that surface +only answers requests that look like Claude Code. + +Every third-party agent that offers "log in with your Claude subscription" (jcode, +OpenCode, others) does it the same way: use Claude Code's own OAuth client id, send +Claude Code's `User-Agent` and `anthropic-beta` headers, and put the string +*"You are Claude Code, Anthropic's official CLI for Claude."* as the first system +block. jcode's own source calls this a **spoof**, and that is the right word. + +### 0.1 The risk, stated plainly + +- Anthropic's terms reserve Pro/Max subscription inference for official surfaces. + Using it from freecode is a ToS gray-to-red zone, however common it is in the + open-source agent ecosystem. +- Anthropic has actively interfered with tools doing this. jcode carries a dedicated + error message for being blocked by a Cloudflare challenge at the token endpoint, + and a scope-validation path for tokens that refresh successfully but are refused + at inference time. Both are scars from enforcement, not theory. +- The blast radius is the **user's own Claude account**. Not freecode's keys, not + freecode's infra. + +Consequences for the design: + +1. **Opt-in, never default.** API key stays the default auth mode. OAuth activates + only when the user explicitly configures it or explicitly runs the login command. +2. **The identity block is quarantined to the OAuth path.** A request authenticated + with a real API key must never carry the Claude Code identity string. jcode + enforces this split in code (`build_claude_api_key_system_param` vs the OAuth + builder) and we adopt it as an invariant with a test. +3. **First-run disclosure.** `freecode auth login anthropic` prints one paragraph: + what this does, that it impersonates Claude Code, that Anthropic may block or + action the account. No repeated nagging afterward. + +## 1. Motivation + +1. The user pays for Claude Max. Freecode dev loops (evals excepted — see §8) burn + API-key dollars that the subscription would cover. +2. jcode's implementation is local, complete, battle-tested, and readable — the cost + of porting the *protocol* knowledge is already paid. What remains is wiring it + into freecode's seams, which are unusually good for this (per-request `fetch` + wrapper already exists for timeouts). +3. Every serious freecode competitor ships this. Its absence is a daily paper cut. + +## 2. Goals / non-goals + +**Goals.** `freecode auth login anthropic` completes a PKCE browser flow (with a +paste fallback) and stores tokens; the `anthropic` provider transparently uses them +when in OAuth mode; tokens refresh automatically and safely (single-flight, rotation- +aware); the API-key path is byte-identical to today. Phase 0 is even smaller: reuse +the official Claude Code login already on this machine. + +**Non-goals.** Multi-account support (jcode has it; we don't need it — YAGNI). +Bedrock/Vertex. OpenAI/Codex OAuth (same file in jcode, different spec if ever). +Making OAuth the default. Hiding what this is. + +## 3. The protocol (as implemented by jcode, verified against its tests) + +### 3.1 Constants + +| Thing | Value | +| --- | --- | +| Client ID | `9d1c250a-e61b-44d9-88ed-5944d1962f5e` (Claude Code's) | +| Authorize URL | `https://claude.com/cai/oauth/authorize` | +| Token URL | `https://platform.claude.com/v1/oauth/token` | +| Manual redirect URI | `https://platform.claude.com/oauth/code/callback` | +| Profile URL | `https://api.anthropic.com/api/oauth/profile` | +| Login scopes | `org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload` | +| Refresh scopes | same minus `org:create_api_key` | + +**Trap (jcode learned this the hard way):** the *console* authorize endpoint +(`platform.claude.com/oauth/authorize`) mints tokens that **refresh successfully but +are rejected by the inference API**. Only the claude.ai surface +(`claude.com/cai/oauth/authorize`) yields `user:inference` tokens. Validate the +scope on every exchange and refresh; fail loudly if `user:inference` is absent +rather than storing a token that will 403 later. + +### 3.2 Login (PKCE) + +1. Verifier: 64 chars from `[A-Za-z0-9]`. Challenge: `base64url(sha256(verifier))`, + no padding. **State = the verifier** (jcode's convention; the token endpoint + expects `state` and this binds it to the PKCE secret). +2. Authorize URL query: `code=true&client_id=…&response_type=code&redirect_uri=…&scope=…&code_challenge=…&code_challenge_method=S256&state=…`. +3. Callback: bind `127.0.0.1:`, redirect URI + `http://localhost:/callback`, wait ≤120 s. On timeout/bind-failure, fall + back to the manual redirect URI and let the user paste the full callback URL or + the code (accept plain code, URL with `code=`, or OpenCode-style `code#state`). +4. Exchange: **JSON** POST (not form-encoded — Anthropic's endpoint wants JSON) to + the token URL: `{grant_type:"authorization_code", code, redirect_uri, client_id, + code_verifier, state}`. If the callback carried a non-empty `state` that differs + from the verifier, abort (stale/CSRF). +5. Response: `{access_token, refresh_token, expires_in, scope?}`. Store + `expires_at = now_ms + expires_in*1000`, parsed scopes. + +### 3.3 Refresh + +- JSON POST `{grant_type:"refresh_token", refresh_token, client_id, scope:}`. On an `invalid_scope` error, retry once **without** `scope` (legacy + tokens). Response may omit `refresh_token`; keep the old one then. +- **Anthropic rotates refresh tokens.** Two concurrent refreshes can persist a dead + token and permanently break the login. Refresh must be single-flighted, and before + refreshing, re-read the store: if the stored refresh token differs from the one + the caller observed and the stored expiry is fresh, someone already refreshed — + use the stored tokens and skip the network call. +- A refresh the endpoint permanently rejected must be marked terminal (don't retry + it on every request forever; surface "run `freecode auth login anthropic`"). + +### 3.4 Request shaping at `/v1/messages` + +| Header | OAuth value | +| --- | --- | +| `Authorization` | `Bearer ` — and **no `x-api-key`** | +| `anthropic-beta` | `claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,…` (jcode's current full list is in `jcode-provider-core/src/anthropic.rs`; start with `claude-code-20250219,oauth-2025-04-20` plus whatever betas we already send, and extend only on observed rejection) | +| `User-Agent` | `claude-cli/2.1.257 (external, sdk-cli)` (version drifts; copy jcode's current) | + +Body requirements: + +- **System param:** two prepended blocks, in the official CLI's order, then our + real system prompt: + 1. `x-anthropic-billing-header: cc_version=2.1.257; cc_entrypoint=sdk-cli; cch=33f85;` + — Claude Code's **billing attribution**, which jcode observed in the real + CLI's system blocks (`OAUTH_BILLING_HEADER`). Despite the name it is a + system block, not an HTTP header. `cc_version` must stay in lockstep with + the spoofed `User-Agent`; `cch` is an opaque build hash. Added 2026-09-05 + after re-reading jcode — Phases 0–2 shipped without it. + 2. `You are Claude Code, Anthropic's official CLI for Claude.` + + Cache breakpoints unchanged, and neither prepended block carries a cache + marker: Anthropic caches everything up to a marked block, so a marker here + would cut the caller's cached prefix short. +- **Tools:** jcode maps its local names to Claude Code's for a builtin subset + (`bash`→`Bash`, `read`→`Read`, `subagent`→`Agent`, …) and ships curated + schemas for them. **It is not an access or billing requirement**: the mapping + falls through as `_ => name`, and jcode explicitly forwards every other + registered tool (websearch, webfetch, memory, …) under its own name — which + it could not do if the endpoint demanded Claude Code's names. What the + mapping buys is tool-use *quality*, since the model has strong priors on + those names and schemas. What it costs is on the record too: jcode's + `oauth_tool_schema_tests.rs` exists because hand-curated schemas drifted from + the real handlers and silently broke every `ScheduleWakeup` call (their + #706). **We ship without the mapping** and port it only if a real turn shows + rejection or visible degradation — see §9 Q1. + +### 3.5 Credential storage + +`~/.freecode/auth.json`, mode `0600`: + +```json +{ + "anthropic": { + "type": "oauth", + "access_token": "…", + "refresh_token": "…", + "expires_at": 1757000000000, + "scopes": ["user:profile", "user:inference", "…"] + } +} +``` + +Separate file from `config.json` on purpose: config is user-edited and sometimes +committed to dotfiles; tokens are machine-written secrets. **Checked 2026-09-05:** +the tokens are `sk-ant-oat01-…`/`sk-ant-ort01-…`, already covered by +`secret-filter.ts`'s `\bsk-ant-` pattern, so a memory quoting one is never +embedded. Pinned by a test there rather than left as an assertion — note the +generic `token[:=]value` pattern does NOT match JSON-quoted keys +(`"access_token": "…"`), so the `sk-ant-` prefix is doing the work. + +## 4. Where it wires into freecode + +The constraint: Anthropic goes through the generic driver + `@ai-sdk/anthropic`, and +the API key is read inside `getSdk()` (`generic-provider.ts`). The seam is **a +composed `fetch`, not the SDK's `apiKey`** — we already wrap fetch for timeouts +(`fetch-timeout.ts`), header rewriting is a two-line composition, and per-request is +exactly where token refresh has to live anyway. + +| Piece | File | Change | +| --- | --- | --- | +| OAuth core | `providers/anthropic-oauth.ts` (new) | Constants, PKCE, exchange, refresh (single-flight + rotation-aware), token store, `getAccessToken()` (refreshes when < 5 min to expiry), `importClaudeCodeCredentials()` (Phase 0) | +| CLI | `cli/` (new subcommand) | `freecode auth login anthropic`, `freecode auth status`, `freecode auth logout anthropic`. Login prints the §0.1 disclosure once | +| Fetch wrapper | `providers/generic-provider.ts` | In OAuth mode, compose `createTimeoutFetch()` with a wrapper that awaits `getAccessToken()`, deletes `x-api-key`, sets `authorization` + `anthropic-beta` + `user-agent`. Pass a dummy `apiKey` to `createAnthropic` so construction doesn't throw | +| Key resolution | `providers/config.ts` | `getApiKey`/`hasApiKey` grow an OAuth-aware branch so provider listing shows anthropic as configured without a key | +| System param | `providers/utils.ts` (`buildAnthropicSystemParam`) | OAuth mode prepends the identity block. API-key path untouched — **invariant, tested** | +| Mode selection | `providers/config.ts` + catalogue | `providers.anthropic.authMode: "oauth" \| "api-key"` (`"apiKey"` accepted too), plus the `FREECODE_ANTHROPIC_AUTH` env pin, default api-key. Unset + no API key + stored OAuth creds in `~/.freecode/auth.json` ⇒ OAuth (mirrors jcode's resolution, keeps zero-config working after a login; an importable Claude Code login alone does NOT flip the mode — import runs only once OAuth mode is active) | +| Cost | `providers/pricing.ts` | See §5 | + +No new provider id. `supportsTools` stays true. Streaming, thinking, and caching go +through the same normalized path — the OAuth endpoint speaks the same Messages SSE. + +## 5. Cost accounting + +Subscription inference has no per-token dollar price. Per the existing pricing +invariant ("an unknown model prices as `undefined`, never 0 or a near-miss"), an +OAuth-authenticated span's cost is **`undefined`, not $0** — a $0 would poison +`freecode trace` cost rollups and any future cost-efficiency eval with fake savings. +Token counts still flow (usage accounting is orthogonal to auth). The trace verdict +line says "subscription" where it would say a dollar figure. + +**The auth mode is a property of the CALL, not of the reader** (corrected in +Phase 2). Phase 0 had `priceUsd` consult `anthropicAuthMode()` directly, which +meant a single login repriced every historical API-key session as +"subscription", and the price of a span depended on which machine folded the +log — it also made `otlp.test.ts` fail on any developer machine holding an +OAuth login. `model.response` now carries `authMode: "oauth"`, stamped by +`agent/loop.ts` when the call is made; `ModelSpan` carries it through the fold, +and `priceUsd(provider, model, usage, authMode?)` takes it as an argument. +`pricing.ts` no longer imports `config.ts` at all. A mixed session totals the +metered calls and marks the total partial, with the `*` explained as +subscription rather than as an unpriced model. + +## 6. Phases + +**Phase 0 — borrow the official login (smallest useful thing).** +`importClaudeCodeCredentials()` reads `~/.claude/.credentials.json` (the machine +already runs Claude Code), plus refresh, the fetch wrapper, and the identity block. +No login flow, no callback server. Proves the endpoint end-to-end: one real turn with +tools through the subscription. Exit criterion: a normal freecode session (read → +edit → bash) completes on OAuth with cost shown as subscription. + +**Phase 1 — own login flow (built 2026-09-05).** PKCE + localhost callback + +paste fallback + `auth login/status/logout`. Exit criterion: login on a machine +without Claude Code installed — **not yet run.** + +Notes from the build: +- `state` is the PKCE verifier (jcode's convention), so the exchange needs the + verifier the authorize URL was built with. That makes login inherently + single-process: there is no `--code` flag, because a fresh process has a fresh + verifier and its exchange would always fail. +- The callback listener binds an ephemeral 127.0.0.1 port. A bind failure is not + an error — it degrades to the manual redirect URI and a paste prompt, which is + also what `--no-browser` selects. +- A successful login **pins** `providers.anthropic.authMode: "oauth"` in + config; `auth logout` deletes the stored tokens and un-pins it. §0.1 counts an + explicit login as an explicit opt-in, and pinning is what makes the login + stick on a machine that also has `ANTHROPIC_API_KEY` set (the unpinned + fallback prefers the key). + +**Phase 2 — hardening (built 2026-09-05).** Terminal refresh-rejection state +(Phase 0's in-process latch, messages now pointing at `freecode auth login`); +`looksLikeCloudflareChallenge` + one shared message, applied to **both** the +refresh and the exchange — and deliberately **not** marked terminal, since the +same token works from another network; automatic API-key fallback on the +"OAuth not allowed for this organization" 403. + +Tool-name mapping is **not** built: §9 Q1 is unresolved, and this phase makes +it conditional on Q1 resolving against us. The smoke test decides. + +How the fallback works, and why it is shaped this way: +- **Detection is at the fetch**, not at the error. `generateText` throws a 403 + while `streamText` surfaces it as an error chunk; the OAuth fetch wrapper + sees the same raw body on both paths, so it latches there + (`markAnthropicOAuthForbidden`) and both callers just ask whether the latch + moved. +- **The latch takes the whole OAuth path out of service** for the process, so + `usesAnthropicOAuth()` answers false for SDK construction *and* for the + system param. That is load-bearing for §0.1: a fallback request goes out on + a real API key and must therefore carry no Claude Code identity block. Tested. +- **Streaming retries only on the first chunk.** Once anything has been yielded + the turn is committed, and a retry would duplicate output. +- No key configured ⇒ no fallback, and the original error stands. + +## 7. Testing + +- **Unit, mock token server** (jcode's `oauth_tests/` is the template): exchange + happy path; exchange with state mismatch aborts; refresh rotates and persists; + refresh single-flight (two concurrent callers, one network call); `invalid_scope` + fallback; missing `user:inference` fails loudly. +- **`generic-provider.test.ts` additions:** OAuth mode's fetch rewrites headers + (bearer present, `x-api-key` absent, betas present); API-key mode's request + carries **no identity block and no OAuth headers** — the §0.1 invariant as a test. +- **Live smoke, manual only:** one real turn per phase exit criterion. Never in CI — + same reasoning as the eval harness's `workflow_dispatch`-only rule, plus this one + spends the user's subscription allowance and login state. + +## 8. Interaction with the eval harness + +Tempting: run `pnpm eval` on the subscription. **Don't, for judged/gated runs.** The +eval gate compares against a baseline on the same resolved model; the OAuth endpoint +may route to differently-tuned serving (and its beta set differs), so an OAuth run is +not the same instrument as an API-key run. Casual `eval ab` exploration on the +subscription is fine — that's a report, not a gate. + +**Built 2026-09-05.** `SuiteReport.authMode` is recorded the way `judge` is, and +`baselineFor(suite, model, authMode)` refuses to compare across a mode switch, +in both directions. An absent mode on either side normalises to `api-key`: +every baseline written before this landed has no mode, and treating that as a +mismatch would discard all of them. + +## 9. Open questions + +1. **Tool acceptance.** Does the OAuth endpoint accept our tool names/schemas as + custom tools, or does it require Claude Code's curated names? jcode's code + says custom names are accepted (see §3.4), so the open part is only whether + tool-use *quality* degrades under our names. The smoke test answers it + before we write any mapping code — and the mapping's own regression history + in jcode is a reason not to write it speculatively. +2. **Version drift.** The spoofed `User-Agent`, the billing block's + `cc_version`/`cch`, and the beta list rot as Claude Code ships. jcode centralizes them as constants and bumps them; we do the + same and accept the maintenance. A stale version string is also a plausible + future enforcement lever — if OAuth requests start failing, check here first. +3. **`~/.claude/.credentials.json` format stability.** Phase 0 parses a file owned + by another program. Guard with a schema check and a clear "re-run Phase 1 login" + error, not a crash. +4. **Rate-limit UX.** Subscription tiers throttle differently from API keys + (5-hour windows). Do we surface Anthropic's rate-limit headers in the TUI, or let + 429s speak for themselves? Deferred until it hurts. + +5. **Identity string divergence.** jcode now sends + `"You are a Claude agent, built on Anthropic's Claude Agent SDK."`, pairing + with its `cc_entrypoint=sdk-cli` billing value, while this spec (and our + code) sends the older `"You are Claude Code, Anthropic's official CLI for + Claude."`. Our `User-Agent` already says `sdk-cli`, so we are currently + mixing vintages. Unresolved deliberately: changing the identity string is + exactly the kind of thing that should be decided by an observed turn, not by + guessing. Check this first if requests start being refused.