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] 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).