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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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` |
Expand Down
3 changes: 3 additions & 0 deletions apps/core/src/agent/loop.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -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,
Expand Down
238 changes: 238 additions & 0 deletions apps/core/src/cli/commands/auth.ts
Original file line numberDiff line numberDiff line change
@@ -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<string> {
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<object, LoginArgs> = {
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<object, { provider: string }> = {
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: () => {},
};
2 changes: 2 additions & 0 deletions apps/core/src/cli/create-cli.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -95,6 +96,7 @@ export function createCli(extraCommands: CommandModule[] = []) {
.command(runCommand)
.command(traceCommand as CommandModule)
.command(evalCommand as CommandModule)
.command(authCommand)
.command(uninstallCommand)
.strict();

Expand Down
29 changes: 29 additions & 0 deletions apps/core/src/eval/report.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 }));
Expand Down
15 changes: 14 additions & 1 deletion apps/core/src/eval/report.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,19 +72,32 @@ 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
* because it looks like data. `undefined` on either side means a run recorded
* 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,
Expand Down
6 changes: 5 additions & 1 deletion apps/core/src/eval/suite.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -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.
Expand All@@ -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
Expand Down
Loading
Loading