diff --git a/apps/app/src/lib/plugin-frontend-reload.test.ts b/apps/app/src/lib/plugin-frontend-reload.test.ts index e255f25b33..96798fd81b 100644 --- a/apps/app/src/lib/plugin-frontend-reload.test.ts +++ b/apps/app/src/lib/plugin-frontend-reload.test.ts @@ -38,6 +38,7 @@ import { PluginSlotMount } from "@/components/plugin/PluginSlotMount"; import { PLUGIN_PANEL_ROUTE_PATH } from "./route-paths"; import { applyAppThemeCss } from "./themes"; import { PluginPanelView } from "@/views/PluginPanelView"; +import { makeInstalledPlugin } from "@/test/fixtures/plugins"; function candidate( pluginId: string, @@ -130,6 +131,40 @@ function makeDeps(initial: PluginFrontendCandidate[] = []): TestReconcileDeps { } describe("reconcilePluginFrontends", () => { + it.each(["running", "needs-configuration", "degraded"] as const)( + "loads frontend candidates for a plugin with %s status", + async (status) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + plugins: [ + makeInstalledPlugin({ + id: "account-pool", + status, + app: { + hasApp: true, + bundle: candidate("account-pool", "v1").bundle, + }, + }), + ], + }), + { headers: { "content-type": "application/json" } }, + ), + ), + ); + + await expect(fetchFrontendCandidates(queryClient)).resolves.toEqual([ + candidate("account-pool", "v1"), + ]); + }, + ); + it("re-imports a plugin exactly once when its bundle hash changes, replacing registrations wholesale", async () => { const state = createPluginFrontendReconcileState(); const deps = makeDeps([ diff --git a/apps/app/src/lib/plugin-frontend.ts b/apps/app/src/lib/plugin-frontend.ts index 35f62d05df..351302f8e7 100644 --- a/apps/app/src/lib/plugin-frontend.ts +++ b/apps/app/src/lib/plugin-frontend.ts @@ -262,7 +262,11 @@ export async function fetchFrontendCandidates( logoDarkUrl: plugin.logoDarkUrl, icons: new Map(Object.entries(plugin.icons)), }); - if (plugin.status !== "running") { + if ( + plugin.status !== "running" && + plugin.status !== "needs-configuration" && + plugin.status !== "degraded" + ) { continue; } const bundle = plugin.app.bundle; diff --git a/apps/cli/src/__tests__/plugin-cli-proxy.test.ts b/apps/cli/src/__tests__/plugin-cli-proxy.test.ts index 98c05f8c56..e7efb08612 100644 --- a/apps/cli/src/__tests__/plugin-cli-proxy.test.ts +++ b/apps/cli/src/__tests__/plugin-cli-proxy.test.ts @@ -496,7 +496,7 @@ describe("runPluginCliCommand", () => { ]); }); - it("materializes an API key from stdin only in the proxied request", async () => { + it("materializes an arbitrary stdin flag only in the proxied request", async () => { const requests: string[][] = []; vi.stubGlobal( "fetch", @@ -517,21 +517,23 @@ describe("runPluginCliCommand", () => { const input = { isTTY: false, async *[Symbol.asyncIterator]() { - yield Buffer.from("sk-from-stdin\n"); + yield Buffer.from("opaque-credential\n"); }, }; + const argv = ["deploy", "--credential-stdin", "--format", "json"]; await expect( runPluginCliCommand( "http://localhost", - "account-pool", - ["account", "add", "--provider", "claude", "--api-key-stdin"], + "fixture", + argv, { stdout: output, stderr: output }, input, ), ).resolves.toBe(0); + expect(argv).toEqual(["deploy", "--credential-stdin", "--format", "json"]); expect(requests).toEqual([ - ["account", "add", "--provider", "claude", "--api-key", "sk-from-stdin"], + ["deploy", "--credential", "opaque-credential", "--format", "json"], ]); expect(writes).toEqual([]); }); diff --git a/apps/cli/src/plugin-cli-proxy.ts b/apps/cli/src/plugin-cli-proxy.ts index d1b83377ca..848093114f 100644 --- a/apps/cli/src/plugin-cli-proxy.ts +++ b/apps/cli/src/plugin-cli-proxy.ts @@ -283,45 +283,50 @@ interface PluginCliInputStream extends AsyncIterable { isTTY?: boolean; } -const PLUGIN_CLI_SECRET_STDIN_MAX_BYTES = 16 * 1024; +const PLUGIN_CLI_STDIN_MAX_BYTES = 16 * 1024; +const PLUGIN_CLI_STDIN_FLAG = /^--([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)-stdin$/u; -async function materializeApiKeyStdin( +async function materializeStdinFlag( argv: readonly string[], input: PluginCliInputStream, ): Promise { - const indexes = argv.flatMap((arg, index) => - arg === "--api-key-stdin" ? [index] : [], - ); - if (indexes.length === 0) return [...argv]; - if (indexes.length > 1 || argv.includes("--api-key")) { - throw new Error("Choose only one API-key input flag."); + const matches = argv.flatMap((flag, index) => { + const match = PLUGIN_CLI_STDIN_FLAG.exec(flag); + const name = match?.[1]; + return name === undefined ? [] : [{ flag, index, name }]; + }); + if (matches.length === 0) return [...argv]; + if (matches.length > 1) throw new Error("Choose only one stdin input flag."); + const match = matches[0]; + if (match === undefined) return [...argv]; + const valueFlag = `--${match.name}`; + if (argv.includes(valueFlag)) { + throw new Error(`Choose only one of ${match.flag} and ${valueFlag}.`); } if (input.isTTY === true) { - throw new Error("--api-key-stdin requires an API key piped on stdin."); + throw new Error(`${match.flag} requires piped stdin.`); } const chunks: Buffer[] = []; let bytes = 0; for await (const chunk of input) { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); bytes += buffer.byteLength; - if (bytes > PLUGIN_CLI_SECRET_STDIN_MAX_BYTES) { - throw new Error("API key from stdin exceeds 16 KiB."); + if (bytes > PLUGIN_CLI_STDIN_MAX_BYTES) { + throw new Error(`${match.flag} input exceeds 16 KiB.`); } chunks.push(buffer); } - const apiKey = Buffer.concat(chunks) + const value = Buffer.concat(chunks) .toString("utf8") - .replace(/[\r\n]+$/u, ""); - if (apiKey.length === 0 || /[\r\n]/u.test(apiKey)) { - throw new Error("--api-key-stdin requires exactly one non-empty API key."); + .replace(/\r?\n$/u, ""); + if (value.length === 0 || /[\r\n]/u.test(value)) { + throw new Error(`${match.flag} requires exactly one non-empty stdin line.`); } - const index = indexes[0]; - if (index === undefined) return [...argv]; return [ - ...argv.slice(0, index), - "--api-key", - apiKey, - ...argv.slice(index + 1), + ...argv.slice(0, match.index), + valueFlag, + value, + ...argv.slice(match.index + 1), ]; } @@ -360,7 +365,7 @@ export async function runPluginCliCommand( ): Promise { let resolvedArgv: string[]; try { - resolvedArgv = await materializeApiKeyStdin(argv, input); + resolvedArgv = await materializeStdinFlag(argv, input); } catch (error) { await writePluginCliOutput( streams.stderr, diff --git a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md index e14daa1112..6e53da9b31 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md @@ -57,6 +57,8 @@ credentials, and inspect its proxy route and account quota with: ```sh bb plugin enable account-pool +bb pool account add --provider claude --login +printf '%s\n' "$CLAUDE_AUTH_CODE" | bb pool account login-complete --session --code-stdin bb pool account add --provider claude --import printf '%s\n' "$ANTHROPIC_API_KEY" | bb pool account add --provider claude --api-key-stdin [--label ] [--priority ] bb pool account add --provider claude --api-key [--label ] [--priority ] @@ -69,7 +71,11 @@ bb pool token rotate --machine bb pool bypass [--off] ``` -Newly added or enabled accounts are available without a plugin reload. With an +`--login` starts a PKCE session, prints a browser URL and session ID, then +exits. Pipe the manual Claude callback code to `account login-complete` with +that session ID within ten minutes. The code stays out of process arguments, +and the browser and bb server may be on different machines. Newly added or +enabled accounts are available without a plugin reload. With an enabled account whose secret file remains readable and valid, Claude Code sessions receive the pool route and a distinct secret token for their machine. Tokens are never printed. `status` prunes tokens for unenrolled machines and diff --git a/docs/configuration.md b/docs/configuration.md index ffdc783b9a..eb382fb50f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -621,10 +621,21 @@ Enable it and add at least one account: ```sh bb plugin enable account-pool +bb pool account add --provider claude --login +printf '%s\n' "$CLAUDE_AUTH_CODE" | bb pool account login-complete --session --code-stdin bb pool account add --provider claude --import printf '%s\n' "$ANTHROPIC_API_KEY" | bb pool account add --provider claude --api-key-stdin [--label ] [--priority ] ``` +The login start command creates a ten-minute in-memory PKCE session, prints a +Claude browser authorization URL and session ID, then exits. After sign-in, +pipe the code shown on Anthropic's manual callback page to +`account login-complete` with that session ID. The browser can be on a different +machine from the bb server, and the code stays out of process arguments. The +Account Pool plugin settings page exposes the same flow with **Sign in to +Claude**, plus the account list, import, API-key, enable/disable, and removal +controls. + The import path reads the Claude Code login on the bb server host. `--api-key-stdin` reads exactly one non-empty key from piped standard input and is the default API-key path for agents. The compatibility form `--api-key diff --git a/packages/templates/src/templates/bb-guide-plugins.md b/packages/templates/src/templates/bb-guide-plugins.md index 757f800b67..b7ce0ac740 100644 --- a/packages/templates/src/templates/bb-guide-plugins.md +++ b/packages/templates/src/templates/bb-guide-plugins.md @@ -29,6 +29,8 @@ Messages API requests through the bb server. Enable it and add an account: ``` bb plugin enable account-pool +bb pool account add --provider claude --login +printf '%s\n' "$CLAUDE_AUTH_CODE" | bb pool account login-complete --session --code-stdin bb pool account add --provider claude --import printf '%s\n' "$ANTHROPIC_API_KEY" | bb pool account add --provider claude --api-key-stdin [--label ] [--priority ] bb pool account add --provider claude --api-key [--label ] [--priority ] @@ -41,6 +43,13 @@ bb pool token rotate --machine bb pool bypass [--off] ``` +`--login` starts a ten-minute in-memory PKCE session, prints the Claude browser +sign-in URL and session ID, then exits. After sign-in, pipe the manual callback +code to `account login-complete` with that session ID. The browser does not need +to run on the bb server machine, and neither the code nor account tokens enter +process arguments. The same flow is available in the plugin settings page +through the **Sign in to Claude** button. + The hub starts immediately, even before an account is configured, so newly added or enabled accounts are available without a plugin reload. With an enabled account whose secret file remains readable and valid, the plugin diff --git a/plugins/account-pool/app.test.tsx b/plugins/account-pool/app.test.tsx new file mode 100644 index 0000000000..ea610ba353 --- /dev/null +++ b/plugins/account-pool/app.test.tsx @@ -0,0 +1,127 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { loadPluginApp, renderSlot } from "@get-bb/plugin-sdk/testing/app"; +import type { AccountSummary } from "./src/contracts.js"; + +const app = await loadPluginApp(() => import("./app")); + +afterEach(cleanup); + +function account(): AccountSummary { + return { + id: "11111111-1111-4111-8111-111111111111", + provider: "claude", + kind: "oauth", + label: "Personal Claude", + email: "person@example.com", + subscriptionType: "max", + rateLimitTier: "default_claude_max_5x", + enabled: true, + priority: 100, + createdAt: 1, + fiveHourUtilization: 0.21, + fiveHourResetAt: null, + fiveHourStatus: null, + sevenDayUtilization: 0.43, + sevenDayResetAt: null, + sevenDayStatus: null, + representativeClaim: null, + bucketExhaustion: {}, + observedAt: 1, + heldUntil: null, + error: null, + inFlight: 0, + status: "ready", + }; +} + +describe("Account Pool settings", () => { + it("completes the browser login step and refreshes the account list", async () => { + const accounts: AccountSummary[] = []; + const opened: string[] = []; + const slot = renderSlot( + app.settingsSections[0]!, + {}, + { + openUrl: (url) => { + opened.push(url); + return true; + }, + rpc: { + "account.list": () => [...accounts], + "login.start": () => ({ + sessionId: "22222222-2222-4222-8222-222222222222", + authorizeUrl: "https://claude.ai/oauth/authorize?state=state", + }), + "login.complete": () => { + const added = account(); + accounts.push(added); + return added; + }, + }, + }, + ); + + expect(await slot.findByText("No Claude accounts yet")).toBeTruthy(); + fireEvent.click(slot.getByRole("button", { name: "Sign in to Claude" })); + expect(await slot.findByText("Finish signing in to Claude")).toBeTruthy(); + expect(opened).toEqual(["https://claude.ai/oauth/authorize?state=state"]); + fireEvent.change(slot.getByLabelText("Claude authorization code"), { + target: { value: "code#state" }, + }); + fireEvent.click(slot.getByRole("button", { name: "Complete sign-in" })); + + expect(await slot.findByText("Personal Claude")).toBeTruthy(); + expect(slot.getByText("person@example.com")).toBeTruthy(); + expect(slot.getByText("5h 21%")).toBeTruthy(); + expect(slot.getByText("7d 43%")).toBeTruthy(); + expect(slot.queryByText("Finish signing in to Claude")).toBeNull(); + expect(slot.rpcCalls).toContainEqual({ + method: "login.complete", + input: { + sessionId: "22222222-2222-4222-8222-222222222222", + pasted: "code#state", + }, + }); + }); + + it("keeps the login step open and shows a completion error inline", async () => { + const slot = renderSlot( + app.settingsSections[0]!, + {}, + { + openUrl: () => true, + rpc: { + "account.list": () => [], + "login.start": () => ({ + sessionId: "22222222-2222-4222-8222-222222222222", + authorizeUrl: "https://claude.ai/oauth/authorize?state=state", + }), + "login.complete": () => { + throw new Error("OAuth state mismatch. Start again."); + }, + }, + }, + ); + + fireEvent.click( + await slot.findByRole("button", { name: "Sign in to Claude" }), + ); + fireEvent.change(await slot.findByLabelText("Claude authorization code"), { + target: { value: "code#wrong" }, + }); + fireEvent.click(slot.getByRole("button", { name: "Complete sign-in" })); + + const alert = await slot.findByRole("alert"); + expect(alert.textContent).toContain("OAuth state mismatch. Start again."); + expect(slot.getByText("Finish signing in to Claude")).toBeTruthy(); + await waitFor(() => + expect( + slot + .getByRole("button", { name: "Complete sign-in" }) + .getAttribute("disabled"), + ).toBeNull(), + ); + }); +}); diff --git a/plugins/account-pool/app.tsx b/plugins/account-pool/app.tsx new file mode 100644 index 0000000000..d104695e9b --- /dev/null +++ b/plugins/account-pool/app.tsx @@ -0,0 +1,448 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + definePluginApp, + useBbNavigate, + useRealtime, + useRpc, +} from "@get-bb/plugin-sdk/app"; +import { Button } from "@bb/shared-ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@bb/shared-ui/dialog"; +import { Input } from "@bb/shared-ui/input"; +import { Switch } from "@bb/shared-ui/switch"; +import type { AccountSummary } from "./src/contracts.js"; +import type { accountPoolRpcContract } from "./src/rpc.js"; +import { ACCOUNT_POOL_ACCOUNTS_CHANGED } from "./src/realtime.js"; + +interface LoginStep { + sessionId: string; + authorizeUrl: string; +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function utilization(value: number | null): string { + return value === null ? "—" : `${Math.round(value * 100)}%`; +} + +function statusLabel(status: AccountSummary["status"]): string { + return status.charAt(0).toUpperCase() + status.slice(1); +} + +function AccountPoolSettings() { + const rpc = useRpc(); + const navigate = useBbNavigate(); + const [accounts, setAccounts] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [loginStep, setLoginStep] = useState(null); + const [pastedCode, setPastedCode] = useState(""); + const [loginPending, setLoginPending] = useState(false); + const [removeAccount, setRemoveAccount] = useState( + null, + ); + const [removePending, setRemovePending] = useState(false); + const [apiKeyOpen, setApiKeyOpen] = useState(false); + const [apiKey, setApiKey] = useState(""); + const [apiKeyPending, setApiKeyPending] = useState(false); + const [accountPending, setAccountPending] = useState(null); + const mounted = useRef(true); + + const refresh = useCallback(async () => { + try { + const next = await rpc.call("account.list", null); + if (!mounted.current) return; + setAccounts(next); + } catch (loadError) { + if (!mounted.current) return; + setError(errorText(loadError)); + } + }, [rpc]); + + useEffect(() => { + mounted.current = true; + void refresh(); + return () => { + mounted.current = false; + }; + }, [refresh]); + + useRealtime(ACCOUNT_POOL_ACCOUNTS_CHANGED, () => { + void refresh(); + }); + + const mutate = useCallback( + async (action: () => Promise) => { + setError(null); + try { + await action(); + await refresh(); + } catch (mutationError) { + setError(errorText(mutationError)); + } + }, + [refresh], + ); + + async function startLogin(): Promise { + if (loginPending) return; + setLoginPending(true); + setError(null); + try { + const started = await rpc.call("login.start", null); + setLoginStep(started); + setPastedCode(""); + navigate.openUrl(started.authorizeUrl); + } catch (startError) { + setError(errorText(startError)); + } finally { + setLoginPending(false); + } + } + + async function completeLogin(): Promise { + if (loginStep === null || pastedCode.trim().length === 0 || loginPending) + return; + setLoginPending(true); + setError(null); + try { + await rpc.call("login.complete", { + sessionId: loginStep.sessionId, + pasted: pastedCode, + }); + setLoginStep(null); + setPastedCode(""); + await refresh(); + } catch (completeError) { + setError(errorText(completeError)); + } finally { + setLoginPending(false); + } + } + + async function importAccount(): Promise { + if (loading) return; + setLoading(true); + await mutate(async () => { + await rpc.call("account.add", { + provider: "claude", + source: { kind: "import" }, + label: null, + priority: 100, + }); + }); + setLoading(false); + } + + async function addApiKey(): Promise { + if (apiKey.trim().length === 0 || apiKeyPending) return; + setApiKeyPending(true); + await mutate(async () => { + await rpc.call("account.add", { + provider: "claude", + source: { kind: "api-key", apiKey: apiKey.trim() }, + label: null, + priority: 100, + }); + setApiKey(""); + setApiKeyOpen(false); + }); + setApiKeyPending(false); + } + + async function toggleAccount(account: AccountSummary): Promise { + if (accountPending !== null) return; + setAccountPending(account.id); + await mutate(async () => { + await rpc.call(account.enabled ? "account.disable" : "account.enable", { + id: account.id, + }); + }); + setAccountPending(null); + } + + async function confirmRemove(): Promise { + if (removeAccount === null || removePending) return; + setRemovePending(true); + await mutate(async () => { + await rpc.call("account.remove", { id: removeAccount.id }); + setRemoveAccount(null); + }); + setRemovePending(false); + } + + async function copyAuthorizeUrl(): Promise { + if (loginStep === null) return; + try { + await navigator.clipboard.writeText(loginStep.authorizeUrl); + } catch { + setError("Copy failed. Select the URL and copy it manually."); + } + } + + return ( +
+
+

Claude accounts

+

+ Account Pool routes Claude Code threads through an available account + and moves away from accounts that reach their limits. +

+
+ + {accounts === null ? ( +

+ Loading accounts… +

+ ) : accounts.length === 0 ? ( +
+

+ No Claude accounts yet +

+

+ Add an account and the plugin will route Claude Code threads through + the pool automatically. +

+
+ ) : ( +
+ {accounts.map((account) => ( +
+
+
+
+ + {account.label} + + + {account.kind === "oauth" ? "OAuth" : "API key"} + + + {statusLabel(account.status)} + +
+ {account.email === null ? null : ( +

+ {account.email} +

+ )} +
+ { + void toggleAccount(account); + }} + /> +
+
+ 5h {utilization(account.fiveHourUtilization)} + 7d {utilization(account.sevenDayUtilization)} + +
+ {account.error === null ? null : ( +

{account.error}

+ )} +
+ ))} +
+ )} + + {loginStep === null ? ( +
+ + + +
+ ) : ( +
+
+

+ Finish signing in to Claude +

+

+ Complete sign-in in the browser, then paste the code shown on the + final page. +

+
+
+ event.currentTarget.select()} + /> + + +
+ setPastedCode(event.target.value)} + /> +
+ + +
+
+ )} + + {error === null ? null : ( +

+ {error} +

+ )} + + { + if (!open && !removePending) setRemoveAccount(null); + }} + > + + {removeAccount === null ? null : ( + <> + + Remove {removeAccount.label}? + + This permanently removes the account and its stored secret + from this bb server. + + + + + + + + )} + + + + { + if (!apiKeyPending) setApiKeyOpen(open); + }} + > + + {apiKeyOpen ? ( + <> + + Add an Anthropic API key + + The key is sent directly to this bb server and stored in its + protected Account Pool secret directory. + + + setApiKey(event.target.value)} + /> + + + + + + ) : null} + + +
+ ); +} + +export default definePluginApp((app) => { + app.slots.settingsSection({ + id: "accounts", + component: AccountPoolSettings, + }); +}); diff --git a/plugins/account-pool/package.json b/plugins/account-pool/package.json index 1a0ebd5009..18e39582e2 100644 --- a/plugins/account-pool/package.json +++ b/plugins/account-pool/package.json @@ -13,20 +13,28 @@ "branding": { "icon": "Layers" }, - "server": "./src/server.ts" + "server": "./src/server.ts", + "app": "./app.tsx" }, "scripts": { "typecheck": "tsc --noEmit", "test": "vitest run --config vitest.config.ts" }, "dependencies": { + "@bb/shared-ui": "workspace:*", "zod": "^4.3.6" }, "devDependencies": { "@get-bb/plugin-sdk": "workspace:*", + "@radix-ui/react-dialog": "^1.1.19", + "@testing-library/react": "^16.3.2", "@types/better-sqlite3": "^7.6.12", "@types/node": "^22.0.0", + "@types/react": "^19.0.0", "better-sqlite3": "12.10.0", + "jsdom": "^29.0.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", "typescript": "npm:@typescript/typescript6@^6.0.2", "vitest": "^4.1.1" } diff --git a/plugins/account-pool/src/cli.ts b/plugins/account-pool/src/cli.ts index 892bcfee6b..3a13487160 100644 --- a/plugins/account-pool/src/cli.ts +++ b/plugins/account-pool/src/cli.ts @@ -3,11 +3,13 @@ import { accountAddInputSchema, accountIdInputSchema, bypassInputSchema, + loginCompleteInputSchema, tokenRotateInputSchema, type AccountSummary, type PoolStatus, } from "./contracts.js"; import type { PoolOperations } from "./operations.js"; +import type { ClaudeOAuthLogin } from "./oauth-login.js"; interface ParsedFlags { booleans: Set; @@ -17,6 +19,8 @@ interface ParsedFlags { const HELP = [ "Usage:", " bb pool account add --provider claude --import [--label ] [--priority ]", + " bb pool account add --provider claude --login", + " printf '%s\\n' \"$CLAUDE_AUTH_CODE\" | bb pool account login-complete --session --code-stdin", " bb pool account add --provider claude --api-key-stdin [--label ] [--priority ]", " bb pool account add --provider claude --api-key [--label ] [--priority ] Unsafe: exposes the key in process arguments.", " bb pool account list [--json]", @@ -123,6 +127,7 @@ function json(value: object): string { export function registerPoolCli( bb: Pick, operations: PoolOperations, + login: ClaudeOAuthLogin, ): void { bb.cli.register({ name: "pool", @@ -131,9 +136,15 @@ export function registerPoolCli( { name: "account-add", summary: - "Import Claude Code OAuth credentials or add an Anthropic API key", + "Sign in to Claude, import Claude Code credentials, or add an Anthropic API key", usage: - "bb pool account add --provider claude (--import | --api-key-stdin) [--label ] [--priority ]\nUnsafe compatibility form: bb pool account add --provider claude --api-key [--label ] [--priority ]", + "bb pool account add --provider claude --login\nbb pool account add --provider claude (--import | --api-key-stdin) [--label ] [--priority ]\nUnsafe compatibility form: bb pool account add --provider claude --api-key [--label ] [--priority ]", + }, + { + name: "account-login-complete", + summary: "Complete a Claude browser login with its manual code", + usage: + "printf '%s\\n' \"$CLAUDE_AUTH_CODE\" | bb pool account login-complete --session --code-stdin", }, { name: "account-list", @@ -179,20 +190,43 @@ export function registerPoolCli( if (argv[0] === "account" && argv[1] === "add") { const flags = parseFlags( argv.slice(2), - ["import", "api-key-stdin"], + ["import", "api-key-stdin", "login"], ["provider", "api-key", "label", "priority"], ); const imported = flags.booleans.has("import"); const apiKeyStdin = flags.booleans.has("api-key-stdin"); + const loginRequested = flags.booleans.has("login"); const apiKey = flags.values.get("api-key"); const sourceCount = Number(imported) + Number(apiKeyStdin) + + Number(loginRequested) + Number(apiKey !== undefined); if (sourceCount !== 1) throw new Error( - "Choose exactly one of --import, --api-key-stdin, or --api-key .", + "Choose exactly one of --login, --import, --api-key-stdin, or --api-key .", ); + if (loginRequested) { + if (flags.values.get("provider") !== "claude") { + throw new Error("--login requires --provider claude."); + } + if (flags.values.has("label") || flags.values.has("priority")) { + throw new Error("--login does not accept --label or --priority."); + } + const started = login.start(); + return { + exitCode: 0, + stdout: `${[ + "Open this URL to sign in to Claude:", + started.authorizeUrl, + "", + `Session ID: ${started.sessionId}`, + "", + "After signing in, pipe the code shown on the final page into:", + `printf '%s\\n' \"$CLAUDE_AUTH_CODE\" | bb pool account login-complete --session ${started.sessionId} --code-stdin`, + ].join("\n")}\n`, + }; + } if (apiKeyStdin) { throw new Error( "--api-key-stdin must be invoked through the bb CLI so it can read stdin safely.", @@ -211,6 +245,27 @@ export function registerPoolCli( stdout: `Added ${account.label} (${account.id}).\n`, }; } + if (argv[0] === "account" && argv[1] === "login-complete") { + const flags = parseFlags( + argv.slice(2), + ["code-stdin"], + ["session", "code"], + ); + if (flags.booleans.has("code-stdin")) { + throw new Error( + "--code-stdin requires the current bb CLI so it can read stdin safely.", + ); + } + const input = loginCompleteInputSchema.parse({ + sessionId: flags.values.get("session"), + pasted: flags.values.get("code"), + }); + const account = await login.complete(input); + return { + exitCode: 0, + stdout: `Added ${account.label} (${account.id}).\n`, + }; + } if (argv[0] === "account" && argv[1] === "list") { const flags = parseFlags(argv.slice(2), ["json"], []); const accounts = await operations.list(); diff --git a/plugins/account-pool/src/contracts.ts b/plugins/account-pool/src/contracts.ts index adb3169f44..5f9e952a8a 100644 --- a/plugins/account-pool/src/contracts.ts +++ b/plugins/account-pool/src/contracts.ts @@ -133,6 +133,20 @@ export const accountAddInputSchema = z export type AccountAddInput = z.infer; +export const loginStartSchema = z + .object({ + sessionId: z.string().uuid(), + authorizeUrl: z.string().url(), + }) + .strict(); + +export const loginCompleteInputSchema = z + .object({ + sessionId: z.string().uuid(), + pasted: z.string().trim().min(1), + }) + .strict(); + export const accountIdInputSchema = z .object({ id: z.string().uuid() }) .strict(); diff --git a/plugins/account-pool/src/oauth-login.test.ts b/plugins/account-pool/src/oauth-login.test.ts new file mode 100644 index 0000000000..aa22772635 --- /dev/null +++ b/plugins/account-pool/src/oauth-login.test.ts @@ -0,0 +1,257 @@ +import http, { type IncomingMessage, type ServerResponse } from "node:http"; +import { afterEach, describe, expect, it } from "vitest"; +import type { Account } from "./contracts.js"; +import { + ClaudeOAuthLogin, + parseManualCode, + type ClaudeOAuthAccount, +} from "./oauth-login.js"; + +type Handler = ( + request: IncomingMessage, + response: ServerResponse, +) => void | Promise; + +const cleanups: Array<() => Promise> = []; + +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()?.(); +}); + +async function startServer(handler: Handler): Promise { + const server = http.createServer((request, response) => { + Promise.resolve(handler(request, response)).catch((error) => { + response.statusCode = 500; + response.end(error instanceof Error ? error.message : String(error)); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + cleanups.push( + () => + new Promise((resolve, reject) => { + server.closeAllConnections(); + server.close((error) => (error ? reject(error) : resolve())); + }), + ); + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("OAuth test server did not bind."); + } + return `http://127.0.0.1:${address.port}`; +} + +async function requestBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8")); + if (typeof parsed !== "object" || parsed === null) { + throw new Error("Expected an object request body."); + } + return parsed; +} + +function savedAccount(authenticated: ClaudeOAuthAccount): Account { + return { + id: "11111111-1111-4111-8111-111111111111", + provider: "claude", + kind: "oauth", + label: authenticated.label, + email: authenticated.email, + subscriptionType: authenticated.subscriptionType, + rateLimitTier: authenticated.rateLimitTier, + enabled: true, + priority: 100, + createdAt: 1, + }; +} + +describe("Claude OAuth login", () => { + it("accepts callback URLs, code#state, and bare codes and fills the account from the profile", async () => { + const exchanges: object[] = []; + const profileHeaders: Array = []; + const serverUrl = await startServer(async (request, response) => { + if (request.url?.startsWith("/authorize")) { + response.end("authorize"); + return; + } + if (request.url === "/token") { + exchanges.push(await requestBody(request)); + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + access_token: "access-token", + refresh_token: "refresh-token", + expires_in: 3600, + }), + ); + return; + } + if (request.url === "/profile") { + const beta = request.headers["anthropic-beta"]; + profileHeaders.push(Array.isArray(beta) ? beta[0] : beta); + expect(request.headers.authorization).toBe("Bearer access-token"); + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + account: { + email: "person@example.com", + display_name: "Personal Claude", + has_claude_max: true, + rate_limit_tier: "default_claude_max_5x", + }, + organization: { name: "Personal" }, + }), + ); + return; + } + response.statusCode = 404; + response.end(); + }); + const saved: ClaudeOAuthAccount[] = []; + const login = new ClaudeOAuthLogin({ + authorizeUrl: `${serverUrl}/authorize`, + tokenUrl: `${serverUrl}/token`, + profileUrl: `${serverUrl}/profile`, + now: () => 10_000, + addAccount: async (authenticated) => { + saved.push(authenticated); + return savedAccount(authenticated); + }, + }); + + const shapes: Array<"url" | "hash" | "bare"> = ["url", "hash", "bare"]; + for (const shape of shapes) { + const started = login.start(); + const authorize = new URL(started.authorizeUrl); + expect(await (await fetch(started.authorizeUrl)).text()).toBe( + "authorize", + ); + expect(authorize.searchParams.get("client_id")).toBe( + "9d1c250a-e61b-44d9-88ed-5944d1962f5e", + ); + expect(authorize.searchParams.get("response_type")).toBe("code"); + expect(authorize.searchParams.get("code_challenge_method")).toBe("S256"); + expect(authorize.searchParams.get("redirect_uri")).toBe( + "https://console.anthropic.com/oauth/code/callback", + ); + const state = authorize.searchParams.get("state"); + if (state === null) throw new Error("Missing OAuth state."); + const pasted = + shape === "url" + ? `https://console.anthropic.com/oauth/code/callback?code=code-url&state=${state}` + : shape === "hash" + ? `code-hash#${state}` + : "code-bare"; + await expect( + login.complete({ sessionId: started.sessionId, pasted }), + ).resolves.toMatchObject({ + label: "Personal Claude", + email: "person@example.com", + subscriptionType: "max", + rateLimitTier: "default_claude_max_5x", + }); + } + + expect(saved).toHaveLength(3); + expect(saved[0]).toMatchObject({ + accessToken: "access-token", + refreshToken: "refresh-token", + expiresAt: 3_610_000, + }); + expect(exchanges.map((exchange) => Reflect.get(exchange, "code"))).toEqual([ + "code-url", + "code-hash", + "code-bare", + ]); + expect(exchanges.every((exchange) => Reflect.get(exchange, "state"))).toBe( + true, + ); + expect(profileHeaders).toEqual([ + "oauth-2025-04-20", + "oauth-2025-04-20", + "oauth-2025-04-20", + ]); + }); + + it("rejects a mismatched state before token exchange", async () => { + let tokenRequests = 0; + const serverUrl = await startServer((_request, response) => { + tokenRequests += 1; + response.end(); + }); + const login = new ClaudeOAuthLogin({ + tokenUrl: serverUrl, + addAccount: async (authenticated) => savedAccount(authenticated), + }); + const started = login.start(); + await expect( + login.complete({ + sessionId: started.sessionId, + pasted: "authorization-code#wrong-state", + }), + ).rejects.toThrow("OAuth state mismatch. Start again."); + expect(tokenRequests).toBe(0); + }); + + it("expires an in-memory login session after ten minutes", async () => { + let now = 1_000; + const login = new ClaudeOAuthLogin({ + now: () => now, + addAccount: async (authenticated) => savedAccount(authenticated), + }); + const started = login.start(); + now += 10 * 60 * 1_000; + await expect( + login.complete({ sessionId: started.sessionId, pasted: "code" }), + ).rejects.toThrow("Code expired, start again."); + }); + + it("returns a user-readable exchange failure and consumes the session", async () => { + const serverUrl = await startServer((_request, response) => { + response.statusCode = 400; + response.end("rejected secret detail"); + }); + const login = new ClaudeOAuthLogin({ + tokenUrl: serverUrl, + addAccount: async (authenticated) => savedAccount(authenticated), + }); + const started = login.start(); + await expect( + login.complete({ sessionId: started.sessionId, pasted: "bad-code" }), + ).rejects.toThrow("Claude token exchange failed (HTTP 400). Start again."); + await expect( + login.complete({ sessionId: started.sessionId, pasted: "bad-code" }), + ).rejects.toThrow("Login session was not found. Start again."); + }); + + it("does not expose malformed token response contents", async () => { + const serverUrl = await startServer((_request, response) => { + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ access_token: "sensitive-token" })); + }); + const login = new ClaudeOAuthLogin({ + tokenUrl: serverUrl, + addAccount: async (authenticated) => savedAccount(authenticated), + }); + const started = login.start(); + const completion = login.complete({ + sessionId: started.sessionId, + pasted: "authorization-code", + }); + + await expect(completion).rejects.toThrow( + "Claude token exchange returned an invalid response. Start again.", + ); + await expect(completion).rejects.not.toThrow("sensitive-token"); + }); +}); + +describe("parseManualCode", () => { + it("rejects empty input", () => { + expect(() => parseManualCode(" ", "state")).toThrow( + "Paste the authorization code.", + ); + }); +}); diff --git a/plugins/account-pool/src/oauth-login.ts b/plugins/account-pool/src/oauth-login.ts new file mode 100644 index 0000000000..2ca9d375f3 --- /dev/null +++ b/plugins/account-pool/src/oauth-login.ts @@ -0,0 +1,241 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { z } from "zod"; +import type { Account } from "./contracts.js"; + +const OAUTH_AUTHORIZE_URL = "https://claude.ai/oauth/authorize"; +const OAUTH_TOKEN_URL = "https://platform.claude.com/v1/oauth/token"; +const OAUTH_PROFILE_URL = "https://api.anthropic.com/api/oauth/profile"; +const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; +const OAUTH_SCOPES = + "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"; +const OAUTH_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"; +const OAUTH_BETA = "oauth-2025-04-20"; +const LOGIN_SESSION_TTL_MS = 10 * 60 * 1_000; + +const tokenResponseSchema = z + .object({ + access_token: z.string().min(1), + refresh_token: z.string().min(1), + expires_in: z.number().positive(), + }) + .passthrough(); + +const profileResponseSchema = z + .object({ + account: z + .object({ + email: z.string().email().nullish(), + display_name: z.string().trim().min(1).nullish(), + has_claude_max: z.boolean().nullish(), + has_claude_pro: z.boolean().nullish(), + subscription_type: z.string().trim().min(1).nullish(), + rate_limit_tier: z.string().trim().min(1).nullish(), + }) + .passthrough(), + organization: z + .object({ + name: z.string().trim().min(1).nullish(), + organization_type: z.string().trim().min(1).nullish(), + rate_limit_tier: z.string().trim().min(1).nullish(), + }) + .passthrough() + .nullish(), + }) + .passthrough(); + +interface LoginSession { + sessionId: string; + codeVerifier: string; + state: string; + createdAt: number; +} + +export interface ClaudeOAuthAccount { + label: string; + email: string | null; + subscriptionType: string | null; + rateLimitTier: string | null; + accessToken: string; + refreshToken: string; + expiresAt: number; +} + +export interface ClaudeOAuthLoginOptions { + fetch?: typeof fetch; + now?: () => number; + authorizeUrl?: string; + tokenUrl?: string; + profileUrl?: string; + addAccount: (authenticated: ClaudeOAuthAccount) => Promise; +} + +export interface OAuthLoginStart { + sessionId: string; + authorizeUrl: string; +} + +export interface OAuthLoginComplete { + sessionId: string; + pasted: string; +} + +export function parseManualCode( + pasted: string, + expectedState: string, +): { code: string; state: string } { + const trimmed = pasted.trim(); + if (trimmed.length === 0) throw new Error("Paste the authorization code."); + try { + const url = new URL(trimmed); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + if (code !== null) { + if (state !== null && state !== expectedState) { + throw new Error("OAuth state mismatch. Start again."); + } + return { code, state: state ?? expectedState }; + } + } catch (error) { + if ( + error instanceof Error && + error.message === "OAuth state mismatch. Start again." + ) { + throw error; + } + } + const separator = trimmed.indexOf("#"); + if (separator >= 0) { + const code = trimmed.slice(0, separator).trim(); + const state = trimmed.slice(separator + 1).trim(); + if (code.length === 0) throw new Error("Paste the authorization code."); + if (state.length > 0 && state !== expectedState) { + throw new Error("OAuth state mismatch. Start again."); + } + return { code, state: state.length > 0 ? state : expectedState }; + } + return { code: trimmed, state: expectedState }; +} + +export class ClaudeOAuthLogin { + private session: LoginSession | null = null; + private readonly fetch: typeof fetch; + private readonly now: () => number; + private readonly authorizeUrl: string; + private readonly tokenUrl: string; + private readonly profileUrl: string; + + constructor(private readonly options: ClaudeOAuthLoginOptions) { + this.fetch = options.fetch ?? fetch; + this.now = options.now ?? Date.now; + this.authorizeUrl = options.authorizeUrl ?? OAUTH_AUTHORIZE_URL; + this.tokenUrl = options.tokenUrl ?? OAUTH_TOKEN_URL; + this.profileUrl = options.profileUrl ?? OAUTH_PROFILE_URL; + } + + start(): OAuthLoginStart { + const codeVerifier = randomBytes(32).toString("base64url"); + const codeChallenge = createHash("sha256") + .update(codeVerifier) + .digest("base64url"); + const state = randomBytes(32).toString("base64url"); + const sessionId = randomUUID(); + this.session = { + sessionId, + codeVerifier, + state, + createdAt: this.now(), + }; + const authorizeUrl = new URL(this.authorizeUrl); + authorizeUrl.searchParams.set("code", "true"); + authorizeUrl.searchParams.set("client_id", OAUTH_CLIENT_ID); + authorizeUrl.searchParams.set("response_type", "code"); + authorizeUrl.searchParams.set("redirect_uri", OAUTH_REDIRECT_URI); + authorizeUrl.searchParams.set("scope", OAUTH_SCOPES); + authorizeUrl.searchParams.set("code_challenge", codeChallenge); + authorizeUrl.searchParams.set("code_challenge_method", "S256"); + authorizeUrl.searchParams.set("state", state); + return { sessionId, authorizeUrl: authorizeUrl.toString() }; + } + + async complete(input: OAuthLoginComplete): Promise { + const session = this.session; + if (session === null || session.sessionId !== input.sessionId) { + throw new Error("Login session was not found. Start again."); + } + this.session = null; + if (this.now() - session.createdAt >= LOGIN_SESSION_TTL_MS) { + throw new Error("Code expired, start again."); + } + const parsed = parseManualCode(input.pasted, session.state); + const tokenResponse = await this.fetch(this.tokenUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + grant_type: "authorization_code", + code: parsed.code, + state: parsed.state, + code_verifier: session.codeVerifier, + redirect_uri: OAUTH_REDIRECT_URI, + client_id: OAUTH_CLIENT_ID, + }), + }); + if (!tokenResponse.ok) { + await tokenResponse.body?.cancel(); + throw new Error( + `Claude token exchange failed (HTTP ${tokenResponse.status}). Start again.`, + ); + } + const tokenPayload = await tokenResponse.json().catch(() => null); + const parsedTokens = tokenResponseSchema.safeParse(tokenPayload); + if (!parsedTokens.success) { + throw new Error( + "Claude token exchange returned an invalid response. Start again.", + ); + } + const tokens = parsedTokens.data; + const profileResponse = await this.fetch(this.profileUrl, { + headers: { + authorization: `Bearer ${tokens.access_token}`, + "anthropic-beta": OAUTH_BETA, + }, + }); + if (!profileResponse.ok) { + await profileResponse.body?.cancel(); + throw new Error( + `Claude profile lookup failed (HTTP ${profileResponse.status}). Start again.`, + ); + } + const profilePayload = await profileResponse.json().catch(() => null); + const parsedProfile = profileResponseSchema.safeParse(profilePayload); + if (!parsedProfile.success) { + throw new Error( + "Claude profile lookup returned an invalid response. Start again.", + ); + } + const profile = parsedProfile.data; + const email = profile.account.email ?? null; + const subscriptionType = profile.account.has_claude_max + ? "max" + : profile.account.has_claude_pro + ? "pro" + : (profile.account.subscription_type ?? + profile.organization?.organization_type ?? + null); + return this.options.addAccount({ + label: + profile.account.display_name ?? + email ?? + profile.organization?.name ?? + "Claude account", + email, + subscriptionType, + rateLimitTier: + profile.account.rate_limit_tier ?? + profile.organization?.rate_limit_tier ?? + null, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt: this.now() + tokens.expires_in * 1_000, + }); + } +} diff --git a/plugins/account-pool/src/operations.ts b/plugins/account-pool/src/operations.ts index 7c1df4268a..05c0d05715 100644 --- a/plugins/account-pool/src/operations.ts +++ b/plugins/account-pool/src/operations.ts @@ -17,6 +17,7 @@ import type { QuotaStore, RoutingStore, } from "./store.js"; +import type { ClaudeOAuthAccount } from "./oauth-login.js"; interface PoolHost { id: string; @@ -44,11 +45,12 @@ export class PoolOperations { ) => Promise, private readonly now: () => number = Date.now, private readonly importCredentials: () => Promise = importClaudeCredentials, + private readonly onAccountsChanged: () => void = () => {}, ) {} async add(input: AccountAddInput): Promise { if (input.source.kind === "api-key") { - return this.accounts.add( + const account = await this.accounts.add( { provider: input.provider, kind: "api-key", @@ -61,9 +63,11 @@ export class PoolOperations { }, { kind: "api-key", apiKey: input.source.apiKey }, ); + this.onAccountsChanged(); + return account; } const imported = await this.importCredentials(); - return this.accounts.add( + const account = await this.accounts.add( { provider: input.provider, kind: "oauth", @@ -81,6 +85,31 @@ export class PoolOperations { expiresAt: imported.expiresAt, }, ); + this.onAccountsChanged(); + return account; + } + + async addOAuth(authenticated: ClaudeOAuthAccount): Promise { + const account = await this.accounts.add( + { + provider: "claude", + kind: "oauth", + label: authenticated.label, + email: authenticated.email, + subscriptionType: authenticated.subscriptionType, + rateLimitTier: authenticated.rateLimitTier, + enabled: true, + priority: 100, + }, + { + kind: "oauth", + accessToken: authenticated.accessToken, + refreshToken: authenticated.refreshToken, + expiresAt: authenticated.expiresAt, + }, + ); + this.onAccountsChanged(); + return account; } async list(): Promise { @@ -89,7 +118,10 @@ export class PoolOperations { async remove(id: string): Promise { const removed = await this.accounts.remove(id); - if (removed) this.quotas.remove(id); + if (removed) { + this.quotas.remove(id); + this.onAccountsChanged(); + } return removed; } @@ -98,11 +130,14 @@ export class PoolOperations { if (account === null) return null; const quota = this.quotas.get(id); this.quotas.put({ ...quota, error: null, heldUntil: null }); + this.onAccountsChanged(); return account; } async disable(id: string): Promise { - return this.accounts.setEnabled(id, false); + const account = await this.accounts.setEnabled(id, false); + if (account !== null) this.onAccountsChanged(); + return account; } async status(): Promise { diff --git a/plugins/account-pool/src/realtime.ts b/plugins/account-pool/src/realtime.ts new file mode 100644 index 0000000000..c3e6d61f98 --- /dev/null +++ b/plugins/account-pool/src/realtime.ts @@ -0,0 +1 @@ +export const ACCOUNT_POOL_ACCOUNTS_CHANGED = "accounts-changed"; diff --git a/plugins/account-pool/src/rpc.ts b/plugins/account-pool/src/rpc.ts index e5ff8570af..74f4af1ca9 100644 --- a/plugins/account-pool/src/rpc.ts +++ b/plugins/account-pool/src/rpc.ts @@ -8,10 +8,13 @@ import { bypassInputSchema, bypassResultSchema, hubTokenSummarySchema, + loginCompleteInputSchema, + loginStartSchema, statusSchema, tokenRotateInputSchema, } from "./contracts.js"; import type { PoolOperations } from "./operations.js"; +import type { ClaudeOAuthLogin } from "./oauth-login.js"; export const accountPoolRpcContract = defineRpcContract({ "account.add": { @@ -34,6 +37,14 @@ export const accountPoolRpcContract = defineRpcContract({ input: accountIdInputSchema, output: z.object({ account: accountSchema.nullable() }).strict(), }, + "login.start": { + input: z.null(), + output: loginStartSchema, + }, + "login.complete": { + input: loginCompleteInputSchema, + output: accountSchema, + }, status: { input: z.null(), output: statusSchema, @@ -48,7 +59,10 @@ export const accountPoolRpcContract = defineRpcContract({ }, }); -export function createRpcHandlers(operations: PoolOperations) { +export function createRpcHandlers( + operations: PoolOperations, + login: ClaudeOAuthLogin, +) { return { "account.add": (input: Parameters[0]) => operations.add(input), @@ -62,6 +76,9 @@ export function createRpcHandlers(operations: PoolOperations) { "account.disable": async ({ id }: { id: string }) => ({ account: await operations.disable(id), }), + "login.start": () => login.start(), + "login.complete": (input: { sessionId: string; pasted: string }) => + login.complete(input), status: () => operations.status(), "token.rotate": ({ machine }: { machine: string }) => operations.rotateToken(machine), diff --git a/plugins/account-pool/src/server.test.ts b/plugins/account-pool/src/server.test.ts index dd33497e52..0812589e22 100644 --- a/plugins/account-pool/src/server.test.ts +++ b/plugins/account-pool/src/server.test.ts @@ -387,6 +387,9 @@ describe("Account Pool plugin", () => { "--help", ]); expect(help.exitCode).toBe(0); + expect(help.stdout).toContain("--login"); + expect(help.stdout).toContain("account login-complete"); + expect(help.stdout).toContain("--code-stdin"); expect(help.stdout).toContain("--api-key-stdin"); expect(help.stdout).toContain("Unsafe: exposes the key"); const list = await fixture.host.harness.behavior.runCli([ @@ -459,6 +462,147 @@ describe("Account Pool plugin", () => { ).toEqual([]); }); + it("exposes manual Claude login over RPC and the two-step CLI", async () => { + const tokenBodies: object[] = []; + const oauth = await startUpstream(async (request, response) => { + if (request.url === "/token") { + tokenBodies.push( + JSON.parse((await readRequestBody(request)).toString()), + ); + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + access_token: "login-access", + refresh_token: "login-refresh", + expires_in: 3600, + }), + ); + return; + } + if (request.url === "/profile") { + expect(request.headers.authorization).toBe("Bearer login-access"); + expect(request.headers["anthropic-beta"]).toBe("oauth-2025-04-20"); + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + account: { + email: "login@example.com", + display_name: "Logged-in Claude", + has_claude_pro: true, + rate_limit_tier: "default_claude_pro", + }, + }), + ); + return; + } + response.statusCode = 404; + response.end(); + }); + cleanups.push(oauth.close); + const dataDir = await mkdtemp(path.join(tmpdir(), "bb-pool-login-rpc-")); + const host = createFakePluginHost({ + pluginId: "account-pool", + dataDir, + sdk: sdkStubs(), + }); + await createAccountPoolPlugin({ + oauthAuthorizeUrl: `${oauth.url}/authorize`, + oauthTokenUrl: `${oauth.url}/token`, + oauthProfileUrl: `${oauth.url}/profile`, + })(host.bb); + cleanups.push(async () => { + await host.harness.lifecycle.dispose(); + await fs.rm(dataDir, { recursive: true, force: true }); + }); + const started = z + .object({ sessionId: z.string().uuid(), authorizeUrl: z.string().url() }) + .strict() + .parse(await host.harness.behavior.callRpc("login.start", null)); + const state = new URL(started.authorizeUrl).searchParams.get("state"); + if (state === null) throw new Error("Login start did not return state."); + const account = accountSchema.parse( + await host.harness.behavior.callRpc("login.complete", { + sessionId: started.sessionId, + pasted: `login-code#${state}`, + }), + ); + expect(account).toMatchObject({ + label: "Logged-in Claude", + email: "login@example.com", + subscriptionType: "pro", + rateLimitTier: "default_claude_pro", + kind: "oauth", + enabled: true, + }); + expect(tokenBodies).toHaveLength(1); + expect(tokenBodies[0]).toMatchObject({ + code: "login-code", + state, + grant_type: "authorization_code", + redirect_uri: "https://console.anthropic.com/oauth/code/callback", + client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e", + }); + const secret = accountSecretSchema.parse( + JSON.parse( + await fs.readFile( + path.join( + dataDir, + "plugins", + "account-pool", + "secrets", + "accounts", + `account-${account.id}.json`, + ), + "utf8", + ), + ), + ); + expect(secret).toMatchObject({ + kind: "oauth", + accessToken: "login-access", + refreshToken: "login-refresh", + }); + expect(host.harness.inspection.realtimeSignals).toContainEqual({ + channel: "accounts-changed", + payload: {}, + }); + + const cliStarted = await host.harness.behavior.runCli([ + "account", + "add", + "--provider", + "claude", + "--login", + ]); + expect(cliStarted.exitCode).toBe(0); + expect(cliStarted.stdout).toContain("Open this URL to sign in to Claude:"); + expect(cliStarted.stdout).toContain("account login-complete"); + expect(cliStarted.stdout).toContain("--code-stdin"); + const sessionId = cliStarted.stdout.match(/Session ID: ([0-9a-f-]+)/u)?.[1]; + const authorizeUrl = cliStarted.stdout.match( + /Open this URL to sign in to Claude:\n([^\n]+)/u, + )?.[1]; + if (sessionId === undefined || authorizeUrl === undefined) { + throw new Error("CLI login start did not return its session and URL."); + } + const cliState = new URL(authorizeUrl).searchParams.get("state"); + if (cliState === null) throw new Error("CLI login start omitted state."); + const cliCompleted = await host.harness.behavior.runCli([ + "account", + "login-complete", + "--session", + sessionId, + "--code", + `cli-code#${cliState}`, + ]); + expect(cliCompleted).toMatchObject({ + exitCode: 0, + stdout: expect.stringContaining("Added Logged-in Claude"), + }); + expect(tokenBodies).toHaveLength(2); + expect(tokenBodies[1]).toMatchObject({ code: "cli-code", state: cliState }); + }); + it("resolves distinct secret machine tokens and honors per-thread bypass", async () => { const upstream = await startUpstream(async (request, response) => { await readRequestBody(request); diff --git a/plugins/account-pool/src/server.ts b/plugins/account-pool/src/server.ts index 6e4f813866..f1a0ece7ff 100644 --- a/plugins/account-pool/src/server.ts +++ b/plugins/account-pool/src/server.ts @@ -6,6 +6,8 @@ import type { ImportedClaudeCredentials } from "./credentials.js"; import { createHub } from "./hub.js"; import { PoolOperations } from "./operations.js"; import { accountPoolRpcContract, createRpcHandlers } from "./rpc.js"; +import { ClaudeOAuthLogin } from "./oauth-login.js"; +import { ACCOUNT_POOL_ACCOUNTS_CHANGED } from "./realtime.js"; import { AccountStore, HubTokenStore, @@ -21,6 +23,9 @@ export interface AccountPoolPluginOptions { drainTimeoutMs?: number; disposeTimeoutMs?: number; importCredentials?: () => Promise; + oauthAuthorizeUrl?: string; + oauthTokenUrl?: string; + oauthProfileUrl?: string; } const DISPOSE_INSPECTION_TIMEOUT_MS = 2_000; @@ -103,14 +108,26 @@ export function createAccountPoolPlugin( (await bb.sdk.system.providerStates({ hostId })).providers, now, options.importCredentials, + () => bb.realtime.publish(ACCOUNT_POOL_ACCOUNTS_CHANGED, {}), ); + const login = new ClaudeOAuthLogin({ + fetch: options.fetch, + now, + authorizeUrl: options.oauthAuthorizeUrl, + tokenUrl: options.oauthTokenUrl, + profileUrl: options.oauthProfileUrl, + addAccount: (authenticated) => operations.addOAuth(authenticated), + }); if ((await accounts.list()).every((account) => !account.enabled)) { bb.status.needsConfiguration( "Add and enable a Claude account with `bb pool account add`.", ); } - bb.rpc.register(accountPoolRpcContract, createRpcHandlers(operations)); - registerPoolCli(bb, operations); + bb.rpc.register( + accountPoolRpcContract, + createRpcHandlers(operations, login), + ); + registerPoolCli(bb, operations, login); bb.providers.experimental_contributeEnv("claude-code", async (context) => { if ( (await routing.isBypassed(context.threadId)) || diff --git a/plugins/account-pool/tsconfig.json b/plugins/account-pool/tsconfig.json index c7a065987d..758395fd9a 100644 --- a/plugins/account-pool/tsconfig.json +++ b/plugins/account-pool/tsconfig.json @@ -9,14 +9,21 @@ "noEmit": true, "skipLibCheck": true, "types": ["node", "vitest/globals"], + "jsx": "react-jsx", "paths": { "@get-bb/plugin-sdk": [ "../../packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts" ], "@get-bb/plugin-sdk/testing": [ "../../packages/plugin-sdk/bundled-types/bb-plugin-sdk-testing.d.ts" + ], + "@get-bb/plugin-sdk/app": [ + "../../packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts" + ], + "@get-bb/plugin-sdk/testing/app": [ + "../../packages/plugin-sdk/bundled-types/bb-plugin-sdk-testing-app.d.ts" ] } }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "app.tsx", "app.test.tsx"] } diff --git a/plugins/account-pool/vitest.config.ts b/plugins/account-pool/vitest.config.ts index 6ab8633a37..4341aa864a 100644 --- a/plugins/account-pool/vitest.config.ts +++ b/plugins/account-pool/vitest.config.ts @@ -3,7 +3,7 @@ import { defineWorkspaceTestConfig } from "../../vitest.shared.js"; export default defineWorkspaceTestConfig({ test: { name: "bb-plugin-account-pool", - include: ["**/*.test.ts"], + include: ["**/*.test.{ts,tsx}"], exclude: ["dist/**", "node_modules/**"], }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ca3391758..490a94d1f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2748,6 +2748,9 @@ importers: plugins/account-pool: dependencies: + '@bb/shared-ui': + specifier: workspace:* + version: link:../../packages/shared-ui zod: specifier: 4.3.6 version: 4.3.6 @@ -2755,15 +2758,33 @@ importers: '@get-bb/plugin-sdk': specifier: workspace:* version: link:../../packages/plugin-sdk + '@radix-ui/react-dialog': + specifier: ^1.1.19 + version: 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/better-sqlite3': specifier: ^7.6.12 version: 7.6.13 '@types/node': specifier: ^22.0.0 version: 22.19.10 + '@types/react': + specifier: ^19.0.0 + version: 19.2.13 better-sqlite3: specifier: 12.10.0 version: 12.10.0 + jsdom: + specifier: ^29.0.1 + version: 29.0.1(@noble/hashes@2.4.0) + react: + specifier: ^19.0.0 + version: 19.2.4 + react-dom: + specifier: ^19.0.0 + version: 19.2.4(react@19.2.4) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2'