diff --git a/AGENTS.md b/AGENTS.md index 9004f630..eed30a16 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,5 +76,6 @@ itself driving one of them. or account credentials. Development, demos and tests keep this network check disabled. - **Approved by Val on 2026-10-24 (#377 / #376):** Main may read bounded model metadata from an explicitly configured loopback connection using Ollama's `/api/tags`, `/api/ps` and metadata-only `/api/show`, LM Studio's model listings, or a compatible `/v1/models` listing. Requests use only that connection's credential, refuse redirects, proxy routing and non-loopback destinations, and carry no prompts, files or cloud-account credentials. Discovery must never generate inference, download/load/unload models or execute tools. Coding inference remains inside `codex app-server` or `claude -p`; discovery proves neither coding compatibility nor offline inference. This exception authorizes only metadata discovery; local execution admission and runtime qualification retain their separate gates. - **Approved by Val on 2026-10-25 (#326):** Code Review may write to GitHub through the person's own `gh` login, on a separate main-owned `code-review:write` channel that is never `code-review:read`. Main runs only fixed `gh api` mutation endpoints it builds from its own reads (a top-level PR comment; a submitted review; a merge that passes the reviewed head SHA and offers only the methods the repository allows; and decisions 2–4: closing, reopening and draft state, title and description edits that are refused if GitHub's text changed, and reviewer requests). The only GraphQL is GitHub's two fixed draft mutations; there is no DELETE. Before a preview and again before sending, main reads the signed-in account, the repository's permission for it and the PR's head and target itself; the renderer's word, model findings and check counts are never authority. Sends are explicit, preview-bound and never retried on their own; an unclear outcome stays unknown until a read-back proves it or the person decides. Modex never sees or stores the token. Tests use a synthetic `gh`; a live write runs only against a disposable repository Val names. +- **Approved by Val on 2026-11-01 (#467):** A Claude account may be an OrcaRouter gateway key (`sk-orca-…`). Modex itself never calls OrcaRouter: main only starts `claude -p` with `ANTHROPIC_BASE_URL=https://api.orcarouter.ai` and the key as `ANTHROPIC_AUTH_TOKEN`, so turns for that account go to OrcaRouter through Claude Code and nowhere else. Keys are stored with OS encryption in main, put only in that account's child environment with `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1`, removed from turn output, and never sent to the renderer. Accounts with their own profile keep Claude Code's own login and endpoints. Tests and e2e use a synthetic `claude`; no live OrcaRouter turn runs in CI. See `docs/claude-accounts.md`. - Scratch scripts go in `apps/desktop/.probes/` (git-ignored). Playwright wipes `apps/desktop/test-results/` on every run. diff --git a/apps/desktop/e2e/claude-accounts.spec.ts b/apps/desktop/e2e/claude-accounts.spec.ts new file mode 100644 index 00000000..a1b4076d --- /dev/null +++ b/apps/desktop/e2e/claude-accounts.spec.ts @@ -0,0 +1,144 @@ +import { test, expect, type ElectronApplication } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; +import { launch, seedHome, tid, items } from "./support"; + +// Modex's Claude account pool end to end: Settings → IPC → main's ClaudeAccounts → AccountClaudeBackend → +// the runner's usage-limit rotation, against a local executable that stands in for Claude Code 2.1.316. It +// keeps a separate login per CLAUDE_CONFIG_DIR (as the real CLI does), reports a usage limit when its profile +// holds a `limited` marker, and records each turn's account environment. It is a scripted fixture, not +// live-model or OrcaRouter evidence: no request leaves the machine. + +const KEY = "sk-orca-e2eSyntheticKey0123456789"; + +function fixture(home: string): string { + const cli = path.join(home, "claude-accounts.cjs"); + fs.writeFileSync(cli, `#!${process.execPath} +const fs = require('node:fs'); +const path = require('node:path'); +const dir = process.env.CLAUDE_CONFIG_DIR || ''; +const who = process.env.ANTHROPIC_BASE_URL ? 'gateway' : dir ? path.basename(dir) : 'cli'; +if (process.argv.includes('--version')) { console.log('2.1.316 (Claude Code)'); process.exit(0); } +if (process.argv[2] === 'auth' && process.argv[3] === 'status') { + const loggedIn = !dir || fs.existsSync(path.join(dir, 'signed-in')); + console.log(JSON.stringify({ loggedIn, authMethod: 'claude.ai' })); process.exit(loggedIn ? 0 : 1); +} +if (process.argv[2] === 'auth' && process.argv[3] === 'login') { fs.writeFileSync(path.join(dir, 'signed-in'), 'yes'); process.exit(0); } +if (path.basename(process.cwd()).startsWith('modex-title-')) process.exit(1); +fs.appendFileSync(${JSON.stringify(path.join(home, "claude-turns.jsonl"))}, JSON.stringify({ who, token: process.env.ANTHROPIC_AUTH_TOKEN === ${JSON.stringify(KEY)}, scrub: process.env.CLAUDE_CODE_SUBPROCESS_ENV_SCRUB || null }) + '\\n'); +const emit = (o) => process.stdout.write(JSON.stringify(o) + '\\n'); +require('node:readline').createInterface({ input: process.stdin }).on('line', (line) => { + const msg = JSON.parse(line); + if (msg.type !== 'user') return; + emit({ type: 'system', subtype: 'init', session_id: 'session-' + who, claude_code_version: '2.1.316' }); + if (dir && fs.existsSync(path.join(dir, 'limited'))) { + emit({ type: 'result', subtype: 'success', is_error: true, result: 'Claude AI usage limit reached|resets at 11pm', session_id: 'session-' + who }); + } else { + emit({ type: 'assistant', message: { id: 'm1', content: [{ type: 'text', text: 'Ran on ' + who }] } }); + emit({ type: 'result', subtype: 'success', is_error: false, result: 'Ran on ' + who, session_id: 'session-' + who }); + } + process.stdin.destroy(); setTimeout(() => process.exit(0), 20); +}); +`, { mode: 0o755 }); + return cli; +} + +test("Claude account pool: profiles sign in separately, an OrcaRouter key stays in main, and a usage limit rotates the turn", async () => { + const { home, repo } = seedHome({ default_backend: "claude", default_mode: "agent" }); + const statePath = path.join(home, "app/state.json"); + const state = JSON.parse(fs.readFileSync(statePath, "utf8")); + state.settings.claude_bin = fixture(home); + fs.writeFileSync(statePath, JSON.stringify(state)); + let app: ElectronApplication | undefined; + try { + const opened = await launch(home); app = opened.app; + const page = opened.page; + await tid(page, "open-settings").click(); + await tid(page, "settings-nav").getByRole("button", { name: "Coding CLIs", exact: true }).click(); + const pool = tid(page, "claude-accounts"); + const status = tid(pool, "claude-accounts-status"); + await expect(status).toContainText("Add a Claude account"); + + // Two profiles, each signed in through the CLI's own login in its own directory. + for (const name of ["Work", "Personal"]) { + await tid(pool, "claude-account-add-profile").click(); + await tid(pool, "claude-account-label").fill(name); + await pool.getByRole("button", { name: "Save account" }).click(); + await expect(status).toContainText(`Added ${name}. Sign in to it next.`); + const row = tid(pool, "claude-account-row").filter({ hasText: name }); + await expect(row).toContainText(`Signed out · sign in to ${name}`); + await row.getByRole("button", { name: "Sign in", exact: true }).click(); + await expect(status).toContainText("Claude sign-in confirmed"); + await expect(row).toContainText(`Signed in · ${name}`); + } + + // A gateway key: refused unless it is an OrcaRouter key, then stored in main and never returned. + await tid(pool, "claude-account-add-gateway").click(); + await tid(pool, "claude-account-label").fill("Orca"); + await tid(pool, "claude-account-key").fill("sk-ant-not-a-gateway-key"); + await pool.getByRole("button", { name: "Save account" }).click(); + await expect(status).toContainText("Keys start with sk-orca-"); + await tid(pool, "claude-account-key").fill(KEY); + await tid(pool, "claude-account-model").fill("orcarouter/auto"); + await pool.getByRole("button", { name: "Save account" }).click(); + await expect(status).toContainText("Saved Orca."); + await expect(tid(pool, "claude-account-row").filter({ hasText: "Orca" })).toContainText("OrcaRouter gateway key saved · orcarouter/auto"); + const ipcStatus = await page.evaluate(() => window.modex!.invoke("claudeAccounts:status", undefined)); + expect(JSON.stringify(ipcStatus)).not.toContain("e2eSyntheticKey"); + expect(fs.readFileSync(path.join(home, "app/claude-accounts.json"), "utf8")).not.toContain("e2eSyntheticKey"); + const ids = Object.fromEntries(ipcStatus.accounts.map((account) => [account.label, account.id])); + + // Work is active with rotation on; Work is usage-limited. + await tid(pool, "claude-account-select").selectOption(ids.Work!); + await expect(status).toContainText("Account selected for new conversations."); + // The box follows main's confirmed state, so it changes after the reply rather than on the click itself. + await tid(pool, "claude-account-rotate").click(); + await expect(status).toContainText("Rotation on."); + await expect(tid(pool, "claude-account-rotate")).toBeChecked(); + fs.writeFileSync(path.join(home, "app/claude-profiles", ids.Work!, "limited"), "yes"); + await page.keyboard.press("Escape"); + await expect(tid(page, "settings")).toHaveCount(0); + + // A new conversation starts on Work, hits the limit, and retries on Personal before doing any work. + await tid(page, "new-chat").click(); + await tid(page, "composer-input").fill("hello"); + await tid(page, "send").click(); + await expect(items(page, "assistant").last()).toHaveText(`Ran on ${ids.Personal}`); + await expect(items(page, "notice").filter({ hasText: "usage limit" })).toContainText(`Claude account "Work" hit its usage limit; Modex holds it out of rotation until 11:00 PM. Retrying on "Personal".`); + await expect(page.locator('[data-item-kind="notice"][data-level="error"]')).toHaveCount(0); + const turns = fs.readFileSync(path.join(home, "claude-turns.jsonl"), "utf8").trim().split("\n").map((line) => JSON.parse(line)); + expect(turns.map((turn) => turn.who)).toEqual([ids.Work, ids.Personal]); + const threadId = (await page.locator('[data-testid="thread-row"][aria-current="true"]').getAttribute("data-thread-id"))!; + const thread = await page.evaluate((id) => window.modex!.invoke("state:get", undefined).then((s: any) => s.threads.find((t: any) => t.id === id)), threadId); + expect(thread.claudeAccount).toBe(ids.Personal); + + // The pool shows Work cooling down; new conversations now start on Personal. + await tid(page, "open-settings").click(); + await tid(page, "settings-nav").getByRole("button", { name: "Coding CLIs", exact: true }).click(); + await expect(tid(pool, "claude-account-row").filter({ hasText: "Work" })).toContainText("Usage limit until 11:00 PM"); + + // The gateway runs with its key in the child's environment only, scrubbed from the model's tools. + await tid(pool, "claude-account-select").selectOption(ids.Orca!); + await expect(status).toContainText("Account selected for new conversations."); + await page.keyboard.press("Escape"); + await tid(page, "new-chat").click(); + await tid(page, "composer-input").fill("through the gateway"); + await tid(page, "send").click(); + await expect(items(page, "assistant").last()).toHaveText("Ran on gateway"); + const last = JSON.parse(fs.readFileSync(path.join(home, "claude-turns.jsonl"), "utf8").trim().split("\n").at(-1)!); + expect(last).toEqual({ who: "gateway", token: true, scrub: "1" }); + + // Removing the gateway clears its encrypted key. + await tid(page, "open-settings").click(); + await tid(page, "settings-nav").getByRole("button", { name: "Coding CLIs", exact: true }).click(); + await tid(pool, "claude-account-row").filter({ hasText: "Orca" }).getByRole("button", { name: "Remove" }).click(); + await expect(status).toContainText("Removed Orca."); + await expect(tid(pool, "claude-account-row")).toHaveCount(2); + const secrets = JSON.parse(fs.readFileSync(path.join(home, "app/secrets.json"), "utf8")); + expect(Object.keys(secrets.entries).filter((name) => name.startsWith("claude_account:"))).toEqual([]); + await page.screenshot({ path: test.info().outputPath("claude-accounts.png") }); + } finally { + await app?.close(); + fs.rmSync(home, { recursive: true, force: true }); fs.rmSync(repo, { recursive: true, force: true }); + } +}); diff --git a/apps/desktop/src/main/commands.ts b/apps/desktop/src/main/commands.ts index 7c93702a..fc189683 100644 --- a/apps/desktop/src/main/commands.ts +++ b/apps/desktop/src/main/commands.ts @@ -171,6 +171,11 @@ export function registerCommands(handle: RegisterCommand, { handle("chatgpt:signOut", () => ({ status: unavailable(), detail: "Manage ChatGPT accounts in the Modex desktop app." })); handle("claude:login", () => ({ status: "unsupported", detail: "Sign in to Claude from a terminal on this Mac." })); handle("claude:cancelLogin", () => {}); + const claudeAccountsUnavailable = () => ({ available: false, active: null, rotate: false, accounts: [], detail: "Manage Claude accounts in the Modex desktop app." }); + handle("claudeAccounts:status", claudeAccountsUnavailable); + for (const channel of ["claudeAccounts:addProfile", "claudeAccounts:addGateway", "claudeAccounts:select", "claudeAccounts:rotate", "claudeAccounts:remove", "claudeAccounts:login"] as const) { + handle(channel, () => { throw new Error("Manage Claude accounts in the Modex desktop app."); }); + } handle("shell:openPath", ({ path: p }) => openPath(p)); handle("shell:openTerminal", ({ path: p }) => openTerminal(p)); handle("terminal:open", ({ threadId, cols, rows }) => terminals.open(threadId, cols, rows)); diff --git a/apps/desktop/src/main/engine/backends/account-claude.ts b/apps/desktop/src/main/engine/backends/account-claude.ts new file mode 100644 index 00000000..45777fa8 --- /dev/null +++ b/apps/desktop/src/main/engine/backends/account-claude.ts @@ -0,0 +1,77 @@ +import { claudeSecretSink } from "./claude-secret-sink.js"; +import type { CliExecutable } from "../cli-path.js"; +import type { BackendHealth } from "../../../shared/types.js"; +import type { Backend, TitleOptions, TurnOptions, TurnResult, TurnSink } from "./types.js"; +import { ClaudeBackend } from "./claude.js"; +import { cliEnvironment, cliSpawn, health, installation, probe } from "./health.js"; +import { claudeAccountEnvironment, type ClaudeAccounts } from "../claude-accounts.js"; +import { generateTitle } from "../titles.js"; + +/** + * Claude Code under a Modex-managed account (engine/claude-accounts.ts). One `claude -p` child per + * turn, as before; only its environment changes with the turn's account. "cli" (or no account) is + * the CLI's own login, unchanged. + */ +export class AccountClaudeBackend extends ClaudeBackend { + constructor(private readonly accounts: Pick, executable: CliExecutable = "claude", spawnImpl = cliSpawn, timing: ConstructorParameters[2] = {}) { + super(executable, spawnImpl, timing); + } + + identity(): string { return this.accounts.identity(); } + + /** The base environment plus the account's additions; an empty value removes an inherited variable. */ + private overlay(base: NodeJS.ProcessEnv, account: string | undefined): NodeJS.ProcessEnv { + if (!account || account === "cli") return base; + return claudeAccountEnvironment(base, this.accounts.environment(account).env); + } + + protected override turnEnvironment(bin: string, opts: TurnOptions): NodeJS.ProcessEnv { + return this.overlay(cliEnvironment(bin), opts.account); + } + + override async runTurn(text: string, opts: TurnOptions, sink: TurnSink, signal: AbortSignal): Promise { + let secret: string | undefined; + if (opts.account && opts.account !== "cli") { + if (!this.accounts.account(opts.account)) return { status: "failed", error: "This conversation's Claude account was removed from Modex. Sign in again: add the account in Settings → Coding CLIs, or start a new thread." }; + try { secret = this.accounts.environment(opts.account).secret; } + catch (error) { return { status: "failed", error: (error as Error).message }; } + } + if (!secret) return super.runTurn(text, opts, sink, signal); + const protectedSink = claudeSecretSink(sink, secret); + try { return protectedSink.redact(await super.runTurn(text, opts, protectedSink.sink, signal)); } + catch (error) { throw new Error(protectedSink.redact(error instanceof Error ? error.message : String(error))); } + finally { protectedSink.finish(); } + } + + override generateTitle(text: string, opts: TitleOptions, signal: AbortSignal): Promise { + const runTurn = (t: string, o: TurnOptions, s: TurnSink, sig: AbortSignal) => this.runTurn(t, { ...o, account: opts.account }, s, sig); + const bound: Backend = { id: this.id, runTurn, listModels: () => this.listModels(), dispose: async () => {} }; + return generateTitle(bound, opts, text, signal); + } + + /** The selected account's health: the CLI's login, a profile's own `auth status`, or a saved gateway key. */ + override async health(): Promise { + const id = this.identity(); + if (id === "cli") return super.health(); + return accountHealth(this.bin, this.spawnImpl, this.accounts, id); + } +} + +/** One pool account's health. Profiles are checked with Claude Code's structured `auth status` in their own directory. */ +export async function accountHealth(bin: string, spawnImpl: typeof cliSpawn, accounts: Pick, id: string): Promise { + const installed = await installation(bin, spawnImpl); + if (installed.unavailable) return installed.unavailable; + const account = accounts.account(id); + if (!account) return health("signed-out", "This Claude account was removed", "available", installed.version); + let env: NodeJS.ProcessEnv; + try { env = accounts.environment(id).env; } catch (error) { return health("signed-out", (error as Error).message, "available", installed.version); } + if (account.kind === "gateway") return health("authenticated", `OrcaRouter gateway key saved · ${account.model ?? "gateway default model"} · model access unverified`, "available", installed.version); + const result = await probe(bin, ["auth", "status"], spawnImpl, 5000, claudeAccountEnvironment(process.env, env)); + if (result.failure) return health("failed", result.failure === "timeout" ? "Account check timed out" : "Account check failed", "available", installed.version); + try { + const status = JSON.parse(result.output) as { loggedIn?: unknown; email?: unknown }; + if (status.loggedIn === true && result.code === 0) return health("authenticated", `Signed in · ${account.label} · model access unverified`, "available", installed.version); + if (status.loggedIn === false && result.code === 1) return health("signed-out", `Signed out · sign in to ${account.label}`, "available", installed.version); + } catch { /* Older CLIs do not report structured status. */ } + return health("unknown", "Account status unavailable · check in Claude CLI", "available", installed.version); +} diff --git a/apps/desktop/src/main/engine/backends/claude-secret-sink.ts b/apps/desktop/src/main/engine/backends/claude-secret-sink.ts new file mode 100644 index 00000000..1b3b7c6a --- /dev/null +++ b/apps/desktop/src/main/engine/backends/claude-secret-sink.ts @@ -0,0 +1,62 @@ +import type { Observation } from "../../../shared/conversation-events.js"; +import type { TurnSink } from "./types.js"; + +type Delta = Extract; + +/** Redact before observations enter the journal or IPC, retaining only a possible key prefix per stream. */ +export function claudeSecretSink(sink: TurnSink, secret: string): { sink: TurnSink; finish(): void; redact(value: T): T } { + const redactText = (text: string) => text.split(secret).join("[redacted]"); + const redact = (value: T): T => JSON.parse(redactText(JSON.stringify(value))) as T; + const contains = (value: unknown) => JSON.stringify(value).includes(secret); + const pending = new Map(); + const denied = new Set(); + const nativeKey = (o: Observation) => JSON.stringify(o.native); + const emitTail = (key: string) => { + const tail = pending.get(key); + if (!tail) return; + pending.delete(key); + // The stream is closed: a proper prefix cannot complete a key, so preserve ordinary text. + sink.observe(redact({ ...tail.event, text: tail.text, ...(tail.event.source ? { source: `${tail.event.source}:redaction-tail` } : {}) })); + }; + return { + redact, + finish() { for (const key of pending.keys()) emitTail(key); }, + sink: { + ...sink, + observe(event) { + if (event.type === "message.delta" || event.type === "reasoning.delta") { + const key = `${nativeKey(event)}:${event.type}:${event.type === "reasoning.delta" ? `${event.representation}:${event.index}` : ""}`; + const text = redactText((pending.get(key)?.text ?? "") + event.text); + let length = Math.min(secret.length - 1, text.length); + while (length > 0 && !text.endsWith(secret.slice(0, length))) length--; + if (length) pending.set(key, { event, text: text.slice(-length) }); else pending.delete(key); + const safe = length ? text.slice(0, -length) : text; + if (safe) sink.observe(redact({ ...event, text: safe })); + return; + } + if (event.type === "message.done" || event.type === "reasoning.done") { + for (const key of pending.keys()) if (key.startsWith(`${nativeKey(event)}:`)) emitTail(key); + } + sink.observe(redact(event)); + }, + notice(level, text) { sink.notice(level, redactText(text)); }, + session(handle) { + if (handle.includes(secret)) sink.notice("warn", "Claude returned a credential in its session identifier; that identifier was not saved."); + else sink.session(handle); + }, + async approval(request, withdrawn) { + if (!contains(request)) return sink.approval(request, withdrawn); + // Do not display a redacted command and then approve the different raw command. + if (request.native) denied.add(JSON.stringify(request.native)); + sink.notice("warn", "Claude requested an action containing the gateway credential. Modex denied it without displaying the credential."); + return "no"; + }, + refusedByRule(request) { return contains(request) ? "The action contains a protected gateway credential." : sink.refusedByRule?.(request); }, + async deliverRequest(native, write) { + // A local secret refusal has no human request in the broker; only its fixed denial is written. + if (denied.delete(JSON.stringify(native)) || !sink.deliverRequest) await write(); + else await sink.deliverRequest(native, write); + }, + }, + }; +} diff --git a/apps/desktop/src/main/engine/backends/claude.ts b/apps/desktop/src/main/engine/backends/claude.ts index ca04248c..032f0ec9 100644 --- a/apps/desktop/src/main/engine/backends/claude.ts +++ b/apps/desktop/src/main/engine/backends/claude.ts @@ -67,11 +67,11 @@ export function confinedEnvironment(bin: string, env: NodeJS.ProcessEnv = proces */ export class ClaudeBackend implements Backend { readonly id = "claude" as const; - private get bin(): string { return typeof this.executable === "function" ? this.executable() : this.executable; } + protected get bin(): string { return typeof this.executable === "function" ? this.executable() : this.executable; } constructor( - private readonly executable: CliExecutable = process.env.MODEX_CLAUDE_BIN ?? "claude", - private readonly spawnImpl = cliSpawn, + protected readonly executable: CliExecutable = process.env.MODEX_CLAUDE_BIN ?? "claude", + protected readonly spawnImpl = cliSpawn, /** How long a turn waits on background work; tests shorten these. */ private readonly timing: { backgroundIdleMs?: number; backgroundCapMs?: number; interruptGraceMs?: number; killGraceMs?: number; exitConfirmMs?: number; drainMs?: number } = {}, ) {} @@ -111,6 +111,11 @@ export class ClaudeBackend implements Backend { async dispose(): Promise {} + /** The environment an ordinary turn's child starts with. AccountClaudeBackend adds the turn's account. */ + protected turnEnvironment(bin: string, _opts: TurnOptions): NodeJS.ProcessEnv { + return cliEnvironment(bin); + } + /** CLI argv for a turn. Exported for tests. */ static args(opts: TurnOptions): string[] { if (opts.confinement) { @@ -154,7 +159,7 @@ export class ClaudeBackend implements Backend { const argv = ClaudeBackend.args(opts); try { bin = this.bin; - child = this.spawnImpl(bin, argv, { cwd: opts.cwd, stdio: ["pipe", "pipe", "pipe"], env: opts.confinement ? confinedEnvironment(bin) : cliEnvironment(bin) }); + child = this.spawnImpl(bin, argv, { cwd: opts.cwd, stdio: ["pipe", "pipe", "pipe"], env: opts.confinement ? confinedEnvironment(bin) : this.turnEnvironment(bin, opts) }); } catch (err) { return resolve({ status: "failed", error: `could not start ${bin}: ${(err as Error).message}`, detail: { bin, argv: reportArgv(argv), spawnError: (err as Error).message } }); } diff --git a/apps/desktop/src/main/engine/claude-accounts.ts b/apps/desktop/src/main/engine/claude-accounts.ts new file mode 100644 index 00000000..b409816c --- /dev/null +++ b/apps/desktop/src/main/engine/claude-accounts.ts @@ -0,0 +1,257 @@ +import fs from "node:fs"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import type { ClaudeAccountsStatus } from "../../shared/types.js"; +import type { SecretStore } from "./secrets.js"; + +/** + * Modex's own pool of Claude Code accounts, in the spirit of the multi-subscription routers + * (CC-Router, OmniRoute, teamclaude) but without a proxy on the request path: every turn still + * runs through the installed `claude` CLI, and Modex only decides which credential set that + * child starts with. + * + * Two kinds of account: + * - `profile`: a separate Claude Code configuration directory under `app/claude-profiles/`, + * passed to the child as `CLAUDE_CONFIG_DIR`. Claude Code owns the browser login and the + * credential file inside it, exactly as it does for `~/.claude`. Modex never reads a token. + * - `gateway`: an Anthropic-protocol gateway key (OrcaRouter, `sk-orca-…`). The child gets + * `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN`; the key lives in the OS-encrypted secret + * store and never crosses IPC. Claude Code's own `~/.claude` configuration stays in use, so + * sessions resume across the CLI account and a gateway account. + * + * The pool is the router: `pick()` returns the active account unless it is cooling down after a + * usage limit and rotation is on, in which case the next account in order that is not cooling + * down is chosen. A thread is bound to the account its first turn ran on (the runner stores it, + * as for Codex), because a profile's sessions live in its own directory. Rotation therefore + * affects new conversations; a failed turn's card says which account to continue on. + */ +export type ClaudeAccountKind = "profile" | "gateway"; +export interface ClaudeAccountRecord { id: string; kind: ClaudeAccountKind; label: string; provider?: "orcarouter"; model?: string; createdAt: string } +interface Data { version: 1; active: string | null; rotate: boolean; accounts: ClaudeAccountRecord[] } + +export const ORCAROUTER_BASE_URL = "https://api.orcarouter.ai"; +/** A usage-limited account stays out of rotation this long when the CLI gave no reset time. */ +export const DEFAULT_COOLDOWN_MS = 60 * 60 * 1000; +const LABEL_MAX = 80; +const MAX_ACCOUNTS = 20; + +export class ClaudeAccounts { + readonly file: string; + readonly profilesDir: string; + private readonly cooldowns = new Map(); + private cache: Data | null = null; + /** Fires after any change so Settings and the health card can refresh. */ + onChange?: () => void; + + constructor(private readonly home: string, private readonly secrets: Pick, private readonly now: () => number = Date.now) { + this.file = path.join(home, "app", "claude-accounts.json"); + this.profilesDir = path.join(home, "app", "claude-profiles"); + } + + private read(): Data { + if (this.cache) return this.cache; + let data: Data = { version: 1, active: null, rotate: false, accounts: [] }; + try { + const raw = JSON.parse(fs.readFileSync(this.file, "utf8")) as Partial; + if (raw.version === 1 && Array.isArray(raw.accounts)) { + const accounts = raw.accounts.filter((a): a is ClaudeAccountRecord => Boolean(a) && typeof a.id === "string" && /^[a-f0-9-]{36}$/.test(a.id) && (a.kind === "profile" || a.kind === "gateway") && typeof a.label === "string"); + data = { version: 1, accounts, rotate: raw.rotate === true, active: typeof raw.active === "string" && accounts.some((a) => a.id === raw.active) ? raw.active : null }; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw new Error("Claude account storage could not be read. The file was left in place."); + } + this.cache = data; + return data; + } + + private write(data: Data): void { + fs.mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 }); + const tmp = `${this.file}.${process.pid}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 }); + fs.renameSync(tmp, this.file); + this.cache = data; + this.onChange?.(); + } + + /** The persisted account for `id`, or undefined for the CLI's own login ("cli") and unknown ids. */ + account(id: string | null | undefined): ClaudeAccountRecord | undefined { + return id ? this.read().accounts.find((a) => a.id === id) : undefined; + } + + /** The profile directory Claude Code reads and writes for this account. Created on demand, owner-only. */ + profileDir(id: string): string { + const dir = path.join(this.profilesDir, id); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + return dir; + } + + /** + * What the `claude` child's environment gains for `id`: nothing for the CLI's own login, the + * profile directory for a profile, the gateway's base URL and key for a gateway. The key is the + * only secret and the caller redacts it from every diagnostic. + */ + environment(id: string): { env: NodeJS.ProcessEnv; secret?: string } { + const account = this.account(id); + if (!account) return { env: {} }; + if (account.kind === "profile") return { env: { CLAUDE_CONFIG_DIR: this.profileDir(account.id), ANTHROPIC_API_KEY: "", ANTHROPIC_AUTH_TOKEN: "", CLAUDE_CODE_OAUTH_TOKEN: "", ANTHROPIC_BASE_URL: "" } }; + const key = this.secrets.get(`claude_account:${account.id}`); + if (!key) throw new Error(`The gateway key for "${account.label}" is missing. Add it again in Settings → Coding CLIs.`); + // Claude Code reads ANTHROPIC_AUTH_TOKEN as a bearer token for a non-Anthropic endpoint; the + // base URL has no /v1 because the CLI appends /v1/messages itself (OrcaRouter's documented rule). + // An empty value removes an inherited variable (an ANTHROPIC_API_KEY in the person's shell would win over the + // bearer token). CLAUDE_CODE_SUBPROCESS_ENV_SCRUB (Claude Code 2.1.316) strips Anthropic credentials from the + // environment of the shell commands the model runs, so the key reaches the CLI but not its tools. + const env: NodeJS.ProcessEnv = { ANTHROPIC_BASE_URL: ORCAROUTER_BASE_URL, ANTHROPIC_AUTH_TOKEN: key, ANTHROPIC_API_KEY: "", CLAUDE_CODE_OAUTH_TOKEN: "", CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: "1" }; + if (account.model) { env.ANTHROPIC_MODEL = account.model; env.ANTHROPIC_DEFAULT_OPUS_MODEL = account.model; env.ANTHROPIC_DEFAULT_SONNET_MODEL = account.model; env.ANTHROPIC_DEFAULT_HAIKU_MODEL = account.model; } + return { env, secret: key }; + } + + /** The account a new conversation runs on: "cli" for Claude Code's own login. */ + identity(): string { return this.pick(this.read().active); } + + /** + * The router's choice. The preferred account (the active one, or the one a caller names) unless + * it is cooling down and rotation is on; then the next account in list order that is not cooling + * down, wrapping around, with the CLI's own login as the last resort when every pool account is out. + */ + pick(preferred: string | null): string { + const data = this.read(); + const cooling = (id: string) => (this.cooldowns.get(id) ?? 0) > this.now(); + const want = preferred ?? "cli"; + if (!data.rotate || !cooling(want)) return want; + const order = [...data.accounts.map((a) => a.id), "cli"]; + const start = order.indexOf(want); + for (let step = 1; step <= order.length; step++) { + const candidate = order[(start + step) % order.length]!; + if (!cooling(candidate)) return candidate; + } + return want; + } + + /** Marks `id` usage-limited until `resetAt` (ms since epoch) or the default cooldown. Returns where new conversations go now. */ + noteLimit(id: string, resetAt?: number): { until: number; next: string } { + const until = resetAt && resetAt > this.now() ? resetAt : this.now() + DEFAULT_COOLDOWN_MS; + this.cooldowns.set(id, until); + this.onChange?.(); + return { until, next: this.identity() }; + } + + /** Clears a cooldown, e.g. after a completed turn proved the account usable again. */ + clearLimit(id: string): void { if (this.cooldowns.delete(id)) this.onChange?.(); } + + /** True while `id` is held out of rotation. */ + coolingDown(id: string): boolean { return (this.cooldowns.get(id) ?? 0) > this.now(); } + + status(health: Record = {}): ClaudeAccountsStatus { + const data = this.read(); + return { + available: this.secrets.available(), + active: data.active, + rotate: data.rotate, + accounts: data.accounts.map((a) => { + const cooldownUntil = this.cooldowns.get(a.id); + const h = health[a.id]; + return { + id: a.id, kind: a.kind, label: a.label, ...(a.provider ? { provider: a.provider } : {}), ...(a.model ? { model: a.model } : {}), + signedIn: h ? h.signedIn : a.kind === "gateway" ? Boolean(this.secrets.get(`claude_account:${a.id}`)) : false, + detail: h?.detail ?? (a.kind === "gateway" ? "Gateway key saved · model access unverified" : "Sign-in not checked"), + ...(cooldownUntil && cooldownUntil > this.now() ? { cooldownUntil: new Date(cooldownUntil).toISOString() } : {}), + }; + }), + detail: data.accounts.length ? data.rotate ? "New conversations move to the next account when one hits its usage limit." : "New conversations use the selected account." : "Add a Claude account to keep separate sign-ins, or a gateway key.", + }; + } + + /** Adds a profile (a fresh configuration directory, signed out) and returns its id. */ + addProfile(label: string): ClaudeAccountRecord { + const data = this.read(); + const record: ClaudeAccountRecord = { id: randomUUID(), kind: "profile", label: this.validLabel(label, data), createdAt: new Date(this.now()).toISOString() }; + this.profileDir(record.id); + this.write({ ...data, accounts: [...data.accounts, record] }); + return record; + } + + /** Adds an OrcaRouter gateway account. The key goes to the secret store, never to this file. */ + addGateway(label: string, key: string, model?: string): ClaudeAccountRecord { + const data = this.read(); + if (!this.secrets.available()) throw new Error("Protected OS credential storage is unavailable, so a gateway key cannot be saved."); + const trimmed = key.trim(); + if (!/^sk-orca-[A-Za-z0-9_-]{8,200}$/.test(trimmed)) throw new Error("That is not an OrcaRouter key. Keys start with sk-orca-."); + const chosen = model?.trim(); + if (chosen && !/^[A-Za-z0-9][A-Za-z0-9._:\/-]{0,99}$/.test(chosen)) throw new Error("Model ids use the vendor/model form, such as anthropic/claude-opus-4.8."); + const record: ClaudeAccountRecord = { id: randomUUID(), kind: "gateway", provider: "orcarouter", label: this.validLabel(label, data), ...(chosen ? { model: chosen } : {}), createdAt: new Date(this.now()).toISOString() }; + this.secrets.set(`claude_account:${record.id}`, trimmed); + this.write({ ...data, accounts: [...data.accounts, record] }); + return record; + } + + select(id: string | null): void { + const data = this.read(); + if (id !== null && !data.accounts.some((a) => a.id === id)) throw new Error("That Claude account no longer exists."); + this.write({ ...data, active: id }); + } + + setRotation(rotate: boolean): void { this.write({ ...this.read(), rotate }); } + + /** + * Forgets an account. A profile's directory (Claude Code's own credential and session files) is + * removed with it; a gateway's key is cleared from the secret store. Threads bound to it keep their + * transcripts and fail with a sign-in card on their next turn. + */ + remove(id: string): void { + const data = this.read(); + const account = data.accounts.find((a) => a.id === id); + if (!account) throw new Error("That Claude account no longer exists."); + if (account.kind === "gateway") this.secrets.clear(`claude_account:${id}`); + else fs.rmSync(path.join(this.profilesDir, id), { recursive: true, force: true }); + this.cooldowns.delete(id); + this.write({ ...data, active: data.active === id ? null : data.active, accounts: data.accounts.filter((a) => a.id !== id) }); + } + + private validLabel(label: string, data: Data): string { + const trimmed = label.trim().replace(/\s+/g, " "); + if (!trimmed) throw new Error("Give the account a name."); + if (trimmed.length > LABEL_MAX) throw new Error(`Account names are at most ${LABEL_MAX} characters.`); + if (data.accounts.length >= MAX_ACCOUNTS) throw new Error(`At most ${MAX_ACCOUNTS} Claude accounts.`); + if (data.accounts.some((a) => a.label.toLowerCase() === trimmed.toLowerCase())) throw new Error("Another Claude account already has that name."); + return trimmed; + } +} + +/** + * The moment a usage limit lifts, from Claude Code's own wording ("resets at 3pm", "resets Nov 2 at 7pm (UTC)", + * "try again in 2 hours"), or undefined when it gave none. Exported for tests. + */ +export function limitResetAt(message: string, now = Date.now()): number | undefined { + const relative = /(?:try again|retry|resets?) in (\d+)\s*(second|sec|minute|min|hour|hr|day)s?/i.exec(message); + if (relative) { + const n = Number(relative[1]); + const unit = relative[2]!.toLowerCase(); + const ms = unit.startsWith("s") ? 1000 : unit.startsWith("m") ? 60_000 : unit.startsWith("h") ? 3_600_000 : 86_400_000; + return now + n * ms; + } + // A clock time ("resets 9am", "resets at 3:30pm (UTC)") is read in local time: the next time it occurs. + const clock = /resets?\b[^|\n]*?\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i.exec(message); + const dated = /resets? (?:on )?([A-Za-z]{3,9} \d{1,2})(?:,? \d{4})?(?:,? at)? /i.exec(message); + if (clock) { + const hour = (Number(clock[1]) % 12) + (clock[3]!.toLowerCase() === "pm" ? 12 : 0); + const at = new Date(now); + if (dated) { + const day = new Date(`${dated[1]} ${at.getFullYear()}`); + if (!Number.isNaN(day.getTime())) at.setFullYear(day.getFullYear(), day.getMonth(), day.getDate()); + } + at.setHours(hour, Number(clock[2] ?? 0), 0, 0); + if (at.getTime() <= now) at.setTime(at.getTime() + (dated ? 365 : 1) * 86_400_000); + return at.getTime(); + } + return undefined; +} + +/** Apply account overrides consistently for turns, status and login; empty entries remove inheritance. */ +export function claudeAccountEnvironment(base: NodeJS.ProcessEnv, overrides: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const env = { ...base }; + for (const [name, value] of Object.entries(overrides)) { + if (value === "" || value === undefined) delete env[name]; else env[name] = value; + } + return env; +} diff --git a/apps/desktop/src/main/engine/claude-login.ts b/apps/desktop/src/main/engine/claude-login.ts index b1c7cd42..1e7c2187 100644 --- a/apps/desktop/src/main/engine/claude-login.ts +++ b/apps/desktop/src/main/engine/claude-login.ts @@ -1,3 +1,4 @@ +import { claudeAccountEnvironment } from "./claude-accounts.js"; import type { ChildProcess } from "node:child_process"; import { cliEnvironment, cliSpawn, probe } from "./backends/health.js"; import type { ClaudeLoginResult } from "../../shared/types.js"; @@ -11,14 +12,16 @@ export class ClaudeLogin { cancel(): void { this.cancelCurrent?.(); } - async run(bin: string): Promise { + /** `env` adds to the CLI's environment: a Modex Claude profile passes its CLAUDE_CONFIG_DIR here. */ + async run(bin: string, env: NodeJS.ProcessEnv = {}): Promise { + const childEnv = claudeAccountEnvironment(process.env, env); if (this.running) return { status: "busy", detail: "A Claude sign-in is already running." }; this.running = true; let cancelled = false; this.cancelCurrent = () => { cancelled = true; this.child?.kill(); }; try { // Older CLIs may interpret unknown subcommands as prompts: require structured status first. - const check = await probe(bin, ["auth", "status"], this.spawnImpl); + const check = await probe(bin, ["auth", "status"], this.spawnImpl, 5000, childEnv); if (cancelled) return { status: "cancelled", detail: "Sign-in cancelled. CLI credentials may already have changed; refresh status." }; let account: { loggedIn?: unknown }; try { account = JSON.parse(check.output); } catch { return { status: "unsupported", detail: "Account commands unavailable. Update Claude Code or sign in from its terminal." }; } @@ -35,7 +38,7 @@ export class ClaudeLogin { resolve(result); }; try { - const child = this.spawnImpl(bin, ["auth", "login"], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true, env: cliEnvironment(bin) }); + const child = this.spawnImpl(bin, ["auth", "login"], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true, env: cliEnvironment(bin, childEnv) }); this.child = child; child.stdout?.resume(); child.stderr?.resume(); @@ -46,7 +49,7 @@ export class ClaudeLogin { } catch { finish("failed"); } }); if (exit !== "completed") return { status: exit, detail: exit === "cancelled" ? "Sign-in cancelled. Refresh account status before retrying." : exit === "timeout" ? "Sign-in timed out. Use claude auth login in a terminal if browser login needs manual input." : "Sign-in failed. Use claude auth login in a terminal for interactive recovery." }; - const confirmed = await probe(bin, ["auth", "status"], this.spawnImpl); + const confirmed = await probe(bin, ["auth", "status"], this.spawnImpl, 5000, childEnv); try { if (!confirmed.failure && confirmed.code === 0 && JSON.parse(confirmed.output).loggedIn === true) return { status: "authenticated", detail: "Claude sign-in confirmed. Model access remains unverified." }; } catch { /* Never report success from opening a browser or an exit code alone. */ } diff --git a/apps/desktop/src/main/engine/runner.ts b/apps/desktop/src/main/engine/runner.ts index ee1b4d02..c6d6e0c9 100644 --- a/apps/desktop/src/main/engine/runner.ts +++ b/apps/desktop/src/main/engine/runner.ts @@ -17,7 +17,8 @@ import { Router } from "./routing/router.js"; import { answerFor, decide as decideApproval, gateApplies, receiptFor } from "./approvals/gate.js"; import { policyAnswer, policyReceipt } from "./approvals/policy.js"; import type { SecretStore } from "./secrets.js"; -import { creditFallbackModel, describeFailure, isModelOutOfCredits, modelFamilyName } from "../../shared/failures.js"; +import { classifyFailure, creditFallbackModel, describeFailure, isModelOutOfCredits, modelFamilyName } from "../../shared/failures.js"; +import { limitResetAt, type ClaudeAccounts } from "./claude-accounts.js"; /** After a model is refused for usage credits, how long Modex starts its turns on the fallback instead. */ export const CREDIT_SKIP_MS = 60 * 60_000; @@ -115,6 +116,18 @@ export interface RunnerOptions { attachments?: AttachmentStore; /** Whether a process still exists; tests replace the signal-0 probe. */ isProcessAlive?: (pid: number) => boolean; + /** Modex's Claude account pool: usage-limit cooldowns and rotation (engine/claude-accounts.ts). */ + claudeAccounts?: Pick; +} + +/** The thread's account binding for its current backend: Codex and Claude Code keep separate ones. */ +export function threadAccount(thread: Pick | undefined): string | undefined { + return thread?.backend === "codex" ? thread.codexAccount : thread?.backend === "claude" ? thread.claudeAccount : undefined; +} + +/** A Claude account whose sessions live in Claude Code's own configuration directory (its login and gateways), not a profile's. */ +function sharesClaudeHome(accounts: Pick, id: string): boolean { + return id === "cli" || accounts.account(id)?.kind === "gateway"; } /** @@ -165,6 +178,11 @@ export class ThreadRunner { return this.backends[id]; } + /** Whether a running turn is bound to this backend account (so it must not be removed or re-signed underneath it). */ + accountInUse(backend: BackendId, account: string): boolean { + return [...this.live].some(([threadId, l]) => Boolean(l.abort) && l.backend === backend && threadAccount(this.o.store.thread(threadId)) === account); + } + async listModels(id: BackendId): Promise<{ models: ModelInfo[]; error?: string }> { if (this.disposing) return { models: [], error: "Modex is shutting down." }; if (this.restartHold) return { models: [], error: "Modex is preparing to restart." }; @@ -830,9 +848,14 @@ export class ThreadRunner { if (knowledge.warning) sink.notice("warn", knowledge.warning); if (abort.signal.aborted) { this.setStatus(threadId, "idle"); return; } } - const account = !local && thread.backend === "codex" && backend.identity - ? thread.codexAccount ?? (thread.sessionHandle ? "cli" : backend.identity()) : undefined; - if (account && !thread.codexAccount) this.o.store.updateThread(threadId, { codexAccount: account }); + // Codex and Claude Code bind a thread to the account its first turn ran on; older handles belong to the CLI's own login. + const bound = threadAccount(thread); + const account = !local && (thread.backend === "codex" || thread.backend === "claude") && backend.identity + ? bound ?? (thread.sessionHandle ? "cli" : backend.identity()) : undefined; + if (account && !bound) this.o.store.updateThread(threadId, thread.backend === "codex" ? { codexAccount: account } : { claudeAccount: account }); + const accountFor = (ran: Thread) => threadAccount(ran) ?? account; + // Read before the turn: the store's thread object takes the session id the moment the CLI reports one. + const freshConversation = !thread.sessionHandle; const turnFiles: TurnAttachment[] = files.map((f) => { const file = this.attachments.resolve(f.rel); if (!file) throw new Error(`Attachment ${f.name} is missing or changed. Attach it again before retrying.`); @@ -840,12 +863,12 @@ export class ThreadRunner { }); // Each run of the backend is one attempt with its own sink; ending it settles whatever it left open. const runOn = async (ran: Thread): Promise => { - const attempt = this.beginAttempt(threadId, l, { thread: threadId, turn: turnId, attempt: newId(), backend: ran.backend, ...((ran.codexAccount ?? account) ? { account: ran.codexAccount ?? account } : {}) }); + const attempt = this.beginAttempt(threadId, l, { thread: threadId, turn: turnId, attempt: newId(), backend: ran.backend, ...(accountFor(ran) ? { account: accountFor(ran) } : {}) }); try { const result = await backend.runTurn( describeAttachments(text, turnFiles), { - cwd: ran.cwd, mode: ran.mode, plan: ran.plan, model: ran.model, effort: ran.effort, fast, resume: ran.sessionHandle, account, + cwd: ran.cwd, mode: ran.mode, plan: ran.plan, model: ran.model, effort: ran.effort, fast, resume: ran.sessionHandle, account: accountFor(ran), // Files the CLI reads by path sit outside cwd; --add-dir keeps that from prompting. addDirs: turnFiles.some((f) => f.kind === "file") ? [path.join(this.attachments.root, threadId)] : [], ...(turnFiles.length ? { attachments: turnFiles } : {}), ...(knowledge ? { knowledge: knowledge.connection } : {}), ...(pages ? { pages: pages.connection } : {}), ...(images ? { images: images.connection } : {}), @@ -875,6 +898,28 @@ export class ThreadRunner { result = await runOn(thread); if (result.status === "failed") result = { ...result, recovery: [`${modelFamilyName(from)} was out of usage credits; retried on ${modelFamilyName(fallback)}.`, ...(result.recovery ?? [])] }; } + // A Claude account that reports a usage limit cools down; with rotation on, a turn that had not started work moves on. + const pool = this.o.claudeAccounts; + const limited = accountFor(thread); + if (!local && pool && thread.backend === "claude" && limited && result.status === "failed" && !abort.signal.aborted && classifyFailure(result.error ?? "") === "rate_limited") { + const { until } = pool.noteLimit(limited, limitResetAt(result.error ?? "")); + const next = pool.pick(limited); + const name = (id: string) => id === "cli" ? "Claude Code's own login" : `"${pool.account(id)?.label ?? "removed account"}"`; + const started = l.items.slice(replyStart).some((item) => item.kind === "assistant" || item.kind === "tool" || item.kind === "thinking"); + const movable = next !== limited && !started && (freshConversation || (sharesClaudeHome(pool, limited) && sharesClaudeHome(pool, next))); + this.addItem(threadId, { id: newId(), kind: "notice", level: "warn", text: `Claude account ${name(limited)} hit its usage limit; Modex holds it out of rotation until ${new Date(until).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}.${movable ? ` Retrying on ${name(next)}.` : next !== limited ? ` New Claude conversations start on ${name(next)}.` : ""}`, at: new Date().toISOString() }); + if (movable) { + // A conversation that had no session before this turn starts fresh: the failed attempt's session may live in the old profile. + this.o.store.updateThread(threadId, { claudeAccount: next, ...(freshConversation ? { sessionHandle: undefined } : {}) }); + thread = this.o.store.thread(threadId) ?? thread; + replyStart = l.items.length; + result = await runOn(thread); + // A second limit still updates selection for the next conversation; never retry a third time. + if (result.status === "failed" && !abort.signal.aborted && classifyFailure(result.error ?? "") === "rate_limited") pool.noteLimit(next, limitResetAt(result.error ?? "")); + if (result.status === "failed") result = { ...result, recovery: [`Claude account ${name(limited)} hit its usage limit; retried on ${name(next)}.`, ...(result.recovery ?? [])] }; + } + } + if (pool && thread.backend === "claude" && result.status === "completed" && accountFor(thread)) pool.clearLimit(accountFor(thread)!); if (thread.imageJobId) this.o.imageOutcome?.(thread.imageJobId, result.status); // Record what ran, not what was asked for: the CLI can refuse fast mode (no usage credits, for one). const ranFast = result.fastMode ? result.fastMode.on : fast; @@ -886,7 +931,7 @@ export class ThreadRunner { this.nameThread(threadId, text, fallbackTitle, backend, thread.model, reply?.kind === "assistant" ? reply.text : undefined); } if (auto) this.router.noteOutcome(threadId, result.status, result.usage); - this.recordUsage(thread, account, result); + this.recordUsage(thread, accountFor(thread), result); // A failure can precede Stop; its outcome does not establish process exit. if (result.stop?.outcome === "unconfirmed" && result.stop.pid) (l.unconfirmed ??= new Set()).add(result.stop.pid); if (result.status === "failed") { @@ -976,7 +1021,7 @@ export class ThreadRunner { const abort = new AbortController(); this.naming.set(threadId, abort); const timer = setTimeout(() => abort.abort(), 30_000); - const account = this.o.store.thread(threadId)?.codexAccount; + const account = threadAccount(this.o.store.thread(threadId)); const run = Promise.resolve().then(() => backend.generateTitle!(text, { model, account, reply }, abort.signal)).then((title) => { if (title && !abort.signal.aborted && !this.disposing && this.o.store.thread(threadId)?.title === fallback) { this.updateThread(threadId, { title }, { automaticTitle: true }); @@ -1088,7 +1133,7 @@ export class ThreadRunner { if (record.state === "pending") { const binding = record.request.binding; const current = this.o.store.thread(binding.thread); - if (!current || current.backend !== binding.backend || (current.codexAccount ?? null) !== (binding.account ?? null) || + if (!current || current.backend !== binding.backend || (threadAccount(current) ?? null) !== (binding.account ?? null) || (binding.attempt && l.attempt !== binding.attempt)) { this.withdraw(binding.thread, l, itemId, "ended"); return { ok: false, reason: "wrong-identity" }; @@ -1189,9 +1234,9 @@ export class ThreadRunner { } } // Switching backend starts a fresh backend conversation; the transcript stays. - const extra: Partial = current && patch.backend && patch.backend !== current.backend ? { sessionHandle: undefined, codexAccount: undefined, lastTurn: undefined, model: this.o.store.settings.default_model[patch.backend] ?? "", effort: undefined } : {}; + const extra: Partial = current && patch.backend && patch.backend !== current.backend ? { sessionHandle: undefined, codexAccount: undefined, claudeAccount: undefined, lastTurn: undefined, model: this.o.store.settings.default_model[patch.backend] ?? "", effort: undefined } : {}; // So does any local target change: the old child's handle is dropped, and clearing the target returns to the default cloud model. - const localReset: Partial = switchingLocal ? { sessionHandle: undefined, codexAccount: undefined, lastTurn: undefined, + const localReset: Partial = switchingLocal ? { sessionHandle: undefined, codexAccount: undefined, claudeAccount: undefined, lastTurn: undefined, ...(patch.localTarget === null ? { localTarget: undefined, localContextTokens: undefined, model: this.o.store.settings.default_model.codex ?? "", effort: undefined } : {}) } : {}; if (switchingLocal) { this.naming.get(threadId)?.abort(); this.naming.delete(threadId); @@ -1343,7 +1388,7 @@ export class ThreadRunner { const authority = () => { const current = this.o.store.thread(threadId); return active() && !abort?.signal.aborted && Boolean(current) && current!.backend === (turn?.backend ?? l.backend) && - (current!.codexAccount ?? null) === (turn?.account ?? null); + (threadAccount(current) ?? null) === (turn?.account ?? null); }; const deliveries = new Set(); // Who asked: this turn and attempt, and the CLI's own request id when it gives one (else the card's id). @@ -1469,7 +1514,7 @@ export class ThreadRunner { const current = this.o.store.thread(threadId); const claim = l.pending.get(id)?.claim?.(); if (l.pending.get(id)?.policyCurrent?.() === false || (claim && (claim.digest !== record.request.digest || actionDigest(claim.binding) !== actionDigest(record.request.binding))) || !active() || abort?.signal.aborted || !current || current.backend !== record.request.binding.backend || - (current.codexAccount ?? null) !== (record.request.binding.account ?? null)) { + (threadAccount(current) ?? null) !== (record.request.binding.account ?? null)) { this.withdraw(threadId, l, id, "ended"); return; } diff --git a/apps/desktop/src/main/engine/secrets.ts b/apps/desktop/src/main/engine/secrets.ts index 15d87918..b105b0ed 100644 --- a/apps/desktop/src/main/engine/secrets.ts +++ b/apps/desktop/src/main/engine/secrets.ts @@ -19,7 +19,8 @@ export interface Cipher { } /** `local_connection:` holds an optional token for one local-model server profile (#378). */ -export type SecretName = "typesafe_api_key" | `local_connection:${string}`; +/** `claude_account:` holds one Claude gateway account's key (engine/claude-accounts.ts). */ +export type SecretName = "typesafe_api_key" | `local_connection:${string}` | `claude_account:${string}`; interface SecretsFile { version: 1; diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 76d15290..ee7d4163 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -48,6 +48,9 @@ import { ThreadRunner } from "./engine/runner.js"; import { WorkOSAuth, workosConfig } from "./engine/workos-auth.js"; import { ChatGPTAuth } from "./engine/chatgpt-auth.js"; import { AccountCodexBackend } from "./engine/backends/account-codex.js"; +import { AccountClaudeBackend, accountHealth } from "./engine/backends/account-claude.js"; +import { ClaudeAccounts } from "./engine/claude-accounts.js"; +import { cliSpawn } from "./engine/backends/health.js"; import { ClaudeLogin } from "./engine/claude-login.js"; import * as gitx from "./engine/git.js"; import { listWorkspaceFiles, readWorkspaceFile } from "./engine/workspace-files.js"; @@ -230,6 +233,9 @@ const modexAccount = new WorkOSAuth({ home, cipher: chatgptCipher, config: worko const chatgpt = new ChatGPTAuth({ home, cipher: chatgptCipher, openBrowser: (url) => shell.openExternal(url) }); const sessionCliSettings = store.settings; const accountCodex = new AccountCodexBackend(chatgpt, () => resolveCli("codex", sessionCliSettings.codex_bin)); +// Modex's own Claude account pool: separate Claude Code profiles and OrcaRouter gateway keys, with usage-limit rotation. +const claudeAccounts = new ClaudeAccounts(home, secrets); +const accountClaude = new AccountClaudeBackend(claudeAccounts, () => resolveCli("claude", sessionCliSettings.claude_bin)); process.env.MODEX_VERSION = app.getVersion(); const attachments = new AttachmentStore(path.join(home, "attachments"), { authorizeDraft: owner => owner.kind === "project" ? !!store.project(owner.projectId) : !!store.thread(owner.threadId) && !store.thread(owner.threadId)!.retired, @@ -239,7 +245,7 @@ const attachments = new AttachmentStore(path.join(home, "attachments"), { // before startup inspection can update the installed-version receipt. attachments.setRecoveryRetention(null); const usageLedger = new UsageLedger(store.dir); -const runner = new ThreadRunner({ beforeSend: thread => { if (thread.localTarget === undefined) media?.beforeSend(thread); }, imageOutcome: (id, status) => media?.jobs.outcome(id, status), ...((demo || process.env.MODEX_E2E) ? { imageBackend: imageFixtureBackend } : {}), images: { prepare: (thread, signal) => { if (media) return media.jobs.prepare(thread, signal); return Promise.resolve(undefined); } }, home, store, emit, secrets, knowledge: knowledgeAgents, pages: pagesAgents, usage: usageLedger, backends: { codex: accountCodex }, attachments, beforeDeleteThread: (id) => terminals.close(id), withOwnerRemoval }); +const runner = new ThreadRunner({ beforeSend: thread => { if (thread.localTarget === undefined) media?.beforeSend(thread); }, imageOutcome: (id, status) => media?.jobs.outcome(id, status), ...((demo || process.env.MODEX_E2E) ? { imageBackend: imageFixtureBackend } : {}), images: { prepare: (thread, signal) => { if (media) return media.jobs.prepare(thread, signal); return Promise.resolve(undefined); } }, home, store, emit, secrets, knowledge: knowledgeAgents, pages: pagesAgents, usage: usageLedger, backends: { codex: accountCodex, claude: accountClaude }, claudeAccounts, attachments, beforeDeleteThread: (id) => terminals.close(id), withOwnerRemoval }); let mediaInitializationError: string | undefined; try { media = new Media({ home, store, runner: () => runner, window: () => win, pathReady, offline: Boolean(demo || process.env.MODEX_E2E), changed: kind => { if (win && !win.isDestroyed()) win.webContents.send("media:event", { kind }); @@ -926,6 +932,30 @@ const claudeLogin = new ClaudeLogin(); SPAWNS.add("claude:login"); handle("claude:login", () => claudeLogin.run(resolveCli("claude", sessionCliSettings.claude_bin))); handle("claude:cancelLogin", () => claudeLogin.cancel()); +// Claude account pool: local desktop authority only. Keys go in and never come back out. +const claudeAccountsStatus = async () => { + const bin = resolveCli("claude", sessionCliSettings.claude_bin); + const checks = await Promise.all(claudeAccounts.status().accounts.map(async (account) => { + const h = await accountHealth(bin, cliSpawn, claudeAccounts, account.id); + return [account.id, { signedIn: h.authentication === "authenticated", detail: h.detail }] as const; + })); + return claudeAccounts.status(Object.fromEntries(checks)); +}; +const claudeAccountIdle = (accountId: string) => { if (runner.accountInUse("claude", accountId)) throw new Error("Wait for this account's running Claude turns to finish."); }; +handle("claudeAccounts:status", claudeAccountsStatus, { localOnly: true }); +handle("claudeAccounts:addProfile", ({ label }) => { claudeAccounts.addProfile(String(label ?? "")); return claudeAccountsStatus(); }, { localOnly: true }); +handle("claudeAccounts:addGateway", ({ label, key, model }) => { claudeAccounts.addGateway(String(label ?? ""), String(key ?? ""), typeof model === "string" ? model : undefined); return claudeAccountsStatus(); }, { localOnly: true }); +handle("claudeAccounts:select", ({ accountId }) => { claudeAccounts.select(typeof accountId === "string" ? accountId : null); return claudeAccountsStatus(); }, { localOnly: true }); +handle("claudeAccounts:rotate", ({ rotate }) => { claudeAccounts.setRotation(rotate === true); return claudeAccountsStatus(); }, { localOnly: true }); +handle("claudeAccounts:remove", ({ accountId }) => { claudeAccountIdle(String(accountId)); claudeAccounts.remove(String(accountId)); return claudeAccountsStatus(); }, { localOnly: true }); +for (const channel of ["claudeAccounts:status", "claudeAccounts:addProfile", "claudeAccounts:addGateway", "claudeAccounts:select", "claudeAccounts:rotate", "claudeAccounts:remove", "claudeAccounts:login"] as const) SPAWNS.add(channel); +handle("claudeAccounts:login", async ({ accountId }) => { + const account = claudeAccounts.account(String(accountId)); + if (account?.kind !== "profile") throw new Error("Only a Claude profile signs in through Claude Code. Gateway accounts use their key."); + claudeAccountIdle(account.id); + const result = await claudeLogin.run(resolveCli("claude", sessionCliSettings.claude_bin), claudeAccounts.environment(account.id).env); + return { status: await claudeAccountsStatus(), result }; +}, { localOnly: true }); handle("usage:list", (req) => usageLedger.list(req ?? {}), { localOnly: true }); handle("analytics:show", ({ bounds }) => workspaceAnalytics?.show(bounds) ?? null, { localOnly: true }); handle("routing:status", () => runner.router.status()); diff --git a/apps/desktop/src/renderer/components/ClaudeAccounts.tsx b/apps/desktop/src/renderer/components/ClaudeAccounts.tsx new file mode 100644 index 00000000..d4bf8a73 --- /dev/null +++ b/apps/desktop/src/renderer/components/ClaudeAccounts.tsx @@ -0,0 +1,85 @@ +import { useEffect, useRef, useState } from "react"; +import type { ClaudeAccountsStatus } from "../../shared/types"; +import { bridge } from "../bridge"; +import { SecretInput, SecretOption } from "../streamer"; + +/** + * Modex's Claude account pool, inside Settings → Coding CLIs → Claude Code. Separate Claude Code + * profiles (their own browser login) and OrcaRouter gateway keys, an active account for new + * conversations, and rotation on usage limits. Keys go to main once and never come back. + */ +export function ClaudeAccounts({ disabled, onChanged }: { disabled: boolean; onChanged: () => void }) { + const [status, setStatus] = useState(null); + const [failed, setFailed] = useState(false); + const [busy, setBusy] = useState(false); + const [detail, setDetail] = useState(""); + const [adding, setAdding] = useState<"profile" | "gateway" | null>(null); + const [label, setLabel] = useState(""); + const [key, setKey] = useState(""); + const [model, setModel] = useState(""); + const busyRef = useRef(false); + + useEffect(() => { + let current = true; + void bridge.invoke("claudeAccounts:status", undefined).then((result) => { if (current) setStatus(result); }).catch(() => { if (current) setFailed(true); }); + return () => { current = false; }; + }, []); + + /** Resolves true when the change was made; a refusal leaves its reason in the status line. */ + const act = async (pending: string, action: () => Promise, done: string | (() => string)): Promise => { + if (busyRef.current) return false; + busyRef.current = true; setBusy(true); setDetail(pending); + try { setStatus(await action()); setDetail(typeof done === "function" ? done() : done); setFailed(false); return true; } + catch (error) { setDetail((error as Error).message.replace(/^Error invoking remote method '[^']+': (?:Error: )?/, "") || "The account change did not complete."); return false; } + finally { busyRef.current = false; setBusy(false); onChanged(); } + }; + + const active = status?.accounts.find((account) => account.id === status.active); + const off = disabled || busy || !status; + const resetForm = () => { setAdding(null); setLabel(""); setKey(""); setModel(""); }; + + return ( +
+ + {!!status?.accounts.length && } + {!!status?.accounts.length &&
    + {status.accounts.map((account) =>
  • +
    {account.label}{account.cooldownUntil ? `Usage limit until ${new Date(account.cooldownUntil).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}` : account.detail}
    +
    + {account.kind === "profile" && } + +
    +
  • )} +
} + {adding ?
{ + event.preventDefault(); + const name = label; + void act("Saving account…", () => adding === "gateway" ? bridge.invoke("claudeAccounts:addGateway", { label: name, key, ...(model.trim() ? { model: model.trim() } : {}) }) : bridge.invoke("claudeAccounts:addProfile", { label: name }), + adding === "gateway" ? `Saved ${name.trim()}. Its key is protected in OS storage; a completed turn verifies model access.` : `Added ${name.trim()}. Sign in to it next.`).then((saved) => { if (saved) resetForm(); }); + }}> + + {adding === "gateway" && <> + + + } +
+ + +
+
:
+ + +
} +

{detail || (failed ? "Account pool unavailable. Refresh to retry." : active ? `New conversations use ${active.label}.` : status?.detail ?? "Checking Claude accounts…")}

+
+ ); +} diff --git a/apps/desktop/src/renderer/components/SettingsDialog.tsx b/apps/desktop/src/renderer/components/SettingsDialog.tsx index 7ad11216..d7044031 100644 --- a/apps/desktop/src/renderer/components/SettingsDialog.tsx +++ b/apps/desktop/src/renderer/components/SettingsDialog.tsx @@ -1,5 +1,6 @@ import { SecretOption, SecretInput } from "../streamer"; import { ModexAccount } from "./ModexAccount"; +import { ClaudeAccounts } from "./ClaudeAccounts"; import { ThemePicker } from "./ThemePicker"; import { useEffect, useRef, useState } from "react"; import type { BackendHealth, BackendId, ChatGPTStatus, EffortLevel, Mode, ModelInfo, Project, RoutingPolicy, RoutingStatus, RoutingTest, Settings } from "../../shared/types"; @@ -402,8 +403,9 @@ export function SettingsDialog({ settings, projects, currentProjectId, onSave, o {s.claude_bin !== settings.claude_bin &&

Save and restart Modex to use the new executable, or revert your path change to sign in now.

} {health?.claude.executable === "missing" &&

Install Claude Code, then refresh. If you change its executable below, save and restart Modex.

}

{claudeLoginDetail}

+ setHealthRevision((revision) => revision + 1)} /> -

Claude Code owns this login and shares it with other CLI clients on your Mac. API or enterprise settings may take precedence.

+

Claude Code owns each login, in its own profile folder for added accounts. Gateway keys stay in OS storage. API or enterprise settings may take precedence.

{ACP_CARDS.map((card) =>
diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css index 028be0bf..6cda9cdb 100644 --- a/apps/desktop/src/renderer/styles.css +++ b/apps/desktop/src/renderer/styles.css @@ -263,6 +263,12 @@ kbd, .kbd { font: 11px var(--mono); background: rgba(255,255,255,.08); border: 1 .connection-feedback { margin: 10px 0 0; color: var(--fg-2); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; } .connection-feedback:empty { display: none; } .connection-account { margin: 16px 0 0; } +.claude-accounts { display: grid; gap: 10px; margin: 4px 0 8px; } +.claude-account-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } +.claude-account-list li { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; min-width: 0; } +.claude-account-list li > div:first-child { display: grid; gap: 2px; min-width: 0; } +.claude-account-list small { opacity: .75; overflow-wrap: anywhere; } +.claude-account-form { display: grid; gap: 8px; } .connection-account select { display: block; width: 100%; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .connection-account small { color: var(--fg-2); line-height: 1.5; } .connection-account + .connection-actions { margin-top: 10px; } diff --git a/apps/desktop/src/shared/types.ts b/apps/desktop/src/shared/types.ts index 37822e35..a5d4aef5 100644 --- a/apps/desktop/src/shared/types.ts +++ b/apps/desktop/src/shared/types.ts @@ -94,6 +94,8 @@ export interface Thread { lastTurn?: LastTurn; /** Opaque identity binding; never credentials. Older Codex handles belong to CLI auth. */ codexAccount?: string; + /** Opaque Modex Claude account binding (engine/claude-accounts.ts), "cli" for Claude Code's own login; never credentials. */ + claudeAccount?: string; status: ThreadStatus; /** Absent = ask. */ approvals?: ApprovalPolicy; @@ -515,6 +517,17 @@ export interface ChatGPTStatus { detail: string; } +/** Modex's Claude account pool. Gateway keys and profile credentials never cross IPC. */ +export interface ClaudeAccountsStatus { + available: boolean; + /** The account new Claude conversations start on; null is Claude Code's own login. */ + active: string | null; + /** Move new conversations to the next account when one reports a usage limit. */ + rotate: boolean; + accounts: { id: string; kind: "profile" | "gateway"; label: string; provider?: "orcarouter"; model?: string; signedIn: boolean; detail: string; cooldownUntil?: string }[]; + detail: string; +} + export interface ClaudeLoginResult { status: "authenticated" | "busy" | "unsupported" | "failed" | "timeout" | "cancelled"; detail: string; @@ -644,6 +657,15 @@ export interface BridgeCommands extends NativeDraftCommands, ImageCommands, Plug "chatgpt:signOut": { req: { accountId: string }; res: { status: ChatGPTStatus; detail: string } }; "claude:login": { req: undefined; res: ClaudeLoginResult }; "claude:cancelLogin": { req: undefined; res: void }; + "claudeAccounts:status": { req: undefined; res: ClaudeAccountsStatus }; + "claudeAccounts:addProfile": { req: { label: string }; res: ClaudeAccountsStatus }; + /** The key goes to OS-encrypted storage in main; it is never returned. */ + "claudeAccounts:addGateway": { req: { label: string; key: string; model?: string }; res: ClaudeAccountsStatus }; + "claudeAccounts:select": { req: { accountId: string | null }; res: ClaudeAccountsStatus }; + "claudeAccounts:rotate": { req: { rotate: boolean }; res: ClaudeAccountsStatus }; + "claudeAccounts:remove": { req: { accountId: string }; res: ClaudeAccountsStatus }; + /** Claude Code's own browser login, run in the profile's configuration directory. */ + "claudeAccounts:login": { req: { accountId: string }; res: { status: ClaudeAccountsStatus; result: ClaudeLoginResult } }; "companion:status": { req: undefined; res: CompanionStatus }; "companion:start": { req: undefined; res: CompanionStatus }; "companion:stop": { req: undefined; res: CompanionStatus }; diff --git a/apps/desktop/test/claude-accounts.test.ts b/apps/desktop/test/claude-accounts.test.ts new file mode 100644 index 00000000..4194d2cb --- /dev/null +++ b/apps/desktop/test/claude-accounts.test.ts @@ -0,0 +1,353 @@ +import { projectConversation, type ConversationEvent } from "../src/shared/conversation-events.js"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import type { spawn, SpawnOptions } from "node:child_process"; +import { ClaudeAccounts, DEFAULT_COOLDOWN_MS, ORCAROUTER_BASE_URL, limitResetAt } from "../src/main/engine/claude-accounts.js"; +import { ClaudeLogin } from "../src/main/engine/claude-login.js"; +import { accountHealth, AccountClaudeBackend } from "../src/main/engine/backends/account-claude.js"; +import { Router } from "../src/main/engine/routing/router.js"; +import { Store } from "../src/main/engine/store.js"; +import { ThreadRunner } from "../src/main/engine/runner.js"; +import type { Backend, TurnOptions, TurnResult, TurnSink } from "../src/main/engine/backends/types.js"; +import { FakeProcess, collectSink, local } from "./fakeproc.js"; +import { gitRepo, tmpdir } from "./helpers.js"; + +const KEY = "sk-orca-SECRETkey1234567890"; + +function memorySecrets(available = true) { + const values = new Map(); + return { values, available: () => available, get: (name: string) => values.get(name) ?? null, set: (name: string, value: string) => { values.set(name, value); }, clear: (name: string) => { values.delete(name); } }; +} + +function pool(now = () => Date.now()) { + const home = tmpdir("modex-claude-accounts-"); + const secrets = memorySecrets(); + return { home, secrets, accounts: new ClaudeAccounts(home, secrets, now) }; +} + +test("a gateway key lives only in the secret store; the account file and status never carry it", () => { + const { accounts, secrets } = pool(); + const gateway = accounts.addGateway("Team key", ` ${KEY} `, "anthropic/claude-opus-4.8"); + assert.equal(secrets.values.get(`claude_account:${gateway.id}`), KEY); + assert.equal(fs.readFileSync(accounts.file, "utf8").includes("SECRET"), false); + assert.equal((fs.statSync(accounts.file).mode & 0o777), 0o600); + assert.equal(JSON.stringify(accounts.status()).includes("SECRET"), false); + const { env, secret } = accounts.environment(gateway.id); + assert.equal(secret, KEY); + assert.deepEqual(env, { ANTHROPIC_BASE_URL: ORCAROUTER_BASE_URL, ANTHROPIC_AUTH_TOKEN: KEY, ANTHROPIC_API_KEY: "", CLAUDE_CODE_OAUTH_TOKEN: "", CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: "1", + ANTHROPIC_MODEL: "anthropic/claude-opus-4.8", ANTHROPIC_DEFAULT_OPUS_MODEL: "anthropic/claude-opus-4.8", ANTHROPIC_DEFAULT_SONNET_MODEL: "anthropic/claude-opus-4.8", ANTHROPIC_DEFAULT_HAIKU_MODEL: "anthropic/claude-opus-4.8" }); + assert.equal(ORCAROUTER_BASE_URL.endsWith("/v1"), false, "Claude Code appends /v1/messages itself"); +}); + +test("a profile gets its own owner-only Claude Code configuration directory and no secret", () => { + const { accounts } = pool(); + const profile = accounts.addProfile("Work Max"); + const { env, secret } = accounts.environment(profile.id); + assert.equal(secret, undefined); + assert.equal(env.CLAUDE_CONFIG_DIR, path.join(accounts.profilesDir, profile.id)); + assert.equal(fs.statSync(env.CLAUDE_CONFIG_DIR!).mode & 0o777, 0o700); + assert.deepEqual(accounts.environment("cli"), { env: {} }); +}); + +test("input is validated before anything is stored", () => { + const { accounts, secrets } = pool(); + assert.throws(() => accounts.addGateway("x", "sk-ant-123456789"), /sk-orca-/); + assert.throws(() => accounts.addGateway("x", KEY, "bad model; rm -rf"), /vendor\/model/); + assert.throws(() => accounts.addProfile(" "), /name/); + accounts.addProfile("Work"); + assert.throws(() => accounts.addProfile("work"), /already/); + assert.throws(() => accounts.select("00000000-0000-0000-0000-000000000000"), /no longer exists/); + assert.equal(secrets.values.size, 0); + const locked = new ClaudeAccounts(tmpdir("modex-claude-locked-"), memorySecrets(false)); + assert.throws(() => locked.addGateway("x", KEY), /unavailable/); +}); + +test("rotation skips cooling accounts in order, wraps to Claude Code's own login, and only when turned on", () => { + let now = 1_000_000; + const { accounts } = pool(() => now); + const a = accounts.addProfile("A"), b = accounts.addGateway("B", KEY); + accounts.select(a.id); + assert.equal(accounts.identity(), a.id); + accounts.noteLimit(a.id); + assert.equal(accounts.identity(), a.id, "rotation is off by default"); + accounts.setRotation(true); + assert.equal(accounts.identity(), b.id); + assert.equal(accounts.noteLimit(b.id, now + 5000).next, "cli"); + accounts.noteLimit("cli"); + assert.equal(accounts.identity(), a.id, "every account cooling: stay on the preferred one"); + now += 5001; + assert.equal(accounts.identity(), b.id, "B's reset time passed"); + assert.equal(accounts.status().accounts.find((x) => x.id === a.id)?.cooldownUntil, new Date(1_000_000 + DEFAULT_COOLDOWN_MS).toISOString()); + accounts.clearLimit(a.id); + assert.equal(accounts.identity(), a.id); +}); + +test("removing an account clears its key or profile directory and its selection", () => { + const { accounts, secrets } = pool(); + const g = accounts.addGateway("G", KEY), p = accounts.addProfile("P"); + const dir = accounts.environment(p.id).env.CLAUDE_CONFIG_DIR!; + fs.writeFileSync(path.join(dir, ".credentials.json"), "{}"); + accounts.select(g.id); + accounts.remove(g.id); accounts.remove(p.id); + assert.equal(secrets.values.size, 0); + assert.equal(fs.existsSync(dir), false); + assert.equal(accounts.status().active, null); + assert.deepEqual(accounts.status().accounts, []); +}); + +test("an unreadable account file is reported, not silently replaced", () => { + const { home } = pool(); + fs.mkdirSync(path.join(home, "app"), { recursive: true }); + fs.writeFileSync(path.join(home, "app", "claude-accounts.json"), "{torn"); + assert.throws(() => new ClaudeAccounts(home, memorySecrets()).status(), /could not be read/); + assert.equal(fs.readFileSync(path.join(home, "app", "claude-accounts.json"), "utf8"), "{torn"); +}); + +test("usage-limit reset times are read from Claude Code's wording", () => { + const now = Date.parse("2026-11-01T10:00:00"); + assert.equal(limitResetAt("Rate limited. Try again in 2 hours.", now), now + 7_200_000); + assert.equal(limitResetAt("retry in 30 seconds", now), now + 30_000); + assert.equal(limitResetAt("Claude usage limit reached. Your limit resets at 3pm", now), Date.parse("2026-11-01T15:00:00")); + assert.equal(limitResetAt("5-hour limit reached ∙ resets 9am", now), Date.parse("2026-11-02T09:00:00"), "a passed time is tomorrow"); + assert.equal(limitResetAt("429 Too Many Requests", now), undefined); +}); + +test("the Claude child gets the turn's account environment, and the key is redacted from failures", async () => { + const { accounts } = pool(); + const g = accounts.addGateway("G", KEY), p = accounts.addProfile("P"); + const envs: NodeJS.ProcessEnv[] = []; + const spawnImpl = ((_bin: string, _args: string[], options: SpawnOptions) => { + const child = new FakeProcess(); envs.push(options.env ?? {}); + child.stdin.on("data", () => { child.emitLine({ type: "result", subtype: "error_during_execution", is_error: true, result: `upstream rejected ${KEY}` }); }); + return child; + }) as unknown as typeof spawn; + const previous = process.env.ANTHROPIC_API_KEY; + process.env.ANTHROPIC_API_KEY = "inherited-key"; + try { + const backend = new AccountClaudeBackend(accounts, "claude", spawnImpl); + const run = (account?: string) => backend.runTurn("hi", { cwd: "/repo", mode: "chat", plan: false, model: "", ...(account ? { account } : {}) }, collectSink().sink, new AbortController().signal); + const gateway = await run(g.id); + assert.equal(envs[0]!.ANTHROPIC_AUTH_TOKEN, KEY); + assert.equal(envs[0]!.ANTHROPIC_BASE_URL, ORCAROUTER_BASE_URL); + assert.equal("ANTHROPIC_API_KEY" in envs[0]!, false, "an inherited API key would win over the gateway token"); + assert.equal(envs[0]!.CLAUDE_CODE_SUBPROCESS_ENV_SCRUB, "1"); + assert.equal(gateway.status, "failed"); + assert.equal(JSON.stringify(gateway).includes(KEY), false); + assert.match(gateway.error ?? "", /\[redacted\]/); + await run(p.id); + assert.equal(envs[1]!.CLAUDE_CONFIG_DIR, path.join(accounts.profilesDir, p.id)); + assert.equal(envs[1]!.ANTHROPIC_AUTH_TOKEN, undefined); + await run("cli"); + assert.equal(envs[2]!.CLAUDE_CONFIG_DIR, process.env.CLAUDE_CONFIG_DIR); + assert.equal(envs[2]!.ANTHROPIC_API_KEY, "inherited-key", "Claude Code's own login keeps the person's environment"); + accounts.remove(g.id); + const removed = await run(g.id); + assert.equal(envs.length, 3, "a removed account never starts a child"); + assert.match(removed.error ?? "", /removed from Modex/); + } finally { if (previous === undefined) delete process.env.ANTHROPIC_API_KEY; else process.env.ANTHROPIC_API_KEY = previous; } +}); + +const LIMIT = "Claude AI usage limit reached|resets at 11pm"; +type Step = (sink: TurnSink) => TurnResult; + +async function runnerHarness(steps: Step[], setup: (accounts: ClaudeAccounts) => void) { + const home = tmpdir("modex-claude-rotate-"); + const store = new Store(home); + const project = store.addProject(gitRepo()); + const accounts = new ClaudeAccounts(home, memorySecrets()); + setup(accounts); + const seen: TurnOptions[] = []; + const backend: Backend = { + id: "claude", identity: () => accounts.identity(), dispose: async () => {}, + listModels: async () => [{ id: "opus", label: "Opus", isDefault: true }], + async runTurn(_text, opts, sink) { seen.push(opts); sink.session(`session-${seen.length}`); return (steps.shift() ?? (() => ({ status: "completed" as const })))(sink); }, + }; + const router = new Router({ home, policy: () => store.settings.routing, listModels: async () => ({ models: await backend.listModels() }), transport: null }); + const runner = new ThreadRunner({ home, store, emit: () => {}, router, backends: { claude: backend }, claudeAccounts: accounts }); + const thread = await runner.createThread(project.id, { backend: "claude", model: "opus" }); + return { store, runner, seen, thread, accounts }; +} +const limited: Step = () => ({ status: "failed", error: LIMIT }); +const replies: Step = (sink) => { local(sink).assistant("done"); return { status: "completed" }; }; + +test("a usage-limited Claude account rotates a fresh conversation to the next account and binds it there", async () => { + let first = "", second = ""; + const h = await runnerHarness([limited, replies], (accounts) => { + first = accounts.addProfile("First").id; second = accounts.addProfile("Second").id; + accounts.select(first); accounts.setRotation(true); + }); + try { + await h.runner.send(h.thread.id, "hello"); + assert.deepEqual(h.seen.map((o) => o.account), [first, second]); + assert.equal(h.seen[1]!.resume, undefined, "a fresh conversation does not resume the old profile's session"); + const t = h.store.thread(h.thread.id)!; + assert.equal(t.claudeAccount, second); + assert.equal(t.status, "idle"); + const notice = h.runner.items(h.thread.id).find((i) => i.kind === "notice" && i.level === "warn"); + assert.match(notice?.kind === "notice" ? notice.text : "", /Claude account "First" hit its usage limit; .* Retrying on "Second"\./); + assert.equal(h.accounts.coolingDown(first), true); + assert.equal(h.accounts.identity(), second, "new conversations also skip the limited account"); + } finally { await h.runner.dispose(); } +}); + +test("rotation never moves a turn that already started work, nor a profile conversation with a session", async () => { + const startedThenLimited: Step = (sink) => { local(sink).assistant("half"); return { status: "failed", error: LIMIT }; }; + let first = ""; + const h = await runnerHarness([replies, limited, startedThenLimited], (accounts) => { + first = accounts.addProfile("First").id; accounts.addProfile("Second"); + accounts.select(first); accounts.setRotation(true); + }); + try { + await h.runner.send(h.thread.id, "one"); + await h.runner.send(h.thread.id, "two"); + assert.equal(h.seen.length, 2, "a profile's session cannot resume in another profile"); + assert.equal(h.store.thread(h.thread.id)!.claudeAccount, first); + const notice = h.runner.items(h.thread.id).filter((i) => i.kind === "notice" && i.level === "warn").at(-1); + assert.match(notice?.kind === "notice" ? notice.text : "", /New Claude conversations start on "Second"\./); + } finally { await h.runner.dispose(); } +}); + +test("with rotation off a limit only cools the account down; Claude Code's own login and gateways share sessions", async () => { + let gateway = ""; + const off = await runnerHarness([limited], (accounts) => { accounts.select(accounts.addProfile("Only").id); accounts.addProfile("Other"); }); + try { + await off.runner.send(off.thread.id, "hi"); + assert.equal(off.seen.length, 1); + assert.equal(off.store.thread(off.thread.id)!.status, "error"); + } finally { await off.runner.dispose(); } + const shared = await runnerHarness([replies, limited, replies], (accounts) => { gateway = accounts.addGateway("Gateway", KEY).id; accounts.setRotation(true); }); + try { + await shared.runner.send(shared.thread.id, "one"); + assert.equal(shared.store.thread(shared.thread.id)!.claudeAccount, "cli"); + await shared.runner.send(shared.thread.id, "two"); + assert.deepEqual(shared.seen.map((o) => o.account), ["cli", "cli", gateway]); + assert.equal(shared.seen[2]!.resume, "session-2", "a gateway continues the session in Claude Code's own directory"); + } finally { await shared.runner.dispose(); } +}); + +test("a dated reset is read on its day", () => { + const now = Date.parse("2026-11-01T10:00:00"); + assert.equal(limitResetAt("Weekly limit reached ∙ resets Nov 3 at 7pm (UTC)", now), Date.parse("2026-11-03T19:00:00")); + assert.equal(limitResetAt("Claude AI usage limit reached|resets at 11pm", now), Date.parse("2026-11-01T23:00:00")); +}); + +for (const operation of ["turn", "health", "login"] as const) { + test(`profile ${operation} removes inherited authentication and endpoint overrides`, async () => { + const { accounts } = pool(); + const profile = accounts.addProfile("Isolated"); + const names = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_BASE_URL"]; + const previous = names.map((name) => process.env[name]); + const captured: NodeJS.ProcessEnv[] = []; + let checks = 0; + const spawnImpl = ((_bin: string, args: string[], options: SpawnOptions) => { + const child = new FakeProcess(); + if (args[0] !== "--version") captured.push(options.env ?? {}); + if (args[0] === "--version" || args[0] === "auth") setImmediate(() => { + const loggedIn = operation !== "login" || args[1] !== "status" || checks++ > 0; + child.emitLine(args[0] === "--version" ? "2.1.316" : { loggedIn }); child.close(loggedIn ? 0 : 1); + }); + else child.stdin.once("data", () => { child.emitLine({ type: "result", subtype: "success", result: "done" }); child.close(0); }); + return child; + }) as unknown as typeof spawn; + try { + names.forEach((name) => { process.env[name] = "synthetic-inherited-value"; }); + if (operation === "turn") await new AccountClaudeBackend(accounts, "claude", spawnImpl).runTurn("hi", { cwd: "/repo", mode: "chat", plan: false, model: "", account: profile.id }, collectSink().sink, new AbortController().signal); + else if (operation === "health") await accountHealth("claude", spawnImpl, accounts, profile.id); + else await new ClaudeLogin(spawnImpl).run("claude", accounts.environment(profile.id).env); + assert.equal(captured.length, operation === "login" ? 3 : 1); + for (const env of captured) { + assert.equal(env.CLAUDE_CONFIG_DIR, path.join(accounts.profilesDir, profile.id)); + for (const name of names) assert.equal(env[name], undefined, `${operation} inherited ${name}`); + } + } finally { names.forEach((name, i) => { if (previous[i] === undefined) delete process.env[name]; else process.env[name] = previous[i]; }); } + }); +} + +test("a second usage-limited account cools down without a third retry", async () => { + let first = "", second = "", third = ""; + const h = await runnerHarness([limited, limited], (accounts) => { + first = accounts.addProfile("A").id; second = accounts.addProfile("B").id; third = accounts.addProfile("C").id; + accounts.select(first); accounts.setRotation(true); + }); + try { + await h.runner.send(h.thread.id, "hello"); + assert.deepEqual(h.seen.map((o) => o.account), [first, second]); + assert.equal(h.accounts.coolingDown(second), true); + assert.equal(h.accounts.identity(), third); + assert.equal(h.store.thread(h.thread.id)!.status, "error"); + } finally { await h.runner.dispose(); } +}); + +for (const streamed of [false, true]) { + test(`gateway key is redacted before ${streamed ? "split streaming" : "snapshot"} observations reach the transcript`, async () => { + const { accounts } = pool(); const gateway = accounts.addGateway("G", KEY); + const observed: unknown[] = []; + const collected = collectSink(); + const spawnImpl = ((_bin: string, _args: string[], _options: SpawnOptions) => { + const child = new FakeProcess(); + child.stdin.once("data", () => { + if (streamed) { + child.emitLine({ type: "stream_event", event: { type: "message_start", message: { id: "m" } } }); + child.emitLine({ type: "stream_event", event: { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } } }); + for (const text of ["before " + KEY.slice(0, 12), KEY.slice(12) + " after"]) child.emitLine({ type: "stream_event", event: { type: "content_block_delta", index: 0, delta: { type: "text_delta", text } } }); + child.emitLine({ type: "stream_event", event: { type: "content_block_stop", index: 0 } }); + } else child.emitLine({ type: "assistant", message: { id: "m", content: [{ type: "text", text: `before ${KEY} after` }] } }); + child.emitLine({ type: "result", subtype: "success", result: "done" }); child.close(0); + }); return child; + }) as unknown as typeof spawn; + await new AccountClaudeBackend(accounts, "claude", spawnImpl).runTurn("hi", { cwd: "/repo", mode: "chat", plan: false, model: "", account: gateway.id }, { ...collected.sink, observe(o) { observed.push(o); collected.sink.observe(o); } }, new AbortController().signal); + assert.equal(JSON.stringify(observed).includes(KEY), false); + const stamp = { schema: 1 as const, at: "2026-11-01T00:00:00Z", turn: { thread: "t", turn: "u", attempt: "a", backend: "claude" as const } }; + const events: ConversationEvent[] = [{ ...stamp, type: "attempt.start", id: "start", seq: 1 }, ...collected.observations.map((o, i) => ({ ...stamp, ...o, id: String(i), seq: i + 2 }))]; + const text = projectConversation(events).items.filter((i) => i.kind === "assistant").map((i) => i.text).join(""); + assert.equal(text, "before [redacted] after"); + }); +} + +test("a tool request containing the gateway key is denied without exposing or rewriting the action", async () => { + const { accounts } = pool(); const gateway = accounts.addGateway("G", KEY); + const proc = new FakeProcess(); const captured = collectSink(["yes"]); + let deliveries = 0; + const backend = new AccountClaudeBackend(accounts, "claude", (() => proc) as unknown as typeof spawn); + const pending = backend.runTurn("hi", { cwd: "/repo", mode: "chat", plan: false, model: "", account: gateway.id }, { + ...captured.sink, deliverRequest: async (_native, write) => { deliveries++; await write(); }, + }, new AbortController().signal); + await proc.waitFor((line) => line.includes('"type":"user"')); + proc.emitLine({ type: "control_request", request_id: "secret-action", request: { subtype: "can_use_tool", tool_name: "Bash", input: { command: `echo ${KEY}` } } }); + await proc.waitFor((line) => line.includes('"request_id":"secret-action"')); + const response = proc.sent((msg) => msg.type === "control_response")[0]!; + proc.emitLine({ type: "result", subtype: "success", result: "done" }); proc.close(0); + await pending; + assert.equal(response.response.response.behavior, "deny"); + assert.equal(captured.requests.length, 0, "the raw action must never reach approval storage or IPC"); + assert.equal(deliveries, 0, "a hidden denial has no human-request receipt to revalidate"); + assert.equal(JSON.stringify(captured.events).includes(KEY), false); +}); + +test("gateway redaction preserves an ordinary trailing key-prefix character", async () => { + const { claudeSecretSink } = await import("../src/main/engine/backends/claude-secret-sink.js"); + const collected = collectSink(); const protectedSink = claudeSecretSink(collected.sink, KEY); + const native = { runtime: "r", item: "m" }; + protectedSink.sink.observe({ type: "message.delta", native, text: "ordinary words" }); + protectedSink.sink.observe({ type: "message.done", native }); + protectedSink.finish(); + const text = collected.observations.filter((o) => o.type === "message.delta").map((o) => o.text).join(""); + assert.equal(text, "ordinary words"); +}); + +test("gateway redaction handles every key split independently for message and reasoning streams", async () => { + const { claudeSecretSink } = await import("../src/main/engine/backends/claude-secret-sink.js"); + for (let split = 1; split < KEY.length; split++) { + const collected = collectSink(); const protectedSink = claudeSecretSink(collected.sink, KEY); + const message = { runtime: "r", item: "message" }, thinking = { runtime: "r", item: "reasoning" }; + protectedSink.sink.observe({ type: "message.delta", native: message, text: "hello " + KEY.slice(0, split) }); + protectedSink.sink.observe({ type: "reasoning.delta", native: thinking, representation: "provider-text", index: 0, text: "reason " + KEY.slice(0, split) }); + protectedSink.sink.observe({ type: "message.delta", native: message, text: KEY.slice(split) + " world" }); + protectedSink.sink.observe({ type: "reasoning.delta", native: thinking, representation: "provider-text", index: 0, text: KEY.slice(split) + " end" }); + protectedSink.finish(); + assert.equal(collected.observations.filter((o) => o.type === "message.delta").map((o) => o.text).join(""), "hello [redacted] world"); + assert.equal(collected.observations.filter((o) => o.type === "reasoning.delta").map((o) => o.text).join(""), "reason [redacted] end"); + } +}); diff --git a/apps/store-desktop/src/main/demo.ts b/apps/store-desktop/src/main/demo.ts index c8f763f3..6227c352 100644 --- a/apps/store-desktop/src/main/demo.ts +++ b/apps/store-desktop/src/main/demo.ts @@ -220,6 +220,9 @@ export class StoreDemo { "chatgpt:status": account, "chatgpt:select": unavailable, "chatgpt:signIn": unavailable, "chatgpt:signOut": unavailable, "chatgpt:cancel": () => {}, "claude:login": () => ({ status: "unsupported", detail: "Offline demo: connect your host to sign in to Claude." }), "claude:cancelLogin": () => {}, + "claudeAccounts:status": () => ({ available: false, active: null, rotate: false, accounts: [], detail: "Offline demo: manage Claude accounts on your Mac host." }), + "claudeAccounts:addProfile": unavailable, "claudeAccounts:addGateway": unavailable, "claudeAccounts:select": unavailable, + "claudeAccounts:rotate": unavailable, "claudeAccounts:remove": unavailable, "claudeAccounts:login": unavailable, "companion:status": companion, "companion:stop": companion, "companion:start": unavailable, "companion:reset": unavailable, "approvals:rules:get": () => this.state.settings.approval_rules, "approvals:rules:set": ({ rules }) => { this.state.settings.approval_rules = rules; return rules; }, diff --git a/docs/claude-accounts.md b/docs/claude-accounts.md new file mode 100644 index 00000000..05a3ba5b --- /dev/null +++ b/docs/claude-accounts.md @@ -0,0 +1,69 @@ +# Claude account pool + +Settings → Coding CLIs → Claude Code manages more than one Claude account. Modex keeps its own pool, +picks the account each new conversation starts on, and moves on when an account hits its usage limit. +This is the same job the multi-subscription routers do (CC-Router, OmniRoute, teamclaude), done inside +Modex without a proxy on the request path. Every turn still runs through the installed `claude` CLI. + +## Accounts + +| Kind | What Modex stores | What the `claude` child gets | +| --- | --- | --- | +| Claude Code's own login | nothing | the person's environment, unchanged | +| Profile | a label and an id in `app/claude-accounts.json` | `CLAUDE_CONFIG_DIR=app/claude-profiles/` | +| OrcaRouter key | a label, an optional model id, and the key in `app/secrets.json` (OS-encrypted) | `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, optional `ANTHROPIC_MODEL` and tier models | + +- **Profiles.** Each profile is a separate Claude Code configuration directory, owner-only. **Sign in** + runs `claude auth login` in that directory and confirms it with `claude auth status`, exactly as the + existing Claude sign-in does. Claude Code owns the credential and its refresh. Modex never reads a token. +- **OrcaRouter keys.** Keys must start with `sk-orca-`. The base URL is `https://api.orcarouter.ai` + without `/v1`, because Claude Code appends `/v1/messages` itself (OrcaRouter's documented rule for + Anthropic-protocol tools). Model ids use OrcaRouter's `vendor/model` form, or `orcarouter/auto`. + The key goes to main once, is encrypted with Electron safeStorage, and never crosses IPC again. + A gateway turn removes inherited `ANTHROPIC_API_KEY` and `CLAUDE_CODE_OAUTH_TOKEN`, which would + otherwise win, and sets `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1` so Claude Code 2.1.316 strips the + key from the shell commands the model runs. The key is redacted from any failure text. + +Choosing an OrcaRouter account sends that conversation's prompts, files and tool results to +OrcaRouter, which forwards them to the upstream provider. Modex itself makes no request to OrcaRouter. + +## Binding and rotation + +A Claude thread is bound to the account its first turn ran on, like a Codex thread (`Thread.claudeAccount`). +A profile's sessions live in its own directory, so a resumed conversation must stay on its account. +Claude Code's own login and OrcaRouter accounts share `~/.claude` and can resume each other's sessions. + +**Rotate when an account hits its usage limit** is off by default. When a turn fails with a usage limit: + +1. The account sits out until the reset time Claude Code printed ("resets at 11pm", "resets Nov 3 at + 7pm", "try again in 2 hours"), read in local time, or for one hour when it gave none. +2. With rotation on, the next account in list order that is not cooling down is chosen. Claude Code's + own login is the last resort. New conversations start there. +3. The failed turn retries once on that account only if it had not started work (no reply, tool or + thinking) and either the conversation was new or both accounts share `~/.claude`. Otherwise the + thread says where new conversations go and keeps its failure card. +4. A completed turn clears its account's cooldown. Cooldowns are kept in memory and reset on restart. + +Removing an account clears its key or deletes its profile directory. A thread still bound to it fails +its next turn with a message to add the account again or start a new thread. Removal and sign-in are +refused while a turn on that account is running. + +## Limits + +- Fixture tests cover storage, validation, the child environment, redaction, rotation and the Settings + flow (`test/claude-accounts.test.ts`, `e2e/claude-accounts.spec.ts`). They use a synthetic `claude` + and send nothing to Anthropic or OrcaRouter. +- No live OrcaRouter turn, real second Claude subscription or real usage-limit message has been run. + The reset-time parser follows wording seen in Claude Code's limit messages and may miss new phrasings. +- Rotating between accounts does not change any provider's usage policy. Check each provider's terms + before pooling subscriptions. +- OmniRouter (omnilabs-ai/OmniRouter) was evaluated and not integrated. It is a pre-alpha model-swap + evidence layer that compiles routing policies for an existing gateway. It has no account switching. + +## Review repairs + +Managed profile turns, authentication checks and login all remove inherited `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `CLAUDE_CODE_OAUTH_TOKEN` and `ANTHROPIC_BASE_URL` before selecting the profile's configuration directory. The ordinary CLI account retains the person's environment. + +Gateway observations are redacted before entering the transcript or IPC, including a key split across message or reasoning deltas. A possible key prefix is retained only until it resolves or its stream ends; ordinary trailing text is preserved. If a tool approval contains the gateway credential, Modex denies that exact action without displaying it or approving a redacted substitute. Final errors and diagnostics remain redacted. These are synthetic-process guarantees; live provider acceptance and the destination decision remain separate. + +If the one permitted retry also reports a usage limit, its account receives the reported cooldown too. No third attempt is made; the next new conversation can select another available account. diff --git a/docs/reviews/2026-11-01-claude-pool-repair.md b/docs/reviews/2026-11-01-claude-pool-repair.md new file mode 100644 index 00000000..53e939dc --- /dev/null +++ b/docs/reviews/2026-11-01-claude-pool-repair.md @@ -0,0 +1,11 @@ +# Claude account pool review repair + +The candidate is based on PR #467 at `18965e01a1201bc294562fb26e6f9252fd2e9469`, in the independently owned `claude-pool-repair` worktree. The upstream documentation-only commit `6a95958` records Val's destination approval in AGENTS.md; the repair was reconciled with that commit without changing the tested application source. It leaves the original author's branch untouched and does not claim live provider acceptance. + +Three review findings were reproduced: managed profiles inherited credentials and a base URL from the parent environment; a gateway key reached transcript observations before final-result redaction; and the second usage-limited account was not cooled down after the one allowed retry. The tests exercise the production backend with a fake process and the production runner with a scripted backend, using only synthetic credentials. + +The environment and retry regressions failed first (three environment failures and one cooldown failure), then the account/login run passed 22 tests. Snapshot and split-delta regressions exposed the gateway key; a secret-bearing tool request was allowed by the original code. The repair redacts observations before journal/IPC delivery and denies that action rather than approving a modified display. A further boundary test caught over-redaction of ordinary text ending in a proper key prefix; the stream flush now preserves that text. + +Two test-authoring compilation errors were corrected: the test sink has observations rather than an `items` property, and `deliverRequest` returns `Promise`. The initial full-suite process overlapped test development and is diagnostic only, not final-head acceptance. It ended with the three security-test failures plus `evidence.test.js` failing to import `ThreadRunner` while compiled output was being rebuilt; concurrent output rewriting is the suspected cause, not a proven product defect. Its failures and all focused logs remain outside the worktree under `/Users/buns/Documents/Codex/artifacts/modex-land-and-prune/` with the `pr467-` prefix. + +Final validation passed: `pnpm build`; `pnpm test` (120 worktree, 16 core, 2,115 desktop, 21 native, 10 site and 16 Store passes; 20 desktop and 6 native skips); and the account Electron E2E test (1 passed, 10.8 seconds). The latest focused account/login run passed 27 tests. Independent repair review found no blockers. No live second-subscription, real limit or OrcaRouter inference test has run. The original PR's AGENTS.md records destination approval; live provider acceptance remains unrun. diff --git a/docs/status.md b/docs/status.md index 9a009b00..e114e399 100644 --- a/docs/status.md +++ b/docs/status.md @@ -233,6 +233,10 @@ The published application source is `6a97d23aaea315b29008890f25dde2d7ea07f1d0` ( ## In flight +- **Claude account pool with OrcaRouter keys.** Settings → Coding CLIs → Claude Code adds separate Claude Code profiles (each with its own `CLAUDE_CONFIG_DIR` and browser login), OrcaRouter gateway keys in OS-encrypted storage, an account for new conversations, and opt-in rotation when an account hits its usage limit. Threads bind to their first account; a turn that had not started work retries once on the next account. Unit and E2E coverage use a synthetic `claude`; no live OrcaRouter turn, second subscription or real limit message has been run. OmniRouter was evaluated and not integrated. [Design and limits](claude-accounts.md). + +Claude account pool review repairs passed build, full unit validation and the account Electron E2E test on a separate candidate based on #467: inherited profile credentials are removed, gateway transcript output is redacted before delivery, and a limited retry account cools down without another retry. The original PR remains pending; its AGENTS.md records the OrcaRouter destination approval, while live provider acceptance remains unrun. [Repair evidence](reviews/2026-11-01-claude-pool-repair.md). + - **Current Code Review repository identity.** A reproduced C22 gap kept the inbox's repository name in the PR header and links after the overview returned a new name. The candidate adopts the overview locator after the existing PR-key check and retains it across an unavailable refresh. The Electron regression failed before the fix and passed afterward; independent review found no blockers, and the full unit retry passed (2,277 passes, 26 skips). Full E2E passed (479 desktop tests, 7 skips; 7 site tests). [Evidence](reviews/2026-11-01-cr4-current-locator.md). - **Deferred Markdown for long threads (hosted cold-open budget).** Hosted CI's approved 500 ms cold-open gate failed on runner variance alone: 33 hosted runs on 2026-10-30 had a 377.7 ms median and a 781.9 ms failure on application code identical to a 436.1 ms pass. Val chose on 2026-10-30 to defer offscreen answers. Turns before the newest eight show their Markdown as written until it nears the view, then parse once; scroll anchoring keeps the reader's place. Locally the five-launch median fell from about 190 to 88 ms, and 3× CPU throttling cut it from 806–870 to 312–340 ms. The gate is unchanged. The first hosted run had a 129.9 ms median (481 desktop E2E passes). It landed through #463 (`32f1cb4`) on 2026-10-31 UTC and is unreleased. [Evidence](reviews/2026-10-30-deferred-markdown.md).