Skip to content
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
144 changes: 144 additions & 0 deletions apps/desktop/e2e/claude-accounts.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
5 changes: 5 additions & 0 deletions apps/desktop/src/main/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
77 changes: 77 additions & 0 deletions apps/desktop/src/main/engine/backends/account-claude.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { claudeSecretSink } from "./claude-secret-sink.js";
import type { CliExecutable } from "../cli-path.js";
import type { BackendHealth } from "../../../shared/types.js";
import type { Backend, TitleOptions, TurnOptions, TurnResult, TurnSink } from "./types.js";
import { ClaudeBackend } from "./claude.js";
import { cliEnvironment, cliSpawn, health, installation, probe } from "./health.js";
import { claudeAccountEnvironment, type ClaudeAccounts } from "../claude-accounts.js";
import { generateTitle } from "../titles.js";

/**
* Claude Code under a Modex-managed account (engine/claude-accounts.ts). One `claude -p` child per
* turn, as before; only its environment changes with the turn's account. "cli" (or no account) is
* the CLI's own login, unchanged.
*/
export class AccountClaudeBackend extends ClaudeBackend {
constructor(private readonly accounts: Pick<ClaudeAccounts, "identity" | "environment" | "account" | "status">, executable: CliExecutable = "claude", spawnImpl = cliSpawn, timing: ConstructorParameters<typeof ClaudeBackend>[2] = {}) {
super(executable, spawnImpl, timing);
}

identity(): string { return this.accounts.identity(); }

/** The base environment plus the account's additions; an empty value removes an inherited variable. */
private overlay(base: NodeJS.ProcessEnv, account: string | undefined): NodeJS.ProcessEnv {
if (!account || account === "cli") return base;
return claudeAccountEnvironment(base, this.accounts.environment(account).env);
}

protected override turnEnvironment(bin: string, opts: TurnOptions): NodeJS.ProcessEnv {
return this.overlay(cliEnvironment(bin), opts.account);
}

override async runTurn(text: string, opts: TurnOptions, sink: TurnSink, signal: AbortSignal): Promise<TurnResult> {
let secret: string | undefined;
if (opts.account && opts.account !== "cli") {
if (!this.accounts.account(opts.account)) return { status: "failed", error: "This conversation's Claude account was removed from Modex. Sign in again: add the account in Settings → Coding CLIs, or start a new thread." };
try { secret = this.accounts.environment(opts.account).secret; }
catch (error) { return { status: "failed", error: (error as Error).message }; }
}
if (!secret) return super.runTurn(text, opts, sink, signal);
const protectedSink = claudeSecretSink(sink, secret);
try { return protectedSink.redact(await super.runTurn(text, opts, protectedSink.sink, signal)); }
catch (error) { throw new Error(protectedSink.redact(error instanceof Error ? error.message : String(error))); }
finally { protectedSink.finish(); }
}

override generateTitle(text: string, opts: TitleOptions, signal: AbortSignal): Promise<string | null> {
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<BackendHealth> {
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<ClaudeAccounts, "environment" | "account">, id: string): Promise<BackendHealth> {
const installed = await installation(bin, spawnImpl);
if (installed.unavailable) return installed.unavailable;
const account = accounts.account(id);
if (!account) return health("signed-out", "This Claude account was removed", "available", installed.version);
let env: NodeJS.ProcessEnv;
try { env = accounts.environment(id).env; } catch (error) { return health("signed-out", (error as Error).message, "available", installed.version); }
if (account.kind === "gateway") return health("authenticated", `OrcaRouter gateway key saved · ${account.model ?? "gateway default model"} · model access unverified`, "available", installed.version);
const result = await probe(bin, ["auth", "status"], spawnImpl, 5000, claudeAccountEnvironment(process.env, env));
if (result.failure) return health("failed", result.failure === "timeout" ? "Account check timed out" : "Account check failed", "available", installed.version);
try {
const status = JSON.parse(result.output) as { loggedIn?: unknown; email?: unknown };
if (status.loggedIn === true && result.code === 0) return health("authenticated", `Signed in · ${account.label} · model access unverified`, "available", installed.version);
if (status.loggedIn === false && result.code === 1) return health("signed-out", `Signed out · sign in to ${account.label}`, "available", installed.version);
} catch { /* Older CLIs do not report structured status. */ }
return health("unknown", "Account status unavailable · check in Claude CLI", "available", installed.version);
}
Loading
Loading