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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 9 additions & 10 deletions apps/desktop/src/main/engine/backends/account-claude.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand All @@ -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 {
Expand All @@ -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<string | null> {
Expand All @@ -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 };
Expand Down
62 changes: 62 additions & 0 deletions apps/desktop/src/main/engine/backends/claude-secret-sink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { Observation } from "../../../shared/conversation-events.js";
import type { TurnSink } from "./types.js";

type Delta = Extract<Observation, { type: "message.delta" | "reasoning.delta" }>;

/** 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<T>(value: T): T } {
const redactText = (text: string) => text.split(secret).join("[redacted]");
const redact = <T>(value: T): T => JSON.parse(redactText(JSON.stringify(value))) as T;
const contains = (value: unknown) => JSON.stringify(value).includes(secret);
const pending = new Map<string, { event: Delta; text: string }>();
const denied = new Set<string>();
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);
},
},
};
}
11 changes: 10 additions & 1 deletion apps/desktop/src/main/engine/claude-accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
3 changes: 2 additions & 1 deletion apps/desktop/src/main/engine/claude-login.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<ClaudeLoginResult> {
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;
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/engine/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? [])] };
}
}
Expand Down
123 changes: 122 additions & 1 deletion apps/desktop/test/claude-accounts.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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");
}
});
Loading
Loading