From 18965e01a1201bc294562fb26e6f9252fd2e9469 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 1 Nov 2026 03:42:54 -0600 Subject: [PATCH 1/4] Add a Claude account pool with OrcaRouter keys and usage-limit rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Coding CLIs → Claude Code now keeps several Claude accounts: separate Claude Code profiles (each its own CLAUDE_CONFIG_DIR and browser login) and OrcaRouter gateway keys stored with OS encryption. New conversations start on the selected account. With rotation on, an account that reports a usage limit sits out until its reset time, and a turn that had not started work retries once on the next account. Threads bind to their first account, as Codex threads do. Every turn still runs through the installed claude CLI. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/e2e/claude-accounts.spec.ts | 144 ++++++++++ apps/desktop/src/main/commands.ts | 5 + .../main/engine/backends/account-claude.ts | 78 ++++++ .../src/main/engine/backends/claude.ts | 13 +- .../src/main/engine/claude-accounts.ts | 248 ++++++++++++++++++ apps/desktop/src/main/engine/claude-login.ts | 10 +- apps/desktop/src/main/engine/runner.ts | 69 ++++- apps/desktop/src/main/engine/secrets.ts | 3 +- apps/desktop/src/main/index.ts | 32 ++- .../renderer/components/ClaudeAccounts.tsx | 85 ++++++ .../renderer/components/SettingsDialog.tsx | 4 +- apps/desktop/src/renderer/styles.css | 6 + apps/desktop/src/shared/types.ts | 22 ++ apps/desktop/test/claude-accounts.test.ts | 232 ++++++++++++++++ apps/store-desktop/src/main/demo.ts | 3 + docs/claude-accounts.md | 61 +++++ docs/status.md | 2 + 17 files changed, 993 insertions(+), 24 deletions(-) create mode 100644 apps/desktop/e2e/claude-accounts.spec.ts create mode 100644 apps/desktop/src/main/engine/backends/account-claude.ts create mode 100644 apps/desktop/src/main/engine/claude-accounts.ts create mode 100644 apps/desktop/src/renderer/components/ClaudeAccounts.tsx create mode 100644 apps/desktop/test/claude-accounts.test.ts create mode 100644 docs/claude-accounts.md 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..d9a74c08 --- /dev/null +++ b/apps/desktop/src/main/engine/backends/account-claude.ts @@ -0,0 +1,78 @@ +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 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; + const result = { ...base }; + for (const [name, value] of Object.entries(this.accounts.environment(account).env)) { + if (value === "" || value === undefined) delete result[name]; else result[name] = value; + } + return result; + } + + 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 }; } + } + const result = await super.runTurn(text, opts, sink, signal); + // The gateway key never appears in argv; redact it from anything the CLI may have echoed. + return secret ? JSON.parse(JSON.stringify(result).split(secret).join("[redacted]")) as TurnResult : result; + } + + 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, { ...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.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..8f3bdbd7 --- /dev/null +++ b/apps/desktop/src/main/engine/claude-accounts.ts @@ -0,0 +1,248 @@ +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) } }; + 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; +} diff --git a/apps/desktop/src/main/engine/claude-login.ts b/apps/desktop/src/main/engine/claude-login.ts index b1c7cd42..8b6595eb 100644 --- a/apps/desktop/src/main/engine/claude-login.ts +++ b/apps/desktop/src/main/engine/claude-login.ts @@ -11,14 +11,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 = { ...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 +37,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 +48,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..40785da5 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,26 @@ 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); + 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 +929,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 +1019,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 +1131,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 +1232,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 +1386,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 +1512,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..c28d5752 --- /dev/null +++ b/apps/desktop/test/claude-accounts.test.ts @@ -0,0 +1,232 @@ +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 { 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")); +}); 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..9026a7f1 --- /dev/null +++ b/docs/claude-accounts.md @@ -0,0 +1,61 @@ +# 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. diff --git a/docs/status.md b/docs/status.md index a2469ec3..b0921c39 100644 --- a/docs/status.md +++ b/docs/status.md @@ -233,6 +233,8 @@ 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). + - **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). Val's review is pending. [Evidence](reviews/2026-10-30-deferred-markdown.md). Desktop CI memory diagnostics landed through #458 after #456’s cold transcript budget failure. The workflow reuses the existing resource sampler and retains its log as a separate CI artifact; it does not establish the cause of the earlier failure or change the 500 ms gate. The #458 queue passed desktop and site E2E and retained its memory log. See [the diagnostic review](reviews/2026-10-30-desktop-ci-memory.md). From 6a959580405356880cacdf819f10ee3a2fae9e22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 1 Nov 2026 05:55:03 -0600 Subject: [PATCH 2/4] Record Val's approval of OrcaRouter as a Claude account destination Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) 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. From de1b2e7e58794aa5c0577ac515d5fd73e66a5367 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 1 Nov 2026 06:11:45 -0600 Subject: [PATCH 3/4] Fix Claude pool credential isolation, redaction and retry cooldown --- .../main/engine/backends/account-claude.ts | 19 ++- .../engine/backends/claude-secret-sink.ts | 62 +++++++++ .../src/main/engine/claude-accounts.ts | 11 +- apps/desktop/src/main/engine/claude-login.ts | 3 +- apps/desktop/src/main/engine/runner.ts | 2 + apps/desktop/test/claude-accounts.test.ts | 123 +++++++++++++++++- docs/claude-accounts.md | 8 ++ docs/reviews/2026-11-01-claude-pool-repair.md | 11 ++ docs/status.md | 2 + 9 files changed, 228 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/src/main/engine/backends/claude-secret-sink.ts create mode 100644 docs/reviews/2026-11-01-claude-pool-repair.md diff --git a/apps/desktop/src/main/engine/backends/account-claude.ts b/apps/desktop/src/main/engine/backends/account-claude.ts index d9a74c08..45777fa8 100644 --- a/apps/desktop/src/main/engine/backends/account-claude.ts +++ b/apps/desktop/src/main/engine/backends/account-claude.ts @@ -1,9 +1,10 @@ +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 type { ClaudeAccounts } from "../claude-accounts.js"; +import { claudeAccountEnvironment, type ClaudeAccounts } from "../claude-accounts.js"; import { generateTitle } from "../titles.js"; /** @@ -21,11 +22,7 @@ export class AccountClaudeBackend extends ClaudeBackend { /** 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; - const result = { ...base }; - for (const [name, value] of Object.entries(this.accounts.environment(account).env)) { - if (value === "" || value === undefined) delete result[name]; else result[name] = value; - } - return result; + return claudeAccountEnvironment(base, this.accounts.environment(account).env); } protected override turnEnvironment(bin: string, opts: TurnOptions): NodeJS.ProcessEnv { @@ -39,9 +36,11 @@ export class AccountClaudeBackend extends ClaudeBackend { try { secret = this.accounts.environment(opts.account).secret; } catch (error) { return { status: "failed", error: (error as Error).message }; } } - const result = await super.runTurn(text, opts, sink, signal); - // The gateway key never appears in argv; redact it from anything the CLI may have echoed. - return secret ? JSON.parse(JSON.stringify(result).split(secret).join("[redacted]")) as TurnResult : result; + 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 { @@ -67,7 +66,7 @@ export async function accountHealth(bin: string, spawnImpl: typeof cliSpawn, acc 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, { ...process.env, ...env }); + 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 }; 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/claude-accounts.ts b/apps/desktop/src/main/engine/claude-accounts.ts index 8f3bdbd7..b409816c 100644 --- a/apps/desktop/src/main/engine/claude-accounts.ts +++ b/apps/desktop/src/main/engine/claude-accounts.ts @@ -93,7 +93,7 @@ export class ClaudeAccounts { 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) } }; + 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 @@ -246,3 +246,12 @@ export function limitResetAt(message: string, now = Date.now()): number | undefi } 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 8b6595eb..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"; @@ -13,7 +14,7 @@ export class ClaudeLogin { /** `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 = { ...process.env, ...env }; + 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; diff --git a/apps/desktop/src/main/engine/runner.ts b/apps/desktop/src/main/engine/runner.ts index 40785da5..c6d6e0c9 100644 --- a/apps/desktop/src/main/engine/runner.ts +++ b/apps/desktop/src/main/engine/runner.ts @@ -914,6 +914,8 @@ export class ThreadRunner { 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 ?? [])] }; } } diff --git a/apps/desktop/test/claude-accounts.test.ts b/apps/desktop/test/claude-accounts.test.ts index c28d5752..4194d2cb 100644 --- a/apps/desktop/test/claude-accounts.test.ts +++ b/apps/desktop/test/claude-accounts.test.ts @@ -1,10 +1,12 @@ +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 { AccountClaudeBackend } from "../src/main/engine/backends/account-claude.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"; @@ -230,3 +232,122 @@ test("a dated reset is read on its day", () => { 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/docs/claude-accounts.md b/docs/claude-accounts.md index 9026a7f1..05a3ba5b 100644 --- a/docs/claude-accounts.md +++ b/docs/claude-accounts.md @@ -59,3 +59,11 @@ refused while a turn on that account is running. 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 b0921c39..3612c400 100644 --- a/docs/status.md +++ b/docs/status.md @@ -235,6 +235,8 @@ The published application source is `6a97d23aaea315b29008890f25dde2d7ea07f1d0` ( - **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). + - **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). Val's review is pending. [Evidence](reviews/2026-10-30-deferred-markdown.md). Desktop CI memory diagnostics landed through #458 after #456’s cold transcript budget failure. The workflow reuses the existing resource sampler and retains its log as a separate CI artifact; it does not establish the cause of the earlier failure or change the 500 ms gate. The #458 queue passed desktop and site E2E and retained its memory log. See [the diagnostic review](reviews/2026-10-30-desktop-ci-memory.md). From 4b36d23ad52f6a5fe00c475835c548f19414af2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 1 Nov 2026 07:26:45 -0600 Subject: [PATCH 4/4] Re-run CI after merging the pool repair (#469) Co-Authored-By: Claude Opus 5.5