From e6c6e13a22e465812f509a423990e9f09e32b1d0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:19:45 +0900 Subject: [PATCH 01/59] chore(release): open dev at 2.82.0 before releasing 2.81.0 (#6770) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 180ea4c168e..d2486a72e5a 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2645,7 +2645,7 @@ dependencies = [ [[package]] name = "opencodex-desktop" -version = "2.81.0" +version = "2.82.0" dependencies = [ "base64 0.22.1", "dbus", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 95e4134d16b..b2676e5f3ca 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencodex-desktop" -version = "2.81.0" +version = "2.82.0" description = "OpenCodex desktop shell" authors = ["OpenCodex contributors"] license = "MIT" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index b8f069d1d01..60b72fcd690 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCodex", - "version": "2.81.0", + "version": "2.82.0", "identifier": "com.opencodex.desktop", "build": { "frontendDist": "../ui", diff --git a/package.json b/package.json index 59b051d6298..e33874fcff7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.81.0", + "version": "2.82.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From c8108ef4c13e65a2326101bd67038bf3ee4a7d50 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 9 Oct 2026 08:14:27 +0900 Subject: [PATCH 02/59] fix(claude): compare picker metadata file identity with BigInt stats (#6772) * fix(claude): compare picker metadata file identity with BigInt stats A Windows file ID exceeds 2^53, so Number ino drops low bits and two files can compare equal. Read lstat and fstat with { bigint: true } as local-ca-files.ts already does. * test(claude): pin picker metadata identity beyond 2^53 --- src/claude/intercept/picker-ca-persistence.ts | 13 ++++---- .../claude-picker-ca-store.test.ts | 33 +++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/claude/intercept/picker-ca-persistence.ts b/src/claude/intercept/picker-ca-persistence.ts index c8414acdcce..4f95685d955 100644 --- a/src/claude/intercept/picker-ca-persistence.ts +++ b/src/claude/intercept/picker-ca-persistence.ts @@ -1,5 +1,5 @@ import { X509Certificate } from "node:crypto"; -import { constants, closeSync, fstatSync, lstatSync, openSync, readSync, realpathSync, rmSync, writeFileSync, type Stats } from "node:fs"; +import { constants, closeSync, fstatSync, lstatSync, openSync, readSync, realpathSync, rmSync, writeFileSync, type BigIntStats } from "node:fs"; import { join } from "node:path"; import { decodePickerCaCredential, openPickerCaCredential, pickerCaConfigId, readPickerCaCredential, @@ -21,18 +21,19 @@ export function canonicalPickerConfigDir(configDir: string): string { /** Public records still cannot follow symlinks or accept oversized/malformed recovery data. */ function readState(path: string, configId: string, initialization: boolean): Initialization | AuthorityMetadata | null { - let expected: Stats; - try { expected = lstatSync(path); } + // BigInt stats: a Windows file ID exceeds 2^53, so Number ino loses low bits and two files can compare equal. + let expected: BigIntStats; + try { expected = lstatSync(path, { bigint: true }); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw new Error("picker_ca_metadata_unsafe"); } if (expected.isSymbolicLink()) throw new Error("picker_ca_metadata_unsafe"); let fd: number; try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw new Error("picker_ca_metadata_unsafe"); } try { - const stat = fstatSync(fd); + const stat = fstatSync(fd, { bigint: true }); if (stat.dev !== expected.dev || stat.ino !== expected.ino) throw new Error("picker_ca_metadata_unsafe"); - if (!stat.isFile() || stat.nlink !== 1 || stat.size > MAX_PUBLIC_STATE_BYTES - || (process.platform !== "win32" && stat.uid !== process.getuid!())) throw new Error(); + if (!stat.isFile() || stat.nlink !== 1n || stat.size > BigInt(MAX_PUBLIC_STATE_BYTES) + || (process.platform !== "win32" && stat.uid !== BigInt(process.getuid!()))) throw new Error(); const bytes = Buffer.alloc(MAX_PUBLIC_STATE_BYTES + 1); let used = 0; while (used < bytes.length) { diff --git a/tests/claude-integration/claude-picker-ca-store.test.ts b/tests/claude-integration/claude-picker-ca-store.test.ts index 6a12ab894ab..dab8804f97e 100644 --- a/tests/claude-integration/claude-picker-ca-store.test.ts +++ b/tests/claude-integration/claude-picker-ca-store.test.ts @@ -314,6 +314,39 @@ test("metadata replaced between lstat and open fails before credential access", } finally { opening.mockRestore(); } }); +test("file identities that differ only beyond 2^53 still count as a replacement", () => { + // Windows file IDs carry a sequence number in the high bits, so two files can share a Number ino. + const dir = root(); + const fake = memoryPickerCaStore(); + ensurePickerCa(dir, { persistent: true, rotation: "startup", store: fake.store }); + const path = join(filesystem.realpathSync(dir), "claude-picker", "authority.json"); + const withIno = (stat: T, ino: bigint): T => { + const value = typeof (stat as { ino: unknown }).ino === "bigint" ? ino : Number(ino); + return Object.create(stat, { ino: { value } }); + }; + const { lstatSync, fstatSync } = filesystem; + let authorityFd = -1; + const lstat = spyOn(filesystem, "lstatSync").mockImplementation(((target: filesystem.PathLike, options?: object) => { + const stat = lstatSync(target, options as never); + return target === path && stat ? withIno(stat, 2n ** 60n + 1n) : stat; + }) as typeof lstatSync); + const open = filesystem.openSync; + const opening = spyOn(filesystem, "openSync").mockImplementation((target, flags, mode) => { + const fd = open(target, flags, mode); + if (target === path) authorityFd = fd; + return fd; + }); + const fstat = spyOn(filesystem, "fstatSync").mockImplementation(((fd: number, options?: object) => { + const stat = fstatSync(fd, options as never); + return fd === authorityFd ? withIno(stat, 2n ** 60n + 2n) : stat; + }) as typeof fstatSync); + try { + expect(() => ensurePickerCa(dir, { persistent: true, rotation: "startup", store: fake.store })) + .toThrow("picker_ca_metadata_unsafe"); + expect(fake.writes).toBe(1); + } finally { lstat.mockRestore(); opening.mockRestore(); fstat.mockRestore(); } +}); + test("unsafe picker directory or lock cannot reach the credential store", () => { for (const pathKind of ["directory", "lock"] as const) { const dir = root(); From c15037b324dcd982f32ac3b86b2841ef127c393e Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 9 Oct 2026 09:53:53 +0900 Subject: [PATCH 03/59] fix(claude): word context overflow as "prompt is too long" so Claude Code compacts (#6778) * docs(devlog): plan the Claude 1M-by-default unit Roadmap and diff-level phase docs for treating long-window models as 1M on Claude surfaces, the prompt-is-too-long recovery they rely on, and a 200k accounting opt-in. Co-Authored-By: Claude Opus 5.5 * fix(claude): word context overflow as "prompt is too long" so Claude Code compacts Claude Code recovers from an oversized prompt only when the API error says "prompt is too long" (or "input is too long for requested model"); it then compacts reactively. Routed providers word the refusal their own way, so the session ended on a fatal error instead. anthropicErrorBody now rewrites a context_length_exceeded message into that form, carrying token counts only when the upstream states both. The native Messages lane does the same for a configured provider's 400/413 refusal and its streamed or folded error frames, without changing the envelope shape; a 429 that mentions tokens stays a rate limit, and Anthropic pools skip the stream rewrite. Co-Authored-By: Claude Opus 5.5 * fix(claude): keep throughput limits and stall timing out of the overflow rewrite Review follow-up. The shared classifier files a "too many tokens per minute" 429 under context_length_exceeded; worded as an overflow it would make Claude Code compact instead of wait, so throughput wording keeps its text. The native stream rewrite buffers whole SSE frames. Placed before the log tap it made stall detection time complete frames instead of raw bytes, so a slow but alive frame could be cut. It now runs after the tap. Co-Authored-By: Claude Opus 5.5 * docs(claude): describe the prompt-is-too-long recovery in the Claude Code guide Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../261009_claude_1m_default/000_plan.md | 134 +++++++++ .../010_prompt_too_long_envelope.md | 167 +++++++++++ .../020_long_context_eligibility.md | 206 ++++++++++++++ .../030_context_accounting_policy.md | 125 +++++++++ .../040_context_accounting_gui.md | 89 ++++++ .../src/content/docs/guides/claude-code.md | 15 +- scripts/test-layout/layout.json | 1 + src/claude/outbound.ts | 61 +++- src/server/messages-native.ts | 30 +- structure/data-planes/inbound-compat.md | 11 +- .../claude-outbound.test.ts | 2 +- .../claude-prompt-too-long.test.ts | 265 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + .../protocol-direct-encoders-messages.test.ts | 4 +- 14 files changed, 1092 insertions(+), 19 deletions(-) create mode 100644 devlog/_plan/261009_claude_1m_default/000_plan.md create mode 100644 devlog/_plan/261009_claude_1m_default/010_prompt_too_long_envelope.md create mode 100644 devlog/_plan/261009_claude_1m_default/020_long_context_eligibility.md create mode 100644 devlog/_plan/261009_claude_1m_default/030_context_accounting_policy.md create mode 100644 devlog/_plan/261009_claude_1m_default/040_context_accounting_gui.md create mode 100644 tests/claude-integration/claude-prompt-too-long.test.ts diff --git a/devlog/_plan/261009_claude_1m_default/000_plan.md b/devlog/_plan/261009_claude_1m_default/000_plan.md new file mode 100644 index 00000000000..898b0609d62 --- /dev/null +++ b/devlog/_plan/261009_claude_1m_default/000_plan.md @@ -0,0 +1,134 @@ +# 261009 Claude surfaces: 1M by default, 200k as an opt-in + +## Summary + +Long-window models routed to Claude surfaces are accounted at 200k even when their real window is +872,000. The Desktop Code-tab picker shows `GPT 6 Astra (native)`, `GPT 6 Luna (native)` and +`GPT 6.1 Sol (native)` without the `[1m]` selector, so Claude Code (2.1.288) falls back to its 200k +default for them, although the live picker snapshot already carries `contextWindow: 872000`. The +launch path (`ocx claude`) marks the same models because it also injects +`CLAUDE_CODE_AUTO_COMPACT_WINDOW`; surfaces whose runner does not inherit that variable mark only +windows of 1M or more. + +This unit makes every Claude surface that picks a default (Desktop picker, `cc` picker, launch env +slots, Desktop 3P, generated subagents) mark a long window (>= 829,800) as 1M by default, offers a +`· 1M` row for it in discovery, adds the +recovery that makes this safe where the compaction variable is absent, and adds an explicit 200k +opt-in. It is delivered as a manual PR chain on `dev`, one layer per work-phase. + +## Loop spec + +- Class: C3 (cross-module Claude surface behavior, public config/API/CLI field; no auth, no persistence migration). +- Loop archetype: satisfy-spec, multi-cycle (wp0 docs, wp1..wp4 implementation). +- Trigger: user request 2026-10-09 (Desktop picker shows Astra/K3 as 200k; make 1M the default, 200k opt-in in GUI/CLI; stacked PRs; Sol agents as verifiers only). +- Goal: session eb6a39f9-d450-4b3e-8f06-60c43022f3cf, goalplan `claude-surfaces-claude-code-launch-discovery-des`. +- Non-goals: changing the Codex native window defaults (272,000 / 872,000 opt-in stays a Codex catalog decision); the Anthropic beta allowlist; writing the user's `~/.claude/settings.json` env; windows below 829,800 (K3 bare 262,144, Grok 256,000, Devin 262,000 stay 200k-accounted because the compaction floor cannot sit under their real window). +- Verifier per layer: `bun run typecheck`, the focused test files named in each decade doc, `bun run structure:check`; full suite and cross-platform shards are left to exact-head GitHub CI (local full runs are excluded for this repository per the maintainer's standing instruction). +- Stop condition: wp4 PR opened with its CI observed, or a blocker reported. +- Memory artifact: this unit (000 roadmap, 010..040 phase docs, per-phase audit/done notes). +- Expected outcomes: DONE when the four layers are open with templates filled and typecheck green locally; BLOCKED when CI on a layer fails for a cause outside the layer. +- Escalation: Sol agents act as architect, auditors and reviewers only; main writes all code. A slice is reclaimed after two failed reviewer rounds on the same finding. +- Resource bounds: no token or wall-clock budget was set by the user; tool scope is local repo, `gh` on `lidge-jun/opencodex`, read-only inspection of the installed Claude Code binary. + +## Evidence + +| Fact | Source | +|---|---| +| Claude Code context window: `[1m]` -> 1e6; declared knowledge windows > 200k clamp to 200k unless native 1M; otherwise `CLAUDE_CODE_MAX_CONTEXT_TOKENS` (non-claude ids) else 200k | Claude Code 2.1.288 binary, functions `vv`, `I5r`, `Kd`, `yv` (strings at `~/.local/share/claude/versions/2.1.288`) | +| Claude Code classifies `prompt_too_long` when the error message includes `prompt is too long` or `input is too long for requested model`; parses `prompt is too long[^0-9]*(\d+)\s*tokens?\s*>\s*(\d+)` for the gap; runs reactive compaction | same binary, functions `b4n`, `fdt`, `mDt`; strings `Reactive compact: ...`, `tengu_reactive_compact_triggered` | +| Embedded gateway contract: relay a 400/413 Anthropic envelope like `prompt is too long: ...`; "the client's recovery (auto-compact etc.) keys on it" | same binary, embedded "Claude Code gateway protocol" text | +| opencodex emits `context_length_exceeded` with upstream text or `PROVIDER_INPUT_TOO_LARGE_MESSAGE` | `src/claude/outbound.ts:73`, `src/server/claude-messages.ts:1338-1386`, `src/server/responses/context-overflow.ts:5` | +| Desktop picker marks only >= 1M because Desktop runners do not inherit the compaction env | `src/claude/intercept/picker-models.ts:51-54` | +| Live picker snapshot: Astra/Luna/Sol 6.1 `contextWindow: 872000` unmarked; `K3 1M (kimi)` already `[1m]` | `~/.opencodex/claude-intercept/cli-picker-models.json` (local, not committed) | +| Discovery 1M variant requires >= 1M; `auto` passed but unused | `src/claude/model-info.ts:150-176`, `src/server/index/serve-options.ts:1053` | +| Desktop 3P `supports1m` threshold 1M in three owners | `src/claude/desktop-3p.ts:63,254`, `src/claude/desktop-profile.ts:362`, `src/server/management/shared.ts:417` | +| Brackets: Desktop 3P names are hashed `claude-opus-4-8-*` aliases; `k3[1m]` already round-trips | `src/claude/desktop-3p.ts:122-161`, `src/claude/desktop-3p-guard.ts:11-32` | + +## Architect consultation + +Architect: gpt-6.1-sol subagent, read-only, proposal D1-D7 (session transcript). Dispositions: + +| ID | Proposal | Disposition | +|---|---|---| +| D1 | Normalize at `anthropicErrorBody`; keep recognized text; numbers only when parsed | ACCEPT. Folded the missed lane: `messages-native.ts:901` drops `classified.code`, so 010 rewrites its message without changing the native envelope shape | +| D2 | Pure leaf `long-context.ts`; Desktop/profile/context-windows depend inward | ACCEPT | +| D3 | Discovery emits the unmarked base row first, so variants do not make 1M "the default" | REBUT for discovery: the `/v1/models` list is an explicit choice list and Claude Code shows both rows; the default selection on Claude surfaces is the picker row (one row per model, now marked) and the launch env slot. ACCEPT the ceiling point: variants keep reporting `min(1e6, max input)` | +| D4 | Sparse persistence, `"1m"` deletes, reject others; typed `[1m]` survives; 200k disables compact injection too; precedence over autoContext | ACCEPT, written into 030 | +| D5 | Full field chain incl. system-env reconcile list, picker inputs, GUI normalization, refresh | ACCEPT. Chain table in 030; GUI normalization and manual-env export in 040 | +| D6 | Exact-text tests and ratchet headroom | ACCEPT; tests in 010/020, headroom table below | +| D7 | structure/ ownership, docs-site guide, CLI capability registries, anthropic2 | ACCEPT. `isAnthropicInstanceId` replaces `provider === "anthropic"` in the new predicate and the discovery mode choice; capability registries + `skill:surface` in 030 | + +## Work-phase map (dependency order) + +| wp | Layer | Doc | Depends on | Branch | +|---|---|---|---|---| +| wp0 | Roadmap (this unit) | 000 | - | committed as the first commit of wp1's branch | +| wp1 | Overflow envelope `prompt is too long` (safety net) | 010 | wp0 | `claude/1m-01-prompt-too-long` -> `dev` | +| wp2 | Long-window eligibility on every Claude surface | 020 | wp1 | `claude/1m-02-long-context` -> wp1 | +| wp3 | `claudeCode.contextAccounting: "200k"` opt-in (config, launch, surfaces, API, CLI) | 030 | wp2 | `claude/1m-03-context-accounting` -> wp2 | +| wp4 | Dashboard control + locales | 040 | wp3 | `claude/1m-04-context-accounting-gui` -> wp3 | + +wp1 goes first because wp2 widens marking onto runners that may lack the compaction variable; the +recovery has to exist before the widening ships. wp3 consumes wp2's single marking mode. wp4 only +renders wp3's field. + +## Ratchet headroom + +Universal cap: a file outside `tests/fixtures/file-size-baseline.json` fails at 2000 lines. Files +this unit grows: `tests/claude-integration/claude-outbound.test.ts` 1936 (edit in place only, no +new cases there), `src/server/management/agent-settings-routes.ts` 1858, `src/server/claude-messages.ts` +1730, `src/types/config.ts` 1733, `src/claude/outbound.ts` 1103; none is in the baseline. New cases +go into new sibling test files. All eleven locale catalogs are exempt. + +## Source-of-truth sync + +`structure/` owner docs (checked by `bun run structure:check`) are updated in the layer that changes +the behavior they describe: `structure/runtime.md` and `structure/clients/claude-desktop.md`, the +owners of `src/claude/` per `structure/INDEX.md:126` (wp1, wp2), and the config / +management owners for the new `claudeCode` key and `/api/claude-code` field (wp3). Each layer's P +resolves the exact owner file from `structure/INDEX.md`. The user-facing docs-site Claude Code page is updated +in wp3 (new CLI flag and config key). + +## Architect reflection (round 1: MISALIGNED, folded) + +| Gap | Fold | +|---|---| +| D1 regex took any later count | 010: counts need `resulted in` / `you requested`; otherwise prefix only | +| D2 discovery followed a custom compact window | 020: discovery uses the fixed unpaired rule; `auto` only switches it off | +| D3 base/Fast rows still 200k; sub-1M variant advertised 1e6 | 000 summary + 020 scope note; variant advertises `min(1e6, max input ?? window)` | +| D5 roster/self subagent sites; GUI cache | 030 adds `agents-inject.ts:100,133` via `subagentMarkingMode`; 040 explains the normalizer covers cached reads and `EDITABLE_KEYS` | +| D6 headroom table missing | added above | +| D7 config/management SoT owners | added above | + +## Architect reflection (round 2: MISALIGNED, folded) + +| Gap | Fold | +|---|---| +| GUI cached state bypasses the GET normalizer | 040: normalize in `normalizeFirstPartyState` (`ClaudeCode.tsx:37`), shared by cache (`:115`) and GET (`:153`); test with an older cached state | +| `200k` would strip explicit `[1m]` subagent selectors | 030: explicit markers follow the unpaired safety rule regardless of mode; mode governs unmarked selectors only; precedence tests for roster/self/force | + +## Independent audit (round 1: FAIL, synthesized) + +| Blocker | Disposition | +|---|---| +| 1 native Messages lanes | Pre-stream HTTP folded (010). Classifier widening and native SSE frame rewrite REBUTTED as out of scope with reason in 010 (Codex-shared classifier; no native-lane route crosses the 1M line in this unit); residual recorded | +| 2 roster/self subagents ignore 200k | Already folded after architect reflection (030 `subagentMarkingMode`, explicit-marker rule) | +| 3 Desktop option chain | Folded: option threads through `generateDesktop3pConfig`; writer derives it from the re-read config; remote export endpoint added (030) | +| 4 Pool 2 guard | Folded: pickers and `buildClaudeContextWindows` use `isAnthropicInstanceId` (020) | +| 5 discovery floor / ceiling | Already folded (020 `variantMode`, ceiling from window) | +| 6 boundary tests | Folded: 999,999 cases rewritten to 829,799 / 829,800 (020) | +| notes | GUI test paths, i18n parity tests, structure owners named | + +## Independent audit (round 2: FAIL on B1 only, folded) + +B1 rebuttal withdrawn: a custom `anthropic`-adapter provider reaches the native lane with any +window, so wp2 can widen a native route. 010 now normalizes the native lane locally (HTTP with +upstream `code` and a local wording test, streaming via the existing SSE payload rewriter for +non-Anthropic-instance providers, collected-stream errors -> 400), leaving the Codex-shared +`classifyError` untouched. Regression with a custom provider and unfamiliar wording added. + +## Independent audit (round 3): PASS + +Notes folded into 010: gate the SSE rewrite on `nativeInstance` (`messages-native.ts:309-310`), not +on the provider name; place it after `echoRequestedModel` before the stream/collect split; add a +413 case and an SSE `rate_limit_error` negative; the superseded HTTP-only snippet was removed. diff --git a/devlog/_plan/261009_claude_1m_default/010_prompt_too_long_envelope.md b/devlog/_plan/261009_claude_1m_default/010_prompt_too_long_envelope.md new file mode 100644 index 00000000000..cc500a94fde --- /dev/null +++ b/devlog/_plan/261009_claude_1m_default/010_prompt_too_long_envelope.md @@ -0,0 +1,167 @@ +# 010 wp1: context overflow reaches Claude clients as `prompt is too long` + +## Why + +Claude Code recovers from an oversized prompt only when the API error says so in Anthropic's words: +it lowercases `error.message` and looks for `prompt is too long` or `input is too long for requested +model`, reads the token gap from `prompt is too long[^0-9]*(\d+)\s*tokens?\s*>\s*(\d+)`, and then +runs reactive compaction (2.1.288 binary, `b4n` / `fdt` / `mDt`). opencodex already classifies the +refusal (`code: "context_length_exceeded"`, status 400) but keeps the routed provider's wording, for +example `Your input exceeds the context window` or the proxy-owned +`PROVIDER_INPUT_TOO_LARGE_MESSAGE`. Claude Code then shows a fatal API error instead of compacting. + +This layer is the safety net wp2 relies on: wp2 marks 872k windows as 1M on runners that do not +inherit `CLAUDE_CODE_AUTO_COMPACT_WINDOW`, so such a session can outgrow the real window before +proactive compaction fires. With this layer the first refusal compacts the session instead of +ending it. + +## Scope + +IN: the Anthropic error envelope builder and every Claude-wire producer that already passes +`code: "context_length_exceeded"` through it. OUT: status mapping (already 400), Codex/Responses +wire messages, the native Anthropic passthrough body (Anthropic already writes `prompt is too long`). + +## Diff + +### MODIFY `src/claude/outbound.ts` + +Before (`:73-75`): + +```ts +export function anthropicErrorBody(status: number, message: string, type?: string, code?: string): Rec { + return { type: "error", error: { type: type ?? anthropicErrorType(status), message, ...(code ? { code } : {}) } }; +} +``` + +After: + +```ts +/** + * Claude Code compacts reactively only when an oversized-prompt refusal is worded the way the + * Anthropic API words it: it looks for `prompt is too long` (or `input is too long for requested + * model`) in the message and reads `prompt is too long: tokens > maximum` for the + * gap (2.1.288 binary). Routed providers word the same refusal differently, so a + * context_length_exceeded envelope is rewritten into that form, keeping the counts when the + * upstream text states them. Without this the client ends the turn on a fatal error instead of + * compacting (devlog/_plan/261009_claude_1m_default/010). + */ +const CLAUDE_PROMPT_TOO_LONG_RE = /prompt is too long|input is too long for requested model/i; +const STATED_CONTEXT_COUNTS_RE = /maximum context length is (\d+) tokens[\s\S]*?(?:resulted in|you requested) (\d+) tokens/i; + +export function claudePromptTooLongMessage(message: string): string { + if (CLAUDE_PROMPT_TOO_LONG_RE.test(message)) return message; + const counts = STATED_CONTEXT_COUNTS_RE.exec(message); + if (counts) return `prompt is too long: ${counts[2]} tokens > ${counts[1]} maximum`; + return `prompt is too long: ${message}`; +} + +export function anthropicErrorBody(status: number, message: string, type?: string, code?: string): Rec { + const text = code === "context_length_exceeded" ? claudePromptTooLongMessage(message) : message; + return { type: "error", error: { type: type ?? anthropicErrorType(status), message: text, ...(code ? { code } : {}) } }; +} +``` + +Every producer inherits the rewrite through the builder; none is edited: + +| Producer | Path | +|---|---| +| Translated SSE `response.failed` | `src/claude/outbound.ts:441-471` (`fail` -> `anthropicErrorBody`) | +| Translated JSON `status: failed` | `src/server/claude-messages.ts:1460-1461` (`anthropicErrorResponse`) | +| Translated non-OK HTTP | `src/server/claude-messages.ts:1338-1386` (`anthropicErrorBody(..., contextError ? "context_length_exceeded" : undefined)`) | +| Direct Messages encoder | `src/protocols/encoders/messages.ts:186-201`, `:429-438` | +| Native Messages (pre-stream `fail`) | `src/server/messages-native.ts:349-352` (inherits through the builder) | + +(The native lane, including the architect's D1 note on `messages-native.ts:901`, is covered in the next section.) + +### Native Messages lane (audit blocker 1, round 2: folded, not rebutted) + +A configured `anthropic`-adapter provider can reach the native lane with any window +(`src/server/messages-native-eligibility.ts:136-149`), so wp2 can widen a route served here. The +lane is normalized locally; `classifyError` (shared with the Codex wire) is not touched. + +NEW in `src/claude/outbound.ts` beside `claudePromptTooLongMessage`: + +```ts +/** Provider wordings of an oversized-input refusal, for lanes that relay a provider's own error text. */ +const CONTEXT_OVERFLOW_TEXT_RE = /prompt is too long|input is too long|context window|context length|maximum context|too many tokens|model token limit/i; + +export function isContextOverflowText(message: string): boolean { + return CONTEXT_OVERFLOW_TEXT_RE.test(message); +} + +/** + * SSE payload rewrite for the native Messages lane: an `error` event that refuses an oversized + * input gets the Anthropic wording; every other payload passes through byte-identical. + */ +export function claudeOverflowSsePayload(payload: string): string { + if (!payload.includes("\"error\"")) return payload; + let parsed: unknown; + try { parsed = JSON.parse(payload); } catch { return payload; } + if (!isRec(parsed) || parsed.type !== "error" || !isRec(parsed.error)) return payload; + const error = parsed.error; + const message = typeof error.message === "string" ? error.message : ""; + const sized = error.type === "invalid_request_error" || error.type === "request_too_large"; + if (!sized || !(error.code === "context_length_exceeded" || isContextOverflowText(message))) return payload; + const next = claudePromptTooLongMessage(message); + return next === message ? payload : JSON.stringify({ ...parsed, error: { ...error, message: next } }); +} +``` + +MODIFY `src/server/messages-native.ts`: + +- HTTP (`nativeMessagesErrorResponse`, `:865-906`): read `details.code` beside `details.type`; + `const overflow = classified.code === "context_length_exceeded" || upstreamCode === "context_length_exceeded" || ((response.status === 400 || response.status === 413) && isContextOverflowText(safeMessage));` + and pass `overflow ? claudePromptTooLongMessage(safeMessage) : safeMessage` to `anthropicErrorBody` + (status and envelope shape unchanged). +- Streaming (`:747-770`): when `nativeInstance` (`:309-310`, the configured-instance identity, so a + custom provider merely named `anthropic2` is not exempt) is absent, wrap `source` right after + `echoRequestedModel` and before the stream/collect split, so both consumers see the same payload, with + `relaySseWithPayloadRewrite(source, claudeOverflowSsePayload, translatorBudget)` (the helper the + OAuth tool-name restore already uses, `src/server/sse-payload-rewrite.ts:257`). Real Anthropic + pools skip the wrapper: they refuse oversized input pre-stream in their own wording, and the + passthrough hot path keeps its single relay. +- Collected stream (`:785-792`): when the folded error passes the same test, answer + `fail(400, claudePromptTooLongMessage(text), "invalid_request_error")` instead of 502. + +Tests (new 010 test file, native harness pattern from `tests/claude-integration/`): + +- HTTP 400 `{"type":"error","error":{"type":"invalid_request_error","message":"Your request exceeded model token limit: 262144"}}` from a custom `anthropic`-adapter provider -> client message starts `prompt is too long`. +- HTTP 400 with `"code":"context_length_exceeded"` and unfamiliar text -> rewritten. +- HTTP 413 with overflow wording -> rewritten, status 413 kept. +- HTTP 429 `rate limit ... tokens` -> untouched. +- SSE `rate_limit_error` whose message says `context window` -> untouched (type gate). +- Streaming: upstream `event: error` with `invalid_request_error` + `model token limit` -> relayed frame rewritten; a `content_block_delta` whose text contains `"error"` passes byte-identical. +- Collected (non-stream caller, streaming upstream): overflow error -> 400 with the rewritten message. + +### MODIFY tests whose exact text changes + +- `tests/claude-integration/claude-outbound.test.ts:939`: `message: "Cursor context limit exceeded"` -> `message: "prompt is too long: Cursor context limit exceeded"`. +- `tests/responses/protocol-direct-encoders-messages.test.ts:331` and `:337`: `message: "Synthetic input limit"` -> `message: "prompt is too long: Synthetic input limit"`. +- `tests/server/replay-refusal-parity.test.ts:308` uses `toContain` on the original text and still holds. + +### NEW `tests/claude-integration/claude-prompt-too-long.test.ts` + +Registered in `scripts/test-layout/layout.json` `explicit` and `tests/fixtures/test-layout-expected.json`. + +Cases (activation scenario -> observable effect): + +1. Plain upstream text: `anthropicErrorBody(400, "Your input exceeds the context window", undefined, "context_length_exceeded")` -> message `prompt is too long: Your input exceeds the context window`, code kept. +2. Stated counts: OpenAI wording `This model's maximum context length is 272000 tokens. However, your messages resulted in 301234 tokens.` -> `prompt is too long: 301234 tokens > 272000 maximum`; and the message matches Claude Code's own parser regex `/prompt is too long[^0-9]*(\d+)\s*tokens?\s*>\s*(\d+)/i` with groups 301234 / 272000. +3. Already Anthropic-worded: `prompt is too long: 1000 tokens > 900 maximum` and `Input is too long for requested model.` pass through unchanged. +4. Other codes untouched: `rate_limit` / `translation_buffer_limit` / no code keep the original message. +5. End to end on the translated SSE path: `responsesSseToAnthropicSse` with a `response.failed` frame carrying `context_length_exceeded` and `PROVIDER_INPUT_TOO_LARGE_MESSAGE` -> terminal `error` frame whose message lowercased includes `prompt is too long`. + +## Verification + +- `bun run typecheck` +- `bun test tests/claude-integration/claude-prompt-too-long.test.ts tests/claude-integration/claude-outbound.test.ts tests/responses/protocol-direct-encoders-messages.test.ts tests/server/replay-refusal-parity.test.ts` +- `bun test tests/test-layout.test.ts tests/test-layout-tooling.test.ts` (new file registration) +- Full suite: exact-head CI. + +## Risks + +- Counts are only carried over when both OpenAI phrases are present (`maximum context length is L + tokens` and `resulted in` / `you requested A tokens`); any other wording is prefixed verbatim, so + no number is guessed (architect reflection D1). Test case 2b: `maximum context length is 272000 + tokens; 3 tokens of overhead` (no request phrase) -> prefixed, no counts. +- Native Anthropic text is already compliant; the rewrite is a no-op there. diff --git a/devlog/_plan/261009_claude_1m_default/020_long_context_eligibility.md b/devlog/_plan/261009_claude_1m_default/020_long_context_eligibility.md new file mode 100644 index 00000000000..730acfc923f --- /dev/null +++ b/devlog/_plan/261009_claude_1m_default/020_long_context_eligibility.md @@ -0,0 +1,206 @@ +# 020 wp2: long windows are 1M on every Claude surface + +## Why + +Claude Code (2.1.288) accounts an unmarked gateway id at 200k: knowledge windows above 200k are +clamped unless the model is natively 1M, and the discovery `max_input_tokens` is not part of the +window computation. The only per-model lever a proxy has is the `[1m]` selector (Desktop 3P: +`supports1m` / `prefer1m`). Today it is granted by two different rules: + +| Surface | Rule today | Owner | +|---|---|---| +| `ocx claude` launch env, tier slots | window >= 1M, or > 200k and >= compact window (829,800) | `src/claude/context-windows.ts:133-136`, `:230-274` | +| Desktop Code-tab picker, CLI `cc` picker | window >= 1M only | `src/claude/intercept/picker-models.ts:51-54` | +| Anthropic discovery `· 1M` variant | window >= 1M only (`auto` passed, unused) | `src/claude/model-info.ts:150-176` | +| Desktop 3P `supports1m` / `prefer1m` | window >= 1M only | `src/claude/desktop-3p.ts:254`, `src/claude/desktop-profile.ts:362`, `src/server/management/shared.ts:417` | +| Generated subagent defs | window >= 1M only | `src/claude/subagent-model.ts:23-33`, `:79` | + +The narrow rule exists because those runners may not inherit `CLAUDE_CODE_AUTO_COMPACT_WINDOW`, so +a marked 872k model could outgrow its real window before proactive compaction (the #854 defect +was a 372k route marked while the compact window was 350k). wp1 removes the failure mode: an +overflow now arrives as `prompt is too long` and Claude Code compacts reactively. The compaction +floor still applies, so only windows >= 829,800 widen; 262k / 256k / 400k windows stay at 200k. + +## Decisions + +- One predicate for the unpaired surfaces, in a leaf module so `desktop-3p.ts` and + `desktop-profile.ts` can use it without importing `context-windows.ts` (which imports + `desktop-3p.ts`). +- Real Anthropic routes keep the >= 1M rule everywhere (they ride the native passthrough; the + existing "Anthropic passthrough guard" in `context-windows.ts:196-205` and `model-info.ts:250` stays). +- Discovery uses the fixed unpaired rule, not the configurable launch compact window: a custom + `autoCompactWindow: 350000` must not re-admit a 372k variant (#854). The server's `auto` only + switches the rule off: `autoContext: false` (and wp3's `200k`) fall back to >= 1M. +- Scope note (architect D3): discovery stays a choice list. Its base and `· Fast` rows remain + unmarked (200k-accounted) by design; the `· 1M` row is the long-context choice there. The + surfaces that pick a default (Desktop picker, `cc` picker, launch env slots, Desktop 3P + `prefer1m`, generated subagents) are the ones made 1M by default. + +## Diff + +### NEW `src/claude/long-context.ts` + +```ts +/** + * Long-context thresholds shared by every Claude surface (devlog/_plan/261009_claude_1m_default/020). + * A leaf module: desktop-3p.ts and desktop-profile.ts need the predicate, and context-windows.ts + * already imports desktop-3p.ts. + */ +import { isAnthropicInstanceId } from "../providers/anthropic-instance-id"; + +export const ONE_MILLION = 1_000_000; + +/** A window at or below this is Claude Code's own default accounting; widening it gains nothing. */ +export const AUTO_CONTEXT_FLOOR = 200_000; + +/** (comment moved verbatim from context-windows.ts:30-41) */ +export const AUTO_COMPACT_WINDOW_DEFAULT = 829_800; + +/** + * Whether a window earns the 1M selection on a surface whose Claude Code runner may not inherit + * CLAUDE_CODE_AUTO_COMPACT_WINDOW (Desktop pickers, discovery, Desktop 3P, generated subagents). + * A window >= 1M always does. A smaller one does when it can host the default compact window, so + * a runner that has the variable compacts in time, and one that lacks it overflows into the + * `prompt is too long` envelope (wp1) and compacts reactively. + */ +export function isLongContextWindow(window: number | undefined): boolean { + if (typeof window !== "number" || window <= 0) return false; + return window >= ONE_MILLION || (window > AUTO_CONTEXT_FLOOR && window >= AUTO_COMPACT_WINDOW_DEFAULT); +} + +/** Desktop 3P / dashboard eligibility. Real Anthropic routes (either pool) need a genuine 1M window. */ +export function claudeSurfaceSupportsOneMillion(provider: string, window: number | undefined): boolean { + if (isAnthropicInstanceId(provider)) return typeof window === "number" && window >= ONE_MILLION; + return isLongContextWindow(window); +} +``` + +### MODIFY `src/claude/context-windows.ts` + +- Replace `const ONE_MILLION = 1_000_000;` (`:19`) and the two constant definitions (`:42-43`) with + `import { AUTO_COMPACT_WINDOW_DEFAULT, AUTO_CONTEXT_FLOOR, ONE_MILLION } from "./long-context";` + plus `export { AUTO_COMPACT_WINDOW_DEFAULT, AUTO_CONTEXT_FLOOR } from "./long-context";` (public + names unchanged for every importer). +- Add after `AUTO_CONTEXT_OFF` (`:66`): + +```ts +/** + * Marking mode for surfaces whose runner may not inherit the compaction env (Desktop pickers, + * discovery, generated subagents). Equals isLongContextWindow; kept as a mode so the existing + * withOneMillionMarker / shouldMarkOneMillion helpers serve every surface. + */ +export const UNPAIRED_AUTO_CONTEXT: AutoContextMode = { enabled: true, compactWindow: AUTO_COMPACT_WINDOW_DEFAULT }; +``` + +### MODIFY `src/claude/intercept/picker-models.ts` + +- Import `UNPAIRED_AUTO_CONTEXT` instead of `AUTO_CONTEXT_OFF`. +- `:51-54` before: + +```ts +/** Desktop runners do not inherit the proxy's compaction env: mark only real >=1M windows. */ +function pickerSelector(alias: string, contextWindow: number | undefined): string { + return withOneMillionMarker(alias, contextWindow === undefined ? {} : { [alias]: contextWindow }, AUTO_CONTEXT_OFF)!; +} +``` + +after: + +```ts +/** + * Desktop runners do not inherit the proxy's compaction env, so a long window (>= the default + * compact window) is marked on the strength of the `prompt is too long` recovery instead (020). + */ +function pickerSelector(alias: string, contextWindow: number | undefined): string { + return withOneMillionMarker(alias, contextWindow === undefined ? {} : { [alias]: contextWindow }, UNPAIRED_AUTO_CONTEXT)!; +} +``` + +`buildPickerModels` skips `anthropic/claude-*` already (`:61`); `buildCliPickerModels` the same (`:79`). + +Both picker builders pass the row's provider: `pickerSelector(alias, contextWindow, provider)` uses +`isAnthropicInstanceId(provider) ? AUTO_CONTEXT_OFF : UNPAIRED_AUTO_CONTEXT`, so a sub-1M +`anthropic2` row (Pool 2 is not skipped by the `anthropic/claude-*` filter at `:60`, `:80`) is never +widened (audit blocker 4). + +### MODIFY `src/claude/context-windows.ts:187,199` (Pool 2 guard, audit blocker 4) + +`m.provider === "anthropic"` -> `isAnthropicInstanceId(m.provider)` in both the passthrough `capped` +set and the `registrable` filter, so the launch window map treats Pool 2 rows like Pool 1. + +### MODIFY `src/claude/model-info.ts` + +- Import `shouldMarkOneMillion` with `AUTO_CONTEXT_OFF`. +- `push1mVariant` gains a mode parameter and replaces the >= 1M gate: + +```ts + const push1mVariant = ( + base: AnthropicModelInfo, + contextWindow: number | undefined, + maxInputTokens?: number, + selectorId?: string, + mode: AutoContextMode = variantMode, + ) => { + // The [1m] marker makes Claude Code account 1e6 tokens for the row. A window >= 1M always + // earns it; a long window earns it under the server's auto-context mode, because the + // compaction floor sits under it and an overflow is answered as `prompt is too long`, + // which Claude Code compacts on (020). A 372k route under a 350k compact window was the + // #854 defect; the floor keeps such windows out. + if (!shouldMarkOneMillion(contextWindow, mode)) return; + if (base.id.includes("[1m]")) return; + ... + // A long window under 1M must not be advertised as 1e6 input (architect D3). + const ceiling = typeof maxInputTokens === "number" && maxInputTokens > 0 ? maxInputTokens : contextWindow; + const advertised = typeof ceiling === "number" && ceiling > 0 ? Math.min(ONE_MILLION, ceiling) : ONE_MILLION; +``` + + with, before the loops: `const variantMode = auto.enabled ? UNPAIRED_AUTO_CONTEXT : AUTO_CONTEXT_OFF;` + (`auto` disabled by `autoContext: false`, `maxContextTokens`, or wp3's `200k`). +- Routed call `:251` passes `isAnthropicInstanceId(m.provider) ? AUTO_CONTEXT_OFF : variantMode` as the mode (`anthropic2` is the same Anthropic implementation, `src/providers/anthropic-instance-id.ts`, a no-import leaf). +- Comment block `:150-155` replaced by the one above. + +### MODIFY Desktop 3P eligibility (three owners, one predicate) + +- `src/claude/desktop-3p.ts:254`: `const supports1m = claudeSurfaceSupportsOneMillion(provider, contextWindow) ? { supports1m: true as const } : {};` (import from `./long-context`). `DESKTOP_SUPPORTS_1M_THRESHOLD` stays exported (= `ONE_MILLION`) for the Anthropic rule and its existing test. +- `src/claude/desktop-profile.ts:362`: `supports1m: claudeSurfaceSupportsOneMillion(route.slice(0, route.indexOf("/")), model.contextWindow),`. +- `src/server/management/shared.ts:417`: `supports1m: claudeSurfaceSupportsOneMillion(route.slice(0, route.indexOf("/")), modelByRoute.get(route)?.contextWindow),`; comment keeps "SAME predicate". + +### MODIFY `src/claude/subagent-model.ts` + +- `:30`: `shouldMarkOneMillion(authoritativeWindow, UNPAIRED_AUTO_CONTEXT) ? (withOneMillionMarker(selector, windows, UNPAIRED_AUTO_CONTEXT) ?? selector) : bare`. +- `:79`: the requested-marker admission uses `isLongContextWindow(window)` instead of `window >= 1_000_000`. +- Doc comment `:16-22` updated: generated defs follow the unpaired long-context rule. + +### Tests + +Updated (intentional behavior change, comments rewritten): + +- `tests/claude-integration/claude-picker-models.test.ts:84-91`: 872k / 922k natives now end in `[1m]`; gpt-5.5 (272k) stays unmarked. Title becomes "picker marks long native windows on the prompt-too-long recovery". +- `tests/claude-integration/claude-model-info.test.ts:124-129`: with the default `auto` argument (`AUTO_CONTEXT_OFF`) no native variants still holds; add a sibling case in the new file for the server mode. +- `tests/claude-integration/claude-picker-models.test.ts:93-102`: the 999,999 row now ends in `[1m]`; add 829,799 (bare) and 829,800 (`[1m]`) rows for the floor. +- `tests/claude-integration/claude-subagent-model-force.test.ts:46`: the 999,999 boundary becomes 829,799 (bare) plus 829,800 (`[1m]`); 1,000,000 unchanged. +- `tests/clients/desktop-3p.test.ts:214-231`: `optedIn` (1M cap -> Sol 922,000) now carries `supports1m` / `prefer1m`; `uncapped` and `capped` (272,000) unchanged. + +NEW `tests/claude-integration/claude-long-context-eligibility.test.ts` (registered in `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`): + +1. `isLongContextWindow`: 200,000 / 262,144 / 829,799 false; 829,800 / 872,000 / 1,000,000 true; undefined / 0 false. +2. `claudeSurfaceSupportsOneMillion("anthropic", 872_000)` false, `("native", 872_000)` true. +3. Discovery: a routed 400,000 row under `{ enabled: true, compactWindow: 350_000 }` gets no variant (fixed floor); a routed 900,000 row without `maxInputTokens` gets a variant advertising 900,000. `buildAnthropicModelInfos(["gpt-6-astra"], [], UNPAIRED_AUTO_CONTEXT, "readable", undefined, { modelWindows: { "gpt-6-astra": 872_000 } })` has a `[1m]` variant with `max_input_tokens` 872,000 and display `· 1M`; with `AUTO_CONTEXT_OFF` none; a routed `anthropic` row at 872,000 gets none under either mode; a routed 262,144 row gets none. +4. Desktop 3P: `generateDesktop3pModels(["gpt-6-astra"], [], undefined, { modelWindows: { "gpt-6-astra": 872_000 } })` emits `supports1m` + `prefer1m`; `[{ provider: "kimi", id: "k3", contextWindow: 262_144 }]` emits neither. +5. Pool 2: `buildPickerModels` with `{ provider: "anthropic2", id: "x", contextWindow: 872_000 }` stays bare; `buildClaudeContextWindows([], [{ provider: "anthropic2", id: "x", contextWindow: 872_000 }])` has no entry for it. +6. Subagent: `withSubagentContextMarker("ocx-claude-native--gpt-6-astra", { "ocx-claude-native--gpt-6-astra": 872_000 })` ends in `[1m]`; at 262,144 stays bare. + +## Verification + +- `bun run typecheck` +- `bun test tests/claude-integration/claude-long-context-eligibility.test.ts tests/claude-integration/claude-picker-models.test.ts tests/claude-integration/claude-model-info.test.ts tests/clients/desktop-3p.test.ts tests/clients/desktop-profile.test.ts tests/claude-integration/claude-desktop-1m.test.ts tests/claude-integration/claude-subagent-model-force.test.ts tests/claude-integration/claude-cli-picker.test.ts` +- `bun run structure:check` +- Full suite: exact-head CI. + +## Risks + +- A runner without the compaction env compacts reactively once per overflow instead of + proactively. That costs one refused request per long session, against today's state where the + model is unusable past 200k. +- `prefer1m` now defaults Desktop 3P long-window models to the 1M variant. wp3's `200k` policy + drops `prefer1m` for users who want the old default. diff --git a/devlog/_plan/261009_claude_1m_default/030_context_accounting_policy.md b/devlog/_plan/261009_claude_1m_default/030_context_accounting_policy.md new file mode 100644 index 00000000000..c0589d909a5 --- /dev/null +++ b/devlog/_plan/261009_claude_1m_default/030_context_accounting_policy.md @@ -0,0 +1,125 @@ +# 030 wp3: `claudeCode.contextAccounting: "200k"` opt-in + +## Why + +After wp2 every Claude surface marks long windows as 1M by default. The user asked for 200k to +remain reachable as an explicit opt-in from the CLI and the dashboard. The existing +`autoContext: false` is not that switch: it only stops sub-1M widening, while windows >= 1M stay +marked (`context-windows.ts:133-136`). + +## Semantics (decisions D4 of the architect consultation, accepted) + +- Stored sparse like `autoContext`: absent = `"1m"` (default); `"200k"` is the only stored value. + The API accepts `"1m"` (deletes the key) and `"200k"`; anything else is a 400. A hand-edited + unknown value reads as the default. +- `"200k"` wins over `autoContext` and the compact window: `resolveAutoContext` returns a mode with + `enabled: false` (so `CLAUDE_CODE_AUTO_COMPACT_WINDOW` is not injected) and `accounting200k: true` + (so `shouldMarkOneMillion` is false for every window). `maxContextTokens` keeps its own branch + (it already returns `AUTO_CONTEXT_OFF`); with `"200k"` set it still injects + `CLAUDE_CODE_MAX_CONTEXT_TOKENS` as before. +- A selector the user typed with `[1m]` keeps it (`withOneMillionMarker` returns already-marked + selectors unchanged, `context-windows.ts:232`); the policy changes defaults, not explicit picks. +- Explicit choices stay available: discovery keeps the `· 1M` variant rows for windows >= 1M, and + Desktop 3P keeps `supports1m` but drops `prefer1m`, so 1M is one click away. + +## Field chain (PLAN-FIELD-CHAIN-01) + +| Stage | Path | +|---|---| +| Type | `src/types/config.ts` `OcxClaudeCodeConfig.contextAccounting?: "200k"` next to `autoContext` (`:174`) | +| Creation: API | `src/server/management/agent-settings-routes.ts` PUT `/api/claude-code` body type (`:1460`) + validation block after `autoContext` (`:1724-1729`) | +| Creation: CLI | `src/cli/integrations.ts` `takeOption(args, "--context-accounting")` (`:76-91`), usage text (`:21-22`); `src/cli/capabilities-base.ts:574,578` and `src/cli/capabilities-integrations.ts:142,145` flag + usage; `bun run skill:surface` regenerates the skill map | +| Serialization | config JSON passthrough (`src/config/persist-unlocked.ts:81`, `src/config/schema/config-schema.ts:353` `.passthrough()`); no schema change | +| Read-back | GET `/api/claude-code` DTO (`agent-settings-routes.ts:1422`): `contextAccounting: config.claudeCode?.contextAccounting === "200k" ? "200k" : "1m"` | +| System env reconcile | `agent-settings-routes.ts:1843-1844` add `"contextAccounting"` to `systemEnvInputs` | +| Consumer: launch / system env / discovery | `resolveAutoContext` (`context-windows.ts:92`) is the single entry; `effectiveModelEnv`, `src/cli/claude.ts:407-418`, `src/server/system-env.ts:192,312-323`, `src/server/system-env-shell.ts:110-126`, `serve-options.ts:1053` all call it | +| Consumer: discovery variants | `model-info.ts` variant mode = `auto.accounting200k ? AUTO_CONTEXT_OFF : auto` (>= 1M rows stay selectable) | +| Consumer: pickers | `PickerRouteInput.auto?: AutoContextMode` (`picker-models.ts:15-20`), filled by `loadPickerRoutesFromCatalog` (`src/server/index/claude-intercept-lifecycle.ts:51-57`) with `resolveAutoContext(config.claudeCode)`; `pickerSelector` uses `input.auto?.accounting200k ? ACCOUNTING_200K : UNPAIRED_AUTO_CONTEXT` | +| Consumer: Desktop 3P | `generateDesktop3pModels(..., options?: { preferOneMillion?: boolean })` and `generateDesktop3pConfig(..., options?)` (`desktop-3p.ts:320`, `:377`, call `:401`); `prefer1m` emitted only when `preferOneMillion !== false`, in both the profile branch (`:221`) and the non-profile branch (`:266`, `:296`). The writer derives it from its freshly re-read config: `writeDesktop3pConfig` (`:656`) passes `{ preferOneMillion: latest.config.claudeCode?.contextAccounting !== "200k" }` at `:703`, so the apply (`native-integration-routes.ts:822-834`), CLI (`src/cli/claude-desktop.ts:537`) and sync (`config-routes.ts:804-822`) callers need no change. The remote export endpoint `serve-options.ts:1024-1027` passes the same option from its `config`. `writeRemoteDesktop3pConfig` writes the hub's entries verbatim (hub already applied its policy) | +| Consumer: subagents | `withSubagentContextMarker(selector, windows, mode = UNPAIRED_AUTO_CONTEXT)`; roster defs `src/claude/agents-inject.ts:100` and self def `:133` pass `subagentMarkingMode(config)`; `resolveSubagentForceModel` (`subagent-model.ts:81`) passes the same helper. `subagentMarkingMode(config) = resolveAutoContext(config.claudeCode).accounting200k ? ACCOUNTING_200K : UNPAIRED_AUTO_CONTEXT` lives in `subagent-model.ts` | +| Refresh | Picker snapshots rebuild on their staleness timer; Desktop 3P config is rewritten by the existing apply/sync paths (same as `autoContext` today). The PUT response adds no new side effect beyond the system-env reconcile; documented in the docs-site page | + +## Diff + +### MODIFY `src/claude/context-windows.ts` + +```ts +export interface AutoContextMode { + enabled: boolean; + compactWindow: number; + /** contextAccounting "200k": nothing is marked [1m] automatically, whatever its window. */ + accounting200k?: true; +} + +/** The "200k" opt-in mode: no widening, no compact-window injection, no automatic marker. */ +export const ACCOUNTING_200K: AutoContextMode = { enabled: false, compactWindow: AUTO_COMPACT_WINDOW_DEFAULT, accounting200k: true }; + +interface AutoContextConfigSlice { autoContext?: boolean; autoCompactWindow?: number; maxContextTokens?: number; contextAccounting?: string } + +export function resolveAutoContext(claudeCode, envOverride?) { + if (claudeCode?.contextAccounting === "200k") return ACCOUNTING_200K; + ...unchanged +} + +export function shouldMarkOneMillion(window, auto) { + if (typeof window !== "number" || window <= 0) return false; + if (auto.accounting200k) return false; + ...unchanged +} +``` + +`effectiveModelEnv`'s `claudeCode` parameter type gains `contextAccounting?: string`. + +### MODIFY `src/claude/subagent-model.ts` `withSubagentContextMarker` (architect reflection 2) + +Explicit markers keep their existing safety rule; the mode only controls automatic marking: + +```ts +export function withSubagentContextMarker(selector: string, windows: Record, mode: AutoContextMode = UNPAIRED_AUTO_CONTEXT): string { + const bare = stripOneMillionMarker(selector); + const wasMarked = selector !== bare; + const canonicalExact = wasMarked ? `${bare}[1m]` : selector; + const authoritativeWindow = windows[selector] ?? windows[canonicalExact] ?? windows[bare]; + if (typeof authoritativeWindow === "number" && authoritativeWindow > 0) { + // An explicit [1m] survives when the window can carry it, whatever the policy; an unsafe + // inherited one is stripped. Only an unmarked selector depends on the mode. + const marks = wasMarked + ? shouldMarkOneMillion(authoritativeWindow, UNPAIRED_AUTO_CONTEXT) + : shouldMarkOneMillion(authoritativeWindow, mode); + return marks ? (withOneMillionMarker(selector, windows, UNPAIRED_AUTO_CONTEXT) ?? selector) : bare; + } + return wasMarked ? selector : bare; +} +``` + +Tests (in the new 030 test file): under `ACCOUNTING_200K` an unmarked 872k roster entry stays bare; +a picker-pinned self model `...[1m]` with an 872k window keeps its marker; a `[1m]` selector whose +window is 262,144 is stripped under both modes; force resolution of `kimi/k3[1m]` is unchanged. + +### Other files + +As listed in the chain table. GUI is wp4. + +### docs-site + +`docs-site/src/content/docs/**/claude-code*.md(x)` (exact path resolved at wp3 P): document +`ocx claude config set --context-accounting <1m|200k>` and the default. English source; other +locales get the same flag line without contradicting text. + +## Tests + +NEW `tests/claude-integration/claude-context-accounting.test.ts` (registered in layout files): + +1. `resolveAutoContext({ contextAccounting: "200k" })` is `ACCOUNTING_200K`; `{ contextAccounting: "1m" }` and `{ contextAccounting: "bogus" }` resolve as default. +2. `effectiveModelEnv({ model: "native/gpt-6-astra", contextAccounting: "200k" }, { ...872k map })` has no `[1m]`; without the field it does; a typed `...[1m]` selector keeps its marker under 200k. +3. `buildPickerModels({ ..., auto: ACCOUNTING_200K })` leaves 872k and 1M rows unmarked. +4. `generateDesktop3pModels(["gpt-6-astra"], [], undefined, caps, { preferOneMillion: false })` has `supports1m` without `prefer1m`. +5. Discovery under `ACCOUNTING_200K`: 1M routed row keeps its `· 1M` variant; 872k native has none. +6. Management: PUT `{ contextAccounting: "200k" }` stores it, `"1m"` deletes it, `"x"` -> 400; GET reports `"200k"` / `"1m"` (reuse the existing claude-code route harness file named at P). +7. CLI: `ocx claude config set --context-accounting 200k` sends `{ contextAccounting: "200k" }` (existing integrations CLI test harness named at P). + +## Verification + +- `bun run typecheck` +- focused files above + `tests/ci-workflows/skill-ocx.test.ts` (capability map) + `bun run skill:surface:check` +- `bun run structure:check` diff --git a/devlog/_plan/261009_claude_1m_default/040_context_accounting_gui.md b/devlog/_plan/261009_claude_1m_default/040_context_accounting_gui.md new file mode 100644 index 00000000000..81873b43c39 --- /dev/null +++ b/devlog/_plan/261009_claude_1m_default/040_context_accounting_gui.md @@ -0,0 +1,89 @@ +# 040 wp4: dashboard control for context accounting + +## Why + +wp3 adds `contextAccounting` to config, API and CLI. The Claude Code settings page needs the same +switch so the 200k opt-in is reachable without a terminal. + +## Diff + +### MODIFY `gui/src/pages/claude-code-types.ts:57-59` + +Add `contextAccounting: "1m" | "200k";` after `autoCompactWindow`. + +### MODIFY `gui/src/pages/ClaudeCode.tsx:37` (`normalizeFirstPartyState`) + +Both entry points pass through this normalizer: the session cache (`:115`) and the GET read +(`:153`). Normalize there so an older cached state (no field) and an older server agree: + +```ts +function normalizeFirstPartyState(state: ClaudeCodeState): ClaudeCodeState { + const contextAccounting = state.contextAccounting === "200k" ? "200k" : "1m"; + ...existing body, returning { ...result, contextAccounting } +} +``` + +Test: an older cached state object without `contextAccounting` normalizes to `"1m"` before any +network read (the normalizer is exported for the test if it is not already). + +### MODIFY `gui/src/pages/claude-code-save.ts` + +- `EDITABLE_KEYS` (`:10-20`): add `"contextAccounting"`. +- Save body (`:45-46`): `contextAccounting: state.contextAccounting,`. + +### MODIFY `gui/src/pages/claude-code-sections.tsx` (before the auto-context row, `:117`) + +```tsx +
+
+ {t("claude.contextAccounting")} + {t("claude.contextAccountingDesc")} +
+
+ Use Desktop's ocx command in new terminals +

Reading command configuration…

+

+
    + +

    After enabling or repairing, open a new terminal. Existing shells and aliases can still select another command. Use type -a ocx on POSIX, or Get-Command ocx -All and where.exe ocx on Windows to check.

    +

    Remove the terminal command here before uninstalling Desktop. Removing it keeps your off choice for the next launch.

    +
    + + + +
    + + + + +``` + +## Complete NEW file: `desktop/ui/cli.js` + +```javascript +"use strict"; +const invoke = window.__TAURI__?.core?.invoke; +const nodes = Object.fromEntries(["enabled", "state", "target", "issues", "error", "repair", "remove", "back", "titlebar"] + .map(id => [id, document.getElementById(id)])); +let busy = false; +let latest = null; +let polling = false; +function controls() { + nodes.enabled.disabled = busy || !invoke; + nodes.repair.disabled = busy || !invoke || !latest?.enabled; + nodes.remove.disabled = busy || !invoke; + nodes.back.disabled = busy || !invoke; +} +function render(s) { + latest = s; + nodes.enabled.checked = Boolean(s.enabled); + nodes.state.textContent = { + unobserved: "Configuration has not been inspected yet.", + configured: "Desktop command configuration is installed. Check selection in a new terminal.", + partial: "Some configuration could not be applied. Check the issues below.", + disabled: "Automatic terminal command configuration is off.", + blocked: "Configuration is blocked. Correct the reported issue and try Repair.", + }[s.phase] || "Configuration state is unavailable."; + nodes.target.textContent = s.expectedExecutable ? "Bundled executable: " + s.expectedExecutable : ""; + nodes.issues.replaceChildren(...(s.issues || []).map(code => { + const li = document.createElement("li"); li.textContent = code; return li; + })); + controls(); +} +function bounded(work) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("Desktop did not answer within 30 seconds. Refresh status before retrying.")), 30000); + Promise.resolve(work).then(v => { clearTimeout(timer); resolve(v); }, e => { clearTimeout(timer); reject(e); }); + }); +} +async function refresh() { + if (!invoke || busy || polling) return; + polling = true; + try { render(await bounded(invoke("cli_status"))); } + catch (e) { nodes.error.textContent = String(e); nodes.error.hidden = false; } + finally { polling = false; } +} +async function action(name, args) { + if (!invoke || busy) return; + busy = true; controls(); nodes.error.hidden = true; + try { + const s = await bounded(args === undefined ? invoke(name) : invoke(name, args)); + if (s) render(s); + } catch (e) { + nodes.error.textContent = String(e); nodes.error.hidden = false; + } finally { busy = false; controls(); await refresh(); } +} +nodes.enabled.addEventListener("change", () => action("cli_set_enabled", { enabled: nodes.enabled.checked })); +nodes.repair.addEventListener("click", () => action("cli_install")); +nodes.remove.addEventListener("click", () => action("cli_remove")); +// Reuse the existing path-free dashboard command; only four NEW commands are introduced. +nodes.back.addEventListener("click", () => action("show_dashboard")); +if (invoke && typeof navigator !== "undefined" && /Macintosh/.test(navigator.userAgent)) { + nodes.titlebar.hidden = false; + nodes.titlebar.addEventListener("mousedown", e => { + if (e.button === 0) invoke("plugin:window|start_dragging").catch(() => {}); + }); + nodes.titlebar.addEventListener("dblclick", () => invoke("plugin:window|toggle_maximize").catch(() => {})); +} +if (!invoke) { + nodes.state.textContent = "Open this page from OpenCodex Desktop's Terminal command menu."; + controls(); +} else { + refresh(); + setInterval(refresh, 2000); +} +``` + +UI never passes a file path, command line, registry key, executable target or URL to Rust. Status strings use textContent, not HTML. A frontend deadline does not cancel a blocking operation; a retry serializes behind the operation, and status polling does not launch another write. Process death while pending is handled by journal recovery on next launch. + +## Complete NEW file: `tests/clients/desktop-cli-command-surface.test.ts` + +```typescript +import { describe, expect, test } from "bun:test"; +import { readFileSync, mkdtempSync, writeFileSync, chmodSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { runInNewContext } from "node:vm"; +import { repoPath } from "../helpers/repo-root"; +import { initializeNodeLauncherContext } from "../../src/cli/launcher-context"; +const read = (p: string) => readFileSync(repoPath(p), "utf8"); +const lib = read("desktop/src-tauri/src/lib.rs"); +const policy = read("desktop/src-tauri/src/window.rs"); +const page = read("desktop/ui/cli.html"); +const script = read("desktop/ui/cli.js"); +const posix = read("desktop/src-tauri/src/cli_command_posix.rs"); +const commands = ["cli_status", "cli_set_enabled", "cli_install", "cli_remove"]; +function harness(invoke?: (name: string, args?: unknown) => Promise) { + const handlers = new Map void>(); + const nodes = new Map(["enabled", "state", "target", "issues", "error", "repair", "remove", "back", "titlebar"].map(id => [id, { + checked: false, disabled: false, hidden: false, textContent: "", children: [] as unknown[], + addEventListener: (event: string, cb: () => void) => { handlers.set(id + ":" + event, cb); }, + replaceChildren(...items: unknown[]) { this.children = items; }, + }])); + runInNewContext(script, { + window: { __TAURI__: invoke ? { core: { invoke } } : undefined }, + document: { getElementById: (id: string) => nodes.get(id), createElement: () => ({ textContent: "" }) }, + setTimeout: () => 1, clearTimeout: () => {}, setInterval: () => 1, Promise, Error, + }); + return { nodes, handlers }; +} +async function settle() { for (let i = 0; i < 12; i++) await Promise.resolve(); } +describe("desktop CLI command surface", () => { + test("registers four path-free commands and guards every wrapper", () => { + const registered = lib.match(/generate_handler!\[([\s\S]*?)\]/)?.[1] || ""; + for (const name of commands) { + expect(registered).toContain(name); + const body = lib.match(new RegExp("(?:async )?fn " + name + "\\([\\s\\S]*?(?=\\n#\\[tauri::command\\]|\\npub fn run)"))?.[0] || ""; + expect(body).toContain("window::require_cli_page(&window)?"); + expect(body).not.toMatch(/(?:path|command|executable|registry_key):\s*(?:String|PathBuf)/); + } + expect(policy).toContain('url.path() == "/cli.html"'); + expect(policy).toContain('window.label() != "main"'); + expect(page).toContain(''); + expect(read("desktop/src-tauri/tauri.conf.json")).toContain('"frontendDist": "../ui"'); + }); + test("UI never sends an arbitrary path and only toggles a boolean", async () => { + const calls: Array<[string, unknown]> = []; + const { nodes, handlers } = harness((name, args) => { + calls.push([name, args]); + return Promise.resolve({ enabled: true, phase: "configured", expectedExecutable: "/example/ocx", issues: [] }); + }); + await settle(); + nodes.get("enabled")!.checked = false; + handlers.get("enabled:change")!(); await settle(); + handlers.get("repair:click")!(); await settle(); + handlers.get("remove:click")!(); await settle(); + for (const [name, args] of calls) { + if (name === "cli_set_enabled") expect(args).toEqual({ enabled: false }); + else expect(args).toBeUndefined(); + } + expect(calls.some(([n]) => n === "cli_install")).toBe(true); + expect(calls.some(([n]) => n === "cli_remove")).toBe(true); + expect(script).not.toContain("innerHTML"); + expect(page).not.toMatch(/]*type="(?:text|file)"/); + }); + test("missing Tauri disables controls and states the entry point", () => { + const { nodes } = harness(); + for (const id of ["enabled", "repair", "remove", "back"]) expect(nodes.get(id)!.disabled).toBe(true); + expect(nodes.get("state")!.textContent).toContain("Terminal command menu"); + }); + test("ready completion preserves CLI settings during launch and recovery", () => { + const startup = read("desktop/src-tauri/src/startup.rs"); + expect(startup).toContain("crate::window::shows_cli_page(&window)"); + expect(startup).toContain("|| keeps_update_page(mode, crate::window::shows_update_page(&window))"); + expect(startup).toMatch(/adopt_launch_origin_argument\(app\);\s*crate::cli_command::reconcile_on_launch\(app\);/); + expect(read("desktop/src-tauri/src/cli_command.rs")).toContain("spawn_blocking"); + expect(read("desktop/src-tauri/src/tray.rs")).toContain('"terminal-command" => crate::cli_command::show_page(app)'); + }); +}); +const shellTest = process.platform === "win32" ? test.skip : test; +function runShim(modify: (s: string) => string = s => s) { + const root = mkdtempSync(join(tmpdir(), "ocx-shim-proof-")); + try { + const cli = join(root, "fake ocx"); const shim = join(root, "ocx"); + writeFileSync(cli, '#!/bin/sh\nprintf "%s\\n" "${OCX_NODE_LAUNCH_CONTEXT-}" "${1-}" "${2-}"\n'); chmodSync(cli, 0o700); + const raw = posix.match(/const SHIM: &str = r#"([\s\S]*?)"#;/)?.[1]; + if (!raw) throw new Error("shim template absent"); + writeFileSync(shim, modify(raw.replace("@OWNER@", "00000000-0000-4000-8000-000000000001") + .replace("@CLI@", "'" + cli.replaceAll("'", "'\"'\"'") + "'"))); chmodSync(shim, 0o700); + const env = { ...process.env, ANTHROPIC_API_KEY: "shell-value", ANTHROPIC_AUTH_TOKEN: "", + ANTHROPIC_BASE_URL: "https://example.invalid", OCX_NODE_LAUNCH_CONTEXT: "untrusted-old-context" }; + const child = spawnSync("/bin/sh", [shim, "claude"], { env, encoding: "utf8" }); + expect(child.status).toBe(0); return child.stdout.split("\n"); + } finally { rmSync(root, { recursive: true, force: true }); } +} +shellTest("shim proof is accepted by the actual initializer and captures only nonempty exports", () => { + const [raw, proof, command] = runShim(); + const argv = ["ocx", "entry", proof!, command!]; + const env = { OCX_NODE_LAUNCH_CONTEXT: raw }; + const context = initializeNodeLauncherContext(argv, env); + expect(context?.anthropicEnvSlots).toEqual(["ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL"]); + expect(context?.codexCliInspectionEnv).toBeNull(); + expect(argv).toEqual(["ocx", "entry", "claude"]); + expect(raw).not.toContain("shell-value"); + expect(raw).not.toContain("https://example.invalid"); +}); +shellTest("missing od falls back without inherited proof or context", () => { + const [raw, command] = runShim(s => s.replaceAll("/usr/bin/od", "/not-present/ocx-od")); + expect(raw).toBe(""); expect(command).toBe("claude"); +}); +shellTest("short random read falls back without context", () => { + const [raw, command] = runShim(s => s.replace("-N32", "-N31")); + expect(raw).toBe(""); expect(command).toBe("claude"); +}); +``` + +### Exact test-layout MODIFY diffs + +```diff +--- a/scripts/test-layout/layout.json ++++ b/scripts/test-layout/layout.json +@@ explicit, before desktop-cli-contracts.test.ts (base line 850) ++ "desktop-cli-command-surface.test.ts": "clients", + "desktop-cli-contracts.test.ts": "clients", +--- a/tests/fixtures/test-layout-expected.json ++++ b/tests/fixtures/test-layout-expected.json +@@ before desktop-cli-contracts.test.ts (base line 539) ++ "desktop-cli-command-surface.test.ts": "clients", + "desktop-cli-contracts.test.ts": "clients", "desktop-exit-ownership.test.ts": "clients", +``` + +The filesystem path is `tests/clients/desktop-cli-command-surface.test.ts`; registration keys are basenames, not full paths. Run both root layout guards explicitly because source-read contract tests and JSON registrations are not discovered solely by import graph. + +## Rust in-module tests and activation grounding + +The NEW Rust files consist of the production blocks above plus the applicable cfg(test) block below. Their syntax is checked from stdin; type correctness and Clippy are intentionally unverified here. Additional required acceptance cases below are named Rust test requirements, as requested; their platform/fixture seams must be implemented and their assertions reviewed before wp1 readiness. No new dev dependency: use an RAII tempdir built from `std::env::temp_dir()` and UUID, so every filesystem test runs under a disposable user-owned directory and removes it on Drop. Additional packaged/native acceptance and independent reviews belong to 030. + +Append to `cli_command_record.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + struct Temp(PathBuf); + impl Temp { + fn new() -> Self { let p = fs::canonicalize(std::env::temp_dir()).unwrap().join(format!("ocx-cli-{}", Uuid::new_v4())); private_dir(&p).unwrap(); Self(p) } + fn store(&self) -> Store { Store::open(self.0.join("record"), vec![self.0.join(".zshrc")]).unwrap() } + } + impl Drop for Temp { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } + #[test] + fn record_roundtrip_and_unknown_version_refusal() { + let t = Temp::new(); let s = t.store(); let r = Record::fresh("installation".into()); + s.save(&r).unwrap(); assert_eq!(s.read().unwrap(), Some(r.clone())); + let mut bad = r; bad.version = 2; assert_eq!(s.validate(&bad, false).unwrap_err(), "record-invalid"); + } + #[test] + fn corrupt_record_does_not_become_first_run() { + let t = Temp::new(); let s = t.store(); + atomic(&s.root.join("cli.json"), None, b"{", 0o600).unwrap(); + assert_eq!(s.read().unwrap_err(), "record-invalid"); + } + #[test] + fn lock_serializes_and_is_released_on_close() { + let t = Temp::new(); let s = t.store(); + assert!(Store::open(s.root.clone(), s.rc_allowed.clone()).is_err()); + drop(s); let _ = t.store(); + } + #[test] + fn pending_journal_recovers_each_prefix_and_is_idempotent() { + for prefix in 0..=2 { + let t = Temp::new(); let s = t.store(); let mut r = Record::fresh("installation".into()); + let mut next = r.clone(); next.generation += 1; + let changes: Vec<_> = [s.root.join("bin/ocx"), s.root.join("path.sh")].into_iter().map(|p| Change { + kind: "file".into(), path: p.to_string_lossy().into_owned(), before: None, after: Some(b"new".to_vec()), mode: 0o600, backup_path: None, + }).collect(); + for c in changes.iter().take(prefix) { atomic(Path::new(&c.path), None, c.after.as_ref().unwrap(), 0o600).unwrap(); } + r.pending = Some(Journal { operation: "install".into(), changes, next: Box::new(next.clone()) }); s.save(&r).unwrap(); + s.recover(&mut r).unwrap(); assert_eq!(r, next); s.recover(&mut r).unwrap(); + } + } + #[test] + fn journal_conflict_preserves_user_edit_and_disabled_intent() { + let t = Temp::new(); let s = t.store(); let p = t.0.join(".zshrc"); + atomic(&p, None, b"user edit", 0o600).unwrap(); + let mut r = Record::fresh("installation".into()); r.enabled = false; + let mut next = r.clone(); next.generation += 1; + r.pending = Some(Journal { operation: "remove".into(), changes: vec![Change { + kind: "file".into(), path: p.to_string_lossy().into_owned(), before: Some(b"old".to_vec()), after: None, mode: 0o600, backup_path: None, + }], next: Box::new(next) }); s.save(&r).unwrap(); + assert_eq!(s.recover(&mut r).unwrap_err(), "journal-conflict"); + assert_eq!(fs::read(&p).unwrap(), b"user edit"); assert!(!s.read().unwrap().unwrap().enabled); + } + #[test] + fn journal_cannot_address_an_arbitrary_path() { + let t = Temp::new(); let s = t.store(); let mut r = Record::fresh("installation".into()); + let mut next = r.clone(); next.generation += 1; + r.pending = Some(Journal { operation: "install".into(), changes: vec![Change { + kind: "file".into(), path: t.0.join("unowned").to_string_lossy().into_owned(), before: None, after: Some(vec![]), mode: 0o600, backup_path: None, + }], next: Box::new(next) }); assert_eq!(s.validate(&r, false).unwrap_err(), "record-invalid"); + } + #[test] + fn atomic_write_detects_changed_original() { + let t = Temp::new(); let p = t.0.join("file"); atomic(&p, None, b"user", 0o600).unwrap(); + assert_eq!(atomic(&p, Some(b"old"), b"replacement", 0o600).unwrap_err(), "concurrent-edit"); + assert_eq!(fs::read(p).unwrap(), b"user"); + } + #[cfg(unix)] + #[test] + fn symlinks_special_files_and_readonly_files_are_refused() { + use std::os::unix::fs::{symlink, PermissionsExt}; + let t = Temp::new(); let p = t.0.join("file"); atomic(&p, None, b"bytes", 0o600).unwrap(); + symlink(&p, t.0.join("link")).unwrap(); assert!(read_bytes(&t.0.join("link")).is_err()); + assert!(check(&t.0, false).is_err()); + fs::set_permissions(&p, fs::Permissions::from_mode(0o400)).unwrap(); assert!(read_bytes(&p).is_err()); + } +} +``` + +Append to `cli_command_posix.rs`: + +```rust +#[cfg(all(test, unix))] +mod tests { + use super::*; + fn root() -> PathBuf { fs::canonicalize(std::env::temp_dir()).unwrap().join(format!("ocx-posix-{}", uuid::Uuid::new_v4())) } + #[test] + fn rc_edit_is_idempotent_and_repositions_after_later_npm_prepend() { + let t = root(); record::private_dir(&t).unwrap(); + let p = t.join(".zshrc"); let b = block("zsh", &t).unwrap(); + let first = edit_rc(b"export PATH=/npm:$PATH\n", &b, false).unwrap(); + record::atomic(&p, None, &first, 0o600).unwrap(); + assert_eq!(edit_rc(&first, &b, false).unwrap(), first); + let later = [first.as_slice(), b"export PATH=/later:$PATH\n"].concat(); + let repaired = edit_rc(&later, &b, false).unwrap(); + assert!(repaired.ends_with(b.as_bytes())); + assert_eq!(edit_rc(&repaired, &b, true).unwrap(), b"export PATH=/npm:$PATH\nexport PATH=/later:$PATH\n"); + fs::remove_dir_all(t).unwrap(); + } + #[test] + fn broken_duplicate_nested_or_modified_blocks_are_refused() { + let b = block("bash", Path::new("/example/record")).unwrap(); + for bytes in [START.to_owned(), END.to_owned(), format!("{b}{b}"), + format!("{START}\n{START}\n{END}\n{END}\n"), b.replace("if [ -r", "if [ -w")] { + assert!(edit_rc(bytes.as_bytes(), &b, false).is_err()); + assert!(edit_rc(bytes.as_bytes(), &b, true).is_err()); + } + } + #[test] + fn rc_newlines_and_outside_bytes_survive_removal() { + let b = block("zsh", Path::new("/example/record")).unwrap(); + let original = b"# user's config\r\nexport PATH=\"/npm:$PATH\"\r\n"; + let installed = edit_rc(original, &b, false).unwrap(); + assert_eq!(edit_rc(&installed, &b, true).unwrap(), original); + assert!(edit_rc(b"a\r\nb\n", &b, false).is_err()); + assert!(edit_rc(&[0xff], &b, false).is_err()); + } + #[test] + fn quoting_and_invalid_path_contract_is_exact() { + assert_eq!(quote(Path::new("/example/a'b")).unwrap(), "'/example/a'\"'\"'b'"); + for s in ["relative", "/example/a:b", "/example/a\nb"] { assert!(quote(Path::new(s)).is_err()); } + } + #[test] + fn development_and_temporary_launches_are_refused_before_installation() { + assert_eq!(stable_bundle(Path::new("/example/app"), true, "1").unwrap_err(), "development-launch"); + for p in ["/Volumes/DMG/App.app/Contents/MacOS/app", "/private/var/folders/a/AppTranslocation/b/App.app/Contents/MacOS/app", "/tmp/.mount_example/usr/bin/app"] { + assert_eq!(stable_bundle(Path::new(p), false, "1").unwrap_err(), "temporary-bundle"); + } + } +} +``` + +Append to `cli_command_windows.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn prepend_preserves_raw_expansions_type_and_empty_entries() { + let raw = r"%APPDATA%\npm;;C:\Tools;"; + for ty in ["REG_SZ", "REG_EXPAND_SZ"] { + let (out, owned) = prepend(raw, r"C:\Program Files\OpenCodex", ty).unwrap(); + assert_eq!(out, format!(r"C:\Program Files\OpenCodex;{raw}")); + assert_eq!(owned.value_type, ty); assert_eq!(owned.action, "inserted"); + assert_eq!(remove(&out, &owned).unwrap(), raw); + } + } + #[test] + fn moved_existing_entry_is_restored_and_same_entry_repair_keeps_ownership() { + let raw = r"A;C:\Desktop;B"; + let (out, owned) = prepend(raw, r"c:/desktop/", "REG_SZ").unwrap(); + assert_eq!(owned.action, "moved-existing"); + let (twice, retained) = replace_owned_entry(&out, Some(&owned), r"C:\Desktop", "REG_SZ").unwrap(); + assert_eq!(twice, out); assert_eq!(retained, owned); assert_eq!(remove(&out, &owned).unwrap(), raw); + } + #[test] + fn removal_preserves_concurrent_unrelated_entries_and_ambiguity() { + let (out, owned) = prepend(r"A;B", r"C:\Desktop", "REG_EXPAND_SZ").unwrap(); + assert_eq!(remove(&format!("{out};NEW"), &owned).unwrap(), "A;B;NEW"); + let (out, moved) = prepend(r"A;C:\Desktop;B", r"C:\Desktop", "REG_SZ").unwrap(); + assert_eq!(remove(&out.replace("A;B", "A;NEW;B"), &moved).unwrap_err(), "path-restore-ambiguous"); + } + #[test] + fn replacing_owned_entry_removes_only_the_previous_insertion() { + let (out, old) = prepend("A;B", r"C:\Old", "REG_SZ").unwrap(); + let (new, owned) = replace_owned_entry(&out, Some(&old), r"C:\New", "REG_SZ").unwrap(); + assert_eq!(new, r"C:\New;A;B"); assert_eq!(remove(&new, &owned).unwrap(), "A;B"); + } + #[test] + fn duplicate_invalid_and_oversize_entries_are_refused() { + assert!(prepend(r"C:\Desktop;c:/desktop/", r"C:\Desktop", "REG_SZ").is_err()); + assert!(prepend("A", "bad;entry", "REG_SZ").is_err()); + assert!(prepend("A", r"C:\Desktop", "REG_BINARY").is_err()); + assert!(prepend(&"X".repeat(32_767), r"C:\Desktop", "REG_SZ").is_err()); + } +} +``` + +The following additional tests are implementation acceptance requirements rather than executed planning evidence. They require small fixture seams around target selection/stable-bundle observation and the orchestration action executor; the implementer must add the seams and tests in-module before marking wp1 ready. Do not claim their names alone prove the branches. Keep these seam additions within the four module budgets. + +| Guard / branch activated | Required named evidence | +|---|---| +| Fresh absent record defaults on; existing disabled record Repair/Reconcile stays off; Remove persists off before any cleanup | `fresh_stable_bundle_installs_without_npm`, `disabled_record_survives_relaunch_and_repair`, `remove_persists_disabled_intent_before_first_cleanup_write`, `disabled_pending_install_rolls_back_completed_prefix` | +| Invalid version/UUID/digest/schema/platform/section/journal nesting/path; oversized input and safe-JS generation ceiling | `record_roundtrip_and_unknown_version_refusal`, `corrupt_record_does_not_become_first_run`, `journal_cannot_address_an_arbitrary_path`, `record_validation_rejects_every_malformed_field`, `oversize_serialized_journal_is_refused_before_save` | +| Lock contention, writer crash, same-process action overlap, IO error and blocking-worker join failure | `lock_serializes_and_is_released_on_close`, `crashed_lock_owner_allows_journal_recovery`, `actions_are_serialized`, `io_failure_keeps_app_running`, `worker_failure_is_visible` | +| Missing/foreign/readonly/symlink/special target or parent, bad UTF-8/newline, duplicate/nested/incomplete/edited markers | `symlinks_special_files_and_readonly_files_are_refused`, `foreign_owner_is_refused`, `broken_duplicate_nested_or_modified_blocks_are_refused`, `rc_newlines_and_outside_bytes_survive_removal`, `missing_external_rc_parent_is_partial`, `fresh_default_fish_parents_are_created_without_chmod_existing_config` | +| Changed source between snapshot and rename, failed backup creation/fsync/rename/unlink | `atomic_write_detects_changed_original`, `backup_failure_prevents_edit`, `rename_failure_keeps_journal`, `unlink_failure_keeps_disabled_record` | +| Journal target before/after/third state; crash before/after every artifact write and final save; backup already equal/different | `pending_journal_recovers_each_prefix_and_is_idempotent`, `journal_conflict_preserves_user_edit_and_disabled_intent`, `journal_after_write_before_final_save_recovers`, `backup_conflict_is_refused` | +| Stable macOS path accepted; debug/source, AppTranslocation, /Volumes, canonical symlink-to-temporary rejected; missing/nonexecutable sibling CLI | `stable_macos_bundle_is_accepted`, `development_and_temporary_launches_are_refused_before_installation`, `canonical_temporary_bundle_is_refused`, `missing_or_nonexecutable_bundle_cli_is_refused` | +| Stable Windows sibling ocx.exe; development target/temp or sibling absent; Linux exact /usr/bin/ocx, AppImage/APPDIR/extracted layout | `windows_bundle_requires_sibling_cli`, `linux_deb_requires_installed_usr_bin_cli`, `appimage_environment_and_extracted_layout_are_refused` | +| Absolute ZDOTDIR used, relative/absent fallback; fish XDG fallback; first existing Bash login candidate including unsafe candidate; no login candidate | `zdotdir_and_xdg_selection_match_desktop_environment`, `bash_active_login_file_is_selected_without_creation`, `login_file_absent_is_partial` | +| Generated file absent/owned-identical/owned-old-version/unowned/user-modified; rc block absent/owned EOF/owned followed by npm/unowned exact | `owned_files_repair_after_bundle_move`, `owned_file_conflict_is_not_overwritten`, `generated_permissions_changed_are_refused`, `rc_edit_is_idempotent_and_repositions_after_later_npm_prepend`, `unrecorded_exact_block_is_not_adopted` | +| Remove absent block/file; only whitespace in installer-created rc; other content in preexisting/created rc; modified rc keeps helper and ownership | `remove_deletes_only_owned_artifacts`, `remove_retains_nonempty_created_rc`, `modified_rc_keeps_helper_until_cleanup_is_safe` | +| Proof utilities/urandom available, missing od/awk/urandom, short/error/malformed output, empty exports and duplicate proof argv | TS `shim proof is accepted by the actual initializer and captures only nonempty exports`, `missing od falls back without inherited proof or context`, `short random read falls back without context`; add `missing_awk_or_urandom_falls_back`, `duplicate_internal_proofs_are_untrusted`; packaged `ocx claude` acceptance in 030 | +| PATH unset/empty/empty interior elements/existing Desktop duplicates | `path_helper_preserves_empty_entries_and_prepends_once` (run `/bin/sh` against generated helper and a supplied child env) | +| Windows insert/move/same repair/replacement; missing owned entry; adjacent anchors removed or ambiguous; duplicates/oversize/type errors | `prepend_preserves_raw_expansions_type_and_empty_entries`, `moved_existing_entry_is_restored_and_same_entry_repair_keeps_ownership`, `removal_preserves_concurrent_unrelated_entries_and_ambiguity`, `replacing_owned_entry_removes_only_the_previous_insertion`, `duplicate_invalid_and_oversize_entries_are_refused`, `missing_owned_path_is_idempotent` | +| Raw registry value missing/REG_SZ/REG_EXPAND_SZ/bad UTF-16/unsupported/type changed/concurrent raw edit, read/write denial | Windows cfg tests `registry_roundtrip_preserves_type_and_unexpanded_text`, `registry_type_change_blocks_recovery`, `registry_denial_keeps_disabled_intent`; no Ubuntu/native claim | +| Machine PATH existing CLI / clean / unresolved expansion / read denial; broadcast success/timeout | Injected `machine_path_conflict_is_partial`, `machine_path_unobserved_is_partial`, `broadcast_failure_is_partial`; real Windows evidence in 030 | +| Local page main valid spelling; wrong label/origin/scheme/path/query/fragment/unreadable URL; tray/window/navigation failure | `cli_page_is_exact_local_origin_and_path`, source `registers four path-free commands and guards every wrapper`; native `wrong_label_and_unreadable_url_are_refused`, `tray_navigation_failure_is_visible` | +| CLI page during Launch/Recover; user explicitly Back; UI absent bridge/unknown phase/error/timeout/busy/poll overlap | source `ready completion preserves CLI settings during launch and recovery`, `missing Tauri disables controls and states the entry point`, `UI never sends an arbitrary path and only toggles a boolean`; VM `unknown_status_and_timeout_are_visible`, `overlapping_clicks_send_one_write` | + +These test additions are essential remaining implementation work, not a request to run a suite in this planning worker. Parent should require the names to exist and assertion activation to be reviewed, rather than treating this table as test evidence. + +## MODIFY owning and public documentation + +Append the following section immediately before `## Release packaging and updater` (`structure/desktop-shell.md:418`). This is a present-tense contract accompanying the implemented behavior, not an implementation diary. It adds 26 lines, leaving 28 of the 600-line budget at the fixed base. The manifest already maps the desktop source area; review fan-out includes existing runtime ownership prose, but this record creates no runtime authority and needs no duplicate statement there. + +```diff +@@ before Release packaging and updater ++## Desktop-owned terminal command ++ ++`desktop/src-tauri/src/cli_command.rs` schedules one blocking reconcile after launch-origin ++adoption, independently of proxy startup. A stable release install configures the terminal ++command by default; a disabled choice survives launch and removal. AppImage, temporary, ++translocated, debug and unpackaged launches do not install it. ++ ++`desktop/src-tauri/src/cli_command_record.rs` owns the single private user-home ++`.opencodex-desktop/cli.json` record, OS-backed lock and pending journal. The record describes ++generated-file ownership only; it grants no runtime, service or shutdown authority. Invalid ++records block automatic mutation. Pending changes recover only from recorded before/after bytes. ++ ++`desktop/src-tauri/src/cli_command_posix.rs` generates a fail-closed shim, shared PATH helper ++and final managed zsh/bash/fish blocks. It refuses unsafe files and changed/unowned blocks. ++The shim captures nonempty exported Anthropic slot names with an argv-bound random proof ++before dotenv; inability to generate the proof retains compiled-CLI stripping behavior. ++ ++`desktop/src-tauri/src/cli_command_windows.rs` preserves raw HKCU Path text/type, changes only ++the owned entry and broadcasts Environment. Machine-Path conflicts are partial; already-open ++shells are unobserved. Windows direct execution retains current Anthropic stripping behavior. ++ ++The bundled `desktop/ui/cli.html` page uses four path-free commands guarded by the main ++window's exact local page URL. The tray's Terminal command item opens it. Removal stores off ++intent before cleanup and preserves modified user files. Configuration is not a guarantee ++against aliases, absolute commands, shell caches, skipped startup files or later PATH changes. ++ + ## Release packaging and updater +``` + +English exact text, inserted before `## Updates` (`docs-site/src/content/docs/guides/desktop-app.md:169`): + +```diff +@@ before Updates ++## Terminal command ++ ++On a stable macOS app, Windows installation, or installed Linux deb, Desktop automatically ++configures its bundled `ocx` for new terminals at launch. Choose **Terminal command…** in ++the tray menu to turn this off, repair it, or remove it. The off choice survives restarts. ++AppImage and development launches do not configure the terminal command. ++ ++macOS and Linux use a Desktop-owned shim and managed zsh, bash and fish startup blocks. ++Windows prepends the installation directory to the user `Path`; a command in the system ++`Path` can still win. Open a new terminal and check `type -a ocx` on POSIX, or ++`Get-Command ocx -All` and `where.exe ocx` on Windows. Existing shells, aliases and later ++PATH changes can still select another command. A missing bundle makes the POSIX shim fail ++with repair/removal guidance instead of silently running an npm copy. ++ ++POSIX preserves shell-exported Anthropic settings when launch-proof utilities are available. ++If proof generation is unavailable, and for Windows direct execution, the bundled CLI keeps ++its existing removal of untrusted Anthropic environment settings. Installer errors or ++system-Path conflicts are shown as partial or blocked configuration, not verified selection. ++ + ## Updates +@@ Uninstall, before On macOS ++Before uninstalling a supported Desktop installation, choose **Terminal command… → Remove ++terminal command**. Desktop removes only its unchanged managed configuration. Modified ++blocks or files are preserved and reported; resolve them before deleting the app. Removing ++the app directly does not guarantee cleanup of shell files or the user `Path`. ++ + On macOS, drag `OpenCodex.app` from Applications to the Trash. On Windows, remove +``` + +Korean exact text, inserted before `## 업데이트` (`docs-site/src/content/docs/ko/guides/desktop-app.md:61`): + +```diff +@@ before 업데이트 ++## 터미널 명령 ++ ++안정된 위치의 macOS 앱, Windows 설치본, 설치된 Linux deb에서는 Desktop이 시작할 때 새 터미널이 번들 `ocx`를 사용하도록 자동 설정합니다. 트레이의 **Terminal command…**에서 끄기, 복구, 제거를 선택할 수 있습니다. 꺼 둔 선택은 재시작 후에도 유지됩니다. AppImage와 개발 실행에서는 터미널 명령을 설정하지 않습니다. ++ ++macOS와 Linux에서는 Desktop 소유 shim과 zsh, bash, fish 시작 파일의 관리 블록을 사용합니다. Windows에서는 설치 디렉터리를 사용자 `Path` 앞에 추가하며, 시스템 `Path`의 다른 명령이 먼저 선택될 수 있습니다. 새 터미널에서 POSIX는 `type -a ocx`, Windows는 `Get-Command ocx -All`과 `where.exe ocx`로 확인하세요. 이미 열려 있던 셸, 별칭, 이후 PATH 변경은 다른 명령을 선택할 수 있습니다. 번들이 사라지면 POSIX shim은 npm 복사본으로 조용히 전환하지 않고 복구 또는 제거 안내와 함께 실패합니다. ++ ++POSIX에서는 launch proof 생성 도구가 있으면 셸에서 export한 Anthropic 설정을 보존합니다. proof를 생성할 수 없거나 Windows에서 직접 실행하는 경우에는 신뢰되지 않은 Anthropic 환경 설정을 제거하는 기존 번들 CLI 동작을 유지합니다. 설치 오류나 시스템 Path 충돌은 선택 성공으로 표시하지 않고 부분 적용 또는 차단으로 표시합니다. ++ + ## 업데이트 +@@ 제거, before macOS에서는 ++지원되는 Desktop 설치본을 제거하기 전에 **Terminal command… → Remove terminal command**를 선택하세요. Desktop은 변경되지 않은 자신의 관리 설정만 제거합니다. 사용자가 수정한 블록이나 파일은 보존하고 알려 주므로, 앱을 삭제하기 전에 처리하세요. 앱만 직접 제거하면 셸 파일이나 사용자 `Path` 정리가 보장되지 않습니다. ++ + macOS에서는 Applications의 `OpenCodex.app`을 휴지통으로 옮깁니다. Windows에서는 **Installed apps**에서 OpenCodex를 제거합니다. Debian 기반 Linux에서는 다음 명령을 실행합니다. +``` + +A read-only `rg -n -i` pass over all eight guide files exited 0 and exposed existing removal-first instructions in ja/fr/tr/ru/zh-cn/zh-tw as well as en/ko. It found no new Desktop PATH guarantees in the translated guides. This is discovery evidence, not semantic sign-off. Other locale review must inspect **all eight** desktop guide copies, not GUI translation catalogs. Search installer, first-run, terminal-command, PATH and uninstall sections; compare claims to English for default-on/off persistence, AppImage exclusion, no absolute guarantee, Windows machine/user Path and cleanup-before-delete. Existing locale pages primarily omit PATH behavior; omission alone is not a contradictory guarantee, but their old uninstall-only instructions are incomplete after this change. 030 owns translated follow-ups if needed. Do not mark locale consistency complete until a human reads the matches and records the affected locales; string matching/build success alone cannot establish semantic agreement. + +## Guard layers, bypasses and residual risks (PLAN-BYPASS-NAMED-01) + +This plan is E7 agent-followed direction: no CXC hook implements it. The arrow names the concrete proposed product/OS/CI executing surface after implementation, without inventing CXC hook tiers for ordinary Rust code. Wording is downgraded in **every row** to a configuration/ownership guard or early warning rather than universal command-selection enforcement. Final unbypassable command-selection layer: **none**. + +| Mechanism | Tier / execution location | Known bypass | Residual risk / honest claim | +|---|---|---|---| +| Stable-bundle gate | E7 plan directive → packaged-app Rust guard | Same-user modifying/copying a release executable; Windows sibling layout is not signed-installer attestation | Prevents routine debug/temporary installs; not binary authenticity. Linux exact layout excludes extracted bundles but is not a dpkg signature check. | +| Record validation + permissions + OS lock | E7 plan directive → record writer / kernel lock | Same-user noncooperating edits, profile ACL changes | Cooperating Desktop writers serialize; hashes detect changed content, not hostile same-user control. Windows private-profile ACL is inherited. | +| Journal before mutation + atomic file rename | E7 plan directive → artifact writer | Noncooperating editor in last check-to-rename interval; power loss / registry lacks atomic rename | Conflict blocks and leaves journal; no claim of multi-file atomicity. Backups and before bytes allow inspection/recovery. | +| Exact generated-file/block ownership | E7 plan directive → install/remove | User replaces metadata and files together; rc returns early or stale .zwc compiled file bypasses text | Refuse unowned/changed files; preserve outside bytes. EOF position proves text placement, not executed shell control flow. | +| Shell PATH block/helper | E7 plan directive → new-shell initialization | `zsh -f`, startup early return, aliases/functions, hashed commands, later `nvm use`, explicit npm path, hidden .zshenv ZDOTDIR | Supported new-shell configuration only; parent-shell actual selection is unobserved by Rust. | +| HKCU Path prepend + broadcast | E7 plan directive → Windows environment construction | Machine Path, cwd lookup, app execution aliases, old Terminal parent env, PATH rewrite | Conflicts are partial; restart terminal and inspect native selection. Broadcast does not refresh existing cmd/PowerShell. | +| Fail-closed POSIX shim | E7 plan directive → terminal shim invocation | Explicit unrelated executable, replaced same-user shim, another command before shim | Missing/nonexecutable bundle fails 127; exec failure remains an error, no npm search. | +| argv-bound random launch proof | E7 plan directive → pre-dotenv shim / initializer | Same-user process able to control argv/env; missing utilities; duplicate proof args | Preserves normal shell export provenance; not OS authentication. Fallback keeps existing stripping. Independent security review required. | +| Exact local-page command guard | E7 plan directive → each native wrapper before IO | Determined same-user process acting outside Tauri | Blocks ordinary remote webview/main-page IPC misuse; UI cannot select arbitrary targets. Not an OS-user boundary. | +| File-size / structure / test-layout gates | E7 execution directive → repository static CI | Unregistered NEW file outside index; Rust isn't ratchet-scanned; declarations with weak assertions | Stage only implementation files when authorized; inspect tests, keep explicit Rust budgets. Passing gates do not prove native behavior. | +| Independent security/code review | E7 parent-owned 030 review | Reviewer blind spots / missing native evidence | Requires explicit verdicts and known coverage limits; no review claimed by this plan. | + +## File budgets and ratchets + +`THRESHOLD=2000` at `scripts/file-size-ratchet.ts:4`; `.rs` and `.html` are absent from SCAN_EXTENSIONS at :7, `.js/.ts/.json/.md` are scanned. `devlog/` is excluded at :22, so this detailed plan is not a product-file ratchet exception. `tests/fixtures/file-size-baseline.json:18` starts the cap map; none of this phase's existing scanned targets has an individual ratchet cap. Do not add or raise caps. `structure/manifest.json:3` sets **600 lines**, independently of the generic 2000-line guard. Do not edit manifest budget or baseline to accommodate growth. + +| File(s) | Base lines | Limit / proposed budget | Decision | +|---|---:|---:|---| +| `structure/desktop-shell.md` | 546 | 600 / +26 = 572 | 28 lines remain for concurrent L1 changes; remeasure after rebase and compress/split if needed. | +| `desktop/src-tauri/src/startup.rs` | 2609 | .rs not scanned / +4 | Thin scheduling + page guard only; do not grow orchestration here. | +| `desktop/src-tauri/src/lib.rs` | 388 | .rs not scanned / <=465 | Wrappers/registration/state only. | +| `desktop/src-tauri/src/window.rs` | 357 | .rs not scanned / <=415 | URL guard + tests. | +| `desktop/src-tauri/src/tray.rs` | 637 | .rs not scanned / <=645 | One item and dispatch arm. | +| NEW `cli_command.rs` | 0 | team budget <=220 | Includes orchestration regression seams/tests. | +| NEW `cli_command_record.rs` | 0 | team budget <=600 | Schema/lock/IO/recovery and failure tests. | +| NEW `cli_command_posix.rs` | 0 | team budget <=600 | Exact scripts + pure edits + filesystem tests. | +| NEW `cli_command_windows.rs` | 0 | team budget <=420 | Pure transforms + minimal FFI + portable/native tests. | +| NEW `cli.html` / `cli.js` | 0 | HTML manual <=90 / JS ratchet 2000, plan <=160 | Native local page only. | +| NEW source-contract TS test | 0 | 2000 / <=230 | Dedicated sibling test; don't enlarge desktop-update-surface.test.ts. | +| `scripts/test-layout/layout.json` / `tests/fixtures/test-layout-expected.json` | 1981 / 1179 | 2000 / +1 each = 1982 / 1180 | Both exact registrations required. | +| desktop guide en/ko | 207 / 85 | 2000 / +24 en, +10 ko | Check localized copies semantically. | + +Rust budgets are planning caps, not existing mechanical enforcement. Future growth beyond one is a plan amendment, not justification to loosen the existing repository ratchet. + +## Verifier receipts (PLAN-VERIFIER-REAL-01) + +All shell commands used the delegated repository root as workdir. No product code was created and no test suite was run. Commands below are distinguished from implemented-feature evidence. Validation commands read the current checkout, so a 0 here proves baseline compatibility only. Run-once discovery help commands do **not** claim to read new Rust files. + +| Exact command / observation | Exit | Does it read this change's targets? | +|---|---:|---| +| In-memory NEW Rust block extraction → `rustfmt --edition 2021 --emit stdout` on stdin, for each of four files plus its supplied cfg tests | 0 each | Reads the code printed in this NEW plan. Syntax parsing only, not symbol/type/Clippy/test validation; does not write product files. | +| In-memory `cli.js` block extraction → `node --check` on stdin | 0 | Reads the exact NEW JS code in this plan; syntax only, does not execute UI. | +| `cargo fmt --manifest-path desktop/src-tauri/Cargo.toml --check` | 0 | Reads current Rust module graph; NEW Rust modules only after creation and mod registration. | +| `cargo clippy --help` | 0 | No; command interface discovery only. | +| `cargo test --help` | 0 | No; command interface discovery only. | +| `bun run typecheck` | 1 | Invoked current tsconfig (`tsconfig.json:15`, `include: ["src"]`), failed TS2688 missing `bun-types`. This command does not observe wp1 Rust/UI/test changes; it is a parent integration prerequisite, not this phase's verifier. | +| `bun run structure:check` | 0 | Reads current owning doc/manifest/index (`scripts/structure-ssot.ts:294`, runStructureChecks; `:352`, document reads); NEW paths require implementation staging because this gate uses the index. Does not inspect this devlog plan. | +| `bun run privacy:scan` | 0 | Reads tracked/source tree according to scanner, includes only indexed unit documents (`scripts/privacy-scan.ts:60`, gitLsFiles; `:410`, runScan). This NEW untracked plan was not observed; later staging/scanning must include it and implementation. No new-feature correctness proof. | +| `wc -l scripts/test-layout/layout.json tests/fixtures/test-layout-expected.json docs-site/src/content/docs/guides/desktop-app.md docs-site/src/content/docs/ko/guides/desktop-app.md` | 0 | Reads exact targeted JSON/doc line counts: 1981/1179/207/85. | +| `bun scripts/file-size-ratchet.ts` | 0 | Scans indexed current eligible files/baseline (`scripts/file-size-ratchet.ts:134`, scanRepo/git ls-files); .rs/.html and devlog excluded. NEW JS/TS must be staged before meaningful final proof. | + +Implementation commands that depend on missing NEW files are labeled **NEW 파일 생성 후 실행** (“run after NEW file creation”). They were not run as suites in this planning worker. Do not attach invented exit codes or treat the command's help receipt as the following command's result. + +| Command | Execution status / change-target coverage | +|---|---| +| `cargo clippy --manifest-path desktop/src-tauri/Cargo.toml --all-targets -- -D warnings` | NEW 파일 생성 후 실행; not run (would compile/write artifacts outside the one-document scope). Reads registered modules and cfg test targets. | +| `cargo test --manifest-path desktop/src-tauri/Cargo.toml cli_command -- --test-threads=1` | NEW 파일 생성 후 실행; not run (minimal-test plan-only boundary). Reads all four modules' named tests; window URL test is separately needed. | +| `cargo test --manifest-path desktop/src-tauri/Cargo.toml cli_page_is_exact_local_origin_and_path` | NEW 파일 생성 후 실행; not run; directly activates the local-origin predicate. | +| `bun test tests/clients/desktop-cli-command-surface.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts` | NEW 파일 생성 후 실행; not run; reads Rust/UI source as data and both layout JSON files. No import-graph coverage assumption. | +| `bun test tests/clients/desktop-update-surface.test.ts tests/clients/desktop-startup-surface.test.ts` | NEW 파일 생성 후 실행; not run per no-extra-suites instruction; later regression checks preserve update and startup source contracts. | +| `bun run typecheck` after dependencies are available | NEW 파일 생성 후 실행; baseline failure above must be resolved by parent without claiming it passed. | +| `bun run structure:check` / `bun run privacy:scan` / `bun scripts/file-size-ratchet.ts` | Actual baseline receipts above; repeat after new code/docs to read changed targets. Passing unchanged checks need no additional confidence reruns. | +| `bun --cwd docs-site install --frozen-lockfile` then `bun --cwd docs-site run build` | NEW 파일 생성 후 실행; not run: install/build writes dependencies/output outside delegated file scope. Required by docs-site/AGENTS.md at implementation; no documentation build claim. | +| packaged fresh POSIX shell / native Windows Path validation | Not run; 030 owns temporary-profile evidence, true platform selection and packaged `ocx claude`. Do not mutate a developer's own rc/registry. | + +The user specifically constrained this worker to a single document and minimal local tests. That bound overrides a skill's general desire to execute implementation verifiers. Baseline fmt/structure/privacy/ratchet and command help were inexpensive read-oriented discovery; compiling a nonexistent implementation or running suites would provide no new-feature evidence. + +## Handoff risks and required parent decisions + +1. **No-tray Linux entry:** The accepted A6 tray entry cannot be reached on a Linux session with no tray host. The page can be served independently of the proxy, but no new bootstrap/dashboard entry was authorized in this slice. Parent should approve a fixed `cli.html` link in `desktop/ui/index.html` or another local entry and add that precise diff before wp1 implementation is accepted. This is a reported UI scope expansion, not an implemented file change. +2. **Platform proof:** Ubuntu Rust tests cover Path strings, not raw registry IO/WM_SETTINGCHANGE or cmd/PowerShell selection. macOS/Linux packaged CLI provenance must be checked with `ocx claude`; Windows explicitly retains existing stripping. Missing external ZDOTDIR/XDG parents and unsafe rc files are partial outcomes requiring user repair. +3. **Concurrent L1/doc edits:** Owning doc headroom is 28 lines after this proposal; L1 also changes this doc and the guides. Preserve the runtime-authority separation and remeasure against the integrated head. Source anchors are pinned to 68c9d35457 even though origin/dev has moved. +4. **Schema coordination:** 020 must use these exact field names, nullability and pending semantics. A public record is ownership metadata only. A pending install disabled before recovery rolls back only its completed prefix as defined by `Store::recover`; no launcher should select a pending target. +5. **Verification boundary:** Typecheck failed because bun-types is unavailable. No dependency installation, test suite, native behavior or independent review is claimed. Security analysis beyond these public design controls stays in scratch, under repository AGENTS.md. + +Final plan self-check: all four complete NEW Rust production blocks and supplied test blocks parsed through rustfmt stdin with exit 0; complete NEW cli.js parsed through node --check stdin with exit 0. Markdown fences were balanced and the document contained zero personal-machine path occurrences. These checks inspect proposed text only. No production edit, test suite, branch operation, or native environment mutation was performed. diff --git a/devlog/_plan/261009_desktop_owned_path_cli/011_wp1_execution.md b/devlog/_plan/261009_desktop_owned_path_cli/011_wp1_execution.md new file mode 100644 index 00000000000..455f2cf76b9 --- /dev/null +++ b/devlog/_plan/261009_desktop_owned_path_cli/011_wp1_execution.md @@ -0,0 +1,72 @@ +# 011 — wp1 execution plan (re-verified) + +wp1 implements 010 as amended by 003 (AM-5..AM-13; AM-3 withdrawn by AM-11). This document is the cycle's executable plan: what was re-verified against the current base, the consolidated worklist, the worker split for B, and the C checks. Where 010 and this document differ, this document and 003 win. + +## Stale check against the new base + +Base: `origin/dev` 71c4ae3c8e (after #6802, #6805 and today's other merges); branch `codex/desktop-owned-path-cli` rebased, roadmap commit 8a1ea2c070. + +| 010 anchor | Current state | Action | +|---|---|---| +| `startup.rs` `register` (1449), `adopt_launch_origin_argument(app)` (1473), `finish` (1589), `keeps_update_page` (1688) | Unchanged | Apply as written | +| `lib.rs` update commands guarded by `require_update_page` (221–241), `return_to_dashboard` (249–250), `generate_handler!` (287) | Unchanged | AM-12 widens only line 250 | +| `window.rs` `is_app_origin` (105), `require_update_page` (113) | Unchanged | Add `require_cli_page` and `require_local_settings_page` | +| `tray.rs` `install` (72), `check-updates` item (91) and handler (219) | Unchanged | Add the terminal-command item next to it | +| `structure/desktop-shell.md` | 568 lines (was 546; #6802 added 22) | Budget +26 → 594 ≤ 600; keep the addition at or under 30 lines | +| desktop guide en/ko | #6802 added "Using the ocx CLI with the desktop app" | Extend that section with the terminal-command paragraphs instead of a new competing section | +| `scripts/test-layout/layout.json` 1981 / `tests/fixtures/test-layout-expected.json` 1181 | Unchanged in shape | Register the new test in both | +| Toolchain | cargo/rustc 1.95.0 present locally | `cargo fmt`/`clippy`/`test` run locally on macOS | + +## Consolidated wp1 worklist + +1. Rust record module (`cli_command_record.rs`): schema per 010 with AM-1/AM-6 external journal, AM-5 validation and limits (`rcFiles ≤ 16`, `changes ≤ 32`, record ≤ 64 KiB on write and read), AM-7 ownership list validation, AM-10 `Record::fresh(bundle)`, lock. +2. Rust POSIX module (`cli_command_posix.rs`): stable bundle (AM-10 order), shim with A11 proof generation, `path.sh`, zsh/bash/fish blocks, rc edit/reposition/removal with AM-7 desired-vs-owned reconciliation. +3. Rust Windows module (`cli_command_windows.rs`): pure Path transforms + raw HKCU IO + `WM_SETTINGCHANGE` (FFI as in 010, no new crate). +4. Rust orchestration (`cli_command.rs`): reconcile on launch (blocking worker after `adopt_launch_origin_argument`), `status`, `set_enabled`, `install`, `remove` (disabled intent stored first), AM-6 recovery state table. +5. Wiring: `lib.rs` four commands + `return_to_dashboard` guard (AM-12); `window.rs` guards; `startup.rs` hook + keep `cli.html` on Ready like the update page; `tray.rs` item. +6. Local page: `desktop/ui/cli.html` + `cli.js` (Back → `return_to_dashboard`). +7. Dashboard entry (AM-11): `gui/src/lib/desktop-shell.ts` `desktopCliPageUrl`/`openDesktopCliPage`, desktop-only entry beside the Desktop update row, strings in every GUI locale, GUI unit test. +8. Shared fixtures `tests/fixtures/desktop-cli-record/*.json` (AM-5), used by Rust tests via `include_str!` now and by wp2's TS reader later. +9. Source-contract test `tests/clients/desktop-cli-command-surface.test.ts` + both layout registrations. +10. Docs: `structure/desktop-shell.md` (≤ 30 lines added), desktop guide en/ko extension of the existing CLI section, locale contradiction sweep. + +## B worker split (disjoint files, one worktree, no branch operations) + +| Worker | Files | Depends on | +|---|---|---| +| W1 Rust core | items 1–4, 8 | 010 interfaces | +| W2 Wiring and local page | items 5, 6, 9 | W1's public function names as fixed in 010 (`reconcile_on_launch`, `status`, `set_enabled`, `install`, `remove`, `State`) | +| W3 Dashboard and docs | items 7, 10 | none | + +Main integrates, runs the C checks and fixes compile errors across the seams. + +## C checks for wp1 + +| Command | Reads the change? | +|---|---| +| `cargo fmt --manifest-path desktop/src-tauri/Cargo.toml --check` | Yes, once the modules are declared in `lib.rs` | +| `cargo clippy --manifest-path desktop/src-tauri/Cargo.toml --all-targets -- -D warnings` | Yes (compiles all targets including tests) | +| `cargo test --manifest-path desktop/src-tauri/Cargo.toml cli_command` and `... window` | Yes, the new in-module tests and the URL guards | +| `bun test tests/clients/desktop-cli-command-surface.test.ts tests/clients/desktop-update-surface.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts` | Yes; reads Rust/UI source as data and both layout files | +| `cd gui && bun test && bun run lint && bun x tsc -b` | Yes for the dashboard entry | +| `bun run structure:check`, `bun run privacy:scan`, `bun scripts/file-size-ratchet.ts` | Yes after the files are staged | +| Real-shell check (030, c-1/c-2) with a temporary `HOME` on macOS zsh and bash | Yes; runs the Rust reconcile through a test binary and then real shells | +| A11 compiled check (AM-13) | Moved to wp3 C, where the compiled host CLI is built once for both PRs | + +Everything else (Windows native, fish, Linux deb, packaged app click-through) is release QA, named in PR 1. + + +## Reflection (same architect, 01a11f35): ALIGNED + +Folded into this plan: + +- W2 also depends on W1's `show_page` (the tray handler calls it). +- The surface test's dashboard-gating assertion depends on W3's output, so main runs it after integrating W1–W3. +- C adds a rendered check of `cli.html` with a stubbed `invoke` (static render in a headless browser, separate from the packaged click-through QA), `cd gui && bun run build` (Vite build, not only `tsc -b`), the GUI test file `gui/tests/desktop-shell.test.ts`, and the PR screenshot of the dashboard entry. +- At wp1 close, A11 is recorded as "implemented, verification pending in wp3" (shim run + control run + independent security review; AM-9 merge decision if the compiled check cannot run). + + +## Audit (independent, 01a11f6b): NEAR-PASS + +Folded: C adds `cd gui && bun run lint:i18n` and `cd gui && bun test tests` (callback wiring and conditional render); the rendered `cli.html` check reads its screenshot and exercises toggle, remove and Back against the stubbed `invoke`, recorded separately from any real Desktop round trip; Windows-only code (registry IO, FFI) is neither compiled nor run on macOS `clippy --all-targets` and is named as residual (compile coverage only if a Windows CI job builds the desktop crate; native behavior is release QA). + diff --git a/devlog/_plan/261009_desktop_owned_path_cli/019_wp1_done.md b/devlog/_plan/261009_desktop_owned_path_cli/019_wp1_done.md new file mode 100644 index 00000000000..c3f43022473 --- /dev/null +++ b/devlog/_plan/261009_desktop_owned_path_cli/019_wp1_done.md @@ -0,0 +1,29 @@ +# 019 — wp1 outcome + +wp1 is implemented on `codex/desktop-owned-path-cli` (commits ab5a658b32 → 9a1da663a0, based on `origin/dev` 71c4ae3c8e). Workers: W1 Rust core (`01a11f84-f4e9`), W2 wiring and local page (`01a11f84-f5a1`), W3 dashboard and docs (`01a11f84-f66b`), real-shell test (`01a11fa3-3e5b`), all gpt-6.1-sol; main integrated and fixed the Windows findings. + +## What exists now + +- `desktop/src-tauri/src/cli_command.rs`, `cli_command_record.rs`, `cli_command_posix.rs`, `cli_command_windows.rs`: record and external journal (AM-1/AM-6), shared validation and limits (AM-5), owned-vs-desired rc reconciliation (AM-7), first record carries its bundle (AM-10), shim with launch provenance (A11), `path.sh`, zsh/bash/fish blocks, Windows user `Path` transforms and raw registry IO. +- Wiring: four commands behind `require_cli_page`; `return_to_dashboard` behind `require_local_settings_page` (update and terminal-command pages only; the update commands keep `require_update_page`); reconcile scheduled after `adopt_launch_origin_argument`; tray item; `desktop/ui/cli.html` + `cli.js`. +- Dashboard entry (AM-11): `desktopCliPageUrl`/`openDesktopCliPage`, desktop-only "Terminal command" entry, strings in all 11 GUI locales. +- Shared record fixtures `tests/fixtures/desktop-cli-record/` (10 files), source-contract test `tests/clients/desktop-cli-command-surface.test.ts`, docs (`structure/desktop-shell.md` 597/600, desktop guide en/ko extending the existing CLI section). + +## Evidence + +| Check | Result | Observes | +|---|---|---| +| `cargo fmt --check`, `cargo clippy --all-targets -D warnings`, `cargo test` (macOS, Rust 1.95) | 0 / 0 / 228 passed, 2 ignored | All new modules, guards and tests compiled for macOS | +| Same on Windows 11 host `mini` (Rust 1.98), `cargo clippy --all-targets -D warnings` + `cargo test cli_command` | 0 / 14 passed | Windows-only registry IO, FFI and Path transforms compile and pass; it first found two lints and a JSON escaping bug in a test, fixed in d669955 and 116985a | +| `cargo test real_shell -- --ignored` (macOS) | 1 passed | Real `zsh -i` and `bash -l -i` on a temporary HOME with an npm-style `ocx` prepended in `.zshrc`/`.bashrc`: the Desktop shim wins; an npm prepend appended after the block loses again after reconcile moves the block back to the end; Remove restores the npm `ocx` and keeps the user's lines (c-1/c-2 on macOS) | +| `bun test` surface + update/startup surface + layout guards | 59 pass, 0 fail | Command registration, guards, page invocation contract, layout registration | +| GUI: `lint`, `lint:i18n`, `tsc -b`, `bun test tests`, `build` | 0, 0, 0, 3018 pass, 0 | Dashboard entry, helpers, locales | +| `bun run typecheck`, `structure:check`, `privacy:scan`, file-size ratchet | all pass | Repository gates | +| Rendered `cli.html` with stubbed `invoke` (headless Chrome via agbrowse, isolated profile) | Initial render correct; Repair → `cli_install` and partial state with issue list; Remove → `cli_remove` and "off"; toggle → `cli_set_enabled`; Back → `return_to_dashboard` (never `show_dashboard`) | Page behavior; not a packaged Desktop round trip | + +## Open for wp3 and release QA + +- A11: implemented; compiled-CLI verification (shim run + control run, AM-13) and the independent security review are pending in wp3. +- Not observed by this phase: packaged OpenCodex.app click-through (dashboard → Terminal command → Back), fish, Linux deb, Windows native terminal selection after a real registry write. Release QA items for PR 1. +- Planning line budgets for the new Rust modules were exceeded (`cli_command_record.rs` 1197, `cli_command_posix.rs` ~1000, `cli_command_windows.rs` 476, `cli_command.rs` 439). Rust is not under the repository ratchet; the budgets were planning caps. Accepted as an amendment; reviewers may ask for a split. + diff --git a/devlog/_plan/261009_desktop_owned_path_cli/020_launcher_handoff_and_diagnostics.md b/devlog/_plan/261009_desktop_owned_path_cli/020_launcher_handoff_and_diagnostics.md new file mode 100644 index 00000000000..4a4039fe4f8 --- /dev/null +++ b/devlog/_plan/261009_desktop_owned_path_cli/020_launcher_handoff_and_diagnostics.md @@ -0,0 +1,1421 @@ +# 020 — Launcher handoff and command-selection diagnostics (wp2) + +Desktop owns the terminal `ocx` command through wp1's PATH installation. This work makes a current package launcher delegate to the recorded Desktop CLI before it repairs its package or searches for Bun, so older terminal sessions can use the bundled CLI too. It preserves package update/removal and inspection behavior, regenerates the pre-dotenv launch proof, and adds read-only PATH observations to status and doctor. Diagnostics distinguish configured Desktop intent, the first executable on the current process PATH, and parent-shell selection that this process cannot observe. + +## Authority, execution boundary and dependency + +| Item | Contract | +| --- | --- | +| Archetype / trigger | Bounded satisfy-spec integration work, delegated from 000 wp2 after the Desktop/PATH audit. Parent owns FSM, goals, loop, consultation and review. | +| Goal | Implement A7, A8 and the TS portion of A10 as disposed in 002; Dispositions override the architect proposal. | +| Prerequisite | wp1/010 creates the authoritative record and owned shim. The minimal reader contract below must be reconciled with 010 before implementation. | +| Stop | This planning worker returns this one document; no code edits, git branch mutations, commits, child dispatch or goal/FSM operations. | +| Verifiers | Actual planning-time results and target coverage are recorded below. Newly proposed tests run only after creation. | +| Artifact | This 020 document; 001 is investigation evidence, 002 is decision authority, 010 owns the writer, 030 owns delivery. | +| Outcomes | Plan ready for parent's audit; implementation remains unverified. Contract mismatch or security/provenance concern goes to parent. | +| Escalation | A4 schema change; L1 rebase changes a shared contract; A11 review failure; Windows native selection conflict. | +| Resource boundary | Local validation stays minimal. No full test suite in this planning task. No user-machine installation or PATH writes. | + +All source line references below describe **68c9d354574ed240c17805493409f8c589682b91**, the audit's origin/dev snapshot. Local HEAD is that commit; the moving origin/dev ref was already `8ad91f4c03` when checked. Resolve implementation by function anchors and rebase, never by these line numbers alone. + +| Scope | IN | OUT | +| --- | --- | --- | +| A7 | Fixed-record reader; validated target; async handoff; regenerated proof; command exceptions; probe bypass | Record writes, PATH/rc/registry writes, bundle discovery, resource copying, service/runtime authority | +| A8 | PATH/PATHEXT candidate observation; status `cliCommand`; one human line; doctor section | Executing candidates, aliases/functions, shell cache introspection, changing Codex runtime selection | +| A10 TS | Two CLI test files and both layout registrations; affected owning-doc diffs and public text | Rust/UI tests (010), CI/release changes, approval/merge (030) | +| Shared source | Thin hooks in launcher, status, doctor and managing-cli | `src/cli/index.ts` (1976 lines), other worker's plan files | + +## Source evidence and exact file map + +| Baseline evidence | Function / anchor | Consequence | +| --- | --- | --- | +| `bin/ocx.mjs:951–963` | `updateHelpRequested`, `codexCliUpdateInspection` | Insert only after both existing classification blocks. | +| `bin/ocx.mjs:964–996` | mise update, `runNpmSelfUpdate`, `bootRestoreProbe`, `resolveBun` | Handoff precedes these operations; exceptions still reach them. | +| `bin/ocx.mjs:1018–1074` | pre-Bun slot snapshot and launch-context generation | Wrap the existing body in one factory, reuse for both launch paths; do not trust inherited context. | +| `bin/ocx.mjs:1075–1120` | async child, `FORWARDED`, `clearHandlers`, error/exit listeners | Reproduce asynchronous signal forwarding and exit mirroring for Desktop. Keep the Bun child path intact. | +| `src/cli/launcher-context.ts:54–123` | `initializeNodeLauncherContext` | Consumer requires exactly one argv proof and matching version-1 JSON, removes the proof and environment context. | +| `src/cli/claude.ts:65–77,630` | `deleteUntrustedAnthropicSlots`, `buildNativeClaudeEnv` | The handoff test must reach the real Claude consumer, not merely assert a JSON key. | +| `src/service/managing-cli.ts:62–85` | `probeVersionOnce` | Probe an actual selected package executable by suppressing handoff in its child environment. | +| `src/cli/status.ts:107,220,682,870–937` | `CliStatusJson`, `CliStatusView`, `collectStatus` | Add separate command diagnostics, never repurpose `paths.runtime` or `codexRuntime`. | +| `src/cli/index.ts:1693–1730` | `handleStatus` | Existing renderer emits `status.healthLabel`; JSON emits only `status.json`. A view-only extra line avoids editing the root dispatcher. | +| `src/cli/doctor.ts:94–112,1309–1325,1388` | `OAuthDoctorCheck`, `recordDoctorFailure`, `runDoctor` | Direct section printing is the current registration pattern. Put a separate section immediately after Paths, away from L1's restart-safety edits. | +| `scripts/file-size-ratchet.ts:3,145,182,207` | `THRESHOLD`, `scanRepo`, `updateBaseline`, CLI | New files must be below 2000 lines; existing recorded caps never rise. | +| `structure/AGENTS.md:27–35` | Layout rules | Structure docs have their independent 600-line cap; new source references need staging before the parent's structure gate. | +| `scripts/test-layout/layout.json:1298–1299`; `tests/fixtures/test-layout-expected.json:779` | `explicit`, independent expected roster | Register both new basenames as `cli`; regex placement alone is insufficient. | + +| Exact repository path | Change | Changed functions / content | +| --- | --- | --- | +| `src/lib/desktop-cli-record.mjs` | NEW | `desktopCliRecordPath`, `readDesktopCliRecord` | +| `src/lib/desktop-cli-record.d.mts` | NEW | Reader DTO and options | +| `src/lib/desktop-cli-handoff.mjs` | NEW | `desktopHandoffExcluded`, `planDesktopCliHandoff`, `runDesktopCliHandoff` | +| `src/lib/desktop-cli-handoff.d.mts` | NEW | Plan, exit and dependency declarations | +| `src/cli/cli-path-diagnostics.ts` | NEW | `collectCliPathDiagnostics`, `formatCliCommandLine`, `formatCliStatusHealthLabel`, `cliCommandDoctorChecks` | +| `bin/ocx.mjs` | MODIFY | Imports; `createNodeLaunchContext` extracted from existing top-level snapshot; new pre-update handoff block; Bun argv proof normalization | +| `src/service/managing-cli.ts` | MODIFY | `probeVersionOnce` environment only | +| `src/cli/status.ts` | MODIFY | `CliStatusJson`, `collectStatus` | +| `src/cli/doctor.ts` | MODIFY | `runDoctor` section registration | +| `tests/cli/ocx-launcher-desktop-handoff.test.ts` | NEW | Reader/plan/spawn/proof/probe/launcher behavior tests | +| `tests/cli/cli-path-diagnostics.test.ts` | NEW | Deterministic POSIX/Windows PATH fixtures and view tests | +| `scripts/test-layout/layout.json` | MODIFY | `explicit` two entries | +| `tests/fixtures/test-layout-expected.json` | MODIFY | Two independent entries | +| `structure/runtime.md` | MODIFY | One entrypoint-table row replaced with one row | +| `structure/cli-management.md` | MODIFY | New command-selection contract section | +| `structure/ops/service-and-sidecars.md` | MODIFY | Internal version-probe bypass paragraph | +| `docs-site/src/content/docs/getting-started/installation.md` | MODIFY | Desktop command paragraph | +| `docs-site/src/content/docs/reference/cli.md` | MODIFY | Command-selection diagnostics paragraph | + +030 owns the desktop guide en/ko and translation synchronization. The two public-text changes below are supplied here for its coordinated application; do not have two workers independently edit the same paragraphs. New `.mjs` files ship through `package.json:18–20` (`files` includes `src`); no package whitelist change is needed. + +## A4 consumer contract: reader only + +010 is final writer authority. This reader consumes only `version: 1`, `enabled`, and (when enabled) `bundle.platform` plus `bundle.cliExecutable`. It does not require or expose ownerId, installId, generation, hashes, journal payloads, bundle version or registry/rc details. Additional fields are tolerated. `pending` absent/null means settled; a present non-null pending operation is reported as `record-pending` and cannot select a target. `enabled:false` can be a small tombstone with no bundle. No new persistent fields are introduced by wp2. + +The fixed record is `~/.opencodex-desktop/cli.json` on POSIX and `%USERPROFILE%\.opencodex-desktop\cli.json` on Windows. The reader bounds descriptor reads to 64 KiB plus one sentinel byte and validates a regular file, JSON shape, platform and an absolute target. **No `OPENCODEX_DESKTOP_CLI_RECORD` environment override**: an injected `recordPath`/`home` option is sufficient for unit tests; subprocess tests use an isolated HOME and USERPROFILE. This avoids a second public runtime selector and avoids a project dotenv directing Bun-side diagnostics to a different arbitrary record. The record remains same-user configuration, not executable authenticity evidence. + +Malformed/unreadable/pending records cause a fixed-code launcher error. Missing record or disabled intent resumes the ordinary package path. After a valid target is selected, only its actual ENOENT resumes the package path. EACCES, ELOOP, ENOTDIR, non-file, relative path, self target, and other spawn failures terminate with an error. No search for another Desktop install occurs. + +### NEW `src/lib/desktop-cli-record.mjs` (complete) + +```js +import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs"; +import { homedir } from "node:os"; +import { posix, win32 } from "node:path"; + +export const DESKTOP_CLI_RECORD_MAX_BYTES = 64 * 1024; + +export function desktopCliRecordPath(options = {}) { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const home = options.home ?? (platform === "win32" ? env.USERPROFILE || homedir() : homedir()); + return (platform === "win32" ? win32 : posix).join(home, ".opencodex-desktop", "cli.json"); +} + +export function readDesktopCliRecord(options = {}) { + const platform = options.platform ?? process.platform; + const path = options.recordPath ?? desktopCliRecordPath(options); + let fd; + try { + fd = openSync(path, constants.O_RDONLY); + const info = fstatSync(fd); + if (!info.isFile()) return { state: "invalid", path, issue: "record-invalid" }; + if (info.size > DESKTOP_CLI_RECORD_MAX_BYTES) { + return { state: "invalid", path, issue: "record-too-large" }; + } + const bytes = Buffer.alloc(DESKTOP_CLI_RECORD_MAX_BYTES + 1); + let used = 0; + while (used < bytes.length) { + const n = readSync(fd, bytes, used, bytes.length - used, null); + if (n === 0) break; + used += n; + } + if (used > DESKTOP_CLI_RECORD_MAX_BYTES) { + return { state: "invalid", path, issue: "record-too-large" }; + } + let value; + try { value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, used))); } + catch { return { state: "invalid", path, issue: "record-invalid" }; } + if (!value || typeof value !== "object" || Array.isArray(value) + || value.version !== 1 || typeof value.enabled !== "boolean") { + return { state: "invalid", path, issue: "record-invalid" }; + } + if (!value.enabled) return { state: "disabled", path }; + if (value.pending != null) return { state: "invalid", path, issue: "record-pending" }; + const bundle = value.bundle; + const api = platform === "win32" ? win32 : posix; + if (!bundle || typeof bundle !== "object" || Array.isArray(bundle) + || bundle.platform !== platform || typeof bundle.cliExecutable !== "string" + || bundle.cliExecutable.length > 32 * 1024 || !api.isAbsolute(bundle.cliExecutable) + || /[\x00\r\n]/.test(bundle.cliExecutable)) { + return { state: "invalid", path, issue: "record-invalid" }; + } + return { state: "ready", path, record: { + version: 1, enabled: true, + bundle: { platform, cliExecutable: bundle.cliExecutable }, + } }; + } catch (error) { + return error?.code === "ENOENT" + ? { state: "missing", path } + : { state: "unreadable", path, issue: "record-unreadable" }; + } finally { + if (fd !== undefined) closeSync(fd); + } +} +``` + +### NEW `src/lib/desktop-cli-record.d.mts` (complete) + +```ts +export const DESKTOP_CLI_RECORD_MAX_BYTES: number; +export type DesktopCliRecord = Readonly<{ + version: 1; + enabled: true; + bundle: Readonly<{ platform: NodeJS.Platform; cliExecutable: string }>; +}>; +export type DesktopCliRecordIssue = "record-invalid" | "record-too-large" | "record-pending" | "record-unreadable"; +export type DesktopCliRecordRead = + | { state: "missing" | "disabled"; path: string } + | { state: "ready"; path: string; record: DesktopCliRecord } + | { state: "invalid" | "unreadable"; path: string; issue: DesktopCliRecordIssue }; +export type DesktopCliRecordOptions = { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + home?: string; + recordPath?: string; +}; +export function desktopCliRecordPath(options?: DesktopCliRecordOptions): string; +export function readDesktopCliRecord(options?: DesktopCliRecordOptions): DesktopCliRecordRead; +``` + +### NEW `src/lib/desktop-cli-handoff.mjs` (complete) + +```js +import { spawn } from "node:child_process"; +import { accessSync, constants, realpathSync, statSync } from "node:fs"; +import { posix, win32 } from "node:path"; +import { readDesktopCliRecord } from "./desktop-cli-record.mjs"; +import { isCodexCliUpdateInspectionArgv } from "../update/codex-cli-update-launch-policy.mjs"; + +const PROOF_PREFIX = "--ocx-internal-launch-proof="; + +export function desktopHandoffExcluded(argv, env = process.env) { + const args = argv.filter(arg => !arg.startsWith(PROOF_PREFIX)); + return env.OCX_NO_DESKTOP_HANDOFF === "1" + || ["update", "uninstall", "remove"].includes(args[0]) + || (args[0] ?? "").startsWith("__") + || isCodexCliUpdateInspectionArgv(["node", "ocx.mjs", ...args]); +} + +export function planDesktopCliHandoff(input = {}, deps = {}) { + const argv = input.argv ?? process.argv.slice(2); + const env = input.env ?? process.env; + if (desktopHandoffExcluded(argv, env)) return { kind: "continue", reason: "excluded" }; + const read = input.recordRead ?? readDesktopCliRecord({ env, platform: input.platform }); + if (read.state === "missing" || read.state === "disabled") { + return { kind: "continue", reason: read.state }; + } + if (read.state !== "ready") return { kind: "error", issue: read.issue }; + const platform = input.platform ?? process.platform; + const api = platform === "win32" ? win32 : posix; + const target = read.record.bundle.cliExecutable; + if (!api.isAbsolute(target)) return { kind: "error", issue: "target-invalid" }; + const stat = deps.stat ?? statSync; + const access = deps.access ?? accessSync; + const realpath = deps.realpath ?? realpathSync; + try { + if (!stat(target).isFile()) return { kind: "error", issue: "target-invalid" }; + access(target, platform === "win32" ? constants.F_OK : constants.X_OK); + const physical = realpath(target); + const key = path => platform === "win32" ? path.toLowerCase() : path; + for (const self of input.selfPaths ?? [process.argv[1], process.execPath]) { + if (!self) continue; + let own; + try { own = realpath(self); } + catch (error) { if (error?.code === "ENOENT") continue; throw error; } + if (key(own) === key(physical)) return { kind: "error", issue: "target-self" }; + } + return { kind: "handoff", target: physical }; + } catch (error) { + return error?.code === "ENOENT" + ? { kind: "continue", reason: "target-missing" } + : { kind: "error", issue: "target-unusable" }; + } +} + +export function runDesktopCliHandoff(plan, launch, deps = {}) { + const spawnChild = deps.spawn ?? spawn; + const parent = deps.parent ?? process; + const platform = deps.platform ?? process.platform; + const argv = launch.argv.filter(arg => !arg.startsWith(PROOF_PREFIX)); + const env = { ...launch.env, OCX_NO_DESKTOP_HANDOFF: "1", OCX_NODE_LAUNCH_CONTEXT: launch.context }; + delete env.OCX_BUN_RUNTIME_SOURCE; + delete env.OCX_BUN_RUNTIME_PATH; + return new Promise(resolveExit => { + let child; + try { + child = spawnChild(plan.target, [`${PROOF_PREFIX}${launch.proof}`, ...argv], { + stdio: "inherit", shell: false, windowsHide: true, env, + }); + } catch (error) { + resolveExit(error?.code === "ENOENT" ? { kind: "continue" } : { kind: "error", issue: "spawn-failed" }); + return; + } + const forwarded = platform === "win32" ? ["SIGINT", "SIGTERM"] : ["SIGINT", "SIGTERM", "SIGHUP"]; + const handlers = forwarded.map(signal => { + const handler = () => { try { child.kill(signal); } catch { /* child already exited */ } }; + parent.on(signal, handler); + return [signal, handler]; + }); + const clear = () => { for (const [signal, handler] of handlers) parent.removeListener(signal, handler); }; + let done = false; + const finish = result => { if (done) return; done = true; clear(); resolveExit(result); }; + child.once("error", error => finish(error?.code === "ENOENT" + ? { kind: "continue" } : { kind: "error", issue: "spawn-failed" })); + child.once("exit", (code, signal) => finish({ kind: "exit", code: code ?? 1, signal })); + }); +} +``` + +### NEW `src/lib/desktop-cli-handoff.d.mts` (complete) + +```ts +import type { spawn } from "node:child_process"; +import type { accessSync, statSync } from "node:fs"; +import type { DesktopCliRecordRead, DesktopCliRecordIssue } from "./desktop-cli-record.mjs"; +export type DesktopHandoffIssue = DesktopCliRecordIssue | "target-invalid" | "target-self" | "target-unusable" | "spawn-failed"; +export type DesktopCliHandoffPlan = + | { kind: "continue"; reason: "excluded" | "missing" | "disabled" | "target-missing" } + | { kind: "error"; issue: DesktopHandoffIssue } + | { kind: "handoff"; target: string }; +export type DesktopCliHandoffExit = + | { kind: "continue" } + | { kind: "error"; issue: "spawn-failed" } + | { kind: "exit"; code: number; signal: NodeJS.Signals | null }; +export function desktopHandoffExcluded(argv: string[], env?: NodeJS.ProcessEnv): boolean; +export function planDesktopCliHandoff(input?: { + argv?: string[]; env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; + recordRead?: DesktopCliRecordRead; selfPaths?: (string | undefined)[]; +}, deps?: { stat?: typeof statSync; access?: typeof accessSync; realpath?: (path: string) => string }): DesktopCliHandoffPlan; +export function runDesktopCliHandoff(plan: Extract, launch: { + argv: string[]; env: NodeJS.ProcessEnv; proof: string; context: string; +}, deps?: { spawn?: typeof spawn; parent?: Pick; platform?: NodeJS.Platform }): Promise; +``` + +`realpathSync`'s overloaded declaration is deliberately narrowed to the string call used here. `selfPaths` includes both the launcher module and executing binary. Selected-target races are still possible between access/realpath and spawn; the spawn result is authoritative. Windows checks existence/regular-file and lets CreateProcess decide executability; POSIX checks X_OK first. + +## Observe-only diagnostics + +`pathFirst` is the first runnable PATH candidate, never a claim about aliases/functions or shell caches. POSIX empty PATH elements mean cwd; relative elements resolve against cwd. Unset PATH has no observed candidates. Windows checks PATHEXT in declared order, case-insensitively, with an extensionless candidate last; surrounding directory quotes are removed. A separate `currentDirectoryCandidate` observes cmd's possible cwd override and never changes `pathFirst`. This distinction applies even when PowerShell's command-selection rules differ. + +Expected executable is the fixed owned POSIX shim `~/.opencodex-desktop/bin/ocx`, or Windows's recorded bundled `ocx.exe`. An npm executable first on PATH remains an issue even if that launcher later hands off successfully. Missing/unusable target, missing shim, incomplete record and scan errors remain visible. No `which`, `where`, subprocess version probe or source of shell startup scripts is used. + +### NEW `src/cli/cli-path-diagnostics.ts` (complete) + +```ts +import { accessSync, constants, realpathSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { posix, win32 } from "node:path"; +import { readDesktopCliRecord, type DesktopCliRecordRead, type DesktopCliRecordIssue } from "../lib/desktop-cli-record.mjs"; +import { redactUserPath } from "../lib/redact"; + +export type CliCommandIssue = DesktopCliRecordIssue + | "desktop-target-missing" | "desktop-target-unusable" + | "expected-command-missing" | "expected-command-unusable" + | "path-missing" | "path-first-not-desktop" | "path-unreadable" + | "path-scan-truncated" | "windows-current-directory-shadow"; +export type CliPathCandidate = { + path: string; realpath: string; pathIndex: number; extension: string; +}; +export type CliPathDiagnostics = { + configured: boolean; + recordState: DesktopCliRecordRead["state"]; + expectedExecutable: string | null; + handoffTarget: string | null; + candidates: CliPathCandidate[]; + pathFirst: CliPathCandidate | null; + desktopFirstOnPath: boolean | null; + currentDirectoryCandidate: CliPathCandidate | null; + shellResolution: "unobserved"; + issues: CliCommandIssue[]; +}; +export type CliPathObservation = + | { kind: "usable"; realpath: string } + | { kind: "missing" | "unusable" | "unreadable" }; +export type CliPathDiagnosticOptions = { + platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; cwd?: string; home?: string; + recordRead?: DesktopCliRecordRead; + observe?: (path: string) => CliPathObservation; +}; + +export function collectCliPathDiagnostics(options: CliPathDiagnosticOptions = {}): CliPathDiagnostics { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const api = platform === "win32" ? win32 : posix; + const cwd = options.cwd ?? process.cwd(); + const home = options.home ?? (platform === "win32" ? env.USERPROFILE || homedir() : homedir()); + const read = options.recordRead ?? readDesktopCliRecord({ platform, env, home }); + const configured = read.state === "ready"; + const target = configured && read.state === "ready" ? read.record.bundle.cliExecutable : null; + const expected = configured ? (platform === "win32" ? target : api.join(home, ".opencodex-desktop", "bin", "ocx")) : null; + const issues: CliCommandIssue[] = []; + const add = (issue: CliCommandIssue): void => { if (!issues.includes(issue)) issues.push(issue); }; + if (read.state === "invalid" || read.state === "unreadable") add(read.issue); + const observe = options.observe ?? ((path: string): CliPathObservation => { + try { + if (!statSync(path).isFile()) return { kind: "unusable" }; + accessSync(path, platform === "win32" ? constants.F_OK : constants.X_OK); + return { kind: "usable", realpath: realpathSync(path) }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + return { kind: code === "ENOENT" || code === "ENOTDIR" ? "missing" : code === "EACCES" ? "unusable" : "unreadable" }; + } + }); + const key = (path: string): string => platform === "win32" ? path.toLowerCase() : path; + const same = (a: string, b: string): boolean => key(a) === key(b); + const envValue = (name: string): string | undefined => platform === "win32" + ? env[Object.keys(env).find(key => key.toLowerCase() === name.toLowerCase()) ?? name] : env[name]; + const path = envValue("PATH"); + const rawExtensions = platform === "win32" + ? (envValue("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(ext => /^\.[A-Za-z0-9]+$/.test(ext)) : []; + const uniqueExtensions = [...new Set(rawExtensions.map(ext => ext.toLowerCase()))]; + const extensions = platform === "win32" ? [...uniqueExtensions.slice(0, 32), ""] : [""]; + if (uniqueExtensions.length > 32) add("path-scan-truncated"); + const directories = path === undefined ? [] : path.split(platform === "win32" ? ";" : ":"); + if (directories.length > 256) add("path-scan-truncated"); + const candidates: CliPathCandidate[] = []; + const scan = (directory: string, pathIndex: number): CliPathCandidate[] => { + const result: CliPathCandidate[] = []; + const unquoted = platform === "win32" && directory.startsWith('"') && directory.endsWith('"') ? directory.slice(1, -1) : directory; + for (const extension of extensions) { + const candidate = api.resolve(cwd, unquoted || ".", `ocx${extension}`); + const found = observe(candidate); + if (found.kind === "unreadable") add("path-unreadable"); + if (found.kind === "usable") result.push({ path: candidate, realpath: found.realpath, pathIndex, extension }); + } + return result; + }; + for (const [index, directory] of directories.slice(0, 256).entries()) candidates.push(...scan(directory, index)); + const pathFirst = candidates[0] ?? null; + const currentDirectoryCandidate = platform === "win32" ? scan(cwd, -1)[0] ?? null : null; + let expectedRealpath: string | null = null; + if (target) { + const found = observe(target); + if (found.kind === "missing") add("desktop-target-missing"); + else if (found.kind !== "usable") add("desktop-target-unusable"); + } + if (expected) { + const found = observe(expected); + if (found.kind === "missing") add("expected-command-missing"); + else if (found.kind !== "usable") add("expected-command-unusable"); + else expectedRealpath = found.realpath; + if (!pathFirst) add("path-missing"); + else if (!same(pathFirst.path, expected) && (!expectedRealpath || !same(pathFirst.realpath, expectedRealpath))) add("path-first-not-desktop"); + if (currentDirectoryCandidate && !same(currentDirectoryCandidate.path, expected) + && (!expectedRealpath || !same(currentDirectoryCandidate.realpath, expectedRealpath))) add("windows-current-directory-shadow"); + } + return { + configured, recordState: read.state, expectedExecutable: expected, handoffTarget: target, + candidates, pathFirst, + desktopFirstOnPath: configured && !issues.includes("path-unreadable") && !issues.includes("path-scan-truncated") + ? Boolean(pathFirst && expected && expectedRealpath + && (same(pathFirst.path, expected) || same(pathFirst.realpath, expectedRealpath))) : null, + currentDirectoryCandidate, shellResolution: "unobserved", issues, + }; +} + +export function formatCliCommandLine(value: CliPathDiagnostics): string { + const first = value.pathFirst ? redactUserPath(value.pathFirst.path).replace(/[\x00-\x1f\x7f]/g, "?") : "none observed"; + return `ocx command: PATH first=${first}; Desktop=${!value.configured ? "not configured" : value.desktopFirstOnPath === null ? "unobserved" : value.desktopFirstOnPath ? "first" : "not first"}; shell=unobserved${value.issues.length ? `; issues=${value.issues.join(",")}` : ""}`; +} + +export function formatCliStatusHealthLabel(health: string, value: CliPathDiagnostics, local: boolean): string { + return `${health}${local ? " (local)" : ""}\n ${formatCliCommandLine(value)}`; +} + +export function cliCommandDoctorChecks(value: CliPathDiagnostics): { level: "OK" | "WARN" | "FAIL"; message: string }[] { + const hard = value.issues.some(issue => issue.startsWith("record-") || issue.startsWith("desktop-target-") || issue.startsWith("expected-command-")); + return [{ level: hard ? "FAIL" : value.issues.length ? "WARN" : "OK", message: formatCliCommandLine(value) }]; +} +``` + +Scan bounds are explicit: 256 PATH elements and 32 distinct PATHEXT extensions. A truncated scan is WARN even when Desktop appears first; do not present complete candidate inventory. `shellResolution` is always `unobserved`, including an otherwise OK check. Configured false with a missing/disabled record is informational OK, not a promise that Desktop is configured. + +## MODIFY diffs + +The following exact baseline diffs are generated from the inspected 68c9d35457 files. The factory retains the original inspection snapshot, including `configDir`, relative CODEX_CLI_PATH resolution, manager roots and case-insensitive deletion of duplicated inspection environment variables. It returns one proof/context/env object; Desktop and Bun receive the same freshly captured object. It is **called** after the inspection classification and before handoff, not during module import. + +### MODIFY `bin/ocx.mjs` + +```diff +--- a/bin/ocx.mjs ++++ b/bin/ocx.mjs +@@ -9,6 +9,7 @@ + * src/cli/index.ts — only the published npm/pnpm `bin` routes through here.) + */ + import { spawn, spawnSync } from "node:child_process"; ++import { planDesktopCliHandoff, runDesktopCliHandoff } from "../src/lib/desktop-cli-handoff.mjs"; + import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../src/update/stop-contract.mjs"; + import { probeProxyLiveness } from "../src/update/proxy-liveness-probe.mjs"; + import { decidePostStopUpdate } from "../src/update/stop-decision.mjs"; +@@ -961,6 +962,32 @@ + process.exit(1); + } + ++const { launchProof, launchContext, inheritedEnv } = createNodeLaunchContext(); ++const desktopPlan = planDesktopCliHandoff({ selfPaths: [fileURLToPath(import.meta.url), process.execPath] }); ++if (desktopPlan.kind === "error") { ++ console.error(`opencodex: Desktop CLI handoff refused (${desktopPlan.issue}). Repair or remove the terminal command in Desktop.`); ++ process.exit(1); ++} ++if (desktopPlan.kind === "handoff") { ++ const result = await runDesktopCliHandoff(desktopPlan, { ++ argv: process.argv.slice(2), env: inheritedEnv, proof: launchProof, context: launchContext, ++ }); ++ if (result.kind === "error") { ++ console.error(`opencodex: Desktop CLI handoff failed (${result.issue}). Repair or remove the terminal command in Desktop.`); ++ process.exit(1); ++ } ++ if (result.kind === "exit") { ++ if (result.signal) { ++ process.kill(process.pid, result.signal); ++ } else { ++ process.exit(result.code); ++ } ++ // Signal delivery is asynchronous; never continue into package repair or Bun lookup. ++ await new Promise(() => {}); ++ } ++ // Only target ENOENT reaches the ordinary package path below. ++} ++ + if (process.argv[2] === "update" && installMethod === "mise") { + if (installOwnership.owner) { + console.error( +@@ -1015,64 +1042,68 @@ + // billing and prevents it from redirecting the subscriber's OAuth bearer. + // Disabling Bun's dotenv wholesale with --no-env-file is NOT an option: config + // interpolation and provider settings legitimately read the project environment. +-const preBunAnthropicSlots = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"] +- .filter(name => typeof process.env[name] === "string" && process.env[name] !== ""); +-// A configured CODEX_CLI_PATH may legitimately be cwd-relative (`./tools/codex`), which the +-// ordinary runtime resolver accepts. Inspection only trusts absolute local paths, so capture +-// the absolute form here, in the launcher, while the original cwd is still authoritative; +-// resolving it later would silently reinterpret it against a different working directory. +-// +-// A bare command with no separator (`codex`) is NOT a relative path: the runtime resolver +-// deliberately hands those to executable lookup along PATH. Rewriting it to `/codex` +-// would make the inspector treat it as an explicit path and stop searching PATH entirely. +-const configuredCodexCliPath = typeof process.env.CODEX_CLI_PATH === "string" && process.env.CODEX_CLI_PATH !== "" +- ? process.env.CODEX_CLI_PATH +- : null; +-const preBunCodexCliPath = configuredCodexCliPath !== null +- && (configuredCodexCliPath.includes("/") || configuredCodexCliPath.includes("\\") || /^[A-Za-z]:/.test(configuredCodexCliPath)) +- ? resolve(configuredCodexCliPath) +- : configuredCodexCliPath; +-const preBunPath = typeof process.env.PATH === "string" ? process.env.PATH : null; +-const preBunPathExt = typeof process.env.PATHEXT === "string" ? process.env.PATHEXT : null; +-const preBunCodexCliManagerRoots = Object.fromEntries( +- CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS.flatMap(name => { +- const value = process.env[name]; +- return typeof value === "string" && value !== "" ? [[name, value]] : []; +- }), +-); +-const launchProof = randomBytes(32).toString("base64url"); +-const launchContext = JSON.stringify({ +- version: 1, +- proof: launchProof, +- anthropicEnvSlots: preBunAnthropicSlots, +- codexCliInspectionEnv: codexCliUpdateInspection ? { +- codexCliPath: preBunCodexCliPath, +- path: preBunPath, +- pathExt: preBunPathExt, +- managerRoots: preBunCodexCliManagerRoots, +- configDir: configDir(), +- } : null, +-}); +-// The inspection snapshot above already carries PATH, PATHEXT, and the manager-root slots as +-// proof-bound values, and `inspectCodexCliInstall` reads them from that snapshot rather than +-// from the live environment. Inheriting them again would spend the 32,767-character Windows +-// environment block twice, so a large-but-valid shell environment could stop the Bun child +-// from spawning and fail the command before it reports anything. Drop the duplicates for the +-// one-shot inspection launch only; every other launch inherits the environment unchanged. +-// Windows environment names are case-insensitive, but this spread produces an ordinary +-// case-sensitive object, and a real Windows environment commonly spells the variable `Path`. +-// Deleting only the canonical upper-case spelling would silently leave that copy behind and +-// reintroduce the duplication this block exists to prevent, so match on the lowercase form. +-const inheritedEnv = { ...process.env }; +-if (codexCliUpdateInspection) { +- const snapshotted = new Set( +- ["PATH", "PATHEXT", ...CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS].map(name => name.toLowerCase()), ++function createNodeLaunchContext() { ++ const preBunAnthropicSlots = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"] ++ .filter(name => typeof process.env[name] === "string" && process.env[name] !== ""); ++ // A configured CODEX_CLI_PATH may legitimately be cwd-relative (`./tools/codex`), which the ++ // ordinary runtime resolver accepts. Inspection only trusts absolute local paths, so capture ++ // the absolute form here, in the launcher, while the original cwd is still authoritative; ++ // resolving it later would silently reinterpret it against a different working directory. ++ // ++ // A bare command with no separator (`codex`) is NOT a relative path: the runtime resolver ++ // deliberately hands those to executable lookup along PATH. Rewriting it to `/codex` ++ // would make the inspector treat it as an explicit path and stop searching PATH entirely. ++ const configuredCodexCliPath = typeof process.env.CODEX_CLI_PATH === "string" && process.env.CODEX_CLI_PATH !== "" ++ ? process.env.CODEX_CLI_PATH ++ : null; ++ const preBunCodexCliPath = configuredCodexCliPath !== null ++ && (configuredCodexCliPath.includes("/") || configuredCodexCliPath.includes("\\") || /^[A-Za-z]:/.test(configuredCodexCliPath)) ++ ? resolve(configuredCodexCliPath) ++ : configuredCodexCliPath; ++ const preBunPath = typeof process.env.PATH === "string" ? process.env.PATH : null; ++ const preBunPathExt = typeof process.env.PATHEXT === "string" ? process.env.PATHEXT : null; ++ const preBunCodexCliManagerRoots = Object.fromEntries( ++ CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS.flatMap(name => { ++ const value = process.env[name]; ++ return typeof value === "string" && value !== "" ? [[name, value]] : []; ++ }), + ); +- for (const name of Object.keys(inheritedEnv)) { +- if (snapshotted.has(name.toLowerCase())) delete inheritedEnv[name]; +- } +-} +-const child = spawn(bun, [cliPath, `${NODE_LAUNCH_PROOF_PREFIX}${launchProof}`, ...process.argv.slice(2)], { ++ const launchProof = randomBytes(32).toString("base64url"); ++ const launchContext = JSON.stringify({ ++ version: 1, ++ proof: launchProof, ++ anthropicEnvSlots: preBunAnthropicSlots, ++ codexCliInspectionEnv: codexCliUpdateInspection ? { ++ codexCliPath: preBunCodexCliPath, ++ path: preBunPath, ++ pathExt: preBunPathExt, ++ managerRoots: preBunCodexCliManagerRoots, ++ configDir: configDir(), ++ } : null, ++ }); ++ // The inspection snapshot above already carries PATH, PATHEXT, and the manager-root slots as ++ // proof-bound values, and `inspectCodexCliInstall` reads them from that snapshot rather than ++ // from the live environment. Inheriting them again would spend the 32,767-character Windows ++ // environment block twice, so a large-but-valid shell environment could stop the Bun child ++ // from spawning and fail the command before it reports anything. Drop the duplicates for the ++ // one-shot inspection launch only; every other launch inherits the environment unchanged. ++ // Windows environment names are case-insensitive, but this spread produces an ordinary ++ // case-sensitive object, and a real Windows environment commonly spells the variable `Path`. ++ // Deleting only the canonical upper-case spelling would silently leave that copy behind and ++ // reintroduce the duplication this block exists to prevent, so match on the lowercase form. ++ const inheritedEnv = { ...process.env }; ++ if (codexCliUpdateInspection) { ++ const snapshotted = new Set( ++ ["PATH", "PATHEXT", ...CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS].map(name => name.toLowerCase()), ++ ); ++ for (const name of Object.keys(inheritedEnv)) { ++ if (snapshotted.has(name.toLowerCase())) delete inheritedEnv[name]; ++ } ++ } ++ return { launchProof, launchContext, inheritedEnv }; ++} ++ ++const child = spawn(bun, [cliPath, `${NODE_LAUNCH_PROOF_PREFIX}${launchProof}`, ...process.argv.slice(2).filter(arg => !arg.startsWith(NODE_LAUNCH_PROOF_PREFIX))], { + stdio: "inherit", + // A headless Windows parent (Task Scheduler, dashboard restart, shortcut) has no + // console to inherit. Without this flag Windows allocates a visible console for +``` + +### MODIFY `src/service/managing-cli.ts` + +```diff +--- a/src/service/managing-cli.ts ++++ b/src/service/managing-cli.ts +@@ -81,6 +81,7 @@ + windowsShim ? ["/c", shimCommand] : [...args, "--version"], + { + timeout: VERSION_PROBE_TIMEOUT_MS, encoding: "utf8", stdio: "pipe", ++ env: { ...deps.env, OCX_NO_DESKTOP_HANDOFF: "1" }, + windowsHide: true, ...(windowsShim ? { windowsVerbatimArguments: true } : {}), + }, + ) as SpawnSyncReturns; +``` + +### MODIFY `src/cli/status.ts` + +```diff +--- a/src/cli/status.ts ++++ b/src/cli/status.ts +@@ -1,3 +1,4 @@ ++import { collectCliPathDiagnostics, formatCliStatusHealthLabel, type CliPathDiagnostics } from "./cli-path-diagnostics"; + import type { CodexMainAccountPolicyHealth } from "../oauth/health"; + import { durableBunRuntime } from "../lib/bun-runtime"; + import { existsSync, readFileSync } from "node:fs"; +@@ -215,6 +216,7 @@ + * ignores the key is unaffected, and `proxyVersion` is null when nothing is live. + */ + versionSkew: VersionSkew; ++ cliCommand: CliPathDiagnostics; + }; + + export type CliStatusView = { +@@ -680,6 +682,7 @@ + + /** `mainAccountPolicy`: only `--json` asks; the human report reads the same accounts once via OAuth health. */ + export async function collectStatus(options: { mainAccountPolicy?: boolean } = {}): Promise { ++ const cliCommand = collectCliPathDiagnostics(); + const configDiagnostics = readConfigDiagnostics(); + const config = configDiagnostics.config; + const claudeDesktop = { +@@ -869,7 +872,7 @@ + + return { + proxyLabel, +- healthLabel: health.label, ++ healthLabel: formatCliStatusHealthLabel(health.label, cliCommand, remoteHub.connected), + json: { + ...(mainAccountHardLock ? { mainAccountHardLock } : {}), + schemaVersion: 1, +@@ -932,6 +935,7 @@ + // fact about this install, not about the Codex runtime, and filing it there would + // print it under the wrong heading (#2701). + versionSkew, ++ cliCommand, + }, + }; + } +``` + +### MODIFY `src/cli/doctor.ts` + +```diff +--- a/src/cli/doctor.ts ++++ b/src/cli/doctor.ts +@@ -7,6 +7,7 @@ + * it never sets proxy env, relocates state dirs, mutates quota, or changes + * networking. See devlog/_plan/260630_wsl-account-autoswitch/30_*. + */ ++import { cliCommandDoctorChecks, collectCliPathDiagnostics } from "./cli-path-diagnostics"; + import { accessSync, constants, existsSync, readFileSync } from "node:fs"; + import { homedir } from "node:os"; + import { dirname, join } from "node:path"; +@@ -1324,6 +1325,12 @@ + console.log(` ${row.exists ? "ok " : "-- "} ${row.label}: ${row.path}${flags ? ` (${flags})` : ""}`); + } + ++ console.log("\nocx command selection"); ++ for (const check of cliCommandDoctorChecks(collectCliPathDiagnostics())) { ++ console.log(` ${check.level === "OK" ? "ok " : "!! "} ${check.message}`); ++ if (check.level === "FAIL") recordDoctorFailure(); ++ } ++ + // Runs without the proxy on purpose: the worst accumulation happens when the proxy will + // not start, which is exactly when the in-process periodic reclaim never ticks. + const reclaimTemps = args.includes(RECLAIM_RESPONSE_TEMPS_FLAG); +``` + +### MODIFY `scripts/test-layout/layout.json` + +```diff +--- a/scripts/test-layout/layout.json ++++ b/scripts/test-layout/layout.json +@@ -1295,6 +1295,8 @@ + "oauth-store-multi.test.ts": "oauth", + "oauth-tos-warning.test.ts": "gui", + "oauth-upsert-preserves-api-key.test.ts": "oauth", ++ "ocx-launcher-desktop-handoff.test.ts": "cli", ++ "cli-path-diagnostics.test.ts": "cli", + "ocx-launcher-runtime.test.ts": "cli", + "ocx-launcher-source.test.ts": "cli", + "ocx-run.test.ts": "cli", +``` + +### MODIFY `tests/fixtures/test-layout-expected.json` + +```diff +--- a/tests/fixtures/test-layout-expected.json ++++ b/tests/fixtures/test-layout-expected.json +@@ -776,6 +776,7 @@ + "oauth-refresh-lock-multiprocess.test.ts": "oauth", "oauth-refresh.test.ts": "oauth", + "oauth-status-privacy.test.ts": "oauth", "oauth-store-multi.test.ts": "oauth", + "oauth-tos-warning.test.ts": "gui", "oauth-upsert-preserves-api-key.test.ts": "oauth", ++ "ocx-launcher-desktop-handoff.test.ts": "cli", "cli-path-diagnostics.test.ts": "cli", + "ocx-launcher-runtime.test.ts": "cli", "ocx-launcher-source.test.ts": "cli", + "ocx-run.test.ts": "cli", "ollama-native-parser.test.ts": "providers/ollama", + "ollama-native-reasoning-wire.test.ts": "providers/ollama", +``` + +### MODIFY `structure/runtime.md` + +```diff +--- a/structure/runtime.md ++++ b/structure/runtime.md +@@ -120,7 +120,7 @@ + + | Path | Responsibility | + | --- | --- | +-| `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Resolves the bundled or explicit Bun binary before project dotenv can load, stamps its runtime provenance plus a proof-bound Anthropic parent-env snapshot, lazy-runs `bun/install.js` if only the placeholder stub is present, then execs `src/cli/index.ts` under Bun. Lets `npm install -g` work without a separately-installed Bun. The exact `system codex-cli-update` inspection namespace skips both boot repair and lazy Bun installation; missing runtime support fails closed instead of mutating state. | ++| `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Resolves the bundled or explicit Bun binary before project dotenv can load, stamps its runtime provenance plus a proof-bound Anthropic parent-env snapshot, lazy-runs `bun/install.js` if only the placeholder stub is present, then execs `src/cli/index.ts` under Bun. Lets `npm install -g` work without a separately-installed Bun. The exact `system codex-cli-update` inspection namespace skips both boot repair and lazy Bun installation; missing runtime support fails closed instead of mutating state. Before package repair or Bun resolution, `src/lib/desktop-cli-record.mjs` reads the bounded settled Desktop command record and `src/lib/desktop-cli-handoff.mjs` delegates eligible commands to its validated target with a regenerated argv proof, inherited IO and mirrored exit/signal. Update, removal, internal inspection and `OCX_NO_DESKTOP_HANDOFF=1` retain the package path; only target ENOENT resumes it after selection. Command selection is separate from runtime/service authority; see [command selection](cli-management.md#ocx-command-selection). | + | `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, and `durableBunPath()` (path baked into service/shim artifacts). Durable selection accepts only the source/path pair already stamped for the running executable; it never re-reads a project-dotenv `OPENCODEX_BUN_PATH`. | + | `src/lib/plain-data.ts` | Detached copies for a consumer that must not observe later edits. Descriptor-based reads, including array elements, so an accessor is refused rather than invoked; refuses cycles, functions, class instances and anything else JSON could not have produced, and returns a copy-or-refusal union rather than degrading silently. Symbol-keyed process bookkeeping is skipped. | + | `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. `restart` refuses an in-place restart requested by a CLI whose version differs from the attested `/healthz` version; a newer CLI may use the guarded standalone update path described below, because an in-place replacement respawns from the live installation; placeholder versions (unknown/0.0.0) stay incomparable and keep the restart path. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. Hidden `__update-badge` prints the read-only package badge JSON without refresh or shim repair for the npm tray. The full top-level reference is rendered by `src/cli/help.ts`; this table names the groups, not every verb. After help/version and unknown-root early exits, recognized commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `status`, `doctor`, and `codex-shim status` skip shim auto-repair; `system codex-cli-update` and [command-local messaging](local-messaging.md#command-local-cli) suppress it for their whole namespaces, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). `src/cli/init.ts` validates an explicit setup port as a decimal integer from 1 through 65535 before publishing the initial configuration. Only an empty answer selects 10100; malformed input is reported and the port question is asked again, while EOF or SIGINT still cancels without saving. | +``` + +### MODIFY `structure/cli-management.md` + +```diff +--- a/structure/cli-management.md ++++ b/structure/cli-management.md +@@ -105,3 +105,7 @@ + `src/cli/account-api.ts` forwards explicit quota/refresh intent to the existing per-key owner; ordinary key listing omits both. Opt-in requests reject redirects. `src/cli/account-key-quota.ts` rebuilds the public ProviderQuota projection, including custom windows and credits, with finite-field validation and no credential or private publication fields. Missing mode evidence is unverified/nonzero. Human rendering separates probe/passive/unsupported, unavailable and unmeasured from actual zero; Codex/OAuth rendering remains separate. This read can invoke upstream quota work but does not change key selection or provider aggregate caches. Account list/current rows additionally project, through `src/cli/account-next-actions.ts`, only an allow-listed server health label, a locally generated recovery command built from shell-safe selectors (never server summary/action text), and the Codex boolean `creditsAfterLimit` as read-only consent readback; empty listings add a fixed next-action note to human output and JSON `notes`. + + The operating guide's eight-domain workflow table is navigation rather than parity measurement. Handwritten JSON, recipe, recovery and target docs qualify bounded windows, model-only search, partial/default results and human-only/session actions. Generated chapter counts still derive exclusively from capability declarations. ++ ++## ocx command selection ++ ++`src/cli/cli-path-diagnostics.ts` reads the settled Desktop record through `src/lib/desktop-cli-record.mjs` and observes regular executable candidates on this process's PATH without executing them. `pathFirst` follows PATH/PATHEXT order, including cwd-relative and empty entries; Windows's possible cmd current-directory candidate is separate. The expected command is the owned POSIX shim or the recorded Windows bundled executable. A stale package candidate first on PATH is reported even if its current launcher would hand off. `src/cli/status.ts` exposes additive `cliCommand` JSON and a human-view line; `src/cli/doctor.ts` prints the separate “ocx command selection” checks. Neither observation supplies runtime ownership or service control authority. Parent-shell aliases, functions and caches remain unobserved. Invalid records and unusable Desktop targets are FAIL; PATH ordering and incomplete scans are WARN. +``` + +### MODIFY `structure/ops/service-and-sidecars.md` + +```diff +--- a/structure/ops/service-and-sidecars.md ++++ b/structure/ops/service-and-sidecars.md +@@ -539,3 +539,5 @@ + Desktop startup diagnostics use `src/service/desktop-startup.ts` to read the ownership record, the login registration (macOS: launchd; Linux: the `~/.config/autostart/OpenCodex.desktop` entry that auto-launch writes, credited only when `XDG_CONFIG_HOME` is unset or `~/.config`; it must launch an unquoted absolute `opencodex-desktop` with exactly `--autostart`, without conditional keys, and not be hidden or disabled) and exact parent/child executable paths (Linux: `/proc//exe` and the parent pid from `/proc//stat`) without mutating them. A durable desktop claim survives a failed identity or supervision check; only fresh matching identity, enabled login registration, and live supervision grant protection. On macOS, ownership and PID are re-read before crediting the result; on Linux, the complete evidence chain is read twice and both reads must agree. The startup-health subprocess uses `selfLaunchArgv` to support both source and compiled entrypoints. + + On Linux, a dashboard update worker started from the systemd user service is launched through an executable regular file at a trusted absolute path — `/usr/bin/systemd-run`, `/bin/systemd-run`, `/usr/local/bin/systemd-run` (local installs), or `/run/current-system/sw/bin/systemd-run` (the NixOS layout) — with `--user --scope --quiet --collect` (`src/update/worker-launch.ts`), so it leaves the service cgroup before the updater stops `opencodex-proxy.service`; the default `KillMode=control-group` otherwise kills it with the proxy (#5750). The inherited `PATH` is never searched, and each candidate's resolved target — plus every ancestor directory able to substitute it — must be root-owned and not group/world-writable: a trusted-path symlink into a user-replaceable directory is skipped, as is a group-writable `/usr/local/bin`, rather than exec'd under the service account. Candidates are tried in order and a path whose no-op scope probe fails falls through to the next trusted path; the probe applies only when `INVOCATION_ID` is set, and every other case keeps the plain detached spawn. The management route resolves the launcher with `resolveSystemdRunAsync` before spawning, so first-request probing overlaps other work instead of blocking the event loop for up to twenty seconds. `--scope` moves `systemd-run` itself into the scope and then execs the worker, so the recorded PID is the worker's (`tests/update/update-worker-launch.test.ts`). ++ ++Internal version observations in `src/service/managing-cli.ts` (`probeVersionOnce`) set `OCX_NO_DESKTOP_HANDOFF=1` in the spawned child's environment so the selected package executable reports its own version. This flag suppresses the package launcher's Desktop handoff only; it does not bypass service ownership or update guards. Normal user `ocx --version` remains eligible for handoff. +``` + +### MODIFY `docs-site/src/content/docs/getting-started/installation.md` + +```diff +--- a/docs-site/src/content/docs/getting-started/installation.md ++++ b/docs-site/src/content/docs/getting-started/installation.md +@@ -6,6 +6,12 @@ + opencodex installs two equivalent command names, `ocx` and `opencodex`. Both launch the same small + local HTTP server (built on Bun). Model requests go to the provider selected by routing; optional + vision and web-search sidecars can also use your ChatGPT login when a routed model needs them. ++ ++## Desktop terminal command ++ ++A stable OpenCodex Desktop install configures `ocx` for new terminals when the app starts. Open a new terminal and run `ocx status` or `ocx doctor` to inspect the first executable on PATH. POSIX uses the Desktop-owned shim; Windows uses the bundled `ocx.exe`. Existing terminals, aliases, shell caches and later PATH changes can still select another command; a conflicting Windows system PATH entry can also win. Use the app’s Terminal command settings to repair, disable or remove this configuration. AppImage is excluded. ++ ++A current npm/pnpm package launcher that still runs delegates eligible commands to the Desktop CLI when its record is enabled. Package update/removal and internal inspection keep their existing path. `OCX_NO_DESKTOP_HANDOFF=1` suppresses that package handoff for one invocation; it does not change a direct Desktop shim or PATH configuration. + + ## Prerequisites + +``` + +### MODIFY `docs-site/src/content/docs/reference/cli.md` + +```diff +--- a/docs-site/src/content/docs/reference/cli.md ++++ b/docs-site/src/content/docs/reference/cli.md +@@ -254,6 +254,8 @@ + version mismatch. `unknown` does not confirm matching builds. Offline help, + local configuration and local Lab inspection do not require startup. + ++`ocx status --json` includes `cliCommand`: configured Desktop intent, the expected executable, observed PATH candidates, `pathFirst`, `desktopFirstOnPath`, issue codes, and `shellResolution: "unobserved"`. The human status report prints one command-selection line. `ocx doctor` adds an “ocx command selection” section: invalid records or missing/unusable Desktop targets fail the check, while PATH ordering conflicts and incomplete scans warn. These read-only observations do not execute candidates, resolve parent-shell aliases/functions, or identify the proxy’s runtime owner. On Windows, a possible cmd current-directory candidate is reported separately from PATH order. ++ + Output flags are per command. `doctor` rejects `--json` with exit 2; + [`v2` (family reference)](/reference/cli/agents/) + supports `--json` for local and `--live` targets. Even for JSON-capable management commands, API failures +``` + +The `healthLabel` change is a deliberate **view-only adapter**: the unchanged `handleStatus` already prints this string and prints JSON from `status.json` alone. The line is local to this CLI even when connected to a hub. Do not print inside `collectStatus`; doing so would contaminate JSON callers. This adapter does make the human-view health string multiline. If review requires a dedicated renderer field, report the necessary `index.ts` scope expansion to parent before changing it. The JSON health message remains a single unchanged value. + +## Complete new regression files + +The runnable code below creates only temp fixtures, uses repository-root helpers, does not source startup files, and never changes the user's home or installed Desktop. Native Windows process signal semantics are not inferred from POSIX integration tests; Windows policy/observation fixtures run on every host and 030 owns native Windows smoke evidence. + +### NEW `tests/cli/ocx-launcher-desktop-handoff.test.ts` (complete) + +```ts +import { afterEach, describe, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync, type statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { readDesktopCliRecord, desktopCliRecordPath, DESKTOP_CLI_RECORD_MAX_BYTES, type DesktopCliRecordRead } from "../../src/lib/desktop-cli-record.mjs"; +import { desktopHandoffExcluded, planDesktopCliHandoff, runDesktopCliHandoff } from "../../src/lib/desktop-cli-handoff.mjs"; +import { initializeNodeLauncherContext } from "../../src/cli/launcher-context"; +import { buildNativeClaudeEnv } from "../../src/cli/claude"; +import { observeManagingClis } from "../../src/service/managing-cli"; +import { repoPath } from "../helpers/repo-root"; + +const roots: string[] = []; +const PREFIX = "--ocx-internal-launch-proof="; +const proof = "A".repeat(43); +const context = JSON.stringify({ version: 1, proof, anthropicEnvSlots: ["ANTHROPIC_API_KEY"], codexCliInspectionEnv: null }); +afterEach(() => { + initializeNodeLauncherContext(["bun", "index.ts"], {}); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); +function box(): string { + const root = mkdtempSync(join(tmpdir(), "ocx-handoff-")); roots.push(root); return root; +} +function ready(target = "/fixture/desktop/ocx"): DesktopCliRecordRead { + return { state: "ready", path: "/fixture/cli.json", record: { + version: 1, enabled: true, bundle: { platform: "linux", cliExecutable: target }, + } }; +} +const stat = (() => ({ isFile: () => true })) as unknown as typeof statSync; +const deps = { stat, access: () => {}, realpath: (path: string) => path }; +const coded = (code: string): NodeJS.ErrnoException => Object.assign(new Error("fixture"), { code }); + +function fakeSpawn(error?: string, exitSignal: NodeJS.Signals | null = null) { + const parent = new EventEmitter(); + const child = new EventEmitter() as EventEmitter & { kill: (signal: string) => boolean }; + const killed: string[] = []; + child.kill = signal => { killed.push(signal); return true; }; + let observed: { target: string; argv: string[]; options: Record } | undefined; + const run = ((target: string, argv: string[], options: Record) => { + observed = { target, argv, options }; + queueMicrotask(() => { + parent.emit("SIGTERM"); + if (error) { child.emit("error", coded(error)); child.emit("exit", 99, null); } + else child.emit("exit", 17, exitSignal); + }); + return child; + }) as unknown as typeof spawn; + return { run, parent, killed, observed: () => observed }; +} + +describe("Desktop record and handoff policy", () => { + test("fixed record location and injected reader path", () => { + expect(desktopCliRecordPath({ platform: "darwin", home: "/Users/example" })).toBe("/Users/example" + "/.opencodex-desktop/cli.json"); + expect(desktopCliRecordPath({ platform: "win32", env: { USERPROFILE: "C:\\Users\\example" } })).toBe("C:\\Users\\example\\.opencodex-desktop\\cli.json"); + const root = box(); const recordPath = join(root, "cli.json"); + expect(readDesktopCliRecord({ recordPath }).state).toBe("missing"); + writeFileSync(recordPath, '{"version":1,"enabled":false}'); + expect(readDesktopCliRecord({ recordPath }).state).toBe("disabled"); + writeFileSync(recordPath, JSON.stringify({ version: 1, enabled: true, bundle: { platform: process.platform, cliExecutable: join(root, "ocx") }, ownerId: "ignored" })); + expect(readDesktopCliRecord({ recordPath }).state).toBe("ready"); + }); + test("reader refuses oversized, malformed, pending and wrong-platform records", () => { + const root = box(); const recordPath = join(root, "cli.json"); + const valid = { version: 1, enabled: true, bundle: { platform: process.platform, cliExecutable: join(root, "ocx") } }; + for (const value of ["{", "[]", "null", "42", JSON.stringify({ ...valid, enabled: "yes" }), JSON.stringify({ ...valid, bundle: null }), JSON.stringify({ ...valid, version: 2 }), JSON.stringify({ ...valid, pending: {} }), JSON.stringify({ ...valid, bundle: { platform: "other", cliExecutable: "relative" } }), JSON.stringify({ ...valid, bundle: { platform: process.platform, cliExecutable: "relative" } }), JSON.stringify({ ...valid, bundle: { platform: process.platform, cliExecutable: "/" + "x".repeat(32768) } }), JSON.stringify({ ...valid, bundle: { platform: process.platform, cliExecutable: "/fixture/line\nbreak" } })]) { + writeFileSync(recordPath, value); expect(readDesktopCliRecord({ recordPath }).state).toBe("invalid"); + } + writeFileSync(recordPath, " ".repeat(DESKTOP_CLI_RECORD_MAX_BYTES)); + expect(readDesktopCliRecord({ recordPath })).toMatchObject({ issue: "record-invalid" }); + writeFileSync(recordPath, " ".repeat(DESKTOP_CLI_RECORD_MAX_BYTES + 1)); + expect(readDesktopCliRecord({ recordPath })).toMatchObject({ issue: "record-too-large" }); + rmSync(recordPath); mkdirSync(recordPath); + expect(readDesktopCliRecord({ recordPath }).state).toBe("invalid"); + }); + test("descriptor sentinel bounds growth after metadata and strict UTF-8 rejects corrupt bytes", () => { + const root = box(); const recordPath = join(root, "cli.json"); + writeFileSync(recordPath, Buffer.from([0xff])); + expect(readDesktopCliRecord({ recordPath })).toMatchObject({ issue: "record-invalid" }); + writeFileSync(recordPath, " ".repeat(DESKTOP_CLI_RECORD_MAX_BYTES + 1)); + const script = `import fs from 'node:fs'; import { syncBuiltinESMExports } from 'node:module'; +const original = fs.fstatSync; fs.fstatSync = (...args) => { const info = original(...args); info.size = 1; return info; }; syncBuiltinESMExports(); +const mod = await import(${JSON.stringify(pathToFileURL(repoPath("src", "lib", "desktop-cli-record.mjs")).href)}); +console.log(JSON.stringify(mod.readDesktopCliRecord({recordPath:${JSON.stringify(recordPath)}})));`; + const result = spawnSync("node", ["--input-type=module", "-e", script], { encoding: "utf8", timeout: 5000 }); + expect(result.status).toBe(0); expect(JSON.parse(result.stdout)).toMatchObject({ issue: "record-too-large" }); + }); + (process.platform === "win32" ? test.skip : test)("reader open failure is unreadable and disabled intent wins over pending journal", () => { + const root = box(); const recordPath = join(root, "cli.json"); + symlinkSync(recordPath, recordPath); + expect(readDesktopCliRecord({ recordPath })).toMatchObject({ state: "unreadable", issue: "record-unreadable" }); + rmSync(recordPath); writeFileSync(recordPath, '{"version":1,"enabled":false,"pending":{}}'); + expect(readDesktopCliRecord({ recordPath }).state).toBe("disabled"); + }); + test("record absence and disabled intent keep the package path; invalid records error", () => { + for (const state of ["missing", "disabled"] as const) expect(planDesktopCliHandoff({ argv: ["status"], env: {}, recordRead: { state, path: "fixture" } })).toEqual({ kind: "continue", reason: state }); + for (const issue of ["record-invalid", "record-too-large", "record-pending", "record-unreadable"] as const) expect(planDesktopCliHandoff({ argv: ["status"], env: {}, recordRead: { state: issue === "record-unreadable" ? "unreadable" : "invalid", issue, path: "fixture" } })).toEqual({ kind: "error", issue }); + }); + test("update, removal, internal inspection and codex-cli-update are exceptions", () => { + for (const argv of [["update"], ["update", "--help"], ["uninstall"], ["remove"], ["__update-badge"], ["system", "codex-cli-update", "malformed"], [PREFIX + "invalid", "update"]]) { + expect(desktopHandoffExcluded(argv, {})).toBe(true); + expect(planDesktopCliHandoff({ argv, env: {}, recordRead: ready() }, { stat: (() => { throw new Error("must not read target"); }) as typeof stat })).toMatchObject({ reason: "excluded" }); + } + expect(desktopHandoffExcluded(["--version"], {})).toBe(false); + expect(desktopHandoffExcluded(["inspect", "config"], {})).toBe(false); + }); + test("OCX_NO_DESKTOP_HANDOFF is exact and keeps user version handoff eligible", () => { + expect(desktopHandoffExcluded(["--version"], { OCX_NO_DESKTOP_HANDOFF: "1" })).toBe(true); + expect(desktopHandoffExcluded(["--version"], { OCX_NO_DESKTOP_HANDOFF: "0" })).toBe(false); + }); + test("absolute regular executable target selected; self realpath never delegated", () => { + expect(planDesktopCliHandoff({ argv: [], env: {}, platform: "linux", recordRead: ready(), selfPaths: ["/fixture/package/ocx.mjs"] }, deps)).toEqual({ kind: "handoff", target: "/fixture/desktop/ocx" }); + expect(planDesktopCliHandoff({ argv: [], env: {}, platform: "linux", recordRead: ready(), selfPaths: ["/fixture/alias"] }, { ...deps, realpath: () => "/fixture/same" })).toMatchObject({ kind: "error", issue: "target-self" }); + expect(planDesktopCliHandoff({ argv: [], env: {}, platform: "linux", recordRead: ready("relative") }, deps)).toMatchObject({ issue: "target-invalid" }); + const selfPaths = ["/fixture/missing-self"]; + const missingSelf = { ...deps, realpath: (path: string) => { if (path === selfPaths[0]) throw coded("ENOENT"); return path; } }; + expect(planDesktopCliHandoff({ argv: [], env: {}, platform: "linux", recordRead: ready(), selfPaths }, missingSelf).kind).toBe("handoff"); + const badSelf = { ...deps, realpath: (path: string) => { if (path === selfPaths[0]) throw coded("ELOOP"); return path; } }; + expect(planDesktopCliHandoff({ argv: [], env: {}, platform: "linux", recordRead: ready(), selfPaths }, badSelf)).toMatchObject({ issue: "target-unusable" }); + expect(planDesktopCliHandoff({ argv: [], env: {}, platform: "linux", recordRead: ready() }, { ...deps, stat: (() => ({ isFile: () => false })) as unknown as typeof statSync })).toMatchObject({ issue: "target-invalid" }); + }); + test("target ENOENT resumes and EACCES or other errors fail", () => { + for (const code of ["ENOENT", "EACCES", "ENOTDIR", "ENOEXEC", "ELOOP"]) { + const plan = planDesktopCliHandoff({ argv: ["status"], env: {}, platform: "linux", recordRead: ready() }, { ...deps, access: () => { throw coded(code); } }); + expect(plan.kind).toBe(code === "ENOENT" ? "continue" : "error"); + } + }); + test("Windows realpath comparison is case insensitive and defers execute permission to spawn", () => { + let mode = -1; + const plan = planDesktopCliHandoff({ argv: [], env: {}, platform: "win32", recordRead: ready("C:\\App\\ocx.exe"), selfPaths: ["c:\\app\\OCX.exe"] }, { ...deps, access: (_path, value) => { mode = value ?? -1; } }); + expect(plan).toMatchObject({ issue: "target-self" }); expect(mode).toBe(0); + }); +}); + +describe("async handoff transport", () => { + test("argv, inherited stdin, fresh proof and exit code; signal handlers cleaned", async () => { + const fake = fakeSpawn(); + const result = await runDesktopCliHandoff({ kind: "handoff", target: "/fixture/ocx" }, { + argv: [PREFIX + "obsolete", "claude", "a b", "--literal=$x"], proof, context, + env: { PATH: "fixture", OCX_BUN_RUNTIME_PATH: "stale", OCX_BUN_RUNTIME_SOURCE: "bundled" }, + }, { spawn: fake.run, parent: fake.parent as unknown as NodeJS.Process, platform: "linux" }); + expect(result).toEqual({ kind: "exit", code: 17, signal: null }); + const called = fake.observed()!; + expect(called.argv).toEqual([PREFIX + proof, "claude", "a b", "--literal=$x"]); + expect(called.options).toMatchObject({ stdio: "inherit", shell: false, windowsHide: true }); + expect(called.options.env).toEqual({ PATH: "fixture", OCX_NO_DESKTOP_HANDOFF: "1", OCX_NODE_LAUNCH_CONTEXT: context }); + expect(fake.killed).toEqual(["SIGTERM"]); expect(fake.parent.listenerCount("SIGTERM")).toBe(0); + }); + test("child signal is retained and asynchronous spawn ENOENT alone resumes", async () => { + for (const error of [undefined, "ENOENT", "EACCES", "ENOEXEC"]) { + const fake = fakeSpawn(error, "SIGTERM"); + const result = await runDesktopCliHandoff({ kind: "handoff", target: "/fixture/ocx" }, { argv: [], env: {}, proof, context }, { spawn: fake.run, parent: fake.parent as unknown as NodeJS.Process }); + expect(result.kind).toBe(!error ? "exit" : error === "ENOENT" ? "continue" : "error"); + if (result.kind === "exit") expect(result.signal).toBe("SIGTERM"); + expect(fake.parent.eventNames()).toHaveLength(0); + } + }); + test("Windows forwards INT/TERM only and a kill race still settles from child exit", async () => { + const fake = fakeSpawn(); + const pending = runDesktopCliHandoff({ kind: "handoff", target: "C:\\App\\ocx.exe" }, { argv: [], env: {}, proof, context }, { spawn: fake.run, parent: fake.parent as unknown as NodeJS.Process, platform: "win32" }); + expect(fake.parent.listenerCount("SIGHUP")).toBe(0); expect(fake.parent.listenerCount("SIGINT")).toBe(1); + expect((await pending).kind).toBe("exit"); + const parent = new EventEmitter(); + const child = new EventEmitter() as EventEmitter & { kill: () => never }; + child.kill = () => { throw new Error("already exited"); }; + const run = (() => { queueMicrotask(() => { parent.emit("SIGTERM"); child.emit("exit", null, null); }); return child; }) as unknown as typeof spawn; + expect(await runDesktopCliHandoff({ kind: "handoff", target: "/fixture/ocx" }, { argv: [], env: {}, proof, context }, { spawn: run, parent: parent as unknown as NodeJS.Process })).toEqual({ kind: "exit", code: 1, signal: null }); + }); + test("synchronous spawn errors have the same ENOENT-only policy", async () => { + for (const code of ["ENOENT", "EACCES"]) { + const fail = (() => { throw coded(code); }) as unknown as typeof spawn; + expect((await runDesktopCliHandoff({ kind: "handoff", target: "/fixture/ocx" }, { argv: [], env: {}, proof, context }, { spawn: fail })).kind).toBe(code === "ENOENT" ? "continue" : "error"); + } + }); + test("proof reaches the real Claude native-env consumer and keeps exported slots", () => { + const env = { OCX_NODE_LAUNCH_CONTEXT: context, ANTHROPIC_API_KEY: "fixture-user-key", ANTHROPIC_BASE_URL: "https://fixture.invalid", OCX_PRE_BUN_ANTHROPIC_ENV: "obsolete" }; + const argv = ["bun", "index.ts", PREFIX + proof, "claude"]; + expect(initializeNodeLauncherContext(argv, env)?.anthropicEnvSlots).toEqual(["ANTHROPIC_API_KEY"]); + expect(argv).toEqual(["bun", "index.ts", "claude"]); + const config = { port: 10100, providers: {} } as Parameters[0]; + const child = buildNativeClaudeEnv(config, env); + expect(child.ANTHROPIC_API_KEY).toBe("fixture-user-key"); + expect(child.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(child.OCX_NODE_LAUNCH_CONTEXT).toBeUndefined(); + }); + test("internal managing CLI probes set the bypass in the actual spawn environment", () => { + let actual: NodeJS.ProcessEnv | undefined; + const probe = ((_command: string, _args: string[], options: { env: NodeJS.ProcessEnv }) => { actual = options.env; return { status: 0, stdout: "2.82.0", stderr: "" }; }) as unknown as typeof spawnSync; + const result = observeManagingClis(null, { platform: "linux", env: { PATH: "/fixture", KEEP: "yes" }, execPath: "/fixture/self", exists: path => path === "/fixture/ocx", isFile: () => true, spawn: probe }); + expect(result.path.status).toBe("observed"); expect(actual).toEqual({ PATH: "/fixture", KEEP: "yes", OCX_NO_DESKTOP_HANDOFF: "1" }); + }); +}); + +function packageFixture() { + const home = box(); const directory = join(home, ".opencodex-desktop"); mkdirSync(directory); + const target = join(home, "desktop-cli"); + const recordPath = join(directory, "cli.json"); + writeFileSync(recordPath, JSON.stringify({ version: 1, enabled: true, bundle: { platform: process.platform, cliExecutable: target } })); + const env: NodeJS.ProcessEnv = { ...process.env, HOME: home, USERPROFILE: home, OCX_NO_DESKTOP_HANDOFF: "0", OCX_NODE_LAUNCH_CONTEXT: "forged", ANTHROPIC_API_KEY: "fixture-user-key", OPENCODEX_BUN_PATH: join(home, "missing-bun") }; + return { home, target, env }; +} +const posixTest = process.platform === "win32" ? test.skip : test; +const launcher = repoPath("bin", "ocx.mjs"); +posixTest("real Node launcher regenerates proof, forwards argv/stdin, mirrors exit and bypasses Bun lookup", () => { + const fixture = packageFixture(); + writeFileSync(fixture.target, `#!/usr/bin/env node\nconst fs = require('node:fs');\nconsole.log(JSON.stringify({ argv: process.argv.slice(2), context: JSON.parse(process.env.OCX_NODE_LAUNCH_CONTEXT), stdin: fs.readFileSync(0, 'utf8'), bypass: process.env.OCX_NO_DESKTOP_HANDOFF }));\nprocess.exit(23);\n`); + chmodSync(fixture.target, 0o755); + const result = spawnSync("node", [launcher, "claude", "a b", PREFIX + "old"], { env: fixture.env, input: "fixture stdin\n", encoding: "utf8", timeout: 5000 }); + expect(result.error).toBeUndefined(); expect(result.status).toBe(23); + const row = JSON.parse(result.stdout.trim()); + expect(row.stdin).toBe("fixture stdin\n"); expect(row.bypass).toBe("1"); + expect(row.argv.slice(1)).toEqual(["claude", "a b"]); + expect(row.argv[0]).toBe(PREFIX + row.context.proof); + expect(row.context.proof).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(row.context.anthropicEnvSlots).toContain("ANTHROPIC_API_KEY"); + const argv = ["bun", "index.ts", ...row.argv]; + const captured = initializeNodeLauncherContext(argv, { OCX_NODE_LAUNCH_CONTEXT: JSON.stringify(row.context) }); + expect(captured?.anthropicEnvSlots).toContain("ANTHROPIC_API_KEY"); +}); +posixTest("real target disappearance resumes Bun; EACCES never resumes Bun", () => { + const fixture = packageFixture(); + const absent = spawnSync("node", [launcher, "--version"], { env: fixture.env, encoding: "utf8", timeout: 5000 }); + expect(absent.stderr).toContain("OPENCODEX_BUN_PATH"); + writeFileSync(fixture.target, "not executable"); chmodSync(fixture.target, 0o600); + const denied = spawnSync("node", [launcher, "--version"], { env: fixture.env, encoding: "utf8", timeout: 5000 }); + expect(denied.status).toBe(1); expect(denied.stderr).toContain("target-unusable"); expect(denied.stderr).not.toContain("OPENCODEX_BUN_PATH"); +}); +posixTest("real update-help is untouched; opt-out and bad record activate distinct paths", () => { + const fixture = packageFixture(); + writeFileSync(fixture.target, "#!/usr/bin/env node\nconsole.log('desktop-picked'); process.exit(0);\n"); chmodSync(fixture.target, 0o755); + const help = spawnSync("node", [launcher, "update", "--help"], { env: fixture.env, encoding: "utf8", timeout: 5000 }); + expect(help.status).toBe(0); expect(help.stdout).toContain("Usage: ocx update"); expect(help.stdout).not.toContain("desktop-picked"); + const skipped = spawnSync("node", [launcher, "--version"], { env: { ...fixture.env, OCX_NO_DESKTOP_HANDOFF: "1" }, encoding: "utf8", timeout: 5000 }); + expect(skipped.stdout).not.toContain("desktop-picked"); expect(skipped.stderr).toContain("OPENCODEX_BUN_PATH"); + writeFileSync(join(fixture.home, ".opencodex-desktop", "cli.json"), "{"); + const invalid = spawnSync("node", [launcher, "--version"], { env: fixture.env, encoding: "utf8", timeout: 5000 }); + expect(invalid.status).toBe(1); expect(invalid.stderr).toContain("record-invalid"); expect(invalid.stderr).not.toContain("OPENCODEX_BUN_PATH"); +}); +posixTest("real child terminating signal is mirrored by Node parent", () => { + const fixture = packageFixture(); + writeFileSync(fixture.target, "#!/usr/bin/env node\nprocess.kill(process.pid, 'SIGTERM');\n"); chmodSync(fixture.target, 0o755); + const result = spawnSync("node", [launcher, "status"], { env: fixture.env, encoding: "utf8", timeout: 5000 }); + expect(result.signal).toBe("SIGTERM"); +}); +posixTest("signal sent only to real launcher reaches its child and waits for child exit", async () => { + const fixture = packageFixture(); + writeFileSync(fixture.target, "#!/usr/bin/env node\nprocess.on('SIGTERM', () => process.exit(29));\nconsole.log(process.pid); setInterval(() => {}, 1000);\n"); chmodSync(fixture.target, 0o755); + const child: ChildProcess = spawn("node", [launcher, "status"], { env: fixture.env, stdio: ["ignore", "pipe", "pipe"] }); + let timer: ReturnType | undefined; + let targetPid: number | undefined; + try { + const code = await new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error("fixture timeout")), 5000); + child.once("error", reject); child.once("exit", resolve); + child.stdout!.once("data", bytes => { targetPid = Number(String(bytes).trim()); child.kill("SIGTERM"); }); + }); + expect(code).toBe(29); + } finally { + clearTimeout(timer); + if (child.exitCode === null && child.signalCode === null) { + if (targetPid && Number.isSafeInteger(targetPid)) { try { process.kill(targetPid, "SIGKILL"); } catch { /* exited */ } } + child.kill("SIGKILL"); + } + } +}); +test("launcher source anchors preserve exceptions before handoff and handoff before repair/resolver", () => { + const source = readFileSync(launcher, "utf8"); + const hook = source.indexOf("const desktopPlan ="); + expect(hook).toBeGreaterThan(source.indexOf("const codexCliUpdateInspection =")); + expect(hook).toBeLessThan(source.indexOf('if (process.argv[2] === "update" && installMethod === "mise")')); + expect(hook).toBeLessThan(source.indexOf("const probe = bootRestoreProbe(")); + expect(hook).toBeLessThan(source.indexOf("const bunRuntime = resolveBun(")); + expect(source).toContain("const { launchProof, launchContext, inheritedEnv } = createNodeLaunchContext();"); + expect(source.match(/randomBytes\(32\)/g)).toHaveLength(1); +}); +``` + +### NEW `tests/cli/cli-path-diagnostics.test.ts` (complete) + +```ts +import { describe, expect, test } from "bun:test"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { collectCliPathDiagnostics, formatCliCommandLine, formatCliStatusHealthLabel, cliCommandDoctorChecks, type CliPathDiagnosticOptions, type CliPathObservation } from "../../src/cli/cli-path-diagnostics"; +import type { DesktopCliRecordRead } from "../../src/lib/desktop-cli-record.mjs"; +import { repoPath } from "../helpers/repo-root"; + +function record(platform: NodeJS.Platform, target: string): DesktopCliRecordRead { + return { state: "ready", path: "fixture", record: { version: 1, enabled: true, bundle: { platform, cliExecutable: target } } }; +} +function fixture(platform: NodeJS.Platform, env: NodeJS.ProcessEnv, files: string[], extra: Partial = {}) { + const windows = platform === "win32"; + const normalize = (path: string) => windows ? path.toLowerCase() : path; + const present = new Set(files.map(normalize)); + let observations = 0; + const observe = (path: string): CliPathObservation => { observations++; return present.has(normalize(path)) ? { kind: "usable", realpath: path } : { kind: "missing" }; }; + const result = collectCliPathDiagnostics({ platform, env, cwd: windows ? "C:\\work" : "/work", home: windows ? "C:\\Users\\example" : "/Users/example", recordRead: record(platform, windows ? "C:\\App\\ocx.exe" : "/Applications/OpenCodex.app/Contents/MacOS/ocx"), observe, ...extra }); + return { result, observations }; +} +const desktopBin = "/Users/example" + "/.opencodex-desktop/bin"; +const shim = desktopBin + "/ocx"; +const target = "/Applications/OpenCodex.app/Contents/MacOS/ocx"; +describe("observed ocx PATH selection", () => { + test("POSIX Desktop shim first, npm later; never executes candidates", () => { + const { result } = fixture("darwin", { PATH: `${desktopBin}:/npm/bin` }, [shim, target, "/npm/bin/ocx"]); + expect(result.pathFirst?.path).toBe(shim); expect(result.expectedExecutable).toBe(shim); + expect(result.desktopFirstOnPath).toBe(true); expect(result.shellResolution).toBe("unobserved"); expect(result.issues).toEqual([]); + const source = readFileSync(repoPath("src", "cli", "cli-path-diagnostics.ts"), "utf8"); + expect(source).not.toMatch(/child_process|Bun\.spawn|execSync|spawnSync/); + }); + test("npm first is an issue even if that launcher can hand off", () => { + const { result } = fixture("linux", { PATH: `/npm/bin:${desktopBin}` }, ["/npm/bin/ocx", shim, target]); + expect(result.pathFirst?.path).toBe("/npm/bin/ocx"); expect(result.desktopFirstOnPath).toBe(false); expect(result.issues).toContain("path-first-not-desktop"); + expect(cliCommandDoctorChecks(result)[0]!.level).toBe("WARN"); + }); + test("POSIX empty and relative PATH elements resolve against cwd; absent PATH differs", () => { + for (const path of [":/bin", ".:/bin", "tools:/bin"]) { + const { result } = fixture("linux", { PATH: path }, ["/work/ocx", "/work/tools/ocx", "/bin/ocx", shim, target]); + expect(result.pathFirst?.path).toBe(path.startsWith("tools") ? "/work/tools/ocx" : "/work/ocx"); + } + expect(fixture("linux", {}, ["/work/ocx", shim, target]).result.pathFirst).toBeNull(); + expect(fixture("linux", { PATH: "" }, ["/work/ocx", shim, target]).result.pathFirst?.path).toBe("/work/ocx"); + }); + test("Windows PATHEXT order, case-insensitive Path, quotes and bundled expected exe", () => { + const { result } = fixture("win32", { Path: '"C:\\App";C:\\npm', PathExt: ".CMD;.EXE" }, ["C:\\App\\ocx.exe", "C:\\App\\ocx.cmd", "C:\\npm\\ocx.cmd"]); + expect(result.pathFirst?.extension).toBe(".cmd"); expect(result.expectedExecutable).toBe("C:\\App\\ocx.exe"); expect(result.issues).toContain("path-first-not-desktop"); + const selected = fixture("win32", { PATH: "c:\\app", PATHEXT: ".EXE;.CMD" }, ["C:\\App\\ocx.exe"]).result; + expect(selected.desktopFirstOnPath).toBe(true); + }); + test("Windows empty entry and cmd cwd candidate remain distinct from PATH first", () => { + const row = fixture("win32", { PATH: "C:\\App", PATHEXT: ".EXE" }, ["C:\\App\\ocx.exe", "C:\\work\\ocx.exe"]).result; + expect(row.pathFirst?.path).toBe("C:\\App\\ocx.exe"); expect(row.currentDirectoryCandidate?.path).toBe("C:\\work\\ocx.exe"); expect(row.issues).toContain("windows-current-directory-shadow"); + expect(fixture("win32", { PATH: ";C:\\App", PATHEXT: ".EXE" }, ["C:\\work\\ocx.exe", "C:\\App\\ocx.exe"]).result.pathFirst?.path).toBe("C:\\work\\ocx.exe"); + }); + test("invalid PATHEXT entries ignored; extensionless last; missing PATH and bounded scan", () => { + const row = fixture("win32", { PATH: "C:\\npm", PATHEXT: ".EXE;bad;../evil;.CMD" }, ["C:\\npm\\ocx", "C:\\App\\ocx.exe"]).result; + expect(row.pathFirst?.extension).toBe(""); + expect(fixture("linux", {}, [shim, target]).result.issues).toContain("path-missing"); + const long = fixture("linux", { PATH: Array(257).fill("/missing").join(":") }, [shim, target]); + expect(long.result.issues).toContain("path-scan-truncated"); expect(long.observations).toBe(258); expect(long.result.desktopFirstOnPath).toBeNull(); + const extensions = Array.from({ length: 33 }, (_, i) => `.x${i}`).join(";"); + expect(fixture("win32", { PATH: "C:\\App", PATHEXT: extensions }, ["C:\\App\\ocx.exe"]).result.issues).toContain("path-scan-truncated"); + }); + test("missing or disabled Desktop is informational; bad records and missing target fail", () => { + for (const state of ["missing", "disabled"] as const) { + const row = fixture("linux", { PATH: "/npm" }, ["/npm/ocx"], { recordRead: { state, path: "fixture" } }).result; + expect(row.desktopFirstOnPath).toBeNull(); expect(cliCommandDoctorChecks(row)[0]!.level).toBe("OK"); + } + for (const issue of ["record-invalid", "record-too-large", "record-pending", "record-unreadable"] as const) { + const row = fixture("linux", {}, [], { recordRead: { state: issue === "record-unreadable" ? "unreadable" : "invalid", path: "fixture", issue } }).result; + expect(row.issues).toContain(issue); expect(cliCommandDoctorChecks(row)[0]!.level).toBe("FAIL"); + } + const missing = fixture("linux", { PATH: "/npm" }, ["/npm/ocx"]).result; + expect(missing.issues).toContain("desktop-target-missing"); expect(missing.issues).toContain("expected-command-missing"); + }); + test("unusable/unreadable observations have named codes and no false OK", () => { + const row = fixture("linux", { PATH: "/bad" }, [], { observe: path => path === target ? { kind: "unusable" } : path === shim ? { kind: "unusable" } : { kind: "unreadable" } }).result; + expect(row.issues).toEqual(expect.arrayContaining(["desktop-target-unusable", "expected-command-unusable", "path-unreadable"])); + expect(row.desktopFirstOnPath).toBeNull(); + expect(cliCommandDoctorChecks(row)[0]!.level).toBe("FAIL"); + }); + test("realpath alias of owned command compares physically and output stays one safe line", () => { + const row = fixture("linux", { PATH: "/alias" }, [], { observe: path => ({ kind: "usable", realpath: path === target ? target : shim }) }).result; + expect(row.desktopFirstOnPath).toBe(true); + const line = formatCliCommandLine(row); expect(line).toContain("shell=unobserved"); expect(line).not.toContain("\n"); + const unsafe = { ...row, pathFirst: { ...row.pathFirst!, path: "/fixture/line\nbreak/ocx" } }; + expect(formatCliCommandLine(unsafe)).not.toContain("\n"); + }); + test("connected status adapter renders one command line and preserves local labels", () => { + const row = fixture("linux", { PATH: "/npm" }, ["/npm/ocx", shim, target]).result; + for (const connected of [false, true]) { + const rendered = `Health: ${formatCliStatusHealthLabel("healthy", row, connected)}${connected ? " (local)" : ""}`; + const lines = rendered.split("\n"); expect(lines).toHaveLength(2); + for (const line of lines) expect(line.includes(" (local)")).toBe(connected); + expect(lines[1]).toContain("ocx command:"); + } + const json = JSON.parse(JSON.stringify({ proxy: { health: { message: "healthy" } }, cliCommand: row })); + expect(json.proxy.health.message).toBe("healthy"); + }); + (process.platform === "win32" ? test.skip : test)("default observer rejects directory/non-executable and observes physical symlink without execution", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-path-observe-")); + try { + const bin = join(root, ".opencodex-desktop", "bin"); mkdirSync(bin, { recursive: true }); + const target = join(root, "ocx-target"); writeFileSync(target, "fixture; never executed"); chmodSync(target, 0o755); + const shimPath = join(bin, "ocx"); symlinkSync(target, shimPath); + const platform = process.platform; + const options = { platform, home: root, cwd: root, recordRead: record(platform, target), env: { PATH: bin } }; + expect(collectCliPathDiagnostics(options).desktopFirstOnPath).toBe(true); + rmSync(shimPath); mkdirSync(shimPath); + expect(collectCliPathDiagnostics(options).issues).toContain("expected-command-unusable"); + rmSync(shimPath, { recursive: true }); writeFileSync(shimPath, "fixture"); chmodSync(shimPath, 0o600); + expect(collectCliPathDiagnostics(options).issues).toContain("expected-command-unusable"); + expect(collectCliPathDiagnostics({ ...options, env: { PATH: target } }).pathFirst).toBeNull(); + rmSync(shimPath); symlinkSync(shimPath, shimPath); + expect(collectCliPathDiagnostics(options).issues).toContain("path-unreadable"); + } finally { rmSync(root, { recursive: true, force: true }); } + }); + test("status JSON field and human line, doctor independent registration, root dispatcher untouched", () => { + const status = readFileSync(repoPath("src", "cli", "status.ts"), "utf8"); + expect(status).toContain("cliCommand: CliPathDiagnostics;"); expect(status).toContain(" cliCommand,"); + expect(status).toContain("formatCliStatusHealthLabel(health.label, cliCommand, remoteHub.connected)"); + const doctor = readFileSync(repoPath("src", "cli", "doctor.ts"), "utf8"); + expect(doctor).toContain('console.log("\\nocx command selection")'); expect(doctor).toContain('if (check.level === "FAIL") recordDoctorFailure()'); + expect(doctor.indexOf('console.log("\\nocx command selection")')).toBeLessThan(doctor.indexOf('console.log("\\nCodex runtime selection")')); + const json = JSON.parse(JSON.stringify(fixture("linux", { PATH: "/npm" }, ["/npm/ocx", shim, target]).result)); + expect(json.pathFirst.path).toBe("/npm/ocx"); expect(json.shellResolution).toBe("unobserved"); + }); +}); +``` + +## Conditional activation and evidence (C-ACTIVATION-GROUNDING-01) + +These are **planned test names**, not passing results. Read this table together with the complete files above; a guard without its activating fixture is incomplete implementation proof. Each test is scoped to an owned temp directory or injected IO. No test installs Desktop, reads a real home record, performs npm update, or executes discovered PATH candidates. + +| Conditional path | Reachable scenario / observable result | Test name in the new files | +| --- | --- | --- | +| POSIX/Windows record home; reader injection | Home `/Users/example`, Windows USERPROFILE fixture, explicit temp recordPath; exact fixed file path | `fixed record location and injected reader path` | +| Missing record | File absent; `missing`, package plan continues | `fixed record location and injected reader path`; `record absence and disabled intent keep the package path; invalid records error` | +| Disabled intent, including pending journal | Version-1 false tombstone without bundle; no target read or execution | `reader open failure is unreadable and disabled intent wins over pending journal` | +| Reader rejects directory / metadata over limit | Directory or 65537-byte record; invalid/too-large without full-file allocation | `reader refuses oversized, malformed, pending and wrong-platform records` | +| Read-loop EOF and sentinel | Small valid record ends at EOF; descriptor's mocked metadata says size 1 but actual bytes exceed the cap; sentinel triggers too-large | `fixed record location and injected reader path`; `descriptor sentinel bounds growth after metadata and strict UTF-8 rejects corrupt bytes` | +| JSON/UTF-8 parse failure; invalid shape/version/boolean | `{`, array, null, number, unknown version, string enabled, bad UTF-8 | `reader refuses oversized, malformed, pending and wrong-platform records`; `descriptor sentinel bounds growth after metadata and strict UTF-8 rejects corrupt bytes` | +| Pending enabled record | Non-null `pending`; `record-pending`, no target chosen | `reader refuses oversized, malformed, pending and wrong-platform records` | +| Invalid bundle/platform/path/boundary | Null bundle, wrong platform, relative path, path over 32768 characters, newline path | `reader refuses oversized, malformed, pending and wrong-platform records` | +| Open fails other than ENOENT | Self-loop record symlink gives ELOOP, classified unreadable; descriptor cleanup applies to opened records only | `reader open failure is unreadable and disabled intent wins over pending journal` | +| Update-help short circuit | Valid Desktop fixture but `update --help` emits usage/0, no Desktop target output | `real update-help is untouched; opt-out and bad record activate distinct paths` | +| Update/uninstall/remove/internal namespace exceptions | Effective argv strips old proof before classifying; target IO trap never reached | `update, removal, internal inspection and codex-cli-update are exceptions` | +| Inspection under Bun existing guard | Existing `codexCliUpdateInspection && process.versions.bun` guard remains before hook | `launcher source anchors preserve exceptions before handoff and handoff before repair/resolver`; existing `tests/cli/ocx-launcher-source.test.ts` retained for hosted CI | +| Exact bypass and ordinary version | Bypass equals `1` only; ordinary `--version` eligible | `OCX_NO_DESKTOP_HANDOFF is exact and keeps user version handoff eligible`; real exception test | +| No/disabled record versus invalid/read error | Missing/disabled continues; each record issue is a fixed-code error | `record absence and disabled intent keep the package path; invalid records error` | +| Target relative/non-file rejection | Injected relative path or directory; target-invalid, zero spawn | `absolute regular executable target selected; self realpath never delegated` | +| POSIX X_OK / Windows existence check | EACCES fails; Windows mode is F_OK and process creation supplies final executable verdict | `target ENOENT resumes and EACCES or other errors fail`; `Windows realpath comparison is case insensitive and defers execute permission to spawn` | +| Physical self detection / case folding | Launcher alias and recorded target resolve to same path; Windows case differences also reject | `absolute regular executable target selected; self realpath never delegated`; Windows comparison test | +| Self path missing versus other realpath error | A vanished self module is skipped; ELOOP/EACCES reading a present identity fails closed | `absolute regular executable target selected; self realpath never delegated` | +| Target ENOENT / EACCES / ENOTDIR / ELOOP | Actual target absence continues; other IO failures error | `target ENOENT resumes and EACCES or other errors fail`; `real target disappearance resumes Bun; EACCES never resumes Bun` | +| Valid handoff before resolver/boot repair | Invalid Bun override would emit a warning on package path; successful Desktop target has no such warning and returns 23 | `real Node launcher regenerates proof, forwards argv/stdin, mirrors exit and bypasses Bun lookup`; launcher anchor test | +| Fresh proof, old argv-proof removal | Forged inherited context plus an old argv proof; child receives one new proof matching JSON | Real Node launcher test; `argv, inherited stdin, fresh proof and exit code; signal handlers cleaned` | +| Claude exported slot preservation / pollution deletion | API key is proof-listed; BASE_URL is not; real native-env builder preserves one and removes the other | `proof reaches the real Claude native-env consumer and keeps exported slots` | +| Spawn ENOENT versus all other errors | Sync throw or asynchronous error; ENOENT continues, EACCES/ENOEXEC fail | `child signal is retained and asynchronous spawn ENOENT alone resumes`; `synchronous spawn errors have the same ENOENT-only policy` | +| Error then late exit; settle once | Fake child emits error then exit 99; original error result wins, parent listeners removed | `child signal is retained and asynchronous spawn ENOENT alone resumes` | +| Parent signal forwarding and child termination | SIGTERM reaches child when only parent was signaled; parent waits and exits 29; child signal re-emitted by parent | `signal sent only to real launcher reaches its child and waits for child exit`; `real child terminating signal is mirrored by Node parent` | +| Platform signal set / kill race / null code | Windows has no HUP handler; throwing kill does not change completion; null code maps to 1 | `Windows forwards INT/TERM only and a kill race still settles from child exit` | +| Internal version probe env | Selected executable reports its own version; bypass set after inherited fixture env | `internal managing CLI probes set the bypass in the actual spawn environment` | +| POSIX empty/relative/unset PATH | `:`, `.`, `tools`, empty string, absent PATH; cwd candidates only when represented | `POSIX empty and relative PATH elements resolve against cwd; absent PATH differs` | +| Windows PATH names, quoted directories, PATHEXT, extensionless | Case-insensitive env keys and comparison; declared .CMD before .EXE; invalid extensions ignored | `Windows PATHEXT order, case-insensitive Path, quotes and bundled expected exe`; `invalid PATHEXT entries ignored; extensionless last; missing PATH and bounded scan` | +| cmd cwd versus PATH observation | cwd executable plus Desktop PATH candidate; distinct fields and cwd-shadow issue | `Windows empty entry and cmd cwd candidate remain distinct from PATH first` | +| Record states and target/shim observations | Missing/disabled informational; invalid record, missing or unusable target/shim FAIL | `missing or disabled Desktop is informational; bad records and missing target fail`; `unusable/unreadable observations have named codes and no false OK` | +| npm is PATH first | npm + owned shim present; path-first-not-desktop WARN, even if npm would hand off | `npm first is an issue even if that launcher can hand off` | +| Scan truncation/read failure | 257 PATH entries, 33 valid extensions, unreadable candidate; bounded work and null desktopFirstOnPath | `invalid PATHEXT entries ignored; extensionless last; missing PATH and bounded scan`; unusable/unreadable test | +| Physical alias and output sanitation | Alias realpath matches owned command; control character path renders on one safe line | `realpath alias of owned command compares physically and output stays one safe line` | +| Doctor OK/WARN/FAIL / additive status DTO | Valid Desktop first OK; ordering conflict WARN; invalid record/target FAIL contributes to doctorFailed; serialization preserves issue codes | The fixture tests above; `status JSON field and human line, doctor independent registration, root dispatcher untouched` | +| Connected status local labeling | Existing index appends `(local)` to the multiline view's last line; healthLabel explicitly preserves its first-line local label | `connected status adapter renders one command line and preserves local labels` (both connected values, simulated unchanged root rendering). | + +The growth fixture patches `fstatSync` only inside a disposable **Node child**, followed by `syncBuiltinESMExports`, to deterministically simulate growth between metadata and read. It never monkey-patches the shared Bun test process. Windows native execute-format and signal handling remain 030 native evidence, not a claimed result of the injected Windows policy fixture. + +## Field and enum lifecycle (PLAN-FIELD-CHAIN-01) + +| Field / values | Creation → serialization → deserialization → every in-repository consumer | +| --- | --- | +| Persistent `version`, `enabled`, `bundle.platform`, `bundle.cliExecutable`; optional `pending` gate | wp1 writer (010) → atomic version-1 `cli.json` → bounded `readDesktopCliRecord` → `planDesktopCliHandoff`, `collectCliPathDiagnostics`. Only settled enabled records expose the narrowed bundle DTO. No wp2 write/serializer. | +| Reader `state`: missing/disabled/ready/invalid/unreadable; `path`; `record`; `issue` | Reader IO/schema branches → process-local DTO (not separately persisted) → no extra parser → handoff plan and diagnostics. Tests enumerate the five states. Record issue values are `record-invalid`, `record-too-large`, `record-pending`, `record-unreadable`; plan maps them to stderr errors, diagnostics maps them to status JSON and doctor FAIL. | +| Plan `kind`, `reason`, `issue`, `target` | `planDesktopCliHandoff` creates continue(reason excluded/missing/disabled/target-missing), error(fixed record/target code), handoff(target realpath) → process-local only → `bin/ocx.mjs` branches → continue/update/Bun, fixed-code exit 1, or transport call. Tests consume every discriminant. | +| Transport result kind continue/error/exit, issue spawn-failed, code, signal | Spawn throw/events + cleanup → process-local promise result → launcher awaits → ENOENT continuation, fixed error exit 1, numeric exit or signal re-emission. Test doubles and real Node subprocesses consume it; no IPC record/JSON form added. | +| Existing launch-context `version`, `proof`, `anthropicEnvSlots`, `codexCliInspectionEnv` | Extracted Node factory snapshots parent env and generates random proof → `JSON.stringify` to OCX_NODE_LAUNCH_CONTEXT + exactly one argv proof → `src/cli/index.ts:209 initializeNodeLauncherContext()` consumes/validates/removes both → `trustedNodeLauncherContext()` → `deleteUntrustedAnthropicSlots` / Claude builders; inspection resolver continues consuming its original proof-bound fields. No schema/version change. | +| `OCX_NO_DESKTOP_HANDOFF=1` | `probeVersionOnce` or handoff child env copy (never mutates parent's env) → subprocess env → next package launcher's exact-1 exception → ordinary package path. Compiled CLI does not use it as ownership evidence. Tests verify generated env, exact value and normal --version eligibility. | +| `CliPathObservation.kind`, `realpath` | Default stat/access/realpath IO or fixture observation → process-local only → collector; real filesystem cases are covered by `default observer rejects directory/non-executable and observes physical symlink without execution`. missing skips candidate; unusable flags target/shim failures; unreadable marks incomplete PATH; usable creates candidates and physical comparison. | +| `CliPathCandidate.path`, `realpath`, `pathIndex`, `extension`; `candidates`, `pathFirst`, `currentDirectoryCandidate` | Observe scan, in PATH then PATHEXT order; cwd marker index -1 kept separate → nested `cliCommand` JSON via handleStatus → external JSON clients may inspect without a new in-repo parser → formatter, doctor checks and fixture tests use path/physical order. All DTO fields are plain values, no functions/undefined. | +| `configured`, `recordState`, `expectedExecutable`, `handoffTarget`, `desktopFirstOnPath`, `shellResolution` | Collector derives intention/expected target from reader and observed order; null firstness means no configured Desktop or incomplete scan; shellResolution is always unobserved → status.json.cliCommand / schemaVersion 1 → root JSON.stringify only (`index.ts:1705–1707`) → external callers + tests; human `formatCliCommandLine` and `cliCommandDoctorChecks` consume the same observation. No runtime/service mutation consumes them. | +| `issues` closed union | Collector adds unique record issue codes, desktop-target-missing/unusable, expected-command-missing/unusable, path-missing, path-first-not-desktop, path-unreadable, path-scan-truncated, windows-current-directory-shadow → status JSON → no local deserializer → formatter lists codes, doctor maps record/target/expected errors to FAIL, other issues to WARN. Fixtures cover all values; no freeform record text becomes a code. | +| `CliStatusJson.cliCommand` | `collectStatus` computes the DTO once and includes it in json → unchanged root JSON serializer → consumers tolerant of additive fields; no new local status parser exists (`rg CliStatusJson` finds its declaration/view only). Human view uses `healthLabel` adapter, raw JSON proxy health fields unchanged. | +| Doctor level OK/WARN/FAIL | `cliCommandDoctorChecks` creates an existing severity shape → not persisted → `runDoctor` prints, FAIL calls existing recordDoctorFailure → existing doctorFailed exit semantics. WARN ordering and unknown shell state do not become FAIL. | + +`formatCliStatusHealthLabel` creates the view-only multiline label from health text, diagnostics and the connected/local flag; status is its only production consumer, while its fixture tests simulate the unchanged root rendering in both flag states. It does not alter or serialize JSON health. + +The issue union is diagnostic, not an executable allowlist. New reader/plan DTOs never enter service ownership, supervision or compatibility claims. In particular `handoffTarget` is not the running proxy binary and `configured` is not “Desktop is currently supervising”. + +## Guard strength and named bypasses (PLAN-BYPASS-NAMED-01) + +Tier notation here identifies **E8 executable checks** versus **E7 documented/operator-followed boundaries**; it does not assert that a host hook recognizes this application logic. All bypassable checks are described as local guards or early warnings. **Final global enforcement layer: none.** + +| Guard / tier | Executing surface | Known bypass path | Residual risk / honest wording | +| --- | --- | --- | --- | +| E8 bounded/schema reader | Reader inside current Node launcher and Bun diagnostics | Same-user process replaces record; older package launchers do not call the reader | Configuration validation and early warning; IDs/hashes are not authenticity or service authority. | +| E8 target validation / self guard | Current package launcher before spawn | Explicit compiled executable, different package launcher, direct source CLI; TOCTOU between validation and process creation | Prevents this invocation's self-delegation and obvious invalid target. Cannot bind binary authenticity or defeat same-user modification. | +| E8 ENOENT-only fallback | Current handoff planner/async child | Bypass flag; old launcher; directly invoke npm package path whose version lacks handoff | Chosen-target errors fail this launch; does not promise every package command is forced to Desktop. | +| E8 exceptions and child recursion opt-out | Current package launcher; managing-cli child env | User sets exact bypass flag or executes update/removal/internal namespace | Explicit escape hatch; does not bypass L1 runtime/service guards. | +| E8 argv proof parser | Existing CLI context parser, fed by extracted plain-Node factory | Same-user caller can directly supply argv/env; calling Node under Bun or bypassing launcher loses the original pre-dotenv capture | Provenance for ordinary Node invocations, not an identity/security barrier against a local attacker. A11 shim review stays mandatory. | +| E8 observe-only tests / repository gates | Named Bun regressions, structure/ratchet/layout gates; parent's hosted CI | Not running tests, untracked files not visible to git-index gates, older-head/skipped CI | Early warning for regressions in the observed tree; no claim of OS shell enforcement or native Windows proof. | +| E7 shell-selection limitation | Public docs, status/doctor wording, native smoke reviewer | Aliases/functions, shell cache, absolute paths, zsh -f, later nvm use, stale parent env, Windows system PATH or cmd cwd | Reports only process PATH observations; shellResolution remains unobserved. | +| E7 staging and review protocol | Parent before structure/privacy/ratchet CI; independent 030 reviewer | Ignoring plan or treating unstaged local gate pass as CI evidence | Requires real current-head evidence; no code-quality/security PASS is granted by this plan. | + +## Goalplan acceptance mapping + +The read-only parent goalplan `opencodex-make-opencodex-desktop-own-the-ocx-com` was inspected without calling any goal/loop/FSM commands. The IDs below preserve its actual criterion identity. Goalplan c-1 says “Linux deb unchanged”; A9 accepts adding the same user shim/rc on deb while keeping `/usr/bin/ocx`. Parent should reconcile that wording in its own goalplan; this worker does not edit it. + +| Criterion | wp2 acceptance / observable evidence | Ownership and completion boundary | +| --- | --- | --- | +| c-1 app-only command after Desktop start | Reader can consume wp1's installed record and diagnostics name the POSIX shim or Windows bundled exe | wp1/030 own actual installation/new-terminal proof. Reader alone does not satisfy c-1. | +| c-2 npm/bun/brew precedence; Windows user PATH | POSIX/Windows fixtures show Desktop-first versus npm-first; cwd/system PATH conflicts do not show false success | wp2 contributes diagnostics only; wp1/030 supply real shell/registry selection evidence. | +| c-3 handoff with recursion guard and escape hatch | Fresh proof and one argv proof, stdin/argv, exit/signal, target self/ENOENT/EACCES, exceptions and exact bypass tests pass | Primary wp2 criterion. Existing package update/Bun/inspection behavior remains available; L1 guards intact. | +| c-4 foreign refusal/idempotence/removal/opt-out | Missing/disabled records continue; invalid/pending refuse, physical self rejects; package removal never hands off | wp2 covers consumers; wp1 owns generated-file ownership, idempotence and cleanup tests. | +| c-5 typecheck/focused tests/structure/privacy/current-head CI | Execute the verifier table after implementation, with dependencies present; no baseline failure reported as passing | wp2 contributes TS/gate evidence. Cargo/native and exact-head required hosted CI belong to 030; current planning typecheck/layout are incomplete. | +| c-6 independent code and security review PASS | Reviewer checks record trust, proof extraction, no supervision marker creation, target failure policy and tested provenance chain | Parent/030 obtain independent reviews. Plan and syntax checks cannot mark c-6 met. | +| c-7 admin squash to dev with docs | These diffs and public text are integrated with 030's en/ko/locale updates and stacked PR retargeting | Parent owns PR/rebase/merge authority; this worker performs none. | + +| Additional acceptance | Proving observation | +| --- | --- | +| No user `--version` blanket exemption | Effective argv `--version` handoffs unless exact bypass is present. Internal probe sets bypass independently. | +| No runtime/service authority widening | No code consumes record IDs as authority; no new OCX_DESKTOP_SUPERVISED marker is stamped; #6809 guard logic unchanged. | +| Root dispatcher untouched | No `src/cli/index.ts` diff; current human renderer still receives its view string and JSON remains stdout-clean. | +| New file scope and docs ownership complete | Both test basenames registered in both rosters; runtime row stays one row/600 lines; source references resolve when staged by parent. | +| Platform claims bounded | POSIX subprocess tests and cross-host Windows policy fixtures distinguished from native Windows execution and new-terminal selection. | + +## Open-PR integration notes (read-only diff inspection) + +`gh pr diff 6807`, `gh pr diff 6809` and `gh pr diff 6802` were actually read. Their states were OPEN at the inspection below; this is a dated planning observation, not future merge evidence. + +| PR / inspected head | Actual overlapping change | Rebase instructions and preserved anchor | +| --- | --- | --- | +| [#6807](https://github.com/lidge-jun/opencodex/pull/6807), `b880f9c6b9efaf161336f0b30ec8d5fcc3cea2b4` | Adds `findDesktopCli`/`findPathBun` import, `pinnedBunVersion`, advisory Desktop guidance in `fail`, and validated PATH Bun fallback in `resolveBun` | Keep its resolver body and advisory discovery unchanged. Merge the new handoff import as an independent import. Handoff is before `const bunRuntime = resolveBun(...)`, so successful handoff never probes/install-selects Bun. Do not turn `findDesktopCli` into the executable target selector. Existing source-oracle substring checks for snapshot constants remain present inside the extracted factory. | +| [#6809](https://github.com/lidge-jun/opencodex/pull/6809), `b3b72189078137a00b32571de861d3b963582f44` | Imports live supervision; adds latches/refusals in `runPackageManagerSelfUpdate`, direct recovery, service refresh, pre-stop, replacement and post-install | Preserve every supervision check and latch. Handoff has update/removal exceptions, so it does not move these commands into another owner's updater. In its inspected head the semantic insertion boundary is after the inspection block around line 999, instead of baseline 963. Use `const codexCliUpdateInspection` and the following mise-update `if` as anchors. The public command record grants no stop/replace/restore authority. | +| [#6802](https://github.com/lidge-jun/opencodex/pull/6802), `1718fcad8ba90e6afd12a8c5a03f7fc222f69742` | `selectStatusStartupHealth` returns `{ startup, startupSource }`; status adds `startupSource` and live supervision; doctor changes restart-safety source and advice | Keep those type/signature/caller changes. Add `cliCommand` as a separate additive field near `versionSkew`; compute command diagnostics at `collectStatus` entry, not by changing startupSource or serviceSummary. Doctor's new section is after Paths and before response-state sections, leaving its restart-safety anchor around 1380 alone. Its supervision `unsupported` is not Windows PATH success. | + +Parent's manual stack: wp2 consumes the final wp1 contract; PR2 initially targets the open wp1 branch, then retargets/rebases to `dev` after PR1 lands. Do not blindly replay baseline line patches over L1. Regenerate these narrow diffs against the integrated source and rerun only checks affected by the rebase, then inspect required CI for the exact new head. Missing, skipped, cancelled, awaiting approval or older-head results are not passing evidence. + +`structure/runtime.md` is also shared with all three L1 PRs. Preserve their text and keep this handoff contract in the existing entrypoint row with **zero line growth**. Public desktop-guide edits belong to 030's coordinated change rather than another competing edit here. + +## File-size and structure budgets + +Read `scripts/file-size-ratchet.ts`, `tests/fixtures/file-size-baseline.json` and `structure/manifest.json`. `THRESHOLD=2000`; an uncapped file at **2000 or more** fails, so its usable ceiling is **1999**, not 2000. A recorded cap is stricter and only decreases via `Math.min` (`scripts/file-size-ratchet.ts:188`). None of the modified files below has a per-file recorded cap at the baseline. Structure's separate `sizeBudgetLines=600` still applies, including runtime at exactly 600. `.d.mts` does not match SCAN_EXTENSIONS and needs a manual small-file budget; do not claim the ratchet covers it. + +Counts below use the ratchet's newline-count convention and the complete proposed code/diffs. They are planning budgets, not post-implementation results. Keep room for reviewer fixes and recalculate against the current integration head. + +| Path | Baseline lines | Proposed lines | Applicable ceiling | Budget rule | +| --- | ---: | ---: | ---: | --- | +| `src/lib/desktop-cli-record.mjs` | NEW | 63 | 1999 | New scanned source/test, no recorded cap | +| `src/lib/desktop-cli-record.d.mts` | NEW | 19 | 120 | Manual declaration ceiling; ratchet does not scan .mts | +| `src/lib/desktop-cli-handoff.mjs` | NEW | 84 | 1999 | New scanned source/test, no recorded cap | +| `src/lib/desktop-cli-handoff.d.mts` | NEW | 20 | 120 | Manual declaration ceiling; ratchet does not scan .mts | +| `src/cli/cli-path-diagnostics.ts` | NEW | 124 | 1999 | New scanned source/test, no recorded cap | +| `tests/cli/ocx-launcher-desktop-handoff.test.ts` | NEW | 276 | 1999 | New scanned source/test, no recorded cap | +| `tests/cli/cli-path-diagnostics.test.ts` | NEW | 129 | 1999 | New scanned source/test, no recorded cap | +| `bin/ocx.mjs` | 1120 | 1151 | 1999 | No recorded cap; keep below 2000 | +| `src/service/managing-cli.ts` | 200 | 201 | 1999 | No recorded cap; keep below 2000 | +| `src/cli/status.ts` | 937 | 941 | 1999 | No recorded cap; keep below 2000 | +| `src/cli/doctor.ts` | 1722 | 1729 | 1999 | No recorded cap; keep below 2000 | +| `scripts/test-layout/layout.json` | 1981 | 1983 | 1999 | No recorded cap; keep below 2000 | +| `tests/fixtures/test-layout-expected.json` | 1179 | 1180 | 1999 | No recorded cap; keep below 2000 | +| `structure/runtime.md` | 600 | 600 | 600 | Replace same number of lines; zero headroom | +| `structure/cli-management.md` | 107 | 111 | 600 | No recorded cap; obey structure 600 | +| `structure/ops/service-and-sidecars.md` | 541 | 543 | 600 | No recorded cap; obey structure 600 | +| `docs-site/src/content/docs/getting-started/installation.md` | 125 | 131 | 1999 | No recorded cap; keep below 2000 | +| `docs-site/src/content/docs/reference/cli.md` | 324 | 326 | 1999 | No recorded cap; keep below 2000 | +| `src/cli/index.ts` | 1976 | 1976 | 1999 | OUT; no modifications | + +The plan itself is under `devlog/`, excluded by the file-size ratchet (`scripts/file-size-ratchet.ts:21–22`) and outside the structure-doc 600-line budget. Its full code/diffs are intentionally not scattered into unassigned companion files. Do not run `ratchet:update` to mask growth. The layout manifest has only 16 lines of projected room before its uncapped maximum; combine entries on existing JSON lines or split tooling under an explicit parent-approved plan if concurrent registrations exhaust it. + +`structure/manifest.json` already maps `bin/`, `src/lib/`, `src/cli/` and `src/service/` to runtime; CLI management maps `src/cli/`; service-and-sidecars documents lib helpers. No new source directory/top-level area or manifest ownership change is required. `structure/desktop-shell.md` and the public desktop guide are reviewed/updated by 010/030 for the installer. Before implementation review, parent also reviews the existing broad map dependents for `src/lib/`/`src/cli/` (overview, config, local-messaging, byte-accounting, responses-wire-shapes, responses-failover, responses-spend, inventory, gui-and-management-api, dashboard-and-usage, clients/integrations, clients/chatgpt-desktop, clients/claude-desktop and ops/docs-and-release). Their unrelated contracts receive no copied handoff prose; update only a changed local consequence. Runtime owns entrypoint ordering, CLI management owns command diagnostics, Desktop-shell owns generated-command installation. + +## Verifier grounding and actual planning-time results + +All shell invocations in this delegated task used the assigned checkout as workdir. Only this document was written. No code/new test files were created or test suites beyond the two layout guard files were run. Baseline checks **do not prove the future changes**. Dependencies were not installed: changing lockfiles or setting up the checkout exceeds this one-document worker scope. + +| Command (run once for the verification observation unless noted) | Observed exit / outcome | Does it read this change target? | +| --- | --- | --- | +| `bun run typecheck` | **1**, TS2688: missing `bun-types`; no implementation typecheck proof | `tsconfig.json` has `include: ["src"]`; after creation it reads the new diagnostics TS/declarations and modified status/doctor/probe. It does not typecheck `.mjs` JS, tests, bin, or prose. Baseline dependency failure means not ready. | +| `bun run structure:check` | **0**, structure/ SSOT checks passed at baseline | `scripts/structure-ssot.ts:301,354,373` reads manifest/docs; after parent's staging it checks new source-path references. It does not read this devlog plan or prove runtime behavior. | +| `bun run privacy:scan` | **0**, Privacy scan passed at baseline | `scripts/privacy-scan.ts:411–414` scans tracked files with gitLsFiles; future tracked source/tests/docs and this plan are included, but this currently untracked plan was not read by that run. No post-plan privacy PASS is claimed. | +| `bun scripts/file-size-ratchet.ts` | **0**, file-size ratchet passed at baseline | `scanRepo → gitLsFiles` (`scripts/file-size-ratchet.ts:133–149`) observes tracked scanned extensions and excludes devlog; after staging it reads source/tests/docs. It does not observe `.d.mts` or this plan. | +| `node --check bin/ocx.mjs` | **0**, baseline syntax accepted | Direct argument reads the existing launcher. It does not follow imports, execute handoff, or parse the proposed diff. Rerun after implementation. | +| `bun scripts/test-with-pinned-bun.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts` | **1**, Bun 1.4.0, 0 pass / 2 fail; preload cannot find `zod/v4`. The command was repeated once only to retain the explicit exit receipt; same import failure | Direct arguments read both layout guards, whose resolver/oracle read layout.json and expected fixture. They observe the new names only after creation/registration; this baseline could not start assertions. No layout PASS. | +| Node stdin syntax checks for the two proposed `.mjs` code fences | **0** for reader and **0** for handoff | The extraction command below reads this exact document's NEW JS bodies and passes them to `node --check --input-type=module`. It parses the proposed code only; no function/test/import is executed. | +| `bun scripts/test-with-pinned-bun.ts tests/cli/ocx-launcher-desktop-handoff.test.ts tests/cli/cli-path-diagnostics.test.ts` | **NEW 파일 생성 후 실행**; no exit result yet | Direct arguments target both new files and their imports. Requires checkout dependencies. POSIX real-launcher cases skip on Windows, so a green Windows policy fixture is not native signal/terminal proof. | + +Copy-paste syntax-extraction verifier (already run against the two JS bodies, both exit 0; no output files): + +```sh +python3 - <<'PY' +from pathlib import Path +import re, subprocess +text = Path('devlog/_plan/261009_desktop_owned_path_cli/020_launcher_handoff_and_diagnostics.md').read_text() +for match in re.finditer(r'### NEW `([^`]+\.mjs)`[^\n]*\n\n```js\n(.*?)\n```', text, re.S): + result = subprocess.run(['node', '--check', '--input-type=module'], input=match[2], text=True, capture_output=True) + print(match[1], 'exit', result.returncode) + if result.returncode: + raise SystemExit(result.returncode) +PY +``` + +After implementation, execute only the two focused new files, the two layout guards, typecheck and the relevant read-only gates above as the local wp2 minimum. Existing launcher, managing-cli, Claude provenance and Codex inspection suites remain required hosted-CI coverage. The command-local import graph cannot discover source-as-data tests, so retain the explicit source-oracle/layout checks. This plan does not claim that import-connected testing supersedes them. + +030 owns docs-site dependency/build validation, independent code/security review, exact-head required hosted CI and supported native terminal smoke. Those commands and native scenarios belong to its verifier ledger rather than an unrun PASS here. Parent must stage new source/test paths before its structure/ratchet/privacy validation, because index-based coverage deliberately excludes untracked leftovers. No required verification may be replaced with a green baseline or older-head run. + +## Parent handoff: remaining decisions and risks + +1. Reconcile **64 KiB record limit**, settled `pending` semantics and platform/path normalization with 010's final A4 writer. If 010 needs a larger bounded journal, align both writer and reader in the parent plan before implementation. Disabled tombstones remain valid without bundle fields. +2. Preserve the newly extracted Node proof factory as plain-Node pre-dotenv work. A11's POSIX shim proof generator belongs to 010 and independent security review; Windows direct bundled execution keeps its existing stripping behavior. This wp2 package handoff must not silently substitute a Desktop marker for proof. +3. The multiline status health **view** is the narrow adapter needed by the no-index-edit constraint. Its connected/local labels have an explicit fixture; if reviewers require a dedicated root-renderer field, report that scope expansion before touching index.ts. Raw JSON health remains unchanged. +4. Rebase semantically over #6807/#6809/#6802 and the current dev head. Tests intentionally make missing target enter the resolver by observing the invalid override warning; they do not depend on absence of #6807's PATH Bun fallback. +5. Current typecheck/layout verifier failures are dependency-preflight gaps (`bun-types`, `zod/v4`). Parent's dependency-equipped implementation checkout or exact-head CI must supply real passing evidence; this worker neither repaired dependencies nor claimed implementation success. +6. Reconcile goalplan c-1's Linux wording with accepted A9; preserve `/usr/bin/ocx` while adding the supported user shim/rc. This worker made no goalplan edits. +7. Windows machine PATH, cmd cwd, stale parent environments and shell aliases remain named bypasses. Native Windows selection/process smoke belongs to 030. Record intent or fixture success alone cannot close c-1/c-2. + +### Direct plan privacy observation + +The direct `scanText` verifier initially returned exit 1 for four **synthetic `/Users/example` home-path literals**: the scanner allows that placeholder under tests/docs-site but not under devlog. The complete test snippets now build the fixture path from `/Users/example` plus its suffix; their runtime example paths are unchanged. No real user path was introduced and the scanner itself was not modified. The subsequent direct plan result is recorded below; the earlier repository-wide PASS did not inspect this untracked file. + +```sh +bun -e 'import { readFileSync } from "node:fs"; import { scanText } from "./scripts/privacy-scan.ts"; const path = "devlog/_plan/261009_desktop_owned_path_cli/020_launcher_handoff_and_diagnostics.md"; const findings = scanText(path, readFileSync(path, "utf8")); console.log(`plan findings: ${findings.length}`); process.exit(findings.length ? 1 : 0);' +``` + +Direct plan verifier: **exit 0, 0 findings** after the placeholder construction edit; it explicitly reads this document through `readFileSync(path)` and `scanText(path, text)`. This is privacy-pattern proof for the plan, not runtime/type/test proof. diff --git a/devlog/_plan/261009_desktop_owned_path_cli/030_docs_review_merge.md b/devlog/_plan/261009_desktop_owned_path_cli/030_docs_review_merge.md new file mode 100644 index 00000000000..7f483f63fe6 --- /dev/null +++ b/devlog/_plan/261009_desktop_owned_path_cli/030_docs_review_merge.md @@ -0,0 +1,53 @@ +# 030 — Docs, review, CI and merge (wp3) + +wp3 turns the two implementation phases into merged, documented behavior. It adds no product code of its own beyond review fixes. Its job is to prove the claims in 010 and 020 with evidence that observes them, get independent reviews, and land both PRs on `dev` in order. + +## Scope + +| IN | OUT | +|---|---| +| Final pass on docs written in wp1/wp2: desktop guide en/ko, installation pages, structure docs; a consistency sweep of the other locales | New locales or full translations of the new sections | +| Real-shell evidence for c-1/c-2 on macOS (zsh and bash) using a temporary `HOME` | Changing the maintainer's own shell files or PATH | +| Independent code review of PR 1 and PR 2; independent security review of the rc/PATH writes and the shim launch provenance (A11) | Maintainer human review (owner instruction: admin merge) | +| Exact-head hosted CI for both PRs, rerun of a failed job once when it is a known flake | Cancelling other contributors' runs | +| Admin squash merge of PR 1, retarget and rebase of PR 2, admin squash merge of PR 2, post-merge `dev` check | Release, version bump, deploy | + +## Evidence plan per criterion + +| Criterion | Evidence that observes it | Where it runs | +|---|---|---| +| c-1 app-only install gets `ocx` | Rust test drives `reconcile` against a temporary `HOME` with a fake stable bundle and asserts the shim, `path.sh` and rc blocks exist; a real `zsh -l -i -c 'command -v ocx'` and `bash -l -i -c 'command -v ocx'` with that `HOME` print the shim path. Windows: pure Path transform tests in CI; native registry write is not claimed as CI evidence. | `cargo test` locally on macOS and in the `desktop shell` job (Ubuntu, bash only); the real-shell check runs locally on macOS and is recorded in 039 | +| c-2 Desktop first ahead of npm | Same real-shell check with a fake npm `ocx` directory prepended earlier in `.zshrc`/`.bashrc` (written above the block, the way nvm/bun do); expect the shim to win. A second run appends an npm prepend *after* the block, then a Desktop relaunch (reconcile) moves the block back to the end; expect the shim to win again. | local macOS, recorded in 039 | +| c-3 launcher handoff | `tests/cli/ocx-launcher-desktop-handoff.test.ts` (020) | local focused run + hosted CI | +| c-4 refusal, idempotence, cleanup | Rust tests listed in 010; `tests/clients/desktop-cli-command-surface.test.ts` | `cargo test` + hosted CI | +| c-5 gates | `bun run typecheck`, the focused test files, `cargo fmt --check`/`clippy -D warnings`/`test`, `bun run structure:check`, `bun run privacy:scan`; exact-head Cross-platform CI including `desktop shell` | local + hosted | +| c-6 reviews | gpt-6.1-sol code review per PR (verdict PASS), gpt-6.1-sol security review of PR 1 (rc edits, PATH writes, record permissions, shim provenance) and of PR 2 (handoff exceptions, proof regeneration). Verdicts are pasted into the C>D attest of the owning phase. | subagents | +| c-7 merge | merge commit SHAs on `dev`, Co-authored-by not needed (no carried author), docs present in the merged tree | GitHub | + +The real-shell check is the only native evidence this unit can produce without packaging the app. A packaged smoke (installing a built OpenCodex.app and opening Terminal) remains human QA for the release checklist and is listed as residual in the PR descriptions. + +## Merge procedure + +1. PR 1 (`codex/desktop-owned-path-cli` → `dev`) after its gates pass. Before merging, rebase onto the latest `dev`, run `git merge-tree` against `origin/dev`, and rerun the focused checks if any of today's merges touched the same files. +2. PR 2 is stacked on PR 1's branch. After PR 1 merges, retarget PR 2 to `dev`, rebase it onto `dev` so PR 1's commits drop out, push, and wait for exact-head CI. +3. If lane L1's #6802/#6809/#6807 land before PR 2, rebase PR 2 onto them and rerun `tests/cli/ocx-launcher-*.test.ts` and the diagnostics tests; anchors in 020 are by function name. +4. Admin squash merge with `--match-head-commit`. After merging, confirm the squash commit on `dev` and that the next `dev` push CI is green or, when the Actions queue is backed up, that it is at least not red at the time of the report. + +## Docs checklist + +- `docs-site/src/content/docs/guides/desktop-app.md` and `ko/guides/desktop-app.md`: "The `ocx` terminal command" section (what Desktop writes, how to turn it off or remove it, new-terminal note, Windows system-`Path` caveat, AppImage exclusion), and the uninstall section updated to "remove the terminal command in Desktop first". +- Other locales of the desktop guide: no new section required, but no sentence may contradict the new behavior (for example "the desktop app does not add `ocx` to PATH"). Check with `rg -n "PATH|ocx" docs-site/src/content/docs/*/guides/desktop-app.md`. +- `structure/desktop-shell.md` (wp1) and `structure/runtime.md` (wp2, equal line count under the 600-line cap). + +## Done record + +wp3 closes with `039_outcome.md`: PR numbers, head and merge SHAs, CI run ids, review verdicts, real-shell transcript summary, residuals (Windows native selection, packaged smoke, AppImage, open shells), and the criterion evidence written into the goalplan. + + +## Additions from the plan audit (003, AM-10..AM-13) + +- **Dashboard entry check.** Besides the GUI unit test and source assertions, C records a real click-through: dashboard → "Terminal command" → `cli.html` → Back → dashboard. It needs a running Desktop build. When this unit cannot run a Desktop build locally, the check is listed as release QA in PR 1 and the GUI part is shown by the PR screenshot only; source assertions are never reported as this check. +- **A11 compiled check (AM-13).** Both runs use the same compiled host CLI. The control run starts from an environment without `OCX_NODE_LAUNCH_CONTEXT` and without any `--ocx-internal-launch-proof=` argument, so nothing from the shim run carries over. The dummy `ANTHROPIC_AUTH_TOKEN` value is a fixed non-secret string that does not match the proxy's own admission token format. Only variable names are printed. +- **Journal state table (AM-6, non-blocking item).** Rust tests cover install and remove × current digest equal to before / equal to after / different × operation wanted / not wanted, asserting that a different digest is never overwritten and that journal files are kept while any change is unresolved. The 64 KiB record cap stays separate from the raised `rcFiles ≤ 16` / `changes ≤ 32` limits. +- **Guard scope (AM-12).** The widened guard is used only by `return_to_dashboard`. The update commands keep `require_update_page`, and the four terminal-command commands keep the strict `require_cli_page`. Tests cover the `main` label, both allowed paths, and rejection of a foreign origin, another port and another path. + diff --git a/devlog/_plan/261009_desktop_owned_path_cli/031_wp3_review_fixes.md b/devlog/_plan/261009_desktop_owned_path_cli/031_wp3_review_fixes.md new file mode 100644 index 00000000000..b34ae53ae3a --- /dev/null +++ b/devlog/_plan/261009_desktop_owned_path_cli/031_wp3_review_fixes.md @@ -0,0 +1,67 @@ +# 031 — wp3 cycle 1: PR 1 review fixes + +PR 1 (#6816, `codex/desktop-owned-path-cli` at bcbd0363be) got two independent FAIL verdicts. This cycle fixes every P1 and P2 on the PR 1 branch, re-reviews, and then rebases PR 2 (#6818) onto it. Security details beyond what is written here stay out of the repository. + +## Findings and dispositions + +Code review (`01a11fad-f914`, FAIL): + +| # | Finding | Fix | Proof | +|---|---|---|---| +| R1 (P1) | A crash between writing journal files and saving the first pending record leaves journal files whose names the next attempt reuses with different bytes, so every later install fails with `journal-conflict` | Journal file names carry a per-transaction id (`--.json`, `txid` = random 128-bit hex created with the pending record). On lock acquisition, journal files not referenced by the current record's `pending` are unreferenced leftovers of an unsaved transaction and are deleted (they live in the private `journal/` dir and were never applied, because changes are applied only after the pending record is saved) | Rust test: write journal files, "crash" before saving the record, run install again → succeeds; leftover files removed | +| R2 (P1) | Windows: when `recover()` completes a registry change, the follow-up plan is empty and `WM_SETTINGCHANGE` is skipped; a failed broadcast is never retried | `recover()` reports whether it changed the registry; reconcile broadcasts when recovery or the plan changed the `Path`; the record's `windows` section gains `notifyPending: bool`, set before the registry write and cleared after a successful broadcast; Repair and the next launch broadcast while it is true | Rust test over the orchestration seam with a fake broadcaster: recovery-only change broadcasts once; failed broadcast sets `notifyPending` and the next run retries | +| R3 (P1) | Remove and Off call `posix::targets()` first, so a current `ZDOTDIR`/`XDG_CONFIG_HOME` that cannot be represented blocks removal of recorded files | Remove, Off and disabled reconcile use only the recorded ownership list (AM-7) and never compute current targets | Rust test: install, then set an unrepresentable `ZDOTDIR`; Remove succeeds and removes the recorded blocks | +| R4 (P2) | `cli.js`: an in-flight status poll that resolves after an action overwrites the action's result | Request generation counter: every action and poll takes a number; only the newest response renders; after an action, one fresh status read | `desktop-cli-command-surface.test.ts` VM case with delayed, reversed responses | +| R5 (P2) | Back from `cli.html` fails when the dashboard never became ready (startup failed), stranding the user away from Retry | `return_to_dashboard` falls back to the bundled startup page (`index.html`) when no ready dashboard exists, so Retry and diagnostics are reachable | Rust unit test on `return_ready_dashboard`'s fallback; surface assertion | + +Security review (`01a11fad-f9e1`, FAIL): + +| # | Finding | Fix | Proof | +|---|---|---|---| +| S1 (P1) | Private root, journal, backups and shim inherit ACL entries from the home directory; numeric 0700/0600 does not remove them | macOS: after creating `~/.opencodex-desktop` (and before creating anything inside), clear its extended ACL with `acl_set_file(path, ACL_TYPE_EXTENDED, acl_init(0))` and verify it is empty; subdirectories and files created afterwards inherit nothing. An existing root whose ACL cannot be cleared or verified is refused (`private-acl-unsafe`). Linux: remove `system.posix_acl_access` and `system.posix_acl_default` from the root, then verify absent; refuse otherwise. Windows: follow `src/lib/windows-secret-acl.ts`: grant the current user SID full control with object and container inheritance, then `/inheritance:r`, then remove broad SIDs (`*S-1-1-0`, `*S-1-5-11`, `*S-1-5-32-545`), via `icacls` with a timeout; failure refuses (`private-acl-unsafe`). FFI declarations only, no new crate | macOS Rust test: create a temp parent with an inheritable ACL (`chmod +a`), create the root through the store, assert no ACL on root and on a created journal file; Linux path unit-tested where `setfacl` exists, otherwise skipped with a message; Windows verified on `mini` with `icacls` output | +| S2 (P1) | Replacing an rc file by rename drops its ACL | An rc file with an extended ACL (macOS `acl_get_file` non-empty; Linux `system.posix_acl_access` present) is not edited; status reports `rc-acl-present` for that file. Private artifacts are hardened (S1); user rc ACLs are preserved by refusal | macOS Rust test: rc with `chmod +a "nobody deny read"` → install reports `rc-acl-present` and leaves bytes and ACL unchanged | + +## Worker split (PR 1 branch, one worktree) + +| Worker | Files | +|---|---| +| W6 Rust fixes | `desktop/src-tauri/src/cli_command_record.rs`, `cli_command_posix.rs`, `cli_command_windows.rs`, `cli_command.rs` (R1, R2, R3, S1, S2 and their tests) | +| W7 page and Back | `desktop/ui/cli.js`, `desktop/src-tauri/src/startup.rs` (`return_to_dashboard` fallback), `tests/clients/desktop-cli-command-surface.test.ts` (R4, R5) | + +## C checks + +macOS `cargo fmt --check`, `clippy --all-targets -D warnings`, `cargo test`, `cargo test real_shell -- --ignored`; Windows `mini` clippy + `cargo test cli_command` and an `icacls` readout of a hardened temp root; `bun test` surface/layout; then both reviewers re-review the fix commits and must return PASS (or NEAR-PASS with only P2 follow-ups). + + +## Reflection (same architect, 01a11f35): MISALIGNED → dispositions + +These replace the matching rows above. + +- **R1 (amended).** Leftover journal cleanup runs only under the lock, only after the current record was read and validated successfully, and only for regular files in `journal/` that match the journal name pattern and are not referenced by the record's `pending`. A corrupt or unreadable record never triggers cleanup. Why it is safe: a referenced journal is kept; an unreferenced file is either from an unsaved transaction (never applied) or from a transaction whose final record is already saved (its changes are recorded in the settled record), so neither is needed for recovery. The name validator accepts both `-.json` (records written by bcbd0363be) and `--.json`. Tests: crash before the pending save; crash after the final save and before journal deletion; corrupt record leaves the journal dir untouched; referenced journal kept. +- **R2 (amended).** The broadcast debt lives outside `windows`: a top-level record field `notifyPending: bool` with `#[serde(default)]` (old records read as false), set in the same record save that starts any registry change (install, repair, remove, rollback) and cleared only after a successful broadcast. It survives Remove (which clears `windows`). It is not `pending`; the 020 reader ignores it and handoff is never blocked by it. Fixtures: one record with `notifyPending: true` (reader → `ready`), and the existing fixtures without the field. +- **R3 (amended).** Every disabled path (Remove, Off, disabled reconcile at launch) reads the record first and uses only the recorded ownership list; `targets()` runs only for an enabled reconcile or Repair. Tests cover each of the three paths with an unrepresentable `ZDOTDIR`. +- **R4 (amended).** The generation check applies to errors as well as results; when a poll is in flight at the end of an action, the action schedules one more status read after that poll settles. Test covers both. +- **R5 (amended).** The main-label and exact-URL guard stay. Tests: ready dashboard → dashboard URL; no ready dashboard → bundled startup page. +- **S1 (amended).** Every Store open checks each existing private path (root, `bin`, `journal`, `backups`, `cli.json`, `lock`, shim, `path.sh`, each journal and backup file): owned by the current user, mode as expected, no extended ACL (macOS `acl_get_file` returns none or an empty ACL; Linux no `system.posix_acl_access`/`system.posix_acl_default` xattr). Newly created paths are cleared and then verified. Any existing path that fails is refused (`private-acl-unsafe`) and nothing is written; status tells the user which path. FFI: macOS `acl_get_file`/`acl_set_file`/`acl_init`/`acl_free`/`acl_get_entry` from libSystem with the documented constants, every allocation freed, any non-zero return treated as failure; Linux `getxattr`/`removexattr` from libc. Windows: `icacls` resolved as `%SystemRoot%\System32\icacls.exe` (absolute, `SystemRoot` read from the environment and validated as an absolute drive path), the current user SID from the process token (`OpenProcessToken` + `GetTokenInformation(TokenUser)` + `ConvertSidToStringSidW`), `Command` without a shell, a 10-second timeout after which the child is killed and waited, then `icacls ` output parsed to verify the DACL is protected (no inherited ACEs) and contains only the user SID, SYSTEM and Administrators; anything else refuses. +- **S2 (amended).** The rc ACL check runs before every rc write: install, reposition, removal, recovery replay and rollback. A refused file keeps its ownership entry so a later run can still remove the block once the ACL is gone. +- **C additions.** Linux ACL check runs where `setfacl` exists (CI Ubuntu has it; skip with a printed reason otherwise); ACL-failure and existing-child cases; reader regression over the updated fixtures after PR 2 rebases; `structure/desktop-shell.md` updated for `notifyPending`, leftover-journal cleanup and the ACL policy (within the 600-line cap). + + +## A audit round 1 (01a11f6b): FAIL → dispositions + +- **S1-macOS (blocker 1).** ACL inspection distinguishes three outcomes, each with its own return convention: `acl_get_file` returning NULL with `errno == ENOENT` means no ACL (accepted); a non-NULL ACL is walked with `acl_get_entry(acl, ACL_FIRST_ENTRY, &e)` then `ACL_NEXT_ENTRY`, where a return of `0` means an entry exists (refuse), and `-1` with `errno == EINVAL` on the first call means an empty ACL (accepted); any other NULL/`-1` is an inspection failure (refuse with `private-acl-unsafe`). Every ACL handle is freed with `acl_free`. Tests: empty ACL accepted; single inherited entry refused (created with `chmod +a` on a temp parent, and after clearing, accepted); inspection on a missing path is a failure; a fresh Store opens normally (positive case). +- **S1-Windows (blocker 2).** `icacls` is used only to set the policy (grant user SID with `(OI)(CI)F`, `/inheritance:r`, remove broad SIDs). Verification reads the native security descriptor with `GetNamedSecurityInfoW(OWNER | DACL)` and `GetSecurityDescriptorControl`: owner SID equals the token user SID; `SE_DACL_PROTECTED` is set; every ACE is an allow ACE for the user SID, `S-1-5-18` (SYSTEM) or `S-1-5-32-544` (Administrators), compared as SIDs with `EqualSid`, never by name. A newly created child is verified the same way except that inherited ACEs from the hardened root are allowed. Tests on `mini`: hardened root accepted; a root with inheritance not removed (unprotected, no inherited ACEs) refused; a root with an extra explicit ACE for `S-1-1-0` refused. FFI: `advapi32` declarations only, `LocalFree` for every returned buffer. +- **R1.** A missing record (first install) counts as a validated empty record: unreferenced journal files are cleaned under the lock in that case too. Test: crash before the first pending save on a fresh root → the next install succeeds. +- **R2.** Tests cover a settled record that keeps `notifyPending: true` after a failed broadcast, a crash after the registry write and before the broadcast, Remove and rollback, each retried on the next run. +- **R3.** Tests go through the real selection entry point (`perform`/`reconcile` with the environment-reading target selector) for Remove, Off and disabled reconcile, with `ZDOTDIR` set to a value containing `:`. +- **S2.** When an rc ACL refusal leaves a block in place during Remove, the shim and `path.sh` are kept as well (the block still sources `path.sh`), and status reports `rc-acl-present`. Test asserts the three survive together. +- **C.** The `desktop shell` CI job installs `acl` (provides `setfacl`) next to its Tauri packages; a skipped Linux ACL test is not counted as evidence. `structure/desktop-shell.md` changes replace existing lines to stay within 600. + + +## A audit round 2 (01a11f6b): FAIL → dispositions + +- **S1-Windows NULL DACL (blocker).** `GetNamedSecurityInfoW` must return a present, non-NULL DACL; a NULL DACL or a missing DACL refuses (`private-acl-unsafe`). The DACL must contain an allow ACE for the user SID granting `FILE_ALL_ACCESS` (or `GENERIC_ALL` mapped). Negative test on `mini`: owner and protection correct but DACL set to NULL with `SetNamedSecurityInfoW` → refused. +- **Root vs child rules.** Root: owner = user SID, `SE_DACL_PROTECTED`, only allowed SIDs, non-NULL DACL with the user's full-control ACE. Child: owner = user SID, non-NULL DACL, every ACE (inherited or explicit) for an allowed SID; protection not required. Tests: create root and a child, verify immediately, close, then open the Store again and verify both succeed. +- **Memory.** Only the security descriptor returned by `GetNamedSecurityInfoW` is released, with one `LocalFree`; owner and DACL pointers point inside it and are never freed separately. `ConvertSidToStringSidW` output (used only for logs, never compared) is freed with `LocalFree`. +- **macOS.** `NULL`/`ENOENT` from `acl_get_file` counts as "no ACL" only after an `lstat` of the same path succeeded; the missing-path test asserts failure. + diff --git a/devlog/_plan/261009_desktop_owned_path_cli/032_wp3_cycle1_outcome.md b/devlog/_plan/261009_desktop_owned_path_cli/032_wp3_cycle1_outcome.md new file mode 100644 index 00000000000..8cb9a60921c --- /dev/null +++ b/devlog/_plan/261009_desktop_owned_path_cli/032_wp3_cycle1_outcome.md @@ -0,0 +1,27 @@ +# 032 — wp3 cycle 1 outcome: PR 1 review fixes + +PR 1 (#6816) head a1326ccccd. Fix commits: 9c7a9e86fc (R1–R5, S1–S2 by W6 `01a11fc0-569d` and W7 `01a11fc0-57aa`), bcbf48d (exact unprotected-root test on Windows), 1f948d295f (structure doc, 600/600), a1326ccccd (R3 regression: every enabled reconcile observes the current bundle). + +## Reviews + +| Reviewer | Round 1 (bcbd0363be) | Final | +|---|---|---| +| Code (`01a11fad-f914`) | FAIL: R1–R3 P1, R4–R5 P2 | 1f948d295f: R1–R5 resolved, new P1 (stored record skipped bundle observation); a1326ccccd: **PASS** | +| Security (`01a11fad-f9e1`) | FAIL: S1–S2 P1 (inherited ACLs on private files; rc rewrite dropped ACLs) | 1f948d295f: **PASS** (macOS ACL return conventions, Linux xattrs, Windows owner/protected DACL/NULL DACL/allowed SIDs, icacls absolute path without shell and with timeout, fixed log codes) | + +## Evidence + +| Check | Result | +|---|---| +| macOS `cargo fmt --check` / `clippy --all-targets -D warnings` / `cargo test` | 0 / 0 / 246 passed, 2 ignored | +| macOS `cargo test real_shell -- --ignored` | 1 passed (real zsh and bash) | +| Windows 11 `mini` `clippy --all-targets -D warnings` / full `cargo test` | 0 / 188 passed (includes native DACL checks, NULL DACL refusal, root/child reopen) | +| `bun test` desktop CLI, update and startup surface | 43 pass | +| A11 compiled check (AM-13): host standalone CLI from `scripts/build-standalone.ts`, shim rendered by `render_shim_for_compiled_cli`, `claudeCode.enabled:false` (native route), fake `claude` printing protected names only, cwd `.env` with `ANTHROPIC_BASE_URL` | Shim + exported `ANTHROPIC_AUTH_TOKEN` → present; compiled CLI directly (control) → absent; shim + exported `ANTHROPIC_BASE_URL` → present; shim with `.env`-only `ANTHROPIC_BASE_URL` → absent. A11 verified. | + +The A11 check initially failed to render the shim under `/tmp`: on macOS `/tmp` is a symlink, and the file writer refuses symlinked ancestors, as designed. The check ran under a regular directory instead. + +## Still open (wp4) + +PR 2 review findings (security P1: record reader does not check owner/permissions/symlinks; code P2: FIFO record blocks the reader), rebase of both PRs onto `dev` after #6807 (merged 32abcc363d) and #6809, PR 1 GUI screenshot, exact-head CI, admin merges. + diff --git a/devlog/_plan/261009_desktop_owned_path_cli/040_wp4_land.md b/devlog/_plan/261009_desktop_owned_path_cli/040_wp4_land.md new file mode 100644 index 00000000000..4662f49b158 --- /dev/null +++ b/devlog/_plan/261009_desktop_owned_path_cli/040_wp4_land.md @@ -0,0 +1,62 @@ +# 040 — wp4: land both PRs + +wp4 takes PR 1 (#6816) and PR 2 (#6818) from review-complete to merged on `dev`. It fixes PR 2's review findings, rebases both PRs over lane L1's merged and pending launcher work, adds the GUI screenshot PR 1 needs, waits for exact-head CI, and admin-merges in stack order. + +## Current state + +- PR 1 head a1326ccccd + devlog commit 1cf9eada5c: code and security review PASS; merges cleanly into `origin/dev` (32abcc363d, after #6807). +- PR 2 head 53c2de3241 on the old PR 1: code review NEAR-PASS (P2), security review FAIL (P1). Against current `dev` it conflicts only in `structure/runtime.md` (with #6807); `bin/ocx.mjs`, `installation.md` and `service-and-sidecars.md` auto-merge. +- #6809 (L1) is open at f8fa8c5465 and also edits `bin/ocx.mjs`; it is rebasing onto #6807. + +## PR 2 fixes + +| # | Finding | Fix | Test | +|---|---|---|---| +| P2-S1 (security P1) | The record reader follows symlinks and accepts a record or directory writable by others, so a same-machine attacker who can write there chooses what `ocx` executes | POSIX: `lstat` `~/.opencodex-desktop` and `cli.json`; both must be owned by the effective uid, neither may be a symlink, and neither may have group or other permission bits (`mode & 0o077 === 0`, matching the writer's 0700/0600). Open with `O_RDONLY | O_NOFOLLOW | O_NONBLOCK`, then `fstat` the descriptor and require a regular file with the same `dev`/`ino` as the `lstat`. Any failure is `record-unsafe` (new issue code, handled like the other errors: AM-2 message, no handoff). Windows: refuse when the directory or the record is a reparse point (`lstat().isSymbolicLink()` or a junction); ACL ownership is not read in the launcher hot path. The Desktop writer hardens and verifies this directory's protected DACL on every launch (031 S1), and the residual is stated in PR 2 | Unit tests on a temp HOME: mode 0666 record, 0777 directory, record symlink, directory symlink, foreign owner (skipped unless running as root, with a printed reason), each → `record-unsafe` and no spawn; valid 0700/0600 → `ready` | +| P2-C1 (code P2) | A FIFO at `cli.json` blocks `openSync` forever | Covered by `O_NONBLOCK` + regular-file check above | Subprocess test with `mkfifo` and a 2-second timeout → `record-unsafe` or `record-invalid` returned promptly | + +The diagnostics (`status`/`doctor`) use the same reader and report `record-unsafe` as FAIL with the path. + +## Rebase and order + +1. PR 1: rebase `codex/desktop-owned-path-cli` onto the latest `origin/dev` (clean today), push, and wait for exact-head CI. +2. PR 2: rebase `codex/desktop-cli-handoff` onto the rebased PR 1, resolve `structure/runtime.md` by keeping #6807's text and re-applying this PR's single-row change at zero line growth, re-run the launcher, managing-cli, handoff and diagnostics tests, then push. +3. If #6809 lands first, PR 2 rebases again over it; `bin/ocx.mjs` anchors are `const codexCliUpdateInspection` and the mise `if`. + +## PR 1 screenshot + +PR 1 changes `gui/`, so `enforce-target` requires a screenshot. Start an isolated proxy from PR 1's tree (`OPENCODEX_HOME` and `HOME` in a temp directory, an unused port), open the dashboard in headless Chrome with a user agent that contains `OpenCodexDesktop/2.82.0` so `isDesktopShell()` is true, and capture the sidebar showing "Terminal command" next to the Desktop update row. Upload the image to the `pr-assets` branch and link it by commit SHA in PR 1's description (AGENTS.md). Also attach the rendered `cli.html` screenshot from wp1. + +## Merge + +1. PR 1: all non-policy checks pass on its exact head, both reviews PASS, merge-tree clean → admin squash merge with `--match-head-commit`; mark ready first. +2. PR 2: retarget to `dev`, rebase so PR 1's commits drop out, push, both reviewers re-check the fix commits and return PASS, exact-head CI → admin squash merge. +3. After both: update the goalplan criteria with evidence, write `049_outcome.md`, and close the goal. + +## C checks + +PR 2 focused tests (handoff, diagnostics, launcher runtime/source, managing-cli, status JSON, doctor, layout), `node --check bin/ocx.mjs`, typecheck, structure, privacy, ratchet; the real-launcher smoke from 021; exact-head CI for both PRs; merge SHAs on `dev`. + + +## Reflection (same architect, 01a11f35): MISALIGNED → dispositions + +These replace the matching parts above. + +- **Windows: no package-launcher handoff.** Reading the current owner and DACL from the Node launcher needs a PowerShell/.NET or native helper call on every `ocx` invocation, and trusting the writer's last verification does not cover a launcher that runs before Desktop or after Desktop refused an unsafe state. On Windows the reader still runs for `status`/`doctor` diagnostics, but `planDesktopCliHandoff` returns "continue" (no handoff) on `win32`. The Windows path to the Desktop CLI is the user `Path` entry from PR 1, which puts the Desktop install directory ahead of `%APPDATA%\\npm` in new shells. PR 2's description and the installation page say so. Test: on a simulated `win32` platform a valid record produces no spawn. +- **POSIX reader order and checks.** Safety checks run before any JSON parsing or disabled/pending interpretation: `lstat` of `~/.opencodex-desktop` must be a real directory (not a symlink), owned by the effective uid, `mode & 0o077 === 0`; `lstat` of `cli.json` must be a regular file, not a symlink, same owner, `mode & 0o077 === 0`; open with `O_RDONLY | O_NOFOLLOW | O_NONBLOCK`; `fstat` must report a regular file with the same `dev`/`ino`, owner and mode bits as the `lstat` (replacement between `lstat` and `open` fails). macOS extended ACLs: `/bin/ls -lde ` (absolute path, no shell, 1-second timeout) must list no ACL entries; any entry or a failed or timed-out check → `record-unsafe`. Linux: a POSIX ACL shows up in the group bits (mask), which the mode check already refuses; no extra call. Platform/kind, path rules, 64 KiB, disabled-over-pending stay as before. `.d.mts` issue union and the diagnostics' issue list gain `record-unsafe`. +- **Tests.** Added: directory symlink, record symlink, 0666 record, 0777 directory, record replaced between `lstat` and `open` (simulated through the injected fs seam), FIFO with a 2-second timeout, macOS ACL entry on the directory and on the record (`chmod +a` on temp paths; skipped off macOS with a printed reason), valid 0700/0600 → `ready`. +- **Rebase review.** After auto-merging `bin/ocx.mjs` with #6807 (and #6809 if it lands first), compare the proof factory, the maintenance/update exceptions and #6807's PATH Bun fallback against both parents, then rerun the launcher runtime/source, handoff, managing-cli and smoke checks. +- **Merge gate for these two PRs.** Every check on the exact head passes, policy checks included (`enforce-target`, PR hygiene); a queued policy check is rerun and waited for instead of being treated as an exception. `--match-head-commit` and a fresh `merge-tree` against the latest `dev` immediately before each merge. +- **Screenshot.** Taken from PR 1's merge-candidate GUI build; the PR text says the Desktop shell was simulated with a user-agent override, so it shows the sidebar entry only, not Tauri navigation. +- **Close-out.** `049_outcome.md` records the post-merge `dev` CI result and the remaining packaged/native QA items (packaged click-through, fish, Linux deb, Windows terminal selection after a real registry write). + + +## Audit (independent, 01a11f6b): NEAR-PASS + +Folded: Windows returns "continue" before any record read or target check, with tests for valid, unreadable and unsafe records; reader fixtures are written with explicit 0600 files in 0700 directories, and foreign ownership is also tested through the injected stat seam; the FIFO test passes only when the reader returns a refusal within the time limit (a timeout is a failure); Windows handoff expectations, the installation page and the CLI reference are updated together; the screenshot is embedded as an image in PR 1's description; after PR 1's squash, PR 2 is rebased with `git rebase --onto origin/dev ` and re-reviewed and re-tested on its final head. Current red checks (PR 1 `enforce-target` for the missing screenshot and `test 2/4`; PR 2 `ci`) are inspected and fixed or rerun before any merge. + + +## B finding: no child-process kill in the desktop shell + +PR 1's CI (`test 2/4`) failed `tests/clients/desktop-exit-ownership.test.ts` "no file in the shell kills the runtime process": the shell sources must not contain `.kill()`, and the `icacls` timeout in `cli_command_windows.rs` killed its child. The invariant stays. The Windows hardening is rewritten to set the DACL natively instead of running `icacls`: build an ACL with `InitializeAcl` and `AddAccessAllowedAceEx` (`OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE` on directories) for the token user SID (full control), SYSTEM and Administrators, then `SetNamedSecurityInfoW(path, SE_FILE_OBJECT, DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, ...)`, then the existing native verification. No child process, no trusted executable path, no timeout. Tests on `mini` stay as they are (hardened root, unprotected root, NULL DACL, extra Everyone ACE, root/child reopen). The security reviewer re-checks this change. + diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md index dadd2064a66..dd48c08f37a 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -73,6 +73,31 @@ or Task Manager remain disabled. ## Using the ocx CLI with the desktop app +On a stable macOS app, Windows installation, or installed Linux deb, Desktop automatically +configures its bundled `ocx` for new terminals at launch. Open **Terminal command** beside +**Desktop update** in the dashboard sidebar, or **Terminal command…** in the tray menu, +to turn it off, repair it, or remove it. Turning off **Use Desktop's ocx command in new +terminals** removes the managed configuration; **Remove terminal command** also keeps +the off choice for future launches. AppImage and development launches do not configure it. + +macOS and Linux use a shim in `~/.opencodex-desktop/bin`, a shared `path.sh` helper and +managed blocks in zsh, bash and fish startup files. Desktop records ownership and recovery +information in `~/.opencodex-desktop/cli.json`; deleting that record does not remove the +command or its shell blocks. The Linux deb's `/usr/bin/ocx` remains unchanged. Windows +prepends the installation directory to the user `Path`, preserving its other entries. +A command in the Windows system `Path` can still take precedence; Desktop reports that +conflict as partial configuration and does not change the system `Path`. + +After enabling or repairing, open a new terminal. Check `type -a ocx` on macOS/Linux, +or `Get-Command ocx -All` and `where.exe ocx` on Windows. Existing shells, aliases, +absolute commands and later PATH changes can still select another executable. +Configuration persists after Desktop quits. If the bundle is missing, the POSIX shim +fails with repair/removal guidance instead of silently selecting another `ocx`. + +On POSIX, the shim preserves shell-exported Anthropic settings when launch-proof tools +are available. If proof generation fails, and for Windows direct execution, the bundled +CLI retains its existing stripping of untrusted Anthropic environment settings. + On macOS and Linux, `ocx status` shows `Runtime supervisor: OpenCodex Desktop` when it verifies that the desktop app runs its bundled proxy, even without recorded ownership. Turn on **Start at Login** in the OpenCodex menu instead of installing a background @@ -230,6 +255,12 @@ local snapshot details. ## Uninstall +Before uninstalling a supported Desktop installation, open **Terminal command** in the +dashboard or tray and choose **Remove terminal command**. Desktop removes only its unchanged +managed configuration. Modified blocks or files are preserved and reported; resolve those +issues before deleting the app. Uninstalling the app directly does not guarantee cleanup +of shell files or the user `Path`. + On macOS, drag `OpenCodex.app` from Applications to the Trash. On Windows, remove OpenCodex from **Installed apps**. On Debian-based Linux systems, run: diff --git a/docs-site/src/content/docs/ko/guides/desktop-app.md b/docs-site/src/content/docs/ko/guides/desktop-app.md index c53dec5ff8f..97a36990665 100644 --- a/docs-site/src/content/docs/ko/guides/desktop-app.md +++ b/docs-site/src/content/docs/ko/guides/desktop-app.md @@ -48,6 +48,31 @@ macOS에서는 대시보드를 닫아도 앱이 메뉴 막대에서 계속 실 ## 데스크톱 앱과 ocx CLI 함께 사용하기 +안정된 위치의 macOS 앱, Windows 설치본, 설치된 Linux deb에서는 Desktop이 시작할 때 +새 터미널이 번들 `ocx`를 사용하도록 자동 설정합니다. 대시보드 사이드바의 데스크톱 +업데이트 옆 **터미널 명령**, 또는 트레이 메뉴의 **Terminal command…**에서 끄기, +복구, 제거를 선택하세요. **Use Desktop's ocx command in new terminals**을 끄면 관리 +설정을 제거합니다. **Remove terminal command**도 꺼 둔 선택을 저장하며, 재시작 후에도 +자동으로 다시 설치하지 않습니다. AppImage와 개발 실행에서는 터미널 명령을 설정하지 않습니다. + +macOS와 Linux에서는 `~/.opencodex-desktop/bin`의 shim, 공유 `path.sh` 도우미, +zsh·bash·fish 시작 파일의 관리 블록을 사용합니다. 소유권과 복구 정보는 +`~/.opencodex-desktop/cli.json`에 저장합니다. 이 기록만 삭제해도 명령이나 셸 블록은 +제거되지 않습니다. Linux deb의 `/usr/bin/ocx`는 그대로 유지됩니다. Windows에서는 +설치 디렉터리를 사용자 `Path` 앞에 추가하고 나머지 항목을 보존합니다. Windows 시스템 +`Path`의 다른 명령이 먼저 선택될 수 있으며, Desktop은 이를 부분 적용으로 표시하고 +시스템 `Path`는 변경하지 않습니다. + +설정을 켜거나 복구한 뒤에는 새 터미널을 여세요. macOS/Linux에서는 `type -a ocx`, +Windows에서는 `Get-Command ocx -All`과 `where.exe ocx`로 확인하세요. 이미 열린 셸, +별칭, 절대 경로 명령, 이후 PATH 변경은 다른 실행 파일을 선택할 수 있습니다. +Desktop을 종료해도 설정은 유지됩니다. 번들이 사라지면 POSIX shim은 다른 `ocx`를 +조용히 선택하지 않고 복구 또는 제거 안내와 함께 실패합니다. + +POSIX에서는 launch proof 생성 도구가 있으면 셸에서 export한 Anthropic 설정을 +보존합니다. proof를 생성할 수 없거나 Windows에서 직접 실행하면 신뢰되지 않은 +Anthropic 환경 설정을 제거하는 기존 번들 CLI 동작을 유지합니다. + macOS와 Linux에서 데스크톱 앱이 번들 프록시를 실행하는 것으로 확인되면, `ocx status`에 `Runtime supervisor: OpenCodex Desktop`이 표시됩니다. 기록된 소유권이 없어도 이 관계를 확인할 수 있습니다. 백그라운드 서비스를 @@ -109,6 +134,12 @@ macOS 앱에는 OpenCodex WidgetKit 확장이 포함됩니다. 위젯 설정과 ## 제거 +지원되는 Desktop 설치본을 제거하기 전에 대시보드나 트레이의 **Terminal command**를 +열어 **Remove terminal command**를 선택하세요. Desktop은 변경되지 않은 자신의 관리 +설정만 제거합니다. 사용자가 수정한 블록이나 파일은 보존하고 알려 주므로, 앱을 삭제하기 +전에 해당 문제를 처리하세요. 앱만 직접 제거하면 셸 파일이나 사용자 `Path` 정리가 +보장되지 않습니다. + macOS에서는 Applications의 `OpenCodex.app`을 휴지통으로 옮깁니다. Windows에서는 **Installed apps**에서 OpenCodex를 제거합니다. Debian 기반 Linux에서는 다음 명령을 실행합니다. ```bash diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 754430c3ca3..9368826439a 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -30,7 +30,7 @@ import { useAppRouteState } from "./use-app-route-state"; import { requestProxyStop } from "./stop-proxy"; import { useCodexRestart } from "./use-codex-restart"; import { confirmAction } from "./action-dialogs"; -import { hostOs, isDesktopShell, isExternalLink, openDesktopUpdatePage } from "./lib/desktop-shell"; +import { hostOs, isDesktopShell, isExternalLink, openDesktopCliPage, openDesktopUpdatePage } from "./lib/desktop-shell"; import { zoomManagedOn } from "./lib/desktop-zoom"; import { useSidebarCollapse } from "./use-sidebar-collapse"; import { useDesktopZoom } from "./use-desktop-zoom"; @@ -537,6 +537,10 @@ export default function App() { if (openDesktopUpdatePage()) return; navigateToPage("dashboard", "update"); }} + onOpenTerminalCommand={() => { + setNavOpen(false); + openDesktopCliPage(); + }} />
    diff --git a/gui/src/components/sidebar-github-row.tsx b/gui/src/components/sidebar-github-row.tsx index 8b5ae752abb..227d9dfa814 100644 --- a/gui/src/components/sidebar-github-row.tsx +++ b/gui/src/components/sidebar-github-row.tsx @@ -1,5 +1,5 @@ /** - * Sidebar footer row: the GitHub link plus two circular satellite buttons. + * Sidebar footer row: the GitHub link plus circular satellite buttons. * * Star: reflects the real state from `gh` rather than always inviting a click. * Already starred renders a filled, non-interactive marker; an unauthenticated @@ -15,7 +15,7 @@ */ import { useState } from "react"; import { useKeyedClientResource } from "../client-resource"; -import { IconDownload, IconGithub, IconStar } from "../icons"; +import { IconDownload, IconGithub, IconStar, IconTerminal } from "../icons"; import { useT } from "../i18n/shared"; import { isDesktopShell, updateBadgeUrl } from "../lib/desktop-shell"; @@ -47,10 +47,13 @@ async function readJson(url: string, signal: AbortSignal): Promise export function SidebarGithubRow({ apiBase, onOpenUpdate, + onOpenTerminalCommand, }: { apiBase: string; /** Opens desktop updates in the app shell, or the package update surface in a browser. */ onOpenUpdate: () => void; + /** Opens the local terminal-command settings in the desktop app shell. */ + onOpenTerminalCommand: () => void; }) { const t = useT(); const [starring, setStarring] = useState(false); @@ -157,6 +160,17 @@ export function SidebarGithubRow({
    ); diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 27133b7b80d..62e7977622b 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -208,6 +208,7 @@ export const de: Record = { "sidebar.updateAvailable": "Update verfügbar: {version}", "sidebar.checkUpdate": "Nach Updates suchen", "sidebar.desktopUpdate": "Desktop-Updates öffnen", + "sidebar.terminalCommand": "Terminalbefehl", "common.save": "Speichern", "common.saving": "Speichern…", "common.cancel": "Abbrechen", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index ae8fc444e6c..705ce95ad76 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -141,6 +141,7 @@ export const en = { "sidebar.updateAvailable": "Update available: {version}", "sidebar.checkUpdate": "Check for updates", "sidebar.desktopUpdate": "Open desktop updates", + "sidebar.terminalCommand": "Terminal command", "common.save": "Save", "common.saving": "Saving…", "common.cancel": "Cancel", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 87bb15068f5..ce26f281cfa 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -138,6 +138,7 @@ export const fr: Record = { "sidebar.updateAvailable": "Mise à jour disponible : {version}", "sidebar.checkUpdate": "Rechercher des mises à jour", "sidebar.desktopUpdate": "Ouvrir les mises à jour de l’application", + "sidebar.terminalCommand": "Commande du terminal", "common.save": "Enregistrer", "common.saving": "Enregistrement…", "common.cancel": "Annuler", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 187ec2231ef..d6f87351d7d 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -208,6 +208,7 @@ export const ja: Record = { "sidebar.updateAvailable": "更新あり: {version}", "sidebar.checkUpdate": "更新を確認", "sidebar.desktopUpdate": "デスクトップアプリの更新を開く", + "sidebar.terminalCommand": "ターミナルコマンド", "common.save": "保存", "common.saving": "保存中…", "common.cancel": "キャンセル", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 0a563c25a99..41276a77ca9 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -208,6 +208,7 @@ export const ko: Record = { "sidebar.updateAvailable": "업데이트 있음: {version}", "sidebar.checkUpdate": "업데이트 확인", "sidebar.desktopUpdate": "데스크톱 앱 업데이트 열기", + "sidebar.terminalCommand": "터미널 명령", "common.save": "저장", "common.saving": "저장 중…", "common.cancel": "취소", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 749cd3d59d8..cbc379f7188 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -163,6 +163,7 @@ export const pt: Record = { "sidebar.updateAvailable": "Atualização disponível: {version}", "sidebar.checkUpdate": "Verificar atualizações", "sidebar.desktopUpdate": "Abrir atualizações do desktop", + "sidebar.terminalCommand": "Comando do terminal", "common.save": "Salvar", "common.saving": "Salvando…", "common.cancel": "Cancelar", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index d2837c3eabc..0e916d3ad77 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -208,6 +208,7 @@ export const ru: Record = { "sidebar.updateAvailable": "Доступно обновление: {version}", "sidebar.checkUpdate": "Проверить обновления", "sidebar.desktopUpdate": "Открыть обновления приложения", + "sidebar.terminalCommand": "Команда терминала", "common.save": "Сохранить", "common.saving": "Сохранение…", "common.cancel": "Отмена", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 82b7885e233..9e57c58adc1 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -140,6 +140,7 @@ export const tr: Record = { "sidebar.updateAvailable": "Güncelleme mevcut: {version}", "sidebar.checkUpdate": "Güncellemeleri kontrol et", "sidebar.desktopUpdate": "Masaüstü güncellemelerini aç", + "sidebar.terminalCommand": "Terminal komutu", "common.save": "Kaydet", "common.saving": "Kaydediliyor…", "common.cancel": "İptal", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index eed6bc70fb6..220c78d7f7f 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -139,6 +139,7 @@ export const vi: Record = { "sidebar.updateAvailable": "Bản cập nhật có sẵn: {version}", "sidebar.checkUpdate": "Kiểm tra bản cập nhật", "sidebar.desktopUpdate": "Mở cập nhật ứng dụng máy tính", + "sidebar.terminalCommand": "Lệnh terminal", "common.save": "Lưu", "common.saving": "Đang lưu…", "common.cancel": "Hủy", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index b667f163793..2b3c05a750d 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2598,6 +2598,7 @@ export const zhTW: Record = { "sidebar.updateAvailable": "有可用更新:{version}", "sidebar.checkUpdate": "檢查更新", "sidebar.desktopUpdate": "開啟桌面應用程式更新", + "sidebar.terminalCommand": "終端機指令", "dash.mem.jsHeapArena": "arena {total}", "dash.mem.pressure": "相對於警告閾值", "dash.mem.pressureOf": "警告閾值的 {pct}%", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 9eb78fc5e1c..a336d6066c6 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -208,6 +208,7 @@ export const zh: Record = { "sidebar.updateAvailable": "有可用更新:{version}", "sidebar.checkUpdate": "检查更新", "sidebar.desktopUpdate": "打开桌面应用更新", + "sidebar.terminalCommand": "终端命令", "common.save": "保存", "common.saving": "保存中…", "common.cancel": "取消", diff --git a/gui/src/lib/desktop-shell.ts b/gui/src/lib/desktop-shell.ts index bc471630a68..c5f9cf88d17 100644 --- a/gui/src/lib/desktop-shell.ts +++ b/gui/src/lib/desktop-shell.ts @@ -48,6 +48,21 @@ export function openDesktopUpdatePage(ua = currentUserAgent()): boolean { return true; } +export function desktopCliPageUrl(ua = currentUserAgent()): string | null { + if (!isDesktopShell(ua)) return null; + const os = hostOs(ua); + if (os === "windows") return "http://tauri.localhost/cli.html"; + if (os === "macos" || os === "linux") return "tauri://localhost/cli.html"; + return null; +} + +export function openDesktopCliPage(ua = currentUserAgent()): boolean { + const url = desktopCliPageUrl(ua); + if (!url) return false; + window.location.assign(url); + return true; +} + export function isExternalLink( href: string, origin = typeof location === "undefined" ? "" : location.origin, diff --git a/gui/tests/desktop-shell.test.ts b/gui/tests/desktop-shell.test.ts index db9739897be..e85de322170 100644 --- a/gui/tests/desktop-shell.test.ts +++ b/gui/tests/desktop-shell.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { + desktopCliPageUrl, desktopSession, desktopUpdatePageUrl, desktopShellVersion, @@ -50,6 +51,16 @@ describe("desktop shell user-agent helpers", () => { .toBe("/api/update/badge"); }); + test("routes terminal command settings to each platform's exact app origin", () => { + expect(desktopCliPageUrl(tauriMac)).toBe("tauri://localhost/cli.html"); + expect(desktopCliPageUrl(tauriLinux)).toBe("tauri://localhost/cli.html"); + expect(desktopCliPageUrl(tauriWindows)).toBe("http://tauri.localhost/cli.html"); + for (const ua of [tauriMac, tauriLinux, tauriWindows]) { + expect(desktopCliPageUrl(ua.replace(/ OpenCodexDesktop\/\S+/, ""))).toBeNull(); + } + expect(desktopCliPageUrl("OpenCodexDesktop/2.61.0")).toBeNull(); + }); + test("recognizes only absolute cross-origin HTTP links", () => { expect(isExternalLink("https://example.com/a", "http://127.0.0.1:10100")).toBe(true); expect(isExternalLink("http://127.0.0.1:10100/a", "http://127.0.0.1:10100")).toBe(false); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 0d15c27d151..589bda24b7f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -851,6 +851,7 @@ "desktop-3p.test.ts": "clients", "desktop-app-restart.test.ts": "clients", "desktop-cli-contracts.test.ts": "clients", + "desktop-cli-command-surface.test.ts": "clients", "desktop-exit-ownership.test.ts": "clients", "desktop-host-visibility.test.ts": "clients", "desktop-install-identity.test.ts": "clients", diff --git a/structure/INDEX.md b/structure/INDEX.md index 28bb9bd2609..270ebdd45c5 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -95,6 +95,7 @@ Background service, docs, release, and design discipline. | Doc | Scope | | --- | --- | | [`desktop-shell.md`](desktop-shell.md) | Tauri desktop shell, proxy attachment and sidecar lifecycle, tray controls, bootstrap navigation, and desktop companion presence. | +| [`desktop-terminal-command.md`](desktop-terminal-command.md) | Desktop-owned ocx command on PATH: record and journal, POSIX shim and shell blocks, Windows user Path, private-path ACL policy, and the local Terminal command page. | | [`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | Service install/repair, platform launchers, tray, and sidecar processes. | | [`ops/plugins.md`](ops/plugins.md) | Plugin loading from OPENCODEX_HOME/plugins and the upstream rewrite slot plugins attach to. | | [`ops/docs-and-release.md`](ops/docs-and-release.md) | Docs site, workflow map, branch policy, release flow, and cross-platform CI. | @@ -113,6 +114,12 @@ A source area can be described by more than one doc, because these docs are orga | `app/` | [`overview.md`](overview.md)
    [`companion.md`](companion.md) | | `bin/` | [`runtime.md`](runtime.md)
    [`ops/docs-and-release.md`](ops/docs-and-release.md) | | `desktop/` | [`desktop-shell.md`](desktop-shell.md)
    [`companion.md`](companion.md) | +| `desktop/src-tauri/src/cli_command.rs` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | +| `desktop/src-tauri/src/cli_command_posix.rs` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | +| `desktop/src-tauri/src/cli_command_record.rs` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | +| `desktop/src-tauri/src/cli_command_windows.rs` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | +| `desktop/ui/cli.html` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | +| `desktop/ui/cli.js` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | | `docs-site/` | [`ops/docs-and-release.md`](ops/docs-and-release.md) | | `gui/` | [`overview.md`](overview.md)
    [`gui-and-management-api.md`](gui-and-management-api.md)
    [`dashboard-and-usage.md`](dashboard-and-usage.md)
    [`design-methodology.md`](design-methodology.md)
    [`companion.md`](companion.md) | | `scripts/` | [`overview.md`](overview.md)
    [`ops/docs-and-release.md`](ops/docs-and-release.md) | diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index e5f1fc5ce39..8e9b9538b5e 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -460,6 +460,11 @@ authentication remains in the shared API boundary. The desktop webview uses a Mozilla-compatible `OpenCodexDesktop/` user-agent marker, which the GUI detects to identify the shell without using IPC. +## Desktop-owned terminal command + +Installing, repairing and removing the Desktop-owned `ocx` command on PATH is documented in +[Desktop-owned terminal command](desktop-terminal-command.md). + ## Release packaging and updater ### Linux packaged-shell acceptance diff --git a/structure/desktop-terminal-command.md b/structure/desktop-terminal-command.md new file mode 100644 index 00000000000..b9d0e753253 --- /dev/null +++ b/structure/desktop-terminal-command.md @@ -0,0 +1,37 @@ +# Desktop-owned terminal command + +How OpenCodex Desktop puts its bundled `ocx` first on the user's PATH, how it records what it owns, and +what it refuses to touch. The shell surrounding this feature (startup sequence, tray, update page) is +described in [Desktop shell](desktop-shell.md); design history is in +`devlog/_plan/261009_desktop_owned_path_cli/`. + +`desktop/src-tauri/src/cli_command.rs` schedules one blocking reconcile after launch-origin +adoption, independently of proxy startup. Stable macOS apps, Windows installs and Linux deb +installs configure the command by default; off survives launch and removal. AppImage, +temporary, translocated, debug and unpackaged launches do not install it. + +`desktop/src-tauri/src/cli_command_record.rs` owns the private `.opencodex-desktop/cli.json` +record (64 KiB cap), OS-backed lock and external pending journals with before/after digests. +The record grants generated-file ownership only, never runtime, service or shutdown authority. +An enabled record carries a validated host-platform bundle even on its first pending save; +disabled intent wins over pending work. Invalid records and journal conflicts block mutation; +unreferenced journals are deleted only under the lock after a valid (or absent) record read. +Every private path must be user-owned without extended ACLs (macOS/Linux) or carry a protected +user/SYSTEM/Administrators DACL (Windows root; children inherit it), otherwise nothing is written. + +`desktop/src-tauri/src/cli_command_posix.rs` generates a fail-closed shim, shared PATH helper +and final managed zsh/bash/fish blocks. Reconcile separates desired rc targets from recorded +ownership; remove walks the recorded list and preserves modified blocks and unsafe files. +An rc file with an ACL is never rewritten (`rc-acl-present`). The shim captures exported +Anthropic slot names with an argv-bound random proof before dotenv; failure keeps stripping. + +`desktop/src-tauri/src/cli_command_windows.rs` preserves raw HKCU Path text/type, changes only +the owned entry and broadcasts Environment; a top-level `notifyPending` debt survives crashes and +removal until a broadcast succeeds. Machine-Path conflicts are partial; open shells unobserved. + +The tray and desktop-only dashboard entry beside Desktop update open `desktop/ui/cli.html`; +`desktop/ui/cli.js` renders only the newest status or action response and never sends a path. +`gui/src/lib/desktop-shell.ts` selects the platform's exact app origin. Four path-free commands +are guarded by the main window's local CLI page; Back uses `return_to_dashboard`, guarded for +the update and CLI pages, falling back to the startup page when no dashboard is ready. Removal +saves off first. Configuration cannot enforce selection against aliases or later PATH edits. diff --git a/structure/manifest.json b/structure/manifest.json index f0e0dbb5d89..8c9027384ad 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -516,6 +516,20 @@ "src/server/management/companion-routes.ts" ] }, + { + "path": "desktop-terminal-command.md", + "tier": 6, + "title": "Desktop-owned terminal command", + "scope": "Desktop-owned ocx command on PATH: record and journal, POSIX shim and shell blocks, Windows user Path, private-path ACL policy, and the local Terminal command page.", + "documents": [ + "desktop/src-tauri/src/cli_command.rs", + "desktop/src-tauri/src/cli_command_record.rs", + "desktop/src-tauri/src/cli_command_posix.rs", + "desktop/src-tauri/src/cli_command_windows.rs", + "desktop/ui/cli.html", + "desktop/ui/cli.js" + ] + }, { "path": "ops/service-and-sidecars.md", "tier": 6, diff --git a/tests/clients/desktop-cli-command-surface.test.ts b/tests/clients/desktop-cli-command-surface.test.ts new file mode 100644 index 00000000000..1c8534777fd --- /dev/null +++ b/tests/clients/desktop-cli-command-surface.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; +import { repoPath } from "../helpers/repo-root"; +import { desktopCliPageUrl, openDesktopCliPage } from "../../gui/src/lib/desktop-shell"; + +const read = (path: string) => readFileSync(repoPath(path), "utf8"); +const lib = read("desktop/src-tauri/src/lib.rs"); +const policy = read("desktop/src-tauri/src/window.rs"); +const page = read("desktop/ui/cli.html"); +const script = read("desktop/ui/cli.js"); +const commands = ["cli_status", "cli_set_enabled", "cli_install", "cli_remove"]; + +function commandBody(name: string): string { + const body = lib.match(new RegExp("(?:async )?fn " + name + "\\([\\s\\S]*?(?=\\n#\\[tauri::command\\]|\\npub fn run)"))?.[0]; + if (!body) throw new Error(`missing native command ${name}`); + return body; +} + +function harness(invoke?: (name: string, args?: unknown) => Promise) { + const handlers = new Map void>(); + const nodes = new Map(["enabled", "state", "target", "issues", "error", "repair", "remove", "back", "titlebar"].map(id => [id, { + checked: false, disabled: false, hidden: false, textContent: "", children: [] as Array<{ textContent: string }>, + addEventListener: (event: string, callback: () => void) => { handlers.set(`${id}:${event}`, callback); }, + replaceChildren(...items: Array<{ textContent: string }>) { this.children = items; }, + }])); + const timers = new Map void>(); + let poll: () => void = () => {}; + let nextTimer = 0; + runInNewContext(script, { + window: { __TAURI__: invoke ? { core: { invoke } } : undefined }, + document: { getElementById: (id: string) => nodes.get(id), createElement: () => ({ textContent: "" }) }, + setTimeout: (callback: () => void) => { const id = ++nextTimer; timers.set(id, callback); return id; }, + clearTimeout: (id: number) => { timers.delete(id); }, + setInterval: (callback: () => void) => { poll = callback; return 1; }, Promise, Error, + }); + return { nodes, handlers, timers, poll: () => poll() }; +} + +async function settle() { for (let i = 0; i < 12; i += 1) await Promise.resolve(); } + +function deferred() { + let resolve!: (value: unknown) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +const configured = { enabled: true, phase: "configured", expectedExecutable: "/example/ocx", issues: [] }; +const disabled = { ...configured, enabled: false, phase: "disabled" }; + +describe("desktop CLI command surface", () => { + test("registers four path-free commands and guards every wrapper before dispatch", () => { + const registered = lib.match(/generate_handler!\[([\s\S]*?)\]/)?.[1] || ""; + for (const name of commands) { + expect(registered).toContain(name); + const body = commandBody(name); + const guard = body.indexOf("window::require_cli_page(&window)?"); + expect(guard).toBeGreaterThan(-1); + expect(guard).toBeLessThan(body.lastIndexOf("cli_command::")); + expect(body).not.toMatch(/(?:path|command|executable|registry_key):\s*(?:String|PathBuf)/); + } + expect(lib).toContain("app.manage(cli_command::State::default())"); + expect(policy).toContain('url.path() == "/cli.html"'); + expect(policy).toContain('window.label() != "main"'); + expect(page).toContain(''); + expect(read("desktop/src-tauri/tauri.conf.json")).toContain('"frontendDist": "../ui"'); + }); + + test("Back uses the settings guard while update commands keep their update-only guard", () => { + expect(commandBody("return_to_dashboard")).toContain("window::require_local_settings_page(&window)?"); + const startup = read("desktop/src-tauri/src/startup.rs"); + const back = startup.match(/fn return_ready_dashboard\([\s\S]*?\n\}/)?.[0] || ""; + expect(back).toContain("dashboard.unwrap_or("); + expect(back).toContain('"tauri://localhost/index.html"'); + expect(back).toContain('"http://tauri.localhost/index.html"'); + expect(back).toContain('cfg!(target_os = "windows")'); + for (const name of ["update_status", "update_check", "update_install"]) { + expect(commandBody(name)).toContain("window::require_update_page(&window)?"); + } + expect(policy).toContain('matches!(url.path(), "/update.html" | "/cli.html")'); + expect(policy).toContain('label == "main"'); + for (const check of ["is_app_origin(url)", "url.username().is_empty()", "url.password().is_none()", "url.query().is_none()", "url.fragment().is_none()"]) + expect(policy).toContain(check); + expect(script).not.toContain("show_dashboard"); + }); + + test("UI sends only a boolean and returns through the existing dashboard command", async () => { + const calls: Array<[string, unknown]> = []; + const { nodes, handlers } = harness((name, args) => { + calls.push([name, args]); + return Promise.resolve(name === "return_to_dashboard" ? undefined : { + enabled: true, phase: "configured", expectedExecutable: "/example/ocx", issues: [], + }); + }); + await settle(); + nodes.get("enabled")!.checked = false; + handlers.get("enabled:change")!(); await settle(); + handlers.get("repair:click")!(); await settle(); + handlers.get("remove:click")!(); await settle(); + handlers.get("back:click")!(); await settle(); + for (const [name, args] of calls) { + expect([...commands, "return_to_dashboard"]).toContain(name); + if (name === "cli_set_enabled") expect(args).toEqual({ enabled: false }); + else expect(args).toBeUndefined(); + } + for (const name of [...commands, "return_to_dashboard"]) expect(calls.some(([called]) => called === name)).toBe(true); + expect(script).not.toMatch(/innerHTML|outerHTML|insertAdjacentHTML/); + expect(page).not.toMatch(/]*type="(?:text|file)"/); + }); + + test("native values stay plain text and missing Tauri disables controls", async () => { + const unsafe = ""; + const { nodes } = harness(() => Promise.resolve({ enabled: false, phase: "disabled", expectedExecutable: unsafe, issues: [unsafe] })); + await settle(); + expect(nodes.get("target")!.textContent).toBe("Bundled executable: " + unsafe); + expect(nodes.get("issues")!.children[0]!.textContent).toBe(unsafe); + expect(nodes.get("repair")!.disabled).toBe(true); + const missing = harness().nodes; + for (const id of ["enabled", "repair", "remove", "back"]) expect(missing.get(id)!.disabled).toBe(true); + expect(missing.get("state")!.textContent).toContain("Terminal command menu"); + }); + + test("native errors and status deadlines remain visible", async () => { + const rejected = harness(() => Promise.reject(new Error("journal-conflict"))); + await settle(); + expect(rejected.nodes.get("error")!.textContent).toContain("journal-conflict"); + expect(rejected.nodes.get("error")!.hidden).toBe(false); + const silent = harness(() => new Promise(() => {})); + expect(silent.timers.size).toBe(1); + silent.timers.values().next().value!(); + await settle(); + expect(silent.nodes.get("error")!.textContent).toContain("30 seconds"); + }); + + for (const outcome of ["result", "error"] as const) { + test(`a delayed poll ${outcome} cannot overwrite a newer action and queues a fresh status`, async () => { + const oldPoll = deferred(); + const action = deferred(); + const freshPoll = deferred(); + let statusCalls = 0; + const ui = harness(name => { + if (name === "cli_status") { + statusCalls += 1; + if (statusCalls === 1) return Promise.resolve(configured); + return statusCalls === 2 ? oldPoll.promise : freshPoll.promise; + } + return action.promise; + }); + await settle(); + ui.poll(); + ui.handlers.get("remove:click")!(); + action.resolve(disabled); + await settle(); + expect(ui.nodes.get("enabled")!.checked).toBe(false); + expect(statusCalls).toBe(2); // the old poll is still in flight + if (outcome === "result") oldPoll.resolve(configured); + else oldPoll.reject(new Error("stale-poll-error")); + await settle(); + expect(ui.nodes.get("enabled")!.checked).toBe(false); + expect(ui.nodes.get("error")!.hidden).toBe(true); + expect(statusCalls).toBe(3); // exactly one follow-up after the old poll settles + freshPoll.resolve({ ...disabled, phase: "partial", issues: ["fresh-status"] }); + await settle(); + expect(ui.nodes.get("state")!.textContent).toContain("Some configuration"); + expect(ui.nodes.get("issues")!.children[0]!.textContent).toBe("fresh-status"); + expect(statusCalls).toBe(3); + expect(ui.timers.size).toBe(0); + }); + } + + test("a stale poll error cannot replace the current action error", async () => { + const oldPoll = deferred(); + const action = deferred(); + let statusCalls = 0; + const ui = harness(name => name === "cli_status" + ? (++statusCalls === 1 ? Promise.resolve(configured) : statusCalls === 2 ? oldPoll.promise : Promise.resolve(configured)) + : action.promise); + await settle(); + ui.poll(); + ui.handlers.get("repair:click")!(); + action.reject(new Error("current-action-error")); + await settle(); + oldPoll.reject(new Error("stale-poll-error")); + await settle(); + expect(ui.nodes.get("error")!.textContent).toBe("Error: current-action-error"); + expect(ui.nodes.get("error")!.hidden).toBe(false); + expect(statusCalls).toBe(3); + }); + + test("ready completion preserves CLI settings during launch and recovery", () => { + const startup = read("desktop/src-tauri/src/startup.rs"); + expect(startup).toContain("crate::window::shows_cli_page(&window)"); + expect(startup).toContain("|| keeps_update_page(mode, crate::window::shows_update_page(&window))"); + expect(startup).toMatch(/adopt_launch_origin_argument\(app\);\s*crate::cli_command::reconcile_on_launch\(app\);/); + expect(read("desktop/src-tauri/src/tray.rs")).toContain('"terminal-command" => crate::cli_command::show_page(app)'); + }); + + test("dashboard CLI entry is gated by isDesktopShell on every supported OS", () => { + for (const [platform, url] of [ + ["Macintosh", "tauri://localhost/cli.html"], + ["X11; Linux", "tauri://localhost/cli.html"], + ["Windows NT", "http://tauri.localhost/cli.html"], + ]) { + expect(desktopCliPageUrl(`Mozilla/5.0 (${platform})`)).toBeNull(); + expect(openDesktopCliPage(`Mozilla/5.0 (${platform})`)).toBe(false); + expect(desktopCliPageUrl(`Mozilla/5.0 (${platform}) OpenCodexDesktop/2.82.0`)).toBe(url); + } + const helpers = read("gui/src/lib/desktop-shell.ts"); + const urlHelper = helpers.match(/export function desktopCliPageUrl\([\s\S]*?\n\}/)?.[0] || ""; + expect(urlHelper).toContain("isDesktopShell("); + const openHelper = helpers.match(/export function openDesktopCliPage\([\s\S]*?\n\}/)?.[0] || ""; + expect(openHelper).toContain("desktopCliPageUrl("); + expect(openHelper).toMatch(/if \(!url\) return false/); + }); +}); diff --git a/tests/fixtures/desktop-cli-record/appimage-kind.json b/tests/fixtures/desktop-cli-record/appimage-kind.json new file mode 100644 index 00000000000..2852b9e8d6b --- /dev/null +++ b/tests/fixtures/desktop-cli-record/appimage-kind.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "ownerId": "12345678-1234-4234-8234-123456789abc", + "installId": "fixture-install", + "generation": 1, + "enabled": true, + "bundle": { + "platform": "darwin", + "kind": "linux-appimage", + "appExecutable": "/Applications/OpenCodex.app/Contents/MacOS/OpenCodex", + "cliExecutable": "/Applications/OpenCodex.app/Contents/MacOS/ocx", + "version": "2.82.0" + }, + "posix": null, + "windows": null, + "pending": null +} diff --git a/tests/fixtures/desktop-cli-record/disabled-tombstone.json b/tests/fixtures/desktop-cli-record/disabled-tombstone.json new file mode 100644 index 00000000000..3aed2387cb3 --- /dev/null +++ b/tests/fixtures/desktop-cli-record/disabled-tombstone.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "ownerId": "12345678-1234-4234-8234-123456789abc", + "installId": "fixture-install", + "generation": 1, + "enabled": false, + "bundle": null, + "posix": null, + "windows": null, + "pending": null +} diff --git a/tests/fixtures/desktop-cli-record/disabled-with-pending.json b/tests/fixtures/desktop-cli-record/disabled-with-pending.json new file mode 100644 index 00000000000..0fa5f72f74f --- /dev/null +++ b/tests/fixtures/desktop-cli-record/disabled-with-pending.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "ownerId": "12345678-1234-4234-8234-123456789abc", + "installId": "fixture-install", + "generation": 1, + "enabled": false, + "bundle": { + "platform": "darwin", + "kind": "macos-app", + "appExecutable": "/Applications/OpenCodex.app/Contents/MacOS/OpenCodex", + "cliExecutable": "/Applications/OpenCodex.app/Contents/MacOS/ocx", + "version": "2.82.0" + }, + "posix": null, + "windows": null, + "pending": { + "operation": "install", + "changes": [], + "next": { + "version": 1, + "ownerId": "12345678-1234-4234-8234-123456789abc", + "installId": "fixture-install", + "generation": 2, + "enabled": false, + "bundle": { + "platform": "darwin", + "kind": "macos-app", + "appExecutable": "/Applications/OpenCodex.app/Contents/MacOS/OpenCodex", + "cliExecutable": "/Applications/OpenCodex.app/Contents/MacOS/ocx", + "version": "2.82.0" + }, + "posix": null, + "windows": null, + "pending": null + } + } +} diff --git a/tests/fixtures/desktop-cli-record/dotdot-target.json b/tests/fixtures/desktop-cli-record/dotdot-target.json new file mode 100644 index 00000000000..a805e075f28 --- /dev/null +++ b/tests/fixtures/desktop-cli-record/dotdot-target.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "ownerId": "12345678-1234-4234-8234-123456789abc", + "installId": "fixture-install", + "generation": 1, + "enabled": true, + "bundle": { + "platform": "darwin", + "kind": "macos-app", + "appExecutable": "/Applications/OpenCodex.app/Contents/MacOS/OpenCodex", + "cliExecutable": "/Applications/OpenCodex.app/../ocx", + "version": "2.82.0" + }, + "posix": null, + "windows": null, + "pending": null +} diff --git a/tests/fixtures/desktop-cli-record/first-pending.json b/tests/fixtures/desktop-cli-record/first-pending.json new file mode 100644 index 00000000000..6a3cce502d6 --- /dev/null +++ b/tests/fixtures/desktop-cli-record/first-pending.json @@ -0,0 +1,58 @@ +{ + "version": 1, + "ownerId": "12345678-1234-4234-8234-123456789abc", + "installId": "fixture-install", + "generation": 1, + "enabled": true, + "bundle": { + "platform": "darwin", + "kind": "macos-app", + "appExecutable": "/Applications/OpenCodex.app/Contents/MacOS/OpenCodex", + "cliExecutable": "/Applications/OpenCodex.app/Contents/MacOS/ocx", + "version": "2.82.0" + }, + "posix": null, + "windows": null, + "pending": { + "operation": "install", + "changes": [ + { + "kind": "file", + "path": "/fixture/desktop/bin/ocx", + "mode": 448, + "backupPath": null, + "journalFile": "/fixture/desktop/journal/2-0.json", + "beforeSha256": "absent", + "afterSha256": "11507a0e2f5e69d5dfa40a62a1bd7b6ee57e6bcd85c67c9b8431b36fff21c437" + } + ], + "next": { + "version": 1, + "ownerId": "12345678-1234-4234-8234-123456789abc", + "installId": "fixture-install", + "generation": 2, + "enabled": true, + "bundle": { + "platform": "darwin", + "kind": "macos-app", + "appExecutable": "/Applications/OpenCodex.app/Contents/MacOS/OpenCodex", + "cliExecutable": "/Applications/OpenCodex.app/Contents/MacOS/ocx", + "version": "2.82.0" + }, + "posix": { + "binDirectory": "/fixture/desktop/bin", + "files": [ + { + "kind": "shim", + "path": "/fixture/desktop/bin/ocx", + "sha256": "11507a0e2f5e69d5dfa40a62a1bd7b6ee57e6bcd85c67c9b8431b36fff21c437", + "created": true + } + ], + "rcFiles": [] + }, + "windows": null, + "pending": null + } + } +} diff --git a/tests/fixtures/desktop-cli-record/notify-pending.json b/tests/fixtures/desktop-cli-record/notify-pending.json new file mode 100644 index 00000000000..54c7c7f6b10 --- /dev/null +++ b/tests/fixtures/desktop-cli-record/notify-pending.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "ownerId": "11111111-1111-4111-8111-111111111111", + "installId": "fixture-install", + "generation": 2, + "enabled": true, + "bundle": { + "platform": "win32", + "appExecutable": "C:\\Desktop\\app.exe", + "cliExecutable": "C:\\Desktop\\ocx.exe", + "version": "1", + "kind": "windows-install" + }, + "posix": null, + "windows": null, + "notifyPending": true, + "pending": null +} diff --git a/tests/fixtures/desktop-cli-record/pending-enabled.json b/tests/fixtures/desktop-cli-record/pending-enabled.json new file mode 100644 index 00000000000..4c843efde21 --- /dev/null +++ b/tests/fixtures/desktop-cli-record/pending-enabled.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "ownerId": "12345678-1234-4234-8234-123456789abc", + "installId": "fixture-install", + "generation": 1, + "enabled": true, + "bundle": { + "platform": "darwin", + "kind": "macos-app", + "appExecutable": "/Applications/OpenCodex.app/Contents/MacOS/OpenCodex", + "cliExecutable": "/Applications/OpenCodex.app/Contents/MacOS/ocx", + "version": "2.82.0" + }, + "posix": null, + "windows": null, + "pending": { + "operation": "install", + "changes": [], + "next": { + "version": 1, + "ownerId": "12345678-1234-4234-8234-123456789abc", + "installId": "fixture-install", + "generation": 2, + "enabled": true, + "bundle": { + "platform": "darwin", + "kind": "macos-app", + "appExecutable": "/Applications/OpenCodex.app/Contents/MacOS/OpenCodex", + "cliExecutable": "/Applications/OpenCodex.app/Contents/MacOS/ocx", + "version": "2.82.0" + }, + "posix": null, + "windows": null, + "pending": null + } + } +} diff --git a/tests/fixtures/desktop-cli-record/relative-target.json b/tests/fixtures/desktop-cli-record/relative-target.json new file mode 100644 index 00000000000..6586142b14d --- /dev/null +++ b/tests/fixtures/desktop-cli-record/relative-target.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "ownerId": "12345678-1234-4234-8234-123456789abc", + "installId": "fixture-install", + "generation": 1, + "enabled": true, + "bundle": { + "platform": "darwin", + "kind": "macos-app", + "appExecutable": "/Applications/OpenCodex.app/Contents/MacOS/OpenCodex", + "cliExecutable": "relative/ocx", + "version": "2.82.0" + }, + "posix": null, + "windows": null, + "pending": null +} diff --git a/tests/fixtures/desktop-cli-record/valid-darwin.json b/tests/fixtures/desktop-cli-record/valid-darwin.json new file mode 100644 index 00000000000..d69271f3e7f --- /dev/null +++ b/tests/fixtures/desktop-cli-record/valid-darwin.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "ownerId": "12345678-1234-4234-8234-123456789abc", + "installId": "fixture-install", + "generation": 1, + "enabled": true, + "bundle": { + "platform": "darwin", + "kind": "macos-app", + "appExecutable": "/Applications/OpenCodex.app/Contents/MacOS/OpenCodex", + "cliExecutable": "/Applications/OpenCodex.app/Contents/MacOS/ocx", + "version": "2.82.0" + }, + "posix": null, + "windows": null, + "pending": null +} diff --git a/tests/fixtures/desktop-cli-record/valid-linux.json b/tests/fixtures/desktop-cli-record/valid-linux.json new file mode 100644 index 00000000000..15cbb814987 --- /dev/null +++ b/tests/fixtures/desktop-cli-record/valid-linux.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "ownerId": "12345678-1234-4234-8234-123456789abc", + "installId": "fixture-install", + "generation": 1, + "enabled": true, + "bundle": { + "platform": "linux", + "kind": "linux-deb", + "appExecutable": "/usr/bin/opencodex-desktop", + "cliExecutable": "/usr/bin/ocx", + "version": "2.82.0" + }, + "posix": null, + "windows": null, + "pending": null +} diff --git a/tests/fixtures/desktop-cli-record/valid-win32.json b/tests/fixtures/desktop-cli-record/valid-win32.json new file mode 100644 index 00000000000..fdd953cbafe --- /dev/null +++ b/tests/fixtures/desktop-cli-record/valid-win32.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "ownerId": "12345678-1234-4234-8234-123456789abc", + "installId": "fixture-install", + "generation": 1, + "enabled": true, + "bundle": { + "platform": "win32", + "kind": "windows-install", + "appExecutable": "C:\\Program Files\\OpenCodex\\OpenCodex.exe", + "cliExecutable": "C:\\Program Files\\OpenCodex\\ocx.exe", + "version": "2.82.0" + }, + "posix": null, + "windows": null, + "pending": null +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 2702cf1368e..ef830f7b5f8 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -540,6 +540,7 @@ "desktop-3p-guard.test.ts": "clients", "desktop-3p-removal.test.ts": "clients", "desktop-3p.test.ts": "clients", "desktop-app-restart.test.ts": "clients", "desktop-cli-contracts.test.ts": "clients", "desktop-exit-ownership.test.ts": "clients", + "desktop-cli-command-surface.test.ts": "clients", "desktop-host-visibility.test.ts": "clients", "desktop-install-identity.test.ts": "clients", "desktop-profile.test.ts": "clients", "desktop-proxy-direct-transport.test.ts": "clients", "desktop-remote-store.test.ts": "clients", "desktop-runtime-identity.test.ts": "clients", From 7f642e30e8f38d45fd9bb7a0aecc9be93d734abb Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 9 Oct 2026 21:59:04 +0900 Subject: [PATCH 31/59] fix(test): keep test processes out of the real Claude config directory (#6835) * docs(devlog): plan N3 test-infra unit for #6775, #6777, #6776 * fix(test): keep test processes out of the real Claude config directory (#6775) A bare `bun test` pruned the developer's real ~/.claude/agents/ocx-*.md. The preload rewrites HOME after Bun has started, Bun's os.homedir() keeps the start-time home, so claudeConfigDir() resolved the real directory and the management toggles' agent sync deleted the generated files there. - scripts/test.ts pins CLAUDE_CONFIG_DIR and GROK_HOME inside the sandbox and hands the developer's own Claude directory to the guard. - claudeConfigDir() reads HOME (USERPROFILE on Windows) at call time, as Claude Code's Node runtime does; production resolves the same path. - The armed test-home guard refuses Claude agent sync and gateway-cache writes into the real Claude config directory and protects it from removal. - The toggle test passes an explicit agents directory. * fix(test): judge Claude writes by where they resolve, and guard intercept writers (#6775) Review found three ways past the first guard: an agents/cache child linked into the real directory, a case alias of a protected root that did not exist when the guard loaded, and the intercept settings.json and served-catalog writers, which never called it. Writers now pass every directory they touch, roots are resolved at check time and compared case-insensitively on macOS and Windows, and both intercept writers call the guard outside their best-effort catches. * fix(test): guard the Claude cache file and removals by current identity (#6775) Second review round: writeFileSync followed a link at gateway-models.json, a child named "..fixture" was read as outside the root, and removal protection still compared the import-time Claude roots case-sensitively. The cache file and each agent definition path are now judged where they resolve, containment rejects only a leading ".." component, and protectedRemovalReason checks the Claude directory with the same current-root, case-folding comparison. * fix(test): split Claude containment on the platform separator and keep checkout content removable (#6775) Third review round: splitting relative() on both separators read a POSIX child named "..\fixture" as outside the root, and the new Claude removal check dropped the checkout-content lift the other protected trees keep. Containment now rejects only ".." or "..", and content of a checkout that lives inside the Claude directory stays removable while the root and its ancestors do not. --- devlog/_plan/261009_n3_test_infra/000_plan.md | 41 +++ .../010_6775_client_home_isolation.md | 184 ++++++++++++ .../020_6777_combo_fixture.md | 99 +++++++ .../030_6776_toggle_ownership_fixture.md | 55 ++++ scripts/test-layout/layout.json | 2 +- scripts/test.ts | 10 + src/claude/agents-inject.ts | 11 + src/claude/gateway-cache.ts | 19 +- src/claude/intercept/cli-catalog.ts | 3 + src/claude/intercept/settings.ts | 3 + src/lib/test-home-guard.ts | 88 +++++- structure/ops/test-sandbox-cleanup.md | 15 + .../claude-config-home-isolation.test.ts | 263 ++++++++++++++++++ .../claude-gateway-cache.test.ts | 7 +- .../claude-messages-endpoint.test.ts | 2 +- tests/fixtures/test-layout-expected.json | 2 +- 16 files changed, 796 insertions(+), 8 deletions(-) create mode 100644 devlog/_plan/261009_n3_test_infra/000_plan.md create mode 100644 devlog/_plan/261009_n3_test_infra/010_6775_client_home_isolation.md create mode 100644 devlog/_plan/261009_n3_test_infra/020_6777_combo_fixture.md create mode 100644 devlog/_plan/261009_n3_test_infra/030_6776_toggle_ownership_fixture.md create mode 100644 tests/claude-integration/claude-config-home-isolation.test.ts diff --git a/devlog/_plan/261009_n3_test_infra/000_plan.md b/devlog/_plan/261009_n3_test_infra/000_plan.md new file mode 100644 index 00000000000..1680e798145 --- /dev/null +++ b/devlog/_plan/261009_n3_test_infra/000_plan.md @@ -0,0 +1,41 @@ +# N3 test infrastructure: client-home isolation and fixture determinism + +Unit for three test-infrastructure defects reported against dev in October 2026. +Each item ships as its own pull request to `dev` so a reviewer can judge the +home-isolation change apart from the two fixture fixes. + +| Doc | Issue | Change | PR branch | +| --- | --- | --- | --- | +| 010 | #6775 | Pin client homes in the test sandbox, resolve the Claude default home at call time, and make the test guard refuse writes to the real Claude config directory | `codex/n3-test-infra` | +| 020 | #6777 | Stop the combo management fixture from reaching provider discovery and keep a timed-out case from running into the next fixture | `codex/n3-combo-rename-fixture` | +| 030 | #6776 | Give the native Codex toggle fixture deterministic service-manager evidence | `codex/n3-codex-toggle-fixture` | + +## Constraints + +- Production behavior stays the same. The only production edits are in 010: + `claudeConfigDir()` reads the home the way Node's `os.homedir()` does at call + time, and two Claude writers call a guard that is inert unless + `OCX_TEST_HOME_GUARD=1`. +- Other `homedir()` call sites (OpenCodex config dir, Codex home, client + integrations) are deliberately out of scope. Moving them to an environment-first + home would change where production reads its own config on Windows hosts whose + `HOME` differs from the profile directory. The sandbox and the guard cover tests + without that risk. +- Workflows under `.github/` are not touched (L7 #6806 owns CI changes). +- Every local test run starts Bun with a temporary `HOME`, `USERPROFILE` and + `CLAUDE_CONFIG_DIR`; a bare `bun test` against the developer home is not used, + because that is the defect in #6775. +- File-size ratchet: no capped file grows past its cap. New tests are registered in + `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`. + +## Order + +010, 020 and 030 are independent; each starts from `origin/dev`. 010 goes first +because the other two reproductions are only safe once the sandbox pins the Claude +home. + +## Verification + +Focused files per doc plus `bun run typecheck`, run with a temporary home at +process start. The full suite runs in hosted CI on each PR's exact head. + diff --git a/devlog/_plan/261009_n3_test_infra/010_6775_client_home_isolation.md b/devlog/_plan/261009_n3_test_infra/010_6775_client_home_isolation.md new file mode 100644 index 00000000000..a5b0bd30c76 --- /dev/null +++ b/devlog/_plan/261009_n3_test_infra/010_6775_client_home_isolation.md @@ -0,0 +1,184 @@ +# 010 — #6775: keep tests out of the real Claude config directory + +## Defect + +`tests/preload.ts` sandboxes a bare `bun test` by assigning `process.env.HOME` +after Bun has started. Bun's `os.homedir()` keeps the start-time home, so +`claudeConfigDir()` in `src/claude/gateway-cache.ts` resolves the developer's real +`~/.claude` when `CLAUDE_CONFIG_DIR` is unset. Management toggles call +`syncClaudeAgentDefsBestEffort()` (`src/server/management-api.ts`) with no +explicit directory, and `syncClaudeAgentDefs([])` prunes every generated +`ocx-*.md` there. `tests/claude-integration/claude-messages-endpoint.test.ts` +("compatibility survives management toggles…") triggers it. + +Reproduced on `730d898457` with a temporary HOME at process start: a planted +generated `ocx-probe.md` in the fake home is removed by that one test. + +## Change + +### 1. Sandbox pins the Claude and Grok homes — MODIFY `scripts/test.ts` + +`createIsolatedTestEnvironment()` already feeds both the wrapper child env and the +preload. Add, next to `CODEX_HOME`: + +```diff + OPENCODEX_HOME: opencodexHome, + CODEX_HOME: codexHome, ++ // Client homes that default to os.homedir(). Bun keeps the start-time home, so a ++ // preload that only rewrites HOME leaves these pointing at the developer's real ++ // directories. Pin them inside the sandbox; never inherit a live override. ++ CLAUDE_CONFIG_DIR: join(root, ".claude"), ++ GROK_HOME: join(root, ".grok"), +``` + +and capture the real Claude directory for the guard before it is replaced: + +```diff + OCX_REAL_HOME: baseEnv.OCX_REAL_HOME ?? homedir(), ++ OCX_REAL_CLAUDE_CONFIG_DIR: baseEnv.OCX_REAL_CLAUDE_CONFIG_DIR ++ ?? (baseEnv.CLAUDE_CONFIG_DIR?.trim() || join(baseEnv.OCX_REAL_HOME ?? homedir(), ".claude")), +``` + +### 2. Guard refuses the real Claude directory — MODIFY `src/lib/test-home-guard.ts` + +Capture at module load (same moment as `REAL_HOME`), protecting the default +`/.claude` and, when it differs, the developer's own +`CLAUDE_CONFIG_DIR` handed over as `OCX_REAL_CLAUDE_CONFIG_DIR`: + +```ts +const REAL_CLAUDE_DIRS = [...new Set([ + join(REAL_HOME, ".claude"), + process.env.OCX_REAL_CLAUDE_CONFIG_DIR?.trim() || process.env.CLAUDE_CONFIG_DIR?.trim() || join(REAL_HOME, ".claude"), +])]; +const PROTECTED_CLAUDE_TREES = REAL_CLAUDE_DIRS.map(path => ({ + path: canonicalize(path), lexical: resolve(path), label: "the real Claude config directory", +})); + +export function protectedClaudeConfigDirsForTests(): readonly string[]; +/** Throw when an armed test process is about to write or prune inside the real Claude config dir. */ +export function assertNotRealClaudeConfigUnderTest(dir: string): void; +``` + +`assertNotRealClaudeConfigUnderTest` is a no-op unless armed, and refuses when +`dir` (canonical or lexical) equals or sits inside a protected Claude tree. The +Claude trees are appended to `PROTECTED_TREES` so `assertRemovalOutsideProtectedTrees` +refuses removals there too. + +Note on the capture: under `bun run test` the wrapper child already has a +sandboxed `CLAUDE_CONFIG_DIR`, which is why the real value travels in +`OCX_REAL_CLAUDE_CONFIG_DIR`. Under a bare run the preload imports the guard +before it rewrites the environment, so `process.env.CLAUDE_CONFIG_DIR` is still +the developer's own value. + +### 3. Writers call the guard first — MODIFY `src/claude/agents-inject.ts`, `src/claude/gateway-cache.ts` + +```diff + export function syncClaudeAgentDefs(defs, configDir = claudeConfigDir()) { ++ // Outside the best-effort catch: under an armed test process a write or prune of the ++ // real Claude directory must fail loudly, never degrade to "returned null". ++ assertNotRealClaudeConfigUnderTest(configDir); + try { +``` + +Same first line in `writeGatewayModelCache`. Callers that wrap these in their own +best-effort catch still never reach the filesystem. + +### 4. Claude default home follows the current environment — MODIFY `src/claude/gateway-cache.ts` + +```diff + export function claudeConfigDir(): string { + const custom = process.env.CLAUDE_CONFIG_DIR; +- return custom && custom.length > 0 ? custom : join(homedir(), ".claude"); ++ return custom && custom.length > 0 ? custom : join(currentUserHome(), ".claude"); + } ++ ++/** ++ * The home Claude Code itself resolves. Claude Code runs on Node, whose os.homedir() ++ * consults HOME (POSIX) or USERPROFILE (Windows) at call time when it is set; Bun's ++ * caches the start-time value. In production the two agree; under a preload that ++ * rewrites HOME only this one follows the sandbox. Exported as an internal for tests. ++ */ ++export function currentUserHome(env = process.env, platform = process.platform): string { ++ const fromEnv = platform === "win32" ? env.USERPROFILE : env.HOME; ++ return fromEnv !== undefined && fromEnv !== "" ? fromEnv : homedir(); ++} +``` + +### 5. Existing resolver test follows the new contract — MODIFY `tests/claude-integration/claude-gateway-cache.test.ts` + +"claudeConfigDir honors CLAUDE_CONFIG_DIR" deletes `CLAUDE_CONFIG_DIR` and expects +`join(os.homedir(), ".claude")`, Bun's cached start-time home. Under the preload that is the +developer's real home, so the assertion encodes the defect. New fallback expectation: + +```diff +- const { homedir } = require("node:os") as typeof import("node:os"); +- expect(claudeConfigDir()).toBe(join(homedir(), ".claude")); ++ const home = process.platform === "win32" ? process.env.USERPROFILE : process.env.HOME; ++ expect(claudeConfigDir()).toBe(join(home!, ".claude")); +``` + +### 6. The triggering test names its directory — MODIFY `tests/claude-integration/claude-messages-endpoint.test.ts` + +Pass `{ managementApi: { claudeAgentConfigDir: join(testDir, "claude-agents") } }` +to `startServer` in the toggle test (in-place, no line growth; file is at 1993/2000). + +## Regression tests — NEW `tests/claude-integration/claude-config-home-isolation.test.ts` + +1. `createIsolatedTestEnvironment({ CLAUDE_CONFIG_DIR: "/inherited", GROK_HOME: "/inherited" })` + pins both inside `isolated.root` and records `OCX_REAL_CLAUDE_CONFIG_DIR: "/inherited"`. + Fails on dev (inherited values pass through). +2. With `CLAUDE_CONFIG_DIR` unset and `HOME`/`USERPROFILE` reassigned in-process, + `claudeConfigDir()` returns `/.claude`. Fails on dev under Bun. +3. A child Bun process armed with `OCX_TEST_HOME_GUARD=1` and + `OCX_REAL_HOME=` calls `syncClaudeAgentDefs([], /.claude)`: + the call throws the guard error and the planted generated file survives. On dev + the file is pruned. The sentinel is a temp directory, so the real home is never + involved. +4. The preload itself: inside any test process, `process.env.CLAUDE_CONFIG_DIR` + resolves under the sandbox root, not under `OCX_REAL_HOME`. +5. HOME and USERPROFILE disagree: `currentUserHome({HOME:a, USERPROFILE:b}, "linux")` + is `a`, with `"win32"` it is `b`, and an empty variable falls back to `os.homedir()`. +6. Nested isolation keeps the original: `createIsolatedTestEnvironment(isolated.env)` + preserves the outer `OCX_REAL_CLAUDE_CONFIG_DIR`. + +Register the new file in `scripts/test-layout/layout.json` (`explicit`) and +`tests/fixtures/test-layout-expected.json` under `claude-integration`. + +Extend `tests/ci-workflows/test-home-guard.test.ts` only if its roster asserts the +exact protected-tree list (check `protectedRemovalTreesForTests` expectations). + +## Structure docs + +Source areas touched: `scripts/`, `src/claude/`, `src/lib/`, `tests/`. Every doc mapped for +them in `structure/INDEX.md` is reviewed. The contract that changes is the test sandbox, +described by `structure/ops/test-sandbox-cleanup.md` (maps `tests/`). MODIFY it with one +present-tense paragraph: the sandbox pins `CLAUDE_CONFIG_DIR` and `GROK_HOME` inside its +root and hands the real Claude directory to the guard as `OCX_REAL_CLAUDE_CONFIG_DIR`; +`claudeConfigDir()` follows the current platform home variable; the armed guard refuses +Claude agent sync and gateway-cache writes into the real Claude config directory (default and +the developer's own `CLAUDE_CONFIG_DIR`) and refuses removals there; pinned by +`tests/claude-integration/claude-config-home-isolation.test.ts`. The other mapped docs +(`runtime.md`, `clients/claude-desktop.md`, `overview.md`, `ops/docs-and-release.md`, and the +`src/lib/` transport/dashboard docs) describe no behavior this change alters; the PR records +that review. + +## Out of scope, reported in the PR + +The research inventory found other client homes resolved through `homedir()` +(Claude Desktop config library, client integration writers, Kiro, XDG-based +clients). The sandbox now pins Claude and Grok; the rest are follow-up candidates. + +## Verify + +```sh +FH=$(mktemp -d); env -u CLAUDE_CONFIG_DIR -u GROK_HOME HOME="$FH" USERPROFILE="$FH" CLAUDE_CONFIG_DIR="$FH/.claude" \ + bun test tests/claude-integration/claude-config-home-isolation.test.ts tests/ci-workflows/test-home-guard.test.ts \ + tests/claude-integration/claude-messages-endpoint.test.ts tests/claude-integration/claude-agents-inject.test.ts \ + tests/claude-integration/claude-gateway-cache.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts \ + tests/ci-workflows/structure-ssot.test.ts tests/ci-workflows/file-size-ratchet.test.ts +bun run typecheck +bun run structure:check +``` +plus the issue's probe: plant a generated `ocx-probe.md` in `$FH/.claude/agents`, +start Bun with `CLAUDE_CONFIG_DIR` unset, and confirm it survives. + diff --git a/devlog/_plan/261009_n3_test_infra/020_6777_combo_fixture.md b/devlog/_plan/261009_n3_test_infra/020_6777_combo_fixture.md new file mode 100644 index 00000000000..50167fcafca --- /dev/null +++ b/devlog/_plan/261009_n3_test_infra/020_6777_combo_fixture.md @@ -0,0 +1,99 @@ +# 020 — #6777: combo management fixture stays local and serial + +## Defect + +`tests/routing/combo-management-api.test.ts` builds providers `a/b/c` with +synthetic `https://*.example/v1` URLs and live discovery on. The rename route +awaits `syncClaudeAgentDefsBestEffort` → `fetchAllModels` → provider discovery → +`providerOutboundGet` → DNS lookup, which is not bounded by the request's abort +signal. When DNS is slow the case passes Bun's 5 s timeout; Bun then starts the next +case while the first callback is still running, so the first reads the second's +fixture and its `finally` restores the environment under the second (the reported +`new-public`/`stable-public` mismatch and `ENOENT config.json`). + +Hosted CI resolves DNS fast and uses `--timeout 60000`, which hides it. + +The route's await is correct and stays: the test asserts that agent definitions are +migrated before the response returns. + +## Change — MODIFY `tests/routing/combo-management-api.test.ts` only + +1. `baseConfig`: add `liveModels: false` to providers `a`, `b`, `c` (static + catalog path in `src/codex/catalog/provider-models.ts:239`). +2. Serialize the fixture helper and drain it, with a bound, before shared state is + cleared. A drain that exceeds the bound poisons the helper: later owners reject + immediately instead of queueing behind a callback that may still resume and rewrite + the environment. + +```diff +-async function withTempHome(run: (dir: string) => Promise | T): Promise { ++// Bun starts the next case while a timed-out one is still running. Chaining owners ++// keeps one case's environment from being installed or restored under another's. ++let tempHomeSettled: Promise = Promise.resolve(); ++let tempHomeStuck = false; ++let tempHomeDrainMs = 50_000; // lowered only by the poison regression ++const STUCK = "an earlier withTempHome owner never settled; refusing to share its environment"; ++function withTempHome(run: (dir: string) => Promise | T): Promise { ++ if (tempHomeStuck) return Promise.reject(new Error(STUCK)); ++ // Rechecked when the queued owner starts: one queued before poisoning must not run ++ // after a stalled predecessor finally settles. ++ const operation = tempHomeSettled.then(() => { ++ if (tempHomeStuck) throw new Error(STUCK); ++ return runWithTempHome(run); ++ }); ++ tempHomeSettled = operation.then(() => {}, () => {}); ++ return operation; ++} ++ ++async function drainTempHome(): Promise { ++ // Once poisoned, every later drain fails at once instead of waiting out the bound again. ++ if (tempHomeStuck) throw new Error(STUCK); ++ let timer: ReturnType | undefined; ++ const drained = await Promise.race([ ++ tempHomeSettled.then(() => true), ++ new Promise(resolve => { timer = setTimeout(() => resolve(false), tempHomeDrainMs); }), ++ ]); ++ clearTimeout(timer); ++ if (!drained) { ++ tempHomeStuck = true; ++ throw new Error("withTempHome owner still running after the drain bound"); ++ } ++} ++ ++async function runWithTempHome(run: (dir: string) => Promise | T): Promise { +``` + +```diff +-afterEach(() => { ++afterEach(async () => { ++ await drainTempHome(); + clearComboSelectionState(); + clearComboTargetCooldowns(); +-}); ++}, 60_000); +``` + +3. Regression in the rename case without re-indenting it: install + `spyOn(outbound, "providerOutboundGet").mockRejectedValue(...)` at the top of the + case, register its restore in an `afterEach` cleanup list, and assert + `expect(discovery).not.toHaveBeenCalled()` after `withTempHome` resolves. On dev + the spy records the a/b/c discovery calls. +4. Serialization regression at file end: two overlapping `withTempHome` owners; + the first holds a gate and asserts its own `OPENCODEX_HOME` after the second was + requested. On dev the second owner rewrites the environment under the first. + The gate is released in `finally` so a failing assertion cannot strand the queue. +5. Poison regression: with a lowered drain bound, an owner that never settles makes + `drainTempHome()` throw; afterwards (a) a second drain rejects immediately, (b) a new + `withTempHome` rejects immediately, and (c) an owner queued before poisoning rejects + without running its callback when the stalled owner is released. Gate release, + settlement of every owner, and restoration of the bound, the poison flag and the queue + head all happen in `finally`, so a failed assertion cannot leave the file poisoned. + The test-only reset is a small `resetTempHomeQueueForTest()` function in the test file. + +## Verify + +```sh +FH=$(mktemp -d); env -u HTTPS_PROXY -u NODE_EXTRA_CA_CERTS HOME="$FH" USERPROFILE="$FH" CLAUDE_CONFIG_DIR="$FH/.claude" \ + bun test tests/routing/combo-management-api.test.ts +``` + diff --git a/devlog/_plan/261009_n3_test_infra/030_6776_toggle_ownership_fixture.md b/devlog/_plan/261009_n3_test_infra/030_6776_toggle_ownership_fixture.md new file mode 100644 index 00000000000..700834a823f --- /dev/null +++ b/devlog/_plan/261009_n3_test_infra/030_6776_toggle_ownership_fixture.md @@ -0,0 +1,55 @@ +# 030 — #6776: native Codex toggle fixture owns its service-manager evidence + +## Defect + +`tests/codex-integration/native-codex-toggle.test.ts` writes a matching v2 +`service-state.json` into its synthetic `OPENCODEX_HOME`, then enables Codex +through the management route. Enable runs production admission, which calls +`inspectServiceManagerInstallation` (`src/integrations/native/ownership-preflight.ts`). +That probe asks the real service manager: `launchctl print gui//…` on macOS +and `systemctl --user show opencodex-proxy` on Linux, neither of which HOME can +redirect. On a machine with an installed opencodex service the probe returns a +registration that the sandbox plist cannot explain, admission returns +`service-home`, and the round-trip case reads `state: "absent"`. + +Reproduced on `730d898457` by simulating a registered launchd job: 12 pass / 1 fail +with the reported refusal. Hosted runners have no registration, so CI passes. + +The production ownership boundary is correct and unchanged. + +## Change — MODIFY `tests/codex-integration/native-codex-toggle.test.ts` only + +```diff +-import { afterEach, beforeEach, describe, expect, test } from "bun:test"; ++import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; ++import * as serviceManagerProbe from "../../src/service-manager-probe"; ++let restoreServiceManagerProbe = () => {}; + + beforeEach(() => { ++ // The route fixture owns no service, and HOME cannot redirect launchctl/systemctl. ++ // Without this the host's own opencodex registration decides the case. ++ const probe = spyOn(serviceManagerProbe, "inspectServiceManagerInstallation") ++ .mockReturnValue({ kind: "absent" }); ++ restoreServiceManagerProbe = () => probe.mockRestore(); + + afterEach(() => { ++ restoreServiceManagerProbe(); +``` + +The fixture's `service-state.json` is still validated by the real state reader, so +the case keeps exercising the state comparison. + +## Regression + +The existing round-trip case is the regression. Its host dependence is proven by +the scratch reproduction (simulated registered manager: fails on dev, passes with +the spy). A case asserting `inspectServiceManagerInstallation` is called during +enable confirms the stub is on the admission path rather than dead code. + +## Verify + +```sh +FH=$(mktemp -d); env HOME="$FH" USERPROFILE="$FH" CLAUDE_CONFIG_DIR="$FH/.claude" \ + bun test tests/codex-integration/native-codex-toggle.test.ts +``` + diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 589bda24b7f..3c008f1c5e5 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -39,7 +39,7 @@ "antigravity-discovered-families.test.ts": "adapters/google", "provider-antigravity-effort-families.test.ts": "providers", "provider-antigravity-family-catalog.test.ts": "providers", - "claude-subagent-model-force.test.ts": "claude-integration", + "claude-subagent-model-force.test.ts": "claude-integration", "claude-config-home-isolation.test.ts": "claude-integration", "subagent-model-force-api.test.ts": "server", "caller-session-identity.test.ts": "server", "caller-session-ingress.test.ts": "server", diff --git a/scripts/test.ts b/scripts/test.ts index 3ea636228c9..dc9411ebe4e 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -80,6 +80,10 @@ export function createIsolatedTestEnvironment( // real-home write guard can still know which path to protect. // (devlog 260730_codex_rs_upstream_v2_live_handoff/070.) OCX_REAL_HOME: baseEnv.OCX_REAL_HOME ?? homedir(), + // Same hand-off for the guard's Claude entry: the child gets a sandboxed + // CLAUDE_CONFIG_DIR below, so the developer's own value only survives here. + OCX_REAL_CLAUDE_CONFIG_DIR: baseEnv.OCX_REAL_CLAUDE_CONFIG_DIR + ?? (baseEnv.CLAUDE_CONFIG_DIR?.trim() || join(baseEnv.OCX_REAL_HOME ?? homedir(), ".claude")), // Pin git's global config to the developer's real one before HOME moves. // // git resolves ~/.gitconfig from HOME, so a sandboxed HOME makes it invisible. @@ -100,6 +104,12 @@ export function createIsolatedTestEnvironment( USERPROFILE: root, OPENCODEX_HOME: opencodexHome, CODEX_HOME: codexHome, + // Client homes that otherwise default to os.homedir(). Bun keeps the home it read at + // startup, so a preload that only rewrites HOME leaves these at the developer's real + // directories, and Claude agent sync prunes generated files there (#6775). Pin them in + // the sandbox; never inherit a live override. + CLAUDE_CONFIG_DIR: join(root, ".claude"), + GROK_HOME: join(root, ".grok"), TEMP: containedTemp, TMP: containedTemp, TMPDIR: containedTemp, diff --git a/src/claude/agents-inject.ts b/src/claude/agents-inject.ts index 652da8083bc..3ed355f2842 100644 --- a/src/claude/agents-inject.ts +++ b/src/claude/agents-inject.ts @@ -15,6 +15,7 @@ import { lstatSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileS import { join } from "node:path"; import type { OcxConfig } from "../types"; import { renameAtomicFile } from "../lib/windows-atomic-replace"; +import { assertNotRealClaudeConfigUnderTest } from "../lib/test-home-guard"; import { entryParts, SAFE_AGENT_MODEL_ID, withSubagentContextMarker } from "./subagent-model"; import { stripOneMillionMarker } from "./context-windows"; import { claudeConfigDir } from "./gateway-cache"; @@ -203,6 +204,16 @@ function isOwnedFile(path: string): boolean { * atomic (tmp + rename). Best-effort — returns null on any failure. */ export function syncClaudeAgentDefs(defs: readonly ClaudeAgentDef[], configDir = claudeConfigDir()): string[] | null { + // Outside the best-effort catch: under an armed test process a write or prune of the + // real Claude agents directory must throw, never degrade to "returned null" (#6775). + // Every path written is judged by where it resolves, including a pre-placed link named like + // a definition or its temporary file. + const agentsDir = join(configDir, "agents"); + assertNotRealClaudeConfigUnderTest( + configDir, + agentsDir, + ...defs.flatMap(def => [join(agentsDir, def.file), `${join(agentsDir, def.file)}.tmp-${process.pid}`]), + ); try { const dir = join(configDir, "agents"); if (defs.length === 0) { diff --git a/src/claude/gateway-cache.ts b/src/claude/gateway-cache.ts index 2f5c77e990e..fd341cb36e8 100644 --- a/src/claude/gateway-cache.ts +++ b/src/claude/gateway-cache.ts @@ -17,6 +17,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { localAdmissionToken, localInferenceDestination } from "../lib/local-destinations"; +import { assertNotRealClaudeConfigUnderTest } from "../lib/test-home-guard"; import type { OcxConfig } from "../types"; export interface GatewayModelRow { @@ -46,11 +47,27 @@ export interface GatewayModelTarget { /** Claude Code config dir (CLAUDE_CONFIG_DIR override honored, like the CLI). */ export function claudeConfigDir(): string { const custom = process.env.CLAUDE_CONFIG_DIR; - return custom && custom.length > 0 ? custom : join(homedir(), ".claude"); + return custom && custom.length > 0 ? custom : join(currentUserHome(), ".claude"); +} + +/** + * The home Claude Code itself resolves. Claude Code runs on Node, whose `os.homedir()` + * consults HOME (POSIX) or USERPROFILE (Windows) at call time when it is set; Bun's + * `os.homedir()` keeps the value it read at startup. In production the two agree. Under + * the test preload, which rewrites HOME after Bun has started, only this one follows the + * sandbox — the cached value is the developer's real home (#6775). Exported for tests. + */ +export function currentUserHome(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): string { + const fromEnv = platform === "win32" ? env.USERPROFILE : env.HOME; + return fromEnv !== undefined && fromEnv !== "" ? fromEnv : homedir(); } /** Write the cache file; returns its path or null (best-effort, never throws). */ export function writeGatewayModelCache(baseUrl: string, models: readonly GatewayModelRow[], configDir = claudeConfigDir()): string | null { + // Outside the best-effort catch: an armed test process must not write here, and must + // fail loudly rather than degrade to "returned null". + // The file itself too: writeFileSync follows a link at gateway-models.json. + assertNotRealClaudeConfigUnderTest(configDir, join(configDir, "cache"), join(configDir, "cache", "gateway-models.json")); try { // Mirror the CLI's usable-id filter so our file matches what it would cache. const usable = models.filter(m => /(claude|anthropic)/i.test(m.id)); diff --git a/src/claude/intercept/cli-catalog.ts b/src/claude/intercept/cli-catalog.ts index 1508f754f88..eec388288c7 100644 --- a/src/claude/intercept/cli-catalog.ts +++ b/src/claude/intercept/cli-catalog.ts @@ -14,6 +14,7 @@ */ import { readdirSync, unlinkSync } from "node:fs"; import { join } from "node:path"; +import { assertNotRealClaudeConfigUnderTest } from "../../lib/test-home-guard"; import type { ClaudeFirstPartyDesired } from "../first-party-settings"; import { classifyInterceptClient } from "./client-class"; import { BOOTSTRAP_MAX_DECODED_BYTES, injectPickerModels, type PickerModelEntry } from "./picker-bootstrap"; @@ -150,6 +151,8 @@ export async function rewriteCliCatalogResponse( */ export function invalidateClaudeCodeServedCatalog(claudeDir: string): number { const dir = join(claudeDir, "cache", "model-catalog"); + // Outside the catch below: an armed test process must not prune the real Claude cache (#6775). + assertNotRealClaudeConfigUnderTest(claudeDir, dir); let names: string[]; try { names = readdirSync(dir); } catch { return 0; } // no-excuse-ok: catch -- no cache directory means nothing to drop. let removed = 0; diff --git a/src/claude/intercept/settings.ts b/src/claude/intercept/settings.ts index cb4776f823c..51a70354699 100644 --- a/src/claude/intercept/settings.ts +++ b/src/claude/intercept/settings.ts @@ -1,6 +1,7 @@ import { mkdirSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { atomicWriteFile } from "../../config/atomic-write"; +import { assertNotRealClaudeConfigUnderTest } from "../../lib/test-home-guard"; import { claudeConfigDir } from "../auth-detect"; import { invalidateClaudeCodeServedCatalog } from "./cli-catalog"; @@ -112,6 +113,8 @@ export function inspectClaudeInterceptSettings( } function writeSettings(path: string, doc: SettingsDoc): void { + // An armed test process must not rewrite the real Claude settings.json (#6775). + assertNotRealClaudeConfigUnderTest(dirname(path), path); mkdirSync(dirname(path), { recursive: true }); // The managed env embeds the proxy token, so the file must stay owner-only: // atomicWriteFile applies the real NTFS ACL on Windows where chmod is a no-op. diff --git a/src/lib/test-home-guard.ts b/src/lib/test-home-guard.ts index 3373d5020b3..252fde3c5d8 100644 --- a/src/lib/test-home-guard.ts +++ b/src/lib/test-home-guard.ts @@ -20,7 +20,7 @@ * how this incident happened. */ import { homedir } from "node:os"; -import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -31,6 +31,8 @@ const GUARD_ENV = "OCX_TEST_HOME_GUARD"; * loads, so the true home is only knowable from this hand-off. */ const REAL_HOME_ENV = "OCX_REAL_HOME"; +/** Set by `scripts/test.ts` to the developer's own Claude config directory before it sandboxes one. */ +const REAL_CLAUDE_CONFIG_DIR_ENV = "OCX_REAL_CLAUDE_CONFIG_DIR"; /** * Resolve symlinks so two spellings of one location compare equal — macOS hands out @@ -62,6 +64,25 @@ function canonicalize(path: string): string { const REAL_HOME = process.env[REAL_HOME_ENV]?.trim() || homedir(); const PROTECTED_HOME = canonicalize(join(REAL_HOME, ".opencodex")); const PROTECTED_CODEX_HOME = canonicalize(join(REAL_HOME, ".codex")); +/** + * The developer's Claude config directory: the default `/.claude`, plus their own + * `CLAUDE_CONFIG_DIR` when they set one. `scripts/test.ts` hands the original over as + * `OCX_REAL_CLAUDE_CONFIG_DIR` because its child already starts with a sandboxed + * `CLAUDE_CONFIG_DIR`; a bare run imports this module before the preload rewrites the + * environment, so `CLAUDE_CONFIG_DIR` still holds the developer's value here. + * + * Claude agent sync prunes generated `agents/ocx-*.md`, and a bare `bun test` once did that + * to a live directory: Bun's `os.homedir()` ignores a HOME rewritten after startup (#6775). + */ +const REAL_CLAUDE_CONFIG_DIRS = [...new Set([ + join(REAL_HOME, ".claude"), + process.env[REAL_CLAUDE_CONFIG_DIR_ENV]?.trim() || process.env.CLAUDE_CONFIG_DIR?.trim() || join(REAL_HOME, ".claude"), +])]; +const PROTECTED_CLAUDE_TREES = REAL_CLAUDE_CONFIG_DIRS.map(path => ({ + path: canonicalize(path), + lexical: resolve(path), + label: "the real Claude config directory", +})); /** * `~/Library/LaunchAgents` needs its own entry because HOME isolation does not reach it: * `os.homedir()` reads the password database, not `$HOME`, so a macOS test that rewrites @@ -83,6 +104,11 @@ export function protectedCodexHomeForTests(): string { return PROTECTED_CODEX_HOME; } +/** The Claude config directories this process protects. Exported for the guard's own tests. */ +export function protectedClaudeConfigDirsForTests(): readonly string[] { + return PROTECTED_CLAUDE_TREES.map(tree => tree.path); +} + export function isTestHomeGuardArmed(): boolean { return process.env[GUARD_ENV] === "1"; } @@ -155,6 +181,54 @@ export function assertNotRealCodexHomeUnderTest(dir: string): void { ); } +/** + * Throw when an armed test process is about to write or prune inside the real Claude config + * directory. Unlike the OpenCodex and Codex homes this refuses descendants too, and callers pass + * every directory they are about to touch (the config root AND its `agents`/`cache` child), so a + * sandbox child that is a link into the real directory is judged by where it resolves. + * + * The protected roots are resolved at check time rather than at import, so a custom directory + * created after this module loaded still compares by its current identity. On macOS and Windows, + * whose default filesystems ignore case, paths compare case-insensitively: `.CLAUDE` is the same + * directory there. Comparing too broadly only refuses more, which is the safe direction here. + * + * Call FIRST, outside any best-effort catch, so a refusal is visible and nothing is touched. + */ +export function assertNotRealClaudeConfigUnderTest(...paths: string[]): void { + if (!isTestHomeGuardArmed()) return; + const roots = currentClaudeConfigRoots(); + for (const path of paths) { + for (const candidate of [canonicalize(path), resolve(path)]) { + for (const root of roots) { + if (!isSameOrInsideIgnoringPlatformCase(root, candidate)) continue; + throw new Error( + `refusing to write the real Claude config directory (${root}) from a test process. ` + + "Set CLAUDE_CONFIG_DIR to a temp directory, or pass an explicit config directory " + + "(for a server, startServer's managementApi.claudeAgentConfigDir).", + ); + } + } + } +} + +function currentClaudeConfigRoots(): string[] { + return REAL_CLAUDE_CONFIG_DIRS.flatMap(path => [canonicalize(path), resolve(path)]); +} + +function foldPlatformCase(path: string): string { + return process.platform === "darwin" || process.platform === "win32" ? path.toLowerCase() : path; +} + +function isSameOrInsideIgnoringPlatformCase(root: string, candidate: string): boolean { + const parent = foldPlatformCase(root); + const child = foldPlatformCase(candidate); + if (parent === child) return true; + const rel = relative(parent, child); + // Only a leading ".." COMPONENT leaves the root, split on this platform's separator: a child + // named "..fixture", or "..\\fixture" on POSIX where a backslash is a filename character, is inside. + return rel !== "" && !isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`); +} + /** * The trees a removal must never reach, and the reason each one is named. * @@ -172,6 +246,7 @@ const PROTECTED_TREES: ReadonlyArray<{ path: string; lexical: string; label: str lexical: resolve(join(REAL_HOME, "Library", "LaunchAgents")), label: "the real LaunchAgents directory", }, + ...PROTECTED_CLAUDE_TREES, ]; const PROTECTED_REAL_HOME = canonicalize(REAL_HOME); const LEXICAL_REAL_HOME = resolve(REAL_HOME); @@ -258,6 +333,17 @@ export function protectedRemovalReason(target: string): string | null { if (isInside(candidate, protectedPath)) return `an ancestor of ${tree.label} (${protectedPath})`; } } + // The Claude directory is judged like its writer guard: roots resolved now, case folded + // where the filesystem folds it, so an alias created after import is still refused. + for (const root of currentClaudeConfigRoots()) { + if (isSameOrInsideIgnoringPlatformCase(root, candidate)) { + // Same narrow lift as the trees above: content of the running checkout when that + // checkout itself lives inside the directory. Never the root itself. + if (foldPlatformCase(root) !== foldPlatformCase(candidate) && isOwnCheckoutContent(root, candidate)) continue; + return `the real Claude config directory or a path inside it (${root})`; + } + if (isSameOrInsideIgnoringPlatformCase(candidate, root)) return `an ancestor of the real Claude config directory (${root})`; + } } return null; } diff --git a/structure/ops/test-sandbox-cleanup.md b/structure/ops/test-sandbox-cleanup.md index bcd799df96d..12d22ffdb8a 100644 --- a/structure/ops/test-sandbox-cleanup.md +++ b/structure/ops/test-sandbox-cleanup.md @@ -8,6 +8,21 @@ that root. Its synchronous exit fallback leaves an undrained root for ownership- recovery instead of blocking child cleanup with removal retries. `tests/ci-workflows/test-sandbox-cleanup.test.ts` pins that ordering with a delayed reap. +The sandbox also pins the client homes that otherwise default to `os.homedir()`: +`CLAUDE_CONFIG_DIR` and `GROK_HOME` point inside its root and are never inherited, because +Bun's `os.homedir()` keeps the home it read at startup and a preload that only rewrites HOME +leaves them at the developer's directories. `scripts/test.ts` hands the developer's own Claude +directory to the guard as `OCX_REAL_CLAUDE_CONFIG_DIR`. `claudeConfigDir()` in +`src/claude/gateway-cache.ts` reads the current platform home variable (HOME, or USERPROFILE on +Windows), as Claude Code's Node runtime does. Under an armed process, +`assertNotRealClaudeConfigUnderTest` in `src/lib/test-home-guard.ts` refuses Claude agent sync, +gateway-cache writes, served-catalog invalidation and intercept `settings.json` writes into the +real Claude config directory, its default and the developer's override alike. Writers pass every +directory they touch, so a sandbox child linked into the real directory is judged by its target; +protected roots resolve at check time and compare case-insensitively on macOS and Windows. That +directory is also a protected removal tree. +`tests/claude-integration/claude-config-home-isolation.test.ts` pins each layer. + `tests/helpers/test-sandbox-cleanup.ts` exposes case-scoped lifecycle ownership: cancellation starts listener stops while owned asynchronous work settles, and repeated close/stop calls share one promise. After teardown starts only the lifecycle's own abort reason is absorbed; any other diff --git a/tests/claude-integration/claude-config-home-isolation.test.ts b/tests/claude-integration/claude-config-home-isolation.test.ts new file mode 100644 index 00000000000..1948395db71 --- /dev/null +++ b/tests/claude-integration/claude-config-home-isolation.test.ts @@ -0,0 +1,263 @@ +/** + * #6775: a bare `bun test` pruned the developer's real ~/.claude/agents/ocx-*.md. + * + * The preload rewrites HOME after Bun has started, and Bun's os.homedir() keeps the home it + * read at startup, so `claudeConfigDir()` resolved the real directory whenever + * CLAUDE_CONFIG_DIR was unset. Three layers close that: the sandbox pins the client homes, + * `claudeConfigDir()` follows the current platform home variable, and the armed guard + * refuses Claude writes into the real directory. Each layer has its own case here so one + * cannot silently stand in for another. + */ +import { expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { dirname, join, relative } from "node:path"; +import { createIsolatedTestEnvironment } from "../../scripts/test"; +import { claudeConfigDir, currentUserHome } from "../../src/claude/gateway-cache"; +import { protectedClaudeConfigDirsForTests } from "../../src/lib/test-home-guard"; +import { repoPath, repoRoot } from "../helpers/repo-root"; + +const HOME_ENV = ["HOME", "USERPROFILE", "CLAUDE_CONFIG_DIR"] as const; + +function isWithin(parent: string, child: string): boolean { + const rel = relative(parent, child); + return rel === "" || (!rel.startsWith("..") && !rel.startsWith("/") && !/^[A-Za-z]:/.test(rel)); +} + +test("the sandbox pins client homes and hands the real Claude directory to the guard", () => { + const isolated = createIsolatedTestEnvironment({ + OCX_REAL_HOME: join(tmpdir(), "real-home-sentinel"), + CLAUDE_CONFIG_DIR: join(tmpdir(), "inherited-claude"), + GROK_HOME: join(tmpdir(), "inherited-grok"), + }); + try { + expect(isolated.env.CLAUDE_CONFIG_DIR).toBe(join(isolated.root, ".claude")); + expect(isolated.env.GROK_HOME).toBe(join(isolated.root, ".grok")); + expect(isolated.env.OCX_REAL_CLAUDE_CONFIG_DIR).toBe(join(tmpdir(), "inherited-claude")); + } finally { + isolated.cleanup(); + } +}); + +test("without an inherited override the guard is handed the real home's .claude", () => { + const isolated = createIsolatedTestEnvironment({ OCX_REAL_HOME: join(tmpdir(), "real-home-sentinel") }); + try { + expect(isolated.env.OCX_REAL_CLAUDE_CONFIG_DIR).toBe(join(tmpdir(), "real-home-sentinel", ".claude")); + } finally { + isolated.cleanup(); + } +}); + +test("a nested sandbox keeps the outer hand-off instead of the outer sandbox", () => { + const outer = createIsolatedTestEnvironment({ + OCX_REAL_HOME: join(tmpdir(), "real-home-sentinel"), + CLAUDE_CONFIG_DIR: join(tmpdir(), "developer-claude"), + }); + try { + const inner = createIsolatedTestEnvironment(outer.env); + try { + expect(inner.env.OCX_REAL_CLAUDE_CONFIG_DIR).toBe(join(tmpdir(), "developer-claude")); + expect(inner.env.CLAUDE_CONFIG_DIR).toBe(join(inner.root, ".claude")); + } finally { + inner.cleanup(); + } + } finally { + outer.cleanup(); + } +}); + +test("this test process runs with a sandboxed Claude config directory", () => { + const configured = process.env.CLAUDE_CONFIG_DIR; + expect(configured).toBeTruthy(); + const resolved = existsSync(configured!) ? realpathSync.native(configured!) : configured!; + for (const protectedDir of protectedClaudeConfigDirsForTests()) { + expect(isWithin(protectedDir, resolved)).toBe(false); + } +}); + +test("claudeConfigDir follows a HOME rewritten after startup", () => { + const previous = Object.fromEntries(HOME_ENV.map(name => [name, process.env[name]])); + const sandbox = mkdtempSync(join(tmpdir(), "ocx-claude-calltime-")); + try { + delete process.env.CLAUDE_CONFIG_DIR; + process.env.HOME = sandbox; + process.env.USERPROFILE = sandbox; + expect(claudeConfigDir()).toBe(join(sandbox, ".claude")); + } finally { + for (const name of HOME_ENV) { + if (previous[name] === undefined) delete process.env[name]; + else process.env[name] = previous[name]; + } + } +}); + +test("currentUserHome reads the variable the platform's Node runtime reads", () => { + const env = { HOME: "/posix-home", USERPROFILE: "C:\\Users\\windows-home" }; + expect(currentUserHome(env, "linux")).toBe("/posix-home"); + expect(currentUserHome(env, "darwin")).toBe("/posix-home"); + expect(currentUserHome(env, "win32")).toBe("C:\\Users\\windows-home"); + expect(currentUserHome({ HOME: "" }, "linux")).toBe(homedir()); + expect(currentUserHome({}, "win32")).toBe(homedir()); +}); + +test("an armed guard refuses every Claude writer that resolves into the real directory", () => { + // The "real home" here is a disposable sentinel handed to a child at startup, the same way + // scripts/test.ts hands over the developer's home. Nothing outside the sandbox is touched. + const isolated = createIsolatedTestEnvironment(); + const sentinelHome = join(isolated.root, "sentinel-home"); + const realClaude = join(sentinelHome, ".claude"); + const probe = join(realClaude, "agents", "ocx-guard-probe.md"); + const catalog = join(realClaude, "cache", "model-catalog", "probe-cc.json"); + const fixtureClaude = join(isolated.root, "fixture-claude"); + // A sandbox directory whose agents/cache children are links into the real directory. + const linkedClaude = join(isolated.root, "linked-claude"); + mkdirSync(join(realClaude, "agents"), { recursive: true }); + mkdirSync(join(realClaude, "cache", "model-catalog"), { recursive: true }); + mkdirSync(linkedClaude, { recursive: true }); + writeFileSync(probe, "---\nname: \"ocx-guard-probe\"\nmodel: \"x\"\n---\n\n\n"); + writeFileSync(catalog, "{}"); + const linkType = process.platform === "win32" ? "junction" : "dir"; + symlinkSync(join(realClaude, "agents"), join(linkedClaude, "agents"), linkType); + symlinkSync(join(realClaude, "cache"), join(linkedClaude, "cache"), linkType); + // On case-insensitive platforms another spelling names the same directory, including a + // developer override that does not exist yet when the guard loads. + const foldsCase = process.platform === "darwin" || process.platform === "win32"; + const caseAlias = foldsCase ? join(sentinelHome, ".CLAUDE") : realClaude; + const absentOverride = join(sentinelHome, "Custom-Claude"); + const absentAlias = foldsCase ? join(sentinelHome, "custom-claude") : absentOverride; + // A fixture directory whose cache FILE links to the real cache file. + const fileLinkedClaude = join(isolated.root, "file-linked-claude"); + const realCacheFile = join(realClaude, "cache", "gateway-models.json"); + mkdirSync(join(fileLinkedClaude, "cache"), { recursive: true }); + writeFileSync(realCacheFile, "original"); + // A Windows account without the symlink privilege cannot build this case; it then expects + // the ordinary unrefused write into the fixture. + let fileLinkBuilt = true; + try { + symlinkSync(realCacheFile, join(fileLinkedClaude, "cache", "gateway-models.json"), "file"); + } catch (error) { + if (process.platform !== "win32") throw error; + fileLinkBuilt = false; + } + const modules = { + agents: repoPath("src", "claude", "agents-inject.ts"), + cache: repoPath("src", "claude", "gateway-cache.ts"), + catalog: repoPath("src", "claude", "intercept", "cli-catalog.ts"), + settings: repoPath("src", "claude", "intercept", "settings.ts"), + guard: repoPath("src", "lib", "test-home-guard.ts"), + }; + const code = ` + const m = ${JSON.stringify(modules)}; + const { syncClaudeAgentDefs } = await import(m.agents); + const { writeGatewayModelCache } = await import(m.cache); + const { invalidateClaudeCodeServedCatalog } = await import(m.catalog); + const { applyClaudeInterceptSettings, buildClaudeInterceptEnv } = await import(m.settings); + const { protectedRemovalReason } = await import(m.guard); + const real = ${JSON.stringify(realClaude)}, linked = ${JSON.stringify(linkedClaude)}; + const alias = ${JSON.stringify(caseAlias)}, fixture = ${JSON.stringify(fixtureClaude)}; + const absentAlias = ${JSON.stringify(absentAlias)}; + const fileLinked = ${JSON.stringify(fileLinkedClaude)}; + const env = buildClaudeInterceptEnv(1, "/tmp/ca.pem", "token"); + const attempts = { + agents: () => syncClaudeAgentDefs([], real), + agentsThroughLink: () => syncClaudeAgentDefs([], linked), + cache: () => writeGatewayModelCache("http://127.0.0.1:1", [{ id: "claude-x" }], real), + cacheThroughLink: () => writeGatewayModelCache("http://127.0.0.1:1", [{ id: "claude-x" }], linked), + cacheThroughCaseAlias: () => writeGatewayModelCache("http://127.0.0.1:1", [{ id: "claude-x" }], alias), + cacheThroughAbsentOverrideAlias: () => writeGatewayModelCache("http://127.0.0.1:1", [{ id: "claude-x" }], absentAlias), + cacheThroughFileLink: () => writeGatewayModelCache("http://127.0.0.1:1", [{ id: "claude-x" }], fileLinked), + cacheUnderDotDotNamedChild: () => writeGatewayModelCache("http://127.0.0.1:1", [{ id: "claude-x" }], real + "/..fixture"), + // On POSIX a backslash is an ordinary filename character, so this is still a child. + cacheUnderBackslashNamedChild: () => writeGatewayModelCache("http://127.0.0.1:1", [{ id: "claude-x" }], real + "/..\\\\fixture"), + catalog: () => invalidateClaudeCodeServedCatalog(real), + settings: () => applyClaudeInterceptSettings(env, real), + }; + const refused = {}; + for (const [name, attempt] of Object.entries(attempts)) { + try { attempt(); refused[name] = false; } + catch (error) { refused[name] = /real Claude config directory/.test(String(error)); } + } + const fixtureWrite = writeGatewayModelCache("http://127.0.0.1:1", [{ id: "claude-x" }], fixture) !== null; + // Windows reads the backslash as a separator, so there the same spelling leaves the root. + const backslashChildRefused = process.platform === "win32" || protectedRemovalReason(real + "/..\\\\fixture") !== null; + const removalRefused = protectedRemovalReason(absentAlias) !== null && protectedRemovalReason(real) !== null + && backslashChildRefused; + console.log(JSON.stringify({ refused, fixtureWrite, removalRefused })); + `; + try { + const child = Bun.spawnSync([process.execPath, "-e", code], { + cwd: repoRoot(), + env: { + ...isolated.env, + OCX_TEST_HOME_GUARD: "1", + OCX_REAL_HOME: sentinelHome, + OCX_REAL_CLAUDE_CONFIG_DIR: absentOverride, + CLAUDE_CONFIG_DIR: realClaude, + }, + stdout: "pipe", + stderr: "pipe", + }); + const stderr = new TextDecoder().decode(child.stderr); + expect({ exitCode: child.exitCode, stderr: child.exitCode === 0 ? "" : stderr }).toEqual({ exitCode: 0, stderr: "" }); + const lines = new TextDecoder().decode(child.stdout).trim().split("\n"); + expect(JSON.parse(lines.at(-1)!)).toEqual({ + refused: { + agents: true, + agentsThroughLink: true, + cache: true, + cacheThroughLink: true, + cacheThroughCaseAlias: true, + cacheThroughAbsentOverrideAlias: true, + cacheThroughFileLink: fileLinkBuilt, + cacheUnderDotDotNamedChild: true, + cacheUnderBackslashNamedChild: process.platform !== "win32", + catalog: true, + settings: true, + }, + fixtureWrite: true, + removalRefused: true, + }); + expect(existsSync(probe)).toBe(true); + expect(existsSync(catalog)).toBe(true); + expect(readFileSync(realCacheFile, "utf8")).toBe("original"); + expect(existsSync(join(realClaude, "..fixture"))).toBe(false); + expect(existsSync(join(realClaude, "settings.json"))).toBe(false); + expect(existsSync(absentAlias)).toBe(false); + } finally { + isolated.cleanup(); + } +}); + +test("removal protection keeps the running checkout's own content removable when it sits inside the Claude directory", () => { + const checkout = repoRoot(); + const code = ` + const { protectedRemovalReason } = await import(${JSON.stringify(repoPath("src", "lib", "test-home-guard.ts"))}); + const checkout = ${JSON.stringify(checkout)}; + console.log(JSON.stringify({ + content: protectedRemovalReason(checkout + "/tests/fixtures/n3-removal-probe") === null, + checkoutRoot: protectedRemovalReason(checkout) !== null, + protectedRoot: protectedRemovalReason(${JSON.stringify(dirname(checkout))}) !== null, + })); + `; + const isolated = createIsolatedTestEnvironment(); + try { + const child = Bun.spawnSync([process.execPath, "-e", code], { + cwd: checkout, + env: { + ...isolated.env, + OCX_TEST_HOME_GUARD: "1", + OCX_REAL_HOME: join(isolated.root, "sentinel-home"), + // The checkout's parent stands in for a Claude directory that contains the checkout. + OCX_REAL_CLAUDE_CONFIG_DIR: dirname(checkout), + }, + stdout: "pipe", + stderr: "pipe", + }); + expect(child.exitCode).toBe(0); + const lines = new TextDecoder().decode(child.stdout).trim().split("\n"); + expect(JSON.parse(lines.at(-1)!)).toEqual({ content: true, checkoutRoot: true, protectedRoot: true }); + } finally { + isolated.cleanup(); + } +}); + diff --git a/tests/claude-integration/claude-gateway-cache.test.ts b/tests/claude-integration/claude-gateway-cache.test.ts index f231a5314db..adb36ee2cb4 100644 --- a/tests/claude-integration/claude-gateway-cache.test.ts +++ b/tests/claude-integration/claude-gateway-cache.test.ts @@ -49,9 +49,10 @@ describe("Claude Code gateway-model cache pre-write (devlog 260712 030)", () => process.env.CLAUDE_CONFIG_DIR = "/tmp/custom-claude"; expect(claudeConfigDir()).toBe("/tmp/custom-claude"); delete process.env.CLAUDE_CONFIG_DIR; - // Platform-correct separator (Windows CI joins with backslash). - const { homedir } = require("node:os") as typeof import("node:os"); - expect(claudeConfigDir()).toBe(join(homedir(), ".claude")); + // The current platform home variable, which the preload points at the sandbox; Bun's + // os.homedir() would still name the real home (#6775). join() keeps the separator right. + const home = process.platform === "win32" ? process.env.USERPROFILE : process.env.HOME; + expect(claudeConfigDir()).toBe(join(home!, ".claude")); } finally { if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR; else process.env.CLAUDE_CONFIG_DIR = prev; diff --git a/tests/claude-integration/claude-messages-endpoint.test.ts b/tests/claude-integration/claude-messages-endpoint.test.ts index 33d50590c0d..1663a564e8a 100644 --- a/tests/claude-integration/claude-messages-endpoint.test.ts +++ b/tests/claude-integration/claude-messages-endpoint.test.ts @@ -1576,7 +1576,7 @@ test("native passthrough precedes even invalid compatibility mode", async () => test("compatibility survives management toggles and rejects Desktop source features", async () => { const { server: upstream, urls } = mockChatUpstreamCapturing(); saveConfig(mockConfig(new URL("/v1", upstream.url).href, { compatibility: "enforce" })); - const server = startServer(0); + const server = startServer(0, { managementApi: { claudeAgentConfigDir: join(testDir, "claude-agents") } }); try { for (const enabled of [false, true]) { const toggle = await fetch(new URL("/api/native-integrations/claude", server.url), { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index ef830f7b5f8..ebae87ea0e5 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -272,7 +272,7 @@ "claude-estimate-projection.test.ts": "claude-integration", "claude-first-party-agent-sync.test.ts": "claude-integration", "claude-first-party-union.test.ts": "claude-integration", "claude-desktop-system-proxy.test.ts": "claude-integration", - "claude-gateway-cache.test.ts": "claude-integration", "claude-inbound-cache-stabilize.test.ts": "claude-integration", "claude-inbound-token-footer.test.ts": "claude-integration", + "claude-gateway-cache.test.ts": "claude-integration", "claude-config-home-isolation.test.ts": "claude-integration", "claude-inbound-cache-stabilize.test.ts": "claude-integration", "claude-inbound-token-footer.test.ts": "claude-integration", "claude-inbound-debug.test.ts": "claude-integration", "claude-inbound.test.ts": "claude-integration", "claude-inbound-tool-reference.test.ts": "claude-integration", "claude-native-reasoning-continuity.test.ts": "claude-integration", "claude-native-reasoning-provenance.test.ts": "claude-integration", "claude-output-schema-strict.test.ts": "claude-integration", "claude-intercept-integration.test.ts": "server", From 6d7e5f48ad85c0a2cd15e67e81af90c1d85d2394 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 9 Oct 2026 21:59:20 +0900 Subject: [PATCH 32/59] test(combos): keep the rename fixture off provider discovery and serialize its temp homes (#6833) --- tests/routing/combo-management-api.test.ts | 147 +++++++++++++++++++-- 1 file changed, 139 insertions(+), 8 deletions(-) diff --git a/tests/routing/combo-management-api.test.ts b/tests/routing/combo-management-api.test.ts index 22515594541..453429a0e21 100644 --- a/tests/routing/combo-management-api.test.ts +++ b/tests/routing/combo-management-api.test.ts @@ -34,6 +34,7 @@ import { UnknownComboError, } from "../../src/combos"; import { getConfigPath, readConfigDiagnostics, saveConfig } from "../../src/config"; +import * as outbound from "../../src/lib/provider-outbound"; import { routeModel } from "../../src/router"; import { handleManagementAPI } from "../../src/server/management-api"; import { handleResponses } from "../../src/server/responses"; @@ -51,9 +52,9 @@ function baseConfig(overrides: Partial = {}): OcxConfig { port: 10100, defaultProvider: "a", providers: { - a: { adapter: "openai-chat", baseUrl: "https://a.example/v1", apiKey: "ka", models: ["m1"] }, - b: { adapter: "openai-chat", baseUrl: "https://b.example/v1", apiKey: "kb", models: ["m2"] }, - c: { adapter: "openai-chat", baseUrl: "https://c.example/v1", apiKey: "kc", models: ["m3"] }, + a: { adapter: "openai-chat", baseUrl: "https://a.example/v1", apiKey: "ka", models: ["m1"], liveModels: false }, + b: { adapter: "openai-chat", baseUrl: "https://b.example/v1", apiKey: "kb", models: ["m2"], liveModels: false }, + c: { adapter: "openai-chat", baseUrl: "https://c.example/v1", apiKey: "kc", models: ["m3"], liveModels: false }, }, combos: { free: { @@ -96,7 +97,46 @@ function successfulPicks(config: OcxConfig, count: number): string[] { }); } -async function withTempHome(run: (dir: string) => Promise | T): Promise { +// Bun can start another case while a timed-out callback is still running. +// Chain owners so one fixture cannot install or restore another's environment. +let tempHomeSettled: Promise = Promise.resolve(); +let tempHomeStuck = false; +let tempHomeDrainMs = 50_000; +const STUCK = "an earlier withTempHome owner never settled; refusing to share its environment"; +const cleanups: Array<() => void> = []; + +function withTempHome(run: (dir: string) => Promise | T): Promise { + if (tempHomeStuck) return Promise.reject(new Error(STUCK)); + const operation = tempHomeSettled.then(() => { + // Owners queued before poisoning must refuse even if the predecessor resumes. + if (tempHomeStuck) throw new Error(STUCK); + return runWithTempHome(run); + }); + tempHomeSettled = operation.then(() => {}, () => {}); + return operation; +} + +async function drainTempHome(): Promise { + if (tempHomeStuck) throw new Error(STUCK); + let timer: ReturnType | undefined; + const drained = await Promise.race([ + tempHomeSettled.then(() => true), + new Promise(resolve => { timer = setTimeout(() => resolve(false), tempHomeDrainMs); }), + ]); + clearTimeout(timer); + if (!drained) { + tempHomeStuck = true; + throw new Error("withTempHome owner still running after the drain bound"); + } +} + +function resetTempHomeQueueForTest(): void { + tempHomeDrainMs = 50_000; + tempHomeStuck = false; + tempHomeSettled = Promise.resolve(); +} + +async function runWithTempHome(run: (dir: string) => Promise | T): Promise { const previousHome = process.env.OPENCODEX_HOME; const previousClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; const dir = mkdtempSync(join(tmpdir(), "ocx-combos-")); @@ -150,10 +190,15 @@ async function responseJson(response: Response | null): Promise>; } -afterEach(() => { - clearComboSelectionState(); - clearComboTargetCooldowns(); -}); +afterEach(async () => { + try { + await drainTempHome(); + clearComboSelectionState(); + clearComboTargetCooldowns(); + } finally { + for (const cleanup of cleanups.splice(0)) cleanup(); + } +}, 60_000); describe("combo management API", () => { test("bare alias precedence yields back to a non-OpenAI selector after rename and deletion", async () => { @@ -1026,6 +1071,9 @@ describe("combo management API", () => { }); test("PUT renames atomically, migrates public references, and clears both ids", async () => { + const discovery = spyOn(outbound, "providerOutboundGet") + .mockRejectedValue(new Error("the combo fixture must not discover provider models")); + cleanups.push(() => discovery.mockRestore()); await withTempHome(async () => { const config = baseConfig({ disabledModels: ["before", "combo/old", "middle", "old-public", "after"], @@ -1114,6 +1162,7 @@ describe("combo management API", () => { modelMap: { inbound: "new-public", stable: "a/m1" }, }); }); + expect(discovery).not.toHaveBeenCalled(); }); test("PUT rename migrates canonical references when the public alias stays unchanged", async () => { @@ -1780,3 +1829,85 @@ describe("combo response-path strategy accounting", () => { }, 10_000); }); import { ManagementRequest as Request } from "../helpers/management-auth"; + + +describe("combo management temp homes", () => { + test("serializes overlapping withTempHome owners", async () => { + const previousHome = process.env.OPENCODEX_HOME; + const previousClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + let release!: () => void; + let started!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const firstStarted = new Promise(resolve => { started = resolve; }); + let second: Promise | undefined; + const first = withTempHome(async dir => { + started(); + await gate; + expect(process.env.OPENCODEX_HOME).toBe(dir); + expect(process.env.CLAUDE_CONFIG_DIR).toBe(join(dir, "claude")); + }); + try { + await firstStarted; + second = withTempHome(async dir => { + await gate; + expect(process.env.OPENCODEX_HOME).toBe(dir); + expect(process.env.CLAUDE_CONFIG_DIR).toBe(join(dir, "claude")); + }); + release(); + await first; + await second; + expect(process.env.OPENCODEX_HOME).toBe(previousHome); + expect(process.env.CLAUDE_CONFIG_DIR).toBe(previousClaudeConfigDir); + } finally { + release(); + await Promise.allSettled([first, ...(second ? [second] : [])]); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = previousClaudeConfigDir; + resetTempHomeQueueForTest(); + } + }); + + test("poisons a stalled withTempHome queue and rejects later owners immediately", async () => { + let release!: () => void; + let started!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const ownerStarted = new Promise(resolve => { started = resolve; }); + const owners: Array> = []; + const timers: Array> = []; + let queuedRan = false; + let laterRan = false; + const rejectsImmediately = async (operation: Promise) => { + const result = await Promise.race([ + operation.then(() => "resolved", error => error.message), + new Promise(resolve => { timers.push(setTimeout(() => resolve("still waiting"), 100)); }), + ]); + expect(result).toBe(STUCK); + }; + try { + tempHomeDrainMs = 20; + owners.push(withTempHome(async () => { started(); await gate; })); + await ownerStarted; + const queued = withTempHome(() => { queuedRan = true; }); + owners.push(queued); + // Observe the expected rejection immediately, even if an earlier assertion fails. + void queued.catch(() => {}); + await expect(drainTempHome()).rejects.toThrow("withTempHome owner still running after the drain bound"); + tempHomeDrainMs = 1_000; + await rejectsImmediately(drainTempHome()); + const later = withTempHome(() => { laterRan = true; }); + owners.push(later); + await rejectsImmediately(later); + expect(laterRan).toBe(false); + release(); + await expect(queued).rejects.toThrow(STUCK); + expect(queuedRan).toBe(false); + } finally { + release(); + await Promise.allSettled(owners); + for (const timer of timers) clearTimeout(timer); + resetTempHomeQueueForTest(); + } + }); +}); From c4baa30e0993e75bc7bed06bd6c4b1d884cf3c6d Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 9 Oct 2026 21:59:32 +0900 Subject: [PATCH 33/59] test(codex): give the native toggle fixture its own service-manager evidence (#6834) --- .../native-codex-toggle.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/codex-integration/native-codex-toggle.test.ts b/tests/codex-integration/native-codex-toggle.test.ts index dde313a8718..fb45ac311c5 100644 --- a/tests/codex-integration/native-codex-toggle.test.ts +++ b/tests/codex-integration/native-codex-toggle.test.ts @@ -11,7 +11,7 @@ * so a process that dies between the two leaves a decision the next start can * act on — rather than artifacts the next start silently undoes. */ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; @@ -19,12 +19,14 @@ import { join } from "node:path"; import { handleManagementAPI } from "../../src/server/management-api"; import type { ManagementApiDeps } from "../../src/server/management/context"; import type { OcxConfig } from "../../src/types"; +import * as serviceManagerProbe from "../../src/service-manager-probe"; import { removeTreeWithRetry } from "../helpers/remove-tree"; let fixtureRoot = ""; let codexHome = ""; let previousOpencodexHome: string | undefined; let previousCodexHome: string | undefined; +let restoreServiceManagerProbe = () => {}; const cleanup: string[] = []; function baseConfig(): OcxConfig { @@ -73,6 +75,11 @@ function persistedCodexIntent(): unknown { } beforeEach(() => { + // The route fixture owns no service, and HOME cannot redirect launchctl/systemctl. + // Without this the host's own opencodex registration decides the case. + const probe = spyOn(serviceManagerProbe, "inspectServiceManagerInstallation") + .mockReturnValue({ kind: "absent" }); + restoreServiceManagerProbe = () => probe.mockRestore(); previousOpencodexHome = process.env.OPENCODEX_HOME; previousCodexHome = process.env.CODEX_HOME; // Native realpath resolves macOS /var aliases and expands Windows RUNNER~1 @@ -93,6 +100,7 @@ beforeEach(() => { }); afterEach(() => { + restoreServiceManagerProbe(); if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousOpencodexHome; if (previousCodexHome === undefined) delete process.env.CODEX_HOME; @@ -252,6 +260,12 @@ describe("turning Codex off", () => { }); describe("turning Codex back on", () => { + test("enable inspects the fixture's service-manager evidence", async () => { + expect(serviceManagerProbe.inspectServiceManagerInstallation).not.toHaveBeenCalled(); + await put(baseConfig(), { enabled: true }); + expect(serviceManagerProbe.inspectServiceManagerInstallation).toHaveBeenCalled(); + }); + test("removes the key rather than storing true", async () => { await put(baseConfig(), { enabled: false }); expect(persistedCodexIntent()).toBe(false); From 7df198b46565a60d3d4cc9add13336ff76c87633 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 9 Oct 2026 22:01:59 +0900 Subject: [PATCH 34/59] fix(anthropic): end the translated stream at message_stop (#6826) A trailing ping or SSE comment after message_stop was translated into a heartbeat after the terminal event, which the web-search progress collector rejects as a protocol error. Return from the parser at message_stop so the decoder closes and later records in the same body are never read. Terminal classification, usage, pre-terminal heartbeats and the EOF fallback are unchanged. Closes #6766 --- .../src/content/docs/reference/adapters.md | 4 + scripts/test-layout/layout.json | 1 + src/adapters/anthropic.ts | 2 +- structure/adapters/registry.md | 7 + ...anthropic-message-stop-termination.test.ts | 240 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 6 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 tests/adapters/anthropic/anthropic-message-stop-termination.test.ts diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 038b29bea7c..b94a0044fad 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -292,6 +292,10 @@ MiMo model Command Code serves. - Always sends `anthropic-version: 2023-06-01`. Streams `content_block_delta` (`text_delta`, `thinking_delta`, compatible `reasoning_delta`, `input_json_delta`). The SSE decoder preserves event state across fetch chunks and accepts a terminal `message_stop` without a trailing newline. + At `message_stop`, the translated stream emits its terminal outcome and ends without waiting for + upstream EOF. Later pings or comments do not turn that completed message into a web-search stream + error. Pings and comments before `message_stop` still keep long thinking turns alive; refusals + remain non-retryable incomplete outcomes and upstream error stop reasons remain failures. - For routed Anthropic Responses turns with client tools, a bounded terminal guard detects the high-confidence case where the user requested an action but Claude ends with an execution claim and no tool call. It performs at most one internal continuation; normal answers, clarification diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 3c008f1c5e5..44ae9d7e805 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -166,6 +166,7 @@ "anthropic-agentrouter-language-framing.test.ts": "adapters/anthropic", "anthropic-baseurl-override.test.ts": "adapters/anthropic", "anthropic-compatible-stream.test.ts": "adapters/anthropic", + "anthropic-message-stop-termination.test.ts": "adapters/anthropic", "anthropic-cooldown-recovery.test.ts": "adapters/anthropic", "anthropic-empty-content.test.ts": "adapters/anthropic", "anthropic-eof-tolerance.test.ts": "adapters/anthropic", diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 82097110f4a..0211aef03fc 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -1367,7 +1367,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti } case "message_stop": { yield* emitDone(); - break; + return; } case "ping": { // A data-only `{"type":"ping"}` record carries no SSE `event:` line, so the diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index bf56357f414..8303d12cb97 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -174,6 +174,13 @@ A provider web search still in flight at that truncated terminal is finalized as `src/adapters/anthropic.ts` maps a `refusal` or `content_filter` stop reason to an explicit `incomplete` adapter event with `reason: "content_filter"` and `retryable: false` instead of `done` with that stopReason (#4312). Codex otherwise treats a filter incomplete without retryable as a dropped stream and retries a refusal that cannot succeed. Partial output, tool-call integrity, and usage are preserved; `max_tokens` remains a `done` so a legitimate truncation can continue. +The translated Anthropic stream ends at `message_stop` after emitting its existing terminal +classification and usage. The parser does not inspect later records in that response body; +pre-terminal named pings, data-only pings and SSE comments remain heartbeats. Iterator closure +releases decoder reservations, attempts reader cancellation and unlocking on a best-effort basis, +and closes any remaining tool-call budget entry. A never-settling cancellation can delay direct-parser +cleanup. `src/web-search/progress-stream.ts` continues to reject post-terminal adapter events. + Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. diff --git a/tests/adapters/anthropic/anthropic-message-stop-termination.test.ts b/tests/adapters/anthropic/anthropic-message-stop-termination.test.ts new file mode 100644 index 00000000000..0c8f37058fb --- /dev/null +++ b/tests/adapters/anthropic/anthropic-message-stop-termination.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, test } from "bun:test"; +import { createAnthropicAdapter } from "../../../src/adapters/anthropic"; +import { createTranslatorBudget, type TranslatorBudget } from "../../../src/lib/translator-budget"; +import { parseStreamWithProgress } from "../../../src/web-search/progress-stream"; +import type { AdapterEvent, OcxProviderConfig } from "../../../src/types"; + +const provider: OcxProviderConfig = { + adapter: "anthropic", + baseUrl: "https://example.invalid/v1", + authMode: "key", + apiKey: "synthetic-not-used", +}; + +const frame = (type: string, data: Record = {}): string => + `event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`; +const usage = { inputTokens: 2, outputTokens: 3 }; + +function turn(stopReason: string | undefined = "end_turn"): string { + return frame("message_start", { message: { usage: { input_tokens: 2 } } }) + + frame("content_block_start", { index: 0, content_block: { type: "text", text: "" } }) + + frame("content_block_delta", { index: 0, delta: { type: "text_delta", text: "ok" } }) + + frame("content_block_stop", { index: 0 }) + + frame("message_delta", { delta: { stop_reason: stopReason }, usage: { output_tokens: 3 } }) + + frame("message_stop"); +} + +function stream(response: Response, budget: TranslatorBudget, wrapped: boolean): AsyncGenerator { + const adapter = createAnthropicAdapter(provider); + return wrapped + ? parseStreamWithProgress(response, adapter.parseStream.bind(adapter), { + inactivityTimeoutMs: 1_000, + postTerminalDrainTimeoutMs: 200, + translatorBudget: budget, + }) + : adapter.parseStream(response, budget); +} + +async function collect(events: AsyncIterable): Promise { + const output: AdapterEvent[] = []; + for await (const event of events) output.push(event); + return output; +} + +function expectReleased(budget: TranslatorBudget, overflows = 0): void { + // Check before dispose(): disposal would conceal a missing parser release. + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0, overflows }); +} + +const tails = [ + ["no tail", ""], + ["named ping", frame("ping")], + ["data-only ping", 'data: {"type":"ping"}\n\n'], + ["comment", ": keepalive\n\n"], + ["late text", frame("content_block_delta", { delta: { type: "text_delta", text: "late" } })], + ["late error", frame("error", { error: { message: "late failure" } })], + ["duplicate message_stop", frame("message_stop")], +]; + +function openResponse(wire: string, rejectCancel = false) { + const probe = { pulls: 0, cancels: 0 }; + let controller: ReadableStreamDefaultController; + let timer: ReturnType | undefined; + const body = new ReadableStream({ + start(value) { + controller = value; + // An unfixed adapter eventually reaches EOF instead of hanging the test forever. + timer = setTimeout(() => { try { controller.close(); } catch { /* already closed */ } }, 1_000); + }, + pull(value) { + probe.pulls++; + if (probe.pulls === 1) value.enqueue(new TextEncoder().encode(wire)); + // Deliberately stay open without additional bytes. + }, + cancel() { + probe.cancels++; + clearTimeout(timer); + if (rejectCancel) return Promise.reject(new Error("synthetic cancel failure")); + }, + }, { highWaterMark: 0 }); + return { + response: new Response(body, { headers: { "content-type": "text/event-stream" } }), + body, + probe, + cleanup() { + clearTimeout(timer); + try { controller.close(); } catch { /* already closed */ } + }, + }; +} + +describe("Anthropic message_stop termination (#6766)", () => { + test.each(tails)("direct parser stops before %s", async (_name, tail) => { + const budget = createTranslatorBudget(); + try { + const events = await collect(stream(new Response(turn() + tail), budget, false)); + expect(events).toEqual([ + { type: "text_delta", text: "ok" }, + { type: "done", usage, stopReason: "end_turn" }, + ]); + expectReleased(budget); + } finally { budget.dispose(); } + }); + + test.each(tails)("progress collector completes before %s", async (_name, tail) => { + const budget = createTranslatorBudget(); + try { + const events = await collect(stream(new Response(turn() + tail), budget, true)); + // Raw-byte progress may add heartbeats before the validated terminal. + expect(events.filter(event => event.type !== "heartbeat")).toEqual([ + { type: "text_delta", text: "ok" }, + { type: "done", usage, stopReason: "end_turn" }, + ]); + expect(events.at(-1)?.type).toBe("done"); + expectReleased(budget); + } finally { budget.dispose(); } + }); + + test("keeps named ping, data-only ping and comment heartbeats before message_stop", async () => { + const budget = createTranslatorBudget(); + try { + const wire = frame("ping") + 'data: {"type":"ping"}\n\n' + ": keepalive\n\n" + turn(); + const events = await collect(stream(new Response(wire), budget, false)); + expect(events).toEqual([ + { type: "heartbeat" }, { type: "heartbeat" }, { type: "heartbeat" }, + { type: "text_delta", text: "ok" }, + { type: "done", usage, stopReason: "end_turn" }, + ]); + expectReleased(budget); + } finally { budget.dispose(); } + }); + + test.each(["end_turn", "max_tokens", "tool_use", "error", "refusal", "content_filter"])( + "preserves the terminal classification and usage for %s", async stopReason => { + const budget = createTranslatorBudget(); + try { + const events = await collect(stream(new Response(turn(stopReason) + frame("ping")), budget, false)); + const expected: AdapterEvent = stopReason === "error" + ? { type: "error", message: 'upstream ended the turn with stop_reason "error"', + status: 502, errorType: "upstream_error", usage } + : stopReason === "refusal" || stopReason === "content_filter" + ? { type: "incomplete", reason: "content_filter", retryable: false, + message: `upstream ended the turn with stop_reason "${stopReason}"`, usage } + : { type: "done", stopReason, usage }; + expect(events).toEqual([{ type: "text_delta", text: "ok" }, expected]); + expectReleased(budget); + } finally { budget.dispose(); } + }, + ); + + test.each(["refusal", "content_filter"])("collector keeps %s non-retryable", async stopReason => { + const budget = createTranslatorBudget(); + try { + const events = await collect(stream(new Response(turn(stopReason) + ": keepalive\n\n"), budget, true)); + expect(events.at(-1)).toEqual({ type: "incomplete", reason: "content_filter", retryable: false, + message: `upstream ended the turn with stop_reason "${stopReason}"`, usage }); + expect(events.filter(event => ["done", "incomplete", "error"].includes(event.type))).toHaveLength(1); + expectReleased(budget); + } finally { budget.dispose(); } + }); + + test("collector rejects an error stop instead of completing successfully", async () => { + const budget = createTranslatorBudget(); + try { + await expect(collect(stream(new Response(turn("error") + frame("ping")), budget, true))) + .rejects.toThrow('upstream ended the turn with stop_reason "error"'); + } finally { budget.dispose(); } + }); + + test.each([false, true])("returns without upstream EOF and cancels its reader (wrapped=%s)", async wrapped => { + const budget = createTranslatorBudget(); + const upstream = openResponse(turn()); + try { + const events = await collect(stream(upstream.response, budget, wrapped)); + expect(events.at(-1)).toEqual({ type: "done", usage, stopReason: "end_turn" }); + expect(upstream.probe).toEqual({ pulls: 1, cancels: 1 }); + expect(upstream.body.locked).toBe(false); + expectReleased(budget); + } finally { upstream.cleanup(); budget.dispose(); } + }); + + test("a rejected reader cancellation does not replace the terminal outcome", async () => { + const budget = createTranslatorBudget(); + const upstream = openResponse(turn(), true); + try { + const events = await collect(stream(upstream.response, budget, false)); + expect(events.at(-1)).toEqual({ type: "done", usage, stopReason: "end_turn" }); + expect(upstream.probe.cancels).toBe(1); + expect(upstream.body.locked).toBe(false); + expectReleased(budget); + } finally { upstream.cleanup(); budget.dispose(); } + }); + + test("message_stop releases a still-open tool argument reservation", async () => { + const budget = createTranslatorBudget(); + const wire = frame("content_block_start", { index: 0, + content_block: { type: "tool_use", id: "call_6766", name: "web_search" } }) + + frame("content_block_delta", { index: 0, + delta: { type: "input_json_delta", partial_json: '{"query":"test"}' } }) + + frame("message_delta", { delta: { stop_reason: "tool_use" } }) + + frame("message_stop") + frame("ping"); + try { + const events = await collect(stream(new Response(wire), budget, false)); + expect(events.map(event => event.type)).toEqual(["tool_call_start", "tool_call_delta", "done"]); + // Existing behavior does not invent tool_call_end for an unclosed block. + expect(events.at(-1)).toEqual({ type: "done", usage: undefined, stopReason: "tool_use" }); + expectReleased(budget); + } finally { budget.dispose(); } + }); + + test("consumer return releases the decoder reader and an open call", async () => { + const budget = createTranslatorBudget(); + const upstream = openResponse(frame("content_block_start", { index: 0, + content_block: { type: "tool_use", id: "call_cancel", name: "web_search" } })); + const iterator = stream(upstream.response, budget, false); + try { + expect((await iterator.next()).value?.type).toBe("tool_call_start"); + expect(budget.snapshot().activeCalls).toBe(1); + await iterator.return(undefined); + expect(upstream.probe.cancels).toBe(1); + expect(upstream.body.locked).toBe(false); + expectReleased(budget); + } finally { upstream.cleanup(); budget.dispose(); } + }); + + test("pre-terminal argument overflow stays one bounded error", async () => { + const budget = createTranslatorBudget({ maxCallArgumentBytes: 4 }); + const wire = frame("content_block_start", { index: 0, + content_block: { type: "tool_use", id: "call_overflow", name: "web_search" } }) + + frame("content_block_delta", { index: 0, + delta: { type: "input_json_delta", partial_json: '{"query":"test"}' } }) + + frame("message_stop") + frame("ping"); + try { + const events = await collect(stream(new Response(wire), budget, false)); + expect(events.map(event => event.type)).toEqual(["tool_call_start", "error"]); + expect(events.at(-1)).toMatchObject({ type: "error", status: 502, + errorType: "upstream_error", code: "translation_buffer_limit" }); + expectReleased(budget, 1); + } finally { budget.dispose(); } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index ebae87ea0e5..d3d7bfd0cb2 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -143,6 +143,7 @@ "anthropic-agentrouter-language-framing.test.ts": "adapters/anthropic", "anthropic-baseurl-override.test.ts": "adapters/anthropic", "anthropic-compatible-stream.test.ts": "adapters/anthropic", + "anthropic-message-stop-termination.test.ts": "adapters/anthropic", "anthropic-cooldown-recovery.test.ts": "adapters/anthropic", "anthropic-empty-content.test.ts": "adapters/anthropic", "anthropic-eof-tolerance.test.ts": "adapters/anthropic", "anthropic-error-body.test.ts": "adapters/anthropic", "anthropic-error-stop-reason.test.ts": "adapters/anthropic", "anthropic-thinking-usage.test.ts": "adapters/anthropic", "anthropic-fast-opt-in.test.ts": "adapters/anthropic", From d10fc26f055f4e8dc2ce96088eaca9d421a5cfaa Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 9 Oct 2026 22:02:09 +0900 Subject: [PATCH 35/59] fix(claude): log requested effort on native Messages requests (#6827) The native Messages lane returned before Responses preparation, the only place requestedEffort was set, so Logs never showed effort for native Claude Code requests. Record a validated label from output_config.effort, disabled thinking (none) or an enabled thinking budget (budget:) on the final row and physical attempts, without changing the wire body. Existing annotations still win. Closes #6730 --- .../src/content/docs/fr/guides/claude-code.md | 6 +- .../src/content/docs/guides/claude-code.md | 12 ++- .../src/content/docs/ja/guides/claude-code.md | 6 +- .../src/content/docs/ko/guides/claude-code.md | 6 +- .../src/content/docs/ru/guides/claude-code.md | 6 +- .../src/content/docs/tr/guides/claude-code.md | 6 +- .../content/docs/zh-cn/guides/claude-code.md | 6 +- .../content/docs/zh-tw/guides/claude-code.md | 6 +- src/server/messages-native.ts | 13 +++- structure/dashboard-and-usage.md | 2 +- structure/data-planes/protocol-paths.md | 6 ++ .../messages-native-oauth.test.ts | 22 +++++- .../messages-native.test.ts | 78 +++++++++++++++++++ 13 files changed, 156 insertions(+), 19 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index 58e92f8e0ad..f1fd2649697 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -611,7 +611,7 @@ pour de longues exécutions sans surveillance. ## Effort de raisonnement -Le paramètre `/effort` de Claude Code est conservé sur l'ensemble de l'adaptateur : +Pour les requêtes traduites de Messages vers Responses, le paramètre `/effort` de Claude Code est converti comme suit : | Format du protocole | Correspondance | | --- | --- | @@ -619,7 +619,9 @@ Le paramètre `/effort` de Claude Code est conservé sur l'ensemble de l'adaptat | `thinking.type: "enabled"` + `budget_tokens` | ≤4096→`low`, ≤16384→`medium`, ci-dessus→`high` | | `thinking.type: "disabled"` | `reasoning: { effort: "none" }` ; résumé omis | -La valeur résolue apparaît dans la colonne **Effort de raisonnement** du journal des demandes. +Pour les requêtes traduites, le niveau obtenu apparaît dans la colonne **Effort de raisonnement** du journal des demandes. +Les requêtes Messages natives gérées consignent le budget de réflexion activé sous la forme `budget:` +en l'absence d'un `output_config.effort` reconnu ; cette journalisation ne modifie pas le corps transmis. ## Traduction entrante (Messages → Réponses) diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 9a5b708d472..ea1c6761289 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -95,6 +95,12 @@ See [Configuration](/reference/configuration/providers/#anthropicaccountpool-exp ### Native Messages with account pooling +Managed native Messages show validated caller effort in Logs and `usage.jsonl`, including each +attempt: an explicit effort such as `xhigh` or `low`, `none` for disabled thinking, or +`budget:` for an enabled thinking budget. An explicit effort takes precedence over the +thinking fallback. Adaptive thinking without an explicit effort stays blank. These labels describe +request controls; they do not confirm what effort the upstream applied or change the wire body. + An enabled Anthropic account pool prefers native Messages for eligible direct Anthropic routes when neither native rollout flag explicitly disables that path. In Providers → Anthropic → Account pooling → **How account selection works**, **Preserve native Claude requests** stores @@ -812,7 +818,7 @@ would exceed the limit is omitted for that turn. ## Reasoning effort -Claude Code's `/effort` setting is preserved across the adapter: +On translated Messages → Responses requests, Claude Code's `/effort` setting maps as follows: | Wire format | Mapping | | --- | --- | @@ -820,7 +826,9 @@ Claude Code's `/effort` setting is preserved across the adapter: | `thinking.type: "enabled"` + `budget_tokens` | ≤4096→`low`, ≤16384→`medium`, above→`high` | | `thinking.type: "disabled"` | `reasoning: { effort: "none" }`; summary omitted | -The resolved value appears in the request log's **Reasoning effort** column. +For translated requests, the resolved tier appears in the request log's **Reasoning effort** column. +Managed native Messages log enabled thinking budgets as `budget:` when no recognized +`output_config.effort` is present; this logging does not change the wire body. ## Inbound translation (Messages → Responses) diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index a859acaa62d..faf0313c315 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -468,7 +468,7 @@ fingerprint 方式をそのまま踏襲しますが、長時間の無人作業 ## 推論負荷 -Claude Code の `/effort` 設定はアダプターでも維持されます。 +Messages → Responses に変換されるリクエストでは、Claude Code の `/effort` 設定を次のようにマッピングします。 | 転送形式 | マッピング | --- | --- | @@ -476,7 +476,9 @@ Claude Code の `/effort` 設定はアダプターでも維持されます。 | `thinking.type: "enabled"` + `budget_tokens` | ≤4096→`low`、≤16384→`medium`、それより大→`high` | | `thinking.type: "disabled"` | `reasoning: { effort: "none" }` を明示し、`summary` は省略します | -解釈された値はリクエストログの **Reasoning effort** 列に表示されます。 +変換されたリクエストでは、マッピングされたレベルがリクエストログの **Reasoning effort** 列に表示されます。 +管理されたネイティブ Messages は、認識可能な `output_config.effort` がない場合、有効な thinking 予算を +`budget:` として記録します。このログ記録は送信本文を変更しません。 ## 入力変換(Messages → Responses) diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index 1b9947eebb9..4f530872b0c 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -502,7 +502,7 @@ fingerprint 방식을 그대로 따르지만, 장시간 무인 작업에 쓰기 ## 추론 강도 -Claude Code의 `/effort` 설정은 어댑터에서도 유지돼요. +Messages → Responses로 변환되는 요청에서는 Claude Code의 `/effort` 설정을 다음과 같이 매핑해요. | 전송 형식 | 매핑 | | --- | --- | @@ -510,7 +510,9 @@ Claude Code의 `/effort` 설정은 어댑터에서도 유지돼요. | `thinking.type: "enabled"` + `budget_tokens` | ≤4096→`low`, ≤16384→`medium`, 그보다 크면→`high` | | `thinking.type: "disabled"` | `reasoning: { effort: "none" }`을 명시하고 `summary`는 생략해요 | -해석된 값은 요청 로그의 **Reasoning effort** 열에 표시돼요. +변환된 요청에서는 매핑된 단계가 요청 로그의 **Reasoning effort** 열에 표시돼요. +관리형 네이티브 Messages는 인식 가능한 `output_config.effort`가 없을 때 활성화된 thinking 예산을 +`budget:`로 기록하며, 이 로깅은 전송 본문을 변경하지 않아요. ## 입력 변환(Messages → Responses) diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index 2248e9647fa..0499b4441c3 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -496,7 +496,7 @@ Claude Code — это лишь учётные данные для доступ ## Уровень рассуждений -Настройка `/effort` в Claude Code сохраняется при прохождении через адаптер: +Для запросов, преобразуемых из Messages в Responses, настройка `/effort` в Claude Code сопоставляется следующим образом: | Формат передачи | Сопоставление | | --- | --- | @@ -504,7 +504,9 @@ Claude Code — это лишь учётные данные для доступ | `thinking.type: "enabled"` + `budget_tokens` | ≤4096→`low`, ≤16384→`medium`, выше→`high` | | `thinking.type: "disabled"` | Явно передаётся `reasoning: { effort: "none" }`, а `summary` опускается | -Итоговое значение отображается в столбце **Reasoning effort** журнала запросов. +Для преобразованных запросов полученный уровень отображается в столбце **Reasoning effort** журнала запросов. +Управляемые нативные запросы Messages записывают включённый бюджет thinking как `budget:`, +если нет распознанного `output_config.effort`; запись в журнал не изменяет передаваемое тело запроса. ## Входящее преобразование (Messages → Responses) diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index 607937289a1..87634321e60 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -697,7 +697,7 @@ olarak test edilmelidir. ## Akıl yürütme çabası (Reasoning effort) -Claude Code'un `/effort` ayarı adaptör genelinde korunur: +Messages → Responses olarak çevrilen isteklerde Claude Code'un `/effort` ayarı şöyle eşlenir: | Hat formatı | Eşleme | | --- | --- | @@ -705,7 +705,9 @@ Claude Code'un `/effort` ayarı adaptör genelinde korunur: | `thinking.type: "enabled"` + `budget_tokens` | ≤4096→`low`, ≤16384→`medium`, üzeri→`high` | | `thinking.type: "disabled"` | `reasoning: { effort: "none" }`; özet atlanır | -Çözümlenen değer, istek günlüğünün **Reasoning effort** sütununda görünür. +Çevrilen isteklerde eşlenen seviye, istek günlüğünün **Reasoning effort** sütununda görünür. +Yönetilen yerel Messages isteklerinde tanınan bir `output_config.effort` yoksa etkin düşünme bütçesi +`budget:` olarak kaydedilir; bu günlük kaydı gönderilen istek gövdesini değiştirmez. ## Gelen çeviri (Messages → Responses) diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index 1f0ed34b9ce..6559c1a8977 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -433,7 +433,7 @@ Search 和图像描述沿用仓库已有的 Claude Code OAuth fingerprint 先例 ## 推理强度 -Claude Code 的 `/effort` 设置会完整保留并传递给适配器: +对于从 Messages 转换为 Responses 的请求,Claude Code 的 `/effort` 设置按下表映射: | 传输格式 | 映射 | | --- | --- | @@ -441,7 +441,9 @@ Claude Code 的 `/effort` 设置会完整保留并传递给适配器: | `thinking.type: "enabled"` + `budget_tokens` | ≤4096→`low`,≤16384→`medium`,更高→`high` | | `thinking.type: "disabled"` | 显式发送 `reasoning: { effort: "none" }`,并省略 `summary` | -解析后的值会显示在请求日志的 **Reasoning effort** 列中。 +对于转换后的请求,映射得到的档位会显示在请求日志的 **Reasoning effort** 列中。 +托管的原生 Messages 请求在没有可识别的 `output_config.effort` 时,将启用的 thinking 预算记录为 +`budget:`;此日志记录不会更改传输的请求正文。 ## 入站转换(Messages → Responses) diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index 3d6bc538a02..6e6936a0ea6 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -509,7 +509,7 @@ Search 和圖像描述沿用儲存庫已有的 Claude Code OAuth fingerprint 先 ## 推理強度 -Claude Code 的 `/effort` 設定會完整保留並傳遞給適配器: +對於從 Messages 轉換為 Responses 的請求,Claude Code 的 `/effort` 設定依下表對映: | 傳輸格式 | 對映 | | --- | --- | @@ -517,7 +517,9 @@ Claude Code 的 `/effort` 設定會完整保留並傳遞給適配器: | `thinking.type: "enabled"` + `budget_tokens` | ≤4096→`low`,≤16384→`medium`,更高→`high` | | `thinking.type: "disabled"` | `reasoning: { effort: "none" }`;省略摘要 | -解析後的值會顯示在請求日誌的 **Reasoning effort** 列中。 +對於轉換後的請求,對映得到的等級會顯示在請求日誌的 **Reasoning effort** 欄中。 +受管理的原生 Messages 請求在沒有可識別的 `output_config.effort` 時,將啟用的 thinking 預算記錄為 +`budget:`;此日誌記錄不會變更傳輸的請求本文。 ## 入站轉換(Messages → Responses) diff --git a/src/server/messages-native.ts b/src/server/messages-native.ts index dcf7976fe6f..5569092674c 100644 --- a/src/server/messages-native.ts +++ b/src/server/messages-native.ts @@ -36,7 +36,7 @@ import { buildAnthropicMessagesPassthroughRequest, type AnthropicMessagesPassthroughRequest, } from "../adapters/anthropic/passthrough"; -import { resolveInboundModel } from "../claude/inbound"; +import { effortFromOutputConfig, resolveInboundModel } from "../claude/inbound"; import { anthropicErrorBody, anthropicErrorResponse, claudeOverflowSsePayload, claudePromptTooLongMessage, collectAnthropicMessage, isContextOverflowText, isThroughputLimitText } from "../claude/outbound"; import type { AdmissionLease } from "../lib/admission"; import { readBoundedResponseBody } from "../lib/bounded-body"; @@ -108,6 +108,7 @@ import { import { noteProviderAttemptSend, recordAttemptCredentialSource, + recordAttemptRequestedEffort, recordFirstOutput, recordKeyAttemptFailure, recordKeyWireAttemptUsage, @@ -318,6 +319,15 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) logCtx.requestedModel = requestedModel; if (route.routeReason === "model-alias" || route.modelId !== requestedModel) logCtx.requestedAlias = requestedModel; logCtx.requestedServiceTier = typeof body.service_tier === "string" ? body.service_tier : undefined; + if (logCtx.requestedEffort === undefined) { + const effort = effortFromOutputConfig(body.output_config); + const thinking = isRec(body.thinking) ? body.thinking : undefined; + const budget = thinking?.budget_tokens; + if (effort !== undefined) logCtx.requestedEffort = effort; + else if (thinking?.type === "disabled") logCtx.requestedEffort = "none"; + else if (thinking?.type === "enabled" && typeof budget === "number" + && Number.isSafeInteger(budget) && budget > 0) logCtx.requestedEffort = `budget:${budget}`; + } // Reserve spend the way native Chat does: an input estimate that never enters usage, and the // caller's own output ceiling. if (logCtx.usageLogInputTokens === undefined) { @@ -333,6 +343,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) adapter: "anthropic", }); attemptHandle.seal(logCtx.accountLogLabel); + recordAttemptRequestedEffort(logCtx); const { attempt } = attemptHandle; const finalLog = createFinalRequestLog(logIds, logCtx); const finishLog: FinishLog = (status, message, meta = { closeReason: "non_stream" }) => { diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 07d9a76e974..b96fc93103f 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -562,7 +562,7 @@ the client's own selector echoed in `response.model` (Anthropic routes keep `ant `modelIdentityLogFields` drops the same echo from older rows on read, so neither draws a false reroute. An absent upstream model stays absent; the tooltip retains all available model identities. Historical Codex `openai`, `chatgpt` and `openai-multi` main -labels collapse for reporting; configured provider names ending in `-main` remain separate. +labels collapse for reporting; configured provider names ending in `-main` remain separate. Native Messages request and attempt `requestedEffort` labels follow the [native Messages logging contract](data-planes/protocol-paths.md#managed-native-messages). Rows also carry the observed protocol path (`protocolTrace`), persisted in `usage.jsonl` and re-validated on read; the Logs list shows it as a text badge, the detail dialog as a section, and diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index c504a518f55..7a7e6fee0a4 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -400,6 +400,12 @@ advisor and scoped-cache schemas. Advisor may perform sub-inference already requ no feature or beta-gated body is injected. Generic managed callers keep the narrower allowance, and compatible destinations receive no first-party allowance. These handles grant no credential authority. +Managed native Messages record validated caller controls as `requestedEffort` on the final row +and physical attempts. Existing annotations win; otherwise a recognized `output_config.effort` +wins, followed by disabled thinking (`none`) or an enabled positive safe-integer budget +(`budget:`). Adaptive thinking without an explicit effort stays absent. This metadata +comes from the body handed to the lane; it does not rewrite the wire or confirm upstream effort. + `handleNativeMessages` mirrors native Chat on the shared pieces: `beginInferenceAttempt`, `createFinalRequestLog`, the request spend tracker charged per physical send, proactive key selection, 401 and 429 key-pool rotation, same-target 429 replay, the reset/transient retry diff --git a/tests/claude-integration/messages-native-oauth.test.ts b/tests/claude-integration/messages-native-oauth.test.ts index 7a186900f25..07e980157d1 100644 --- a/tests/claude-integration/messages-native-oauth.test.ts +++ b/tests/claude-integration/messages-native-oauth.test.ts @@ -181,6 +181,22 @@ async function send(config: OcxConfig, body: Record, identityHe } describe("managed native Messages over Anthropic OAuth", () => { + for (const stream of [false, true]) for (const effort of ["xhigh", "low"]) { + test(`logs requested effort over OAuth: ${effort}, stream=${stream}`, async () => { + await seed(1); + const body = { ...BODY, output_config: { effort }, thinking: { type: "adaptive" }, stream }; + const { response, row } = await send(fixtureConfig(), body); + expect(response.status).toBe(200); + expect(row.protocolTrace?.mode).toBe("native"); + expect(row.requestedEffort).toBe(effort); + expect(row.attempts).toHaveLength(1); + expect(row.attempts![0]!.requestedEffort).toBe(effort); + expect(sent).toHaveLength(1); + expect(sent[0]!.body.output_config).toEqual(body.output_config); + expect(sent[0]!.body.thinking).toEqual(body.thinking); + }); + } + for (const native of [true, false]) for (const enabled of [true, false]) { for (const state of ["needs-reauth", "unusable", "cooled", "paused-cooled", "healthy"] as const) { test(`Messages native=${native}, pool=${enabled}: pacing ${state} preserves fresh admission`, async () => { @@ -445,13 +461,17 @@ describe("native pooled Messages dispatch", () => { return Response.json(MESSAGE); }) as typeof fetch; const metadata = { user_id: JSON.stringify({ account_uuid: uuids[0], session_id: "fixture-recovery-session" }) }; - const body = { ...BODY, stream: false, metadata, system: [{ type: "text", text: "fixture cache prefix", cache_control: { type: "ephemeral" } }] }; + const body = { ...BODY, output_config: { effort: "xhigh" }, stream: false, metadata, system: [{ type: "text", text: "fixture cache prefix", cache_control: { type: "ephemeral" } }] }; const { response, row } = await send(config, body); expect(response.status).toBe(200); expect(row.protocolTrace?.mode).toBe("native"); + expect(row.requestedEffort).toBe("xhigh"); + expect(row.attempts).toHaveLength(2); + expect(row.attempts!.map(attempt => attempt.requestedEffort)).toEqual(["xhigh", "xhigh"]); expect(sent).toHaveLength(2); expect(sent.map(entry => entry.headers.get("authorization"))).toEqual([`Bearer ${credential(0).access}`, `Bearer ${credential(1).access}`]); for (const [index, entry] of sent.entries()) { + expect(entry.body.output_config).toEqual(body.output_config); expect(entry.headers.has("x-api-key")).toBe(false); expect(JSON.parse((entry.body.metadata as typeof metadata).user_id).account_uuid).toBe(uuids[index]); expect(entry.body.messages).toEqual(body.messages.map(message => message.role !== "assistant" ? message : { ...message, content: (message.content as Record[]).map(block => block.type === "tool_use" ? { ...block, name: "custom_lookup" } : block) })); diff --git a/tests/claude-integration/messages-native.test.ts b/tests/claude-integration/messages-native.test.ts index 9e58f16711e..c6b46ea7bd7 100644 --- a/tests/claude-integration/messages-native.test.ts +++ b/tests/claude-integration/messages-native.test.ts @@ -192,6 +192,84 @@ async function settledRowFor(requestId: string) { } describe("managed native Messages", () => { + for (const stream of [false, true]) { + const cases: { name: string; controls: Record; expected?: string }[] = [ + ...["minimal", "low", "medium", "high", "xhigh", "max", "ultra"].map(effort => ({ + name: `explicit ${effort}`, controls: { output_config: { effort }, thinking: { type: "adaptive" } }, expected: effort, + })), + { name: "explicit effort wins over budget", controls: { output_config: { effort: "xhigh" }, thinking: { type: "enabled", budget_tokens: 1024 } }, expected: "xhigh" }, + { name: "explicit effort wins over disabled thinking", controls: { output_config: { effort: "low" }, thinking: { type: "disabled" } }, expected: "low" }, + { name: "disabled thinking", controls: { thinking: { type: "disabled" } }, expected: "none" }, + { name: "enabled budget", controls: { thinking: { type: "enabled", budget_tokens: 1024 } }, expected: "budget:1024" }, + { name: "large enabled budget", controls: { thinking: { type: "enabled", budget_tokens: 32768 } }, expected: "budget:32768" }, + { name: "invalid effort falls back to budget", controls: { output_config: { effort: "future-tier" }, thinking: { type: "enabled", budget_tokens: 2048 } }, expected: "budget:2048" }, + { name: "invalid effort falls back to disabled", controls: { output_config: { effort: "future-tier" }, thinking: { type: "disabled" } }, expected: "none" }, + { name: "adaptive without effort", controls: { thinking: { type: "adaptive" } } }, + { name: "missing controls", controls: {} }, + ...[null, [], "high", { effort: "" }, { effort: "future-tier" }, { effort: "HIGH" }, { effort: " high " }, { effort: 3 }, { effort: "none" }].map((output_config, index) => ({ + name: `invalid output_config ${index}`, controls: { output_config }, + })), + ...[null, [], "enabled", { type: "unknown", budget_tokens: 1024 }].map((thinking, index) => ({ + name: `invalid thinking ${index}`, controls: { thinking }, + })), + ...[undefined, null, "1024", 0, -1, 1024.5, Number.MAX_SAFE_INTEGER + 1].map((budget_tokens, index) => ({ + name: `invalid budget ${index}`, controls: { thinking: { type: "enabled", budget_tokens } }, + })), + ]; + for (const { name, controls, expected } of cases) test(`logs requested effort: ${name}, stream=${stream}`, async () => { + const config = fixtureConfig(startUpstream()); + const body = { model: "anth/claude-x", max_tokens: 64, messages: [{ role: "user", content: "fixture" }], ...controls, stream }; + const wireBody = { ...JSON.parse(JSON.stringify(body)), model: "claude-x" }; + const { requestId, response } = await send(config, body); + expect(response.status).toBe(200); + expect(seen).toHaveLength(1); + expect(seen[0]!.body).toEqual(wireBody); + const row = rowFor(requestId); + expect(row.protocolTrace?.mode).toBe("native"); + expect(row.requestedEffort).toBe(expected); + expect(row.attempts).toHaveLength(1); + expect(row.attempts![0]!.requestedEffort).toBe(expected); + expect(row.effectiveEffort).toBeUndefined(); + expect(row.reasoningWireField).toBeUndefined(); + const persisted = readFileSync(join(testDir, "usage.jsonl"), "utf8").trim().split("\n") + .map(line => JSON.parse(line)).filter(entry => entry.requestId === requestId); + expect(persisted).toHaveLength(1); + expect(persisted[0].requestedEffort).toBe(expected); + expect(persisted[0].attempts[0].requestedEffort).toBe(expected); + }); + } + + for (const annotation of ["high->low", ""]) test(`preserves existing requested effort ${JSON.stringify(annotation)}`, async () => { + const config = fixtureConfig(startUpstream()); + const body = { model: "anth/claude-x", max_tokens: 64, messages: [{ role: "user", content: "fixture" }], output_config: { effort: "xhigh" }, stream: false }; + const logCtx = { model: "", provider: "", requestedEffort: annotation }; + const requestId = `pf08-${crypto.randomUUID()}`; + const response = await handleClaudeMessages(messagesRequest(body), config, logCtx, { requestId, start: Date.now() }); + await response.text(); + expect(response.status).toBe(200); + expect(logCtx.requestedEffort).toBe(annotation); + expect(seen[0]!.body.output_config).toEqual(body.output_config); + const row = rowFor(requestId); + expect(row.protocolTrace?.mode).toBe("native"); + expect(row.requestedEffort).toBe(annotation || undefined); + expect(row.attempts![0]!.requestedEffort).toBe(annotation || undefined); + }); + + test("key failover keeps requested effort on both physical attempts", async () => { + const config = fixtureConfig(startUpstream((entry, index) => index === 0 + ? Response.json({ type: "error", error: { type: "authentication_error", message: "invalid x-api-key" } }, { status: 401 }) + : ok(entry)), { pool: true }); + const { response, requestId } = await send(config, { ...SOURCE_BODY, output_config: { effort: "xhigh" }, stream: false }); + expect(response.status).toBe(200); + expect(seen).toHaveLength(2); + expect(seen[1]!.body).toEqual(seen[0]!.body); + const row = rowFor(requestId); + expect(row.protocolTrace?.mode).toBe("native"); + expect(row.requestedEffort).toBe("xhigh"); + expect(row.attempts).toHaveLength(2); + expect(row.attempts!.map(attempt => attempt.requestedEffort)).toEqual(["xhigh", "xhigh"]); + }); + for (const initiallyEnforced of [false, true]) test(`freezes ${initiallyEnforced ? "enforced" : "observe-only"} policy across retries`, async () => { const config = fixtureConfig(startUpstream((_entry, index) => { if (index === 0) configureSharedSpendLedger(spendPolicyFromConfig( From d36b6f4f1f5c200c89a1930b95f6a579f3852d9a Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 9 Oct 2026 22:16:40 +0900 Subject: [PATCH 36/59] fix(codex): commit explicit account selections atomically (carry #6813) (#6824) Explicit Codex account select, pin and clear now commit through the scoped persisted-config transaction, revalidate the target against the current disk snapshot, adopt the committed selection and its baseline, and only then reset routing. Previously a newer disk snapshot could win the following whole-config reconcile, so routing and authentication used a different account than the one the API reported. Carries #6813. Co-authored-by: hulkbig --- .../docs/ja/reference/management-api.md | 11 +- .../docs/ko/reference/management-api.md | 10 +- .../content/docs/reference/management-api.md | 10 +- .../docs/ru/reference/management-api.md | 12 +- .../docs/zh-cn/reference/management-api.md | 9 +- scripts/test-layout/layout.json | 1 + src/codex/auth-api/account-selection.ts | 107 +++++ src/codex/auth-api/routes.ts | 59 +-- src/config/live-reconcile.ts | 17 + src/config/rebase-provenance.ts | 11 + structure/config.md | 2 +- structure/providers/openai-accounts.md | 8 + .../codex-account-selection-atomicity.test.ts | 430 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 14 files changed, 633 insertions(+), 55 deletions(-) create mode 100644 src/codex/auth-api/account-selection.ts create mode 100644 tests/codex-integration/codex-account-selection-atomicity.test.ts diff --git a/docs-site/src/content/docs/ja/reference/management-api.md b/docs-site/src/content/docs/ja/reference/management-api.md index 733c910157c..49dd5cdd010 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -325,7 +325,7 @@ CLI コマンドは Anthropic OAuth アカウントを id または一意の別 | `PUT /api/codex-auth/accounts/pause` | アカウントと、同じ ID 情報を持つ既存のメイン/プールのエントリを手動で一時停止または再開する。`affectedAccountIds` を返す | 400 無効なアカウント/状態、404 アカウントが見つからない、503 メインの ID 情報が使用中または読み取り不能 | | `PUT /api/codex-auth/accounts/pause-exhausted` |クォータを使い果たしたアカウントを一時停止する |ミューテーションロックの失敗は 503 になります | | `POST /api/codex-auth/accounts/clear-cooldown` | 1 つのアカウントまたはすべてのアカウントのランタイム クールダウンをクリアする | 400 無効な ID | -| `GET, PUT /api/codex-auth/active` |アクティブなアカウントを読み取るか選択します | 400 アカウントが無効または欠落しています。 409 一時停止/レガシー行の競合 | +| `GET, PUT /api/codex-auth/active` |アクティブなアカウントを読み取るか選択します | 400 アカウントが無効または欠落しています。 409 一時停止/レガシー行の競合、または `account_selection_unavailable` | | `PUT /api/codex-auth/auto-switch` | `id` を省略した `{ threshold }` でグローバルしきい値、`{ id, threshold }` でアカウント別の上書き値を設定する。`id: '__main__'` は Codex Desktop アカウントを指定する。`id` を指定した場合、`threshold: null` は上書き値を削除してグローバル値の継承に戻す | 400 無効な ID/しきい値、404 アカウントなし | | `PUT, PATCH /api/codex-auth/pool-strategy` | Codex アカウントプールの選択戦略を更新 | 400 無効な戦略/構成 | | `PUT /api/codex-auth/failover` |アカウントのフェイルオーバーしきい値を設定する | 400 無効なしきい値 | @@ -337,6 +337,15 @@ CLI コマンドは Anthropic OAuth アカウントを id または一意の別 | `POST /api/codex-auth/login/cancel` | `{ "flowId": "..." }` で指定した保留中の Codex ログインのみキャンセルする | 400 フロー ID が未指定、不明、または保留中ではない | | `GET /api/codex-auth/login-status` |フローまたはアカウントのログイン状態をポーリングする。新規アカウント完了時は回復が必要な場合だけ `catalogRefreshPending: true` を含みます。 |不明なフローは `expired` を報告します。アクティブなフローは `idle` を報告しません | +`PUT /api/codex-auth/active` には `{ "accountId": "" }` が必要です。選択をクリアするには、 +明示的に `{ "accountId": null }` を指定します。別のプロセスが保存済みのアカウントを変更していても、 +現在のアカウントの再選択は明示的な選択として扱われます。サーバーは無関係な保存済み設定を保持し、 +ダッシュボードの読み込み後に削除または一時停止されたアカウントの選択を拒否します。保存済み設定が +利用できないか無効な場合、実行中の選択とアカウントへのアフィニティは変更されません。 +設定を再読み込みしてから再試行してください。成功レスポンスはコミット済みの選択を示します。 +既存のクォータとフェイルオーバーのルールによりピン留めが解除される場合は引き続きあります。 +`pinDrained` は、現在判明しているピン留め解除の理由を示します。 + 新規 account の config row は保存されたものの credential setup を完了できない場合、OAuth の `login-status` は `status: "error"` と `code: "codex_credential_persistence_failed"`、`accountId`、`needsReauth: true`、必要に応じて diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md index f9dd67d9f89..46e59b87f1b 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -337,7 +337,7 @@ OpenAI도 같은 규칙을 따르며, 스위치를 켠다고 별도의 922k 모 | `PUT /api/codex-auth/accounts/pause` | 계정과 동일한 로그인 정보를 가진 기존 메인/풀 항목을 수동으로 일시 중지하거나 재개합니다. `affectedAccountIds`를 반환합니다 | 400 잘못된 계정/상태; 404 계정 없음; 503 메인 계정의 로그인 정보가 사용 중이거나 읽을 수 없음 | | `PUT /api/codex-auth/accounts/pause-exhausted` | quota가 소진된 account를 일시 중지합니다 | mutation-lock 실패는 503이 됩니다 | | `POST /api/codex-auth/accounts/clear-cooldown` | account 하나 또는 모든 account의 runtime cooldown을 지웁니다 | 400 잘못된 id | -| `GET, PUT /api/codex-auth/active` | 활성 account를 읽거나 선택합니다 | 400 잘못되었거나 누락된 account; 409 paused/legacy-row 충돌 | +| `GET, PUT /api/codex-auth/active` | 활성 account를 읽거나 선택합니다 | 400 잘못되었거나 누락된 account; 409 paused/legacy-row 충돌 또는 `account_selection_unavailable` | | `PUT /api/codex-auth/auto-switch` | `id`를 생략한 `{ threshold }`로 전역 임계값을, `{ id, threshold }`로 계정별 재정의 값을 설정합니다. `id: '__main__'`은 Codex Desktop 계정을 지정합니다. `id`가 지정된 경우 `threshold: null`은 재정의 값을 삭제하고 전역 임계값 상속을 복원합니다 | 400 잘못된 ID/임계값, 404 계정 없음 | | `PUT, PATCH /api/codex-auth/pool-strategy` | Codex account-pool 선택 전략을 업데이트합니다 | 400 잘못된 전략/구성 | | `PUT /api/codex-auth/failover` | account failover threshold를 설정합니다 | 400 잘못된 threshold | @@ -349,6 +349,14 @@ OpenAI도 같은 규칙을 따르며, 스위치를 켠다고 별도의 922k 모 | `POST /api/codex-auth/login/cancel` | `{ "flowId": "..." }`로 지정한 대기 중인 Codex 로그인만 취소합니다 | 400 흐름 ID 누락, 알 수 없음 또는 대기 중이 아님 | | `GET /api/codex-auth/login-status` | 흐름 또는 account 로그인 상태를 조회합니다. 새 계정 완료 시 복구가 필요할 때만 `catalogRefreshPending: true`를 포함합니다. | 알 수 없는 흐름은 `expired`로 보고되며, 활성 흐름이 없으면 `idle`로 보고됩니다 | +`PUT /api/codex-auth/active`에는 `{ "accountId": "" }`가 필요합니다. 선택을 지우려면 +`{ "accountId": null }`을 명시적으로 지정해야 합니다. 다른 프로세스가 저장된 계정을 변경했더라도 +현재 계정을 다시 선택하는 것은 명시적인 선택으로 처리됩니다. 서버는 관련 없는 저장된 설정을 +보존하고, 대시보드를 불러온 뒤 삭제되거나 일시 중지된 계정의 선택을 거부합니다. 저장된 구성을 +사용할 수 없거나 구성이 유효하지 않으면 기존 런타임 선택과 계정 어피니티는 변경되지 않습니다. +설정을 다시 불러온 뒤 재시도하십시오. 성공 응답은 커밋된 선택을 나타냅니다. 기존 quota 및 +failover 규칙에 따라 고정이 해제될 수 있으며, `pinDrained`는 현재 알려진 고정 해제 사유를 나타냅니다. + 수동 소비가 `reset`으로 확인되면 같은 계정의 새 usage를 조회하여 기존 shared reset-derived 쿨다운을 즉시 복구할 수 있습니다. 복구는 조건부입니다. 계정이 일시 정지되었거나 재인증이 필요하거나 다른 진행 중인 probe가 쿨다운을 소유하면 쿨다운은 유지됩니다. reset 이전에 시작한 diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 5b7e28afe56..a6849177e36 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -716,7 +716,7 @@ manager. Its routes are: | `PUT /api/codex-auth/accounts/credits` | Allow or stop spending ChatGPT credits after the usage limit. Body `{ id, creditsAfterLimit }` for one account, including `__main__`: true adds the id to `creditCodexAccountIds`, false removes it. Body `{ all }` for the global switch: true lists `__main__` and every pool account, false clears the list. Applies to the next selection. | 400 invalid id or non-boolean value; 404 missing account | | `PUT /api/settings` with `codexQuotaAutoRefresh: { id, window, enabled }` | Enable or disable 5-hour or weekly automatic window activation for one account | 400 invalid id/window/state; 404 missing account; 409 unavailable window | | `POST /api/codex-auth/accounts/clear-cooldown` | Clear runtime cooldown for one account or all accounts | 400 invalid id | -| `GET, PUT /api/codex-auth/active` | Read or select the active account | 400 invalid or missing account; 409 paused/legacy-row conflict | +| `GET, PUT /api/codex-auth/active` | Read or select the active account | 400 invalid or missing account; 409 paused/legacy-row conflict or `account_selection_unavailable` | | `PUT /api/codex-auth/auto-switch` | Set the global quota threshold with `{ threshold }`, or an account override with `{ id, threshold }`; `null` restores global inheritance, and `__main__` selects the Desktop login | 400 invalid id/threshold; 404 missing account | | `PUT, PATCH /api/codex-auth/pool-strategy` | Update Codex account-pool selection strategy | 400 invalid strategy/config | | `PUT /api/codex-auth/failover` | Set the account failover threshold | 400 invalid threshold | @@ -729,6 +729,14 @@ manager. Its routes are: | `POST /api/codex-auth/login/cancel` | Cancel only the pending Codex login identified by `{ "flowId": "..." }` | 400 missing, unknown, or non-pending flow ID | | `GET /api/codex-auth/login-status` | Poll a flow or account login state. A completed new-account flow includes `catalogRefreshPending: true` only when recovery is needed. | Unknown flows report `expired`; no active flow reports `idle` | +`PUT /api/codex-auth/active` requires `{ "accountId": "" }` or an explicit +`{ "accountId": null }` to clear selection. Re-selecting the current account is an explicit +selection even if another process changed the saved account. The server preserves unrelated +saved settings and rejects targets removed or paused since the dashboard loaded. An unavailable +or invalid saved configuration leaves the previous live selection and affinity unchanged; reload +settings before retrying. A successful response describes the committed selection. Existing quota +and failover rules can still release a pin; `pinDrained` reports a currently known drain reason. + For reset-credit consumption, a different `operationId` supplied while the same physical account has an unfinished operation joins that operation as an alias. Its retry uses the original upstream request ID and records the outcome under that same identity, so later diff --git a/docs-site/src/content/docs/ru/reference/management-api.md b/docs-site/src/content/docs/ru/reference/management-api.md index 209c7ffdf05..c4d46c323ee 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -364,7 +364,7 @@ picker изменилась. `catalogRefreshPending: true` в успешном | `PUT /api/codex-auth/accounts/pause` | Вручную приостановить или возобновить аккаунт и существующие записи основного аккаунта и пула с той же идентичностью; возвращает `affectedAccountIds` | 400 неверный аккаунт/состояние; 404 аккаунт не найден; 503 данные идентичности основного аккаунта заняты или недоступны для чтения | | `PUT /api/codex-auth/accounts/pause-exhausted` | Поставить на паузу аккаунты с исчерпанной квотой | Сбои mutation-lock превращаются в 503 | | `POST /api/codex-auth/accounts/clear-cooldown` | Очистить runtime cooldown для одного аккаунта или для всех | 400 invalid id | -| `GET, PUT /api/codex-auth/active` | Прочитать или выбрать активный аккаунт | 400 invalid or missing account; 409 paused/legacy-row conflict | +| `GET, PUT /api/codex-auth/active` | Прочитать или выбрать активный аккаунт | 400 invalid or missing account; 409 paused/legacy-row conflict или `account_selection_unavailable` | | `PUT /api/codex-auth/auto-switch` | Задать глобальный порог через `{ threshold }` без `id` или переопределение аккаунта через `{ id, threshold }`; `id: '__main__'` выбирает аккаунт Codex Desktop. При указанном `id` значение `threshold: null` удаляет переопределение и восстанавливает наследование глобального порога | 400 invalid id/threshold; 404 missing account | | `PUT, PATCH /api/codex-auth/pool-strategy` | Обновить стратегию выбора в пуле аккаунтов Codex | 400 invalid strategy/config | | `PUT /api/codex-auth/failover` | Задать порог failover аккаунтов | 400 invalid threshold | @@ -376,6 +376,16 @@ picker изменилась. `catalogRefreshPending: true` в успешном | `POST /api/codex-auth/login/cancel` | Отменить только ожидающий вход Codex с `{ "flowId": "..." }` | 400 ID потока отсутствует, неизвестен или не ожидает завершения | | `GET /api/codex-auth/login-status` | Опрашивать flow или login-state аккаунта. Завершение нового аккаунта включает `catalogRefreshPending: true` только при необходимости восстановления. | Неизвестные flow'ы сообщаются как `expired`; отсутствие активного flow — как `idle` | +`PUT /api/codex-auth/active` требует `{ "accountId": "" }` или явно указанного +`{ "accountId": null }` для сброса выбора. Повторный выбор текущего аккаунта считается явным +выбором, даже если другой процесс изменил сохранённый аккаунт. Сервер сохраняет остальные +сохранённые настройки и отклоняет выбор аккаунтов, удалённых или приостановленных после загрузки +панели управления. Если сохранённая конфигурация недоступна или некорректна, текущий выбор +в работающем процессе и привязка запросов к аккаунту не меняются; перед повторной попыткой +перезагрузите настройки. Успешный ответ описывает зафиксированный выбор. Существующие правила +квот и failover по-прежнему могут снять закрепление; `pinDrained` сообщает известную на данный +момент причину снятия закрепления. + Если config row нового аккаунта сохранён, но credential setup не завершён, OAuth `login-status` сообщает `status: "error"` и содержит `code: "codex_credential_persistence_failed"`, `accountId`, `needsReauth: true` и при необходимости diff --git a/docs-site/src/content/docs/zh-cn/reference/management-api.md b/docs-site/src/content/docs/zh-cn/reference/management-api.md index a590806cd01..5add376205e 100644 --- a/docs-site/src/content/docs/zh-cn/reference/management-api.md +++ b/docs-site/src/content/docs/zh-cn/reference/management-api.md @@ -318,7 +318,7 @@ OpenAI 也遵循此规则:开关不会选择特殊的 922k 模式。有效上 | `PUT /api/codex-auth/accounts/pause` | 手动暂停或恢复账户及同身份的已有主登录/池内入口;返回 `affectedAccountIds` | 400 账户/状态无效;404 缺少账户;503 主登录身份忙碌或无法读取 | | `PUT /api/codex-auth/accounts/pause-exhausted` | 暂停配额已耗尽的账户 | 变更锁失败会变成 503 | | `POST /api/codex-auth/accounts/clear-cooldown` | 清除一个账户或所有账户的运行时冷却 | 400 id 无效 | -| `GET, PUT /api/codex-auth/active` | 读取或选择当前活跃账户 | 400 账户无效或缺失;409 暂停/旧行冲突 | +| `GET, PUT /api/codex-auth/active` | 读取或选择当前活跃账户 | 400 账户无效或缺失;409 暂停/旧行冲突或 `account_selection_unavailable` | | `PUT /api/codex-auth/auto-switch` | 使用不含 `id` 的 `{ threshold }` 设置全局阈值,或使用 `{ id, threshold }` 设置账号覆盖值;`id: '__main__'` 选择 Codex Desktop 账号。指定 `id` 时,`threshold: null` 删除该账号的覆盖值并恢复继承全局阈值 | 400 ID/阈值无效;404 账号不存在 | | `PUT, PATCH /api/codex-auth/pool-strategy` | 更新 Codex 账户池选择策略 | 400 策略/配置无效 | | `PUT /api/codex-auth/failover` | 设置账户故障转移阈值 | 400 阈值无效 | @@ -330,6 +330,13 @@ OpenAI 也遵循此规则:开关不会选择特殊的 922k 模式。有效上 | `POST /api/codex-auth/login/cancel` | 仅取消 `{ "flowId": "..." }` 指定的待处理 Codex 登录 | 400 流程 ID 缺失、未知或不在待处理状态 | | `GET /api/codex-auth/login-status` | 轮询某个流程或账户登录状态。新账号流程完成时,仅在需要恢复时包含 `catalogRefreshPending: true`。 | 未知流程报告为 `expired`;没有活跃流程时报告为 `idle` | +`PUT /api/codex-auth/active` 必须包含 `{ "accountId": "" }`,清除选择时必须显式传入 +`{ "accountId": null }`。即使其他进程更改了已保存的账户,重新选择当前账户仍会被视为明确的选择操作。 +服务器会保留其他已保存的设置,并拒绝选择在仪表盘加载后被删除或暂停的账户。如果已保存的配置不可用 +或无效,当前运行时的账户选择和会话与账户的绑定关系将保持不变;请重新加载设置后再重试。 +成功响应描述的是已提交的选择。现有的配额和故障转移规则仍可能解除账户固定;`pinDrained` 会报告 +当前已知的固定解除原因。 + 如果新账号的 config row 已保存但 credential setup 未能完成,OAuth `login-status` 会报告 `status: "error"`,并包含 `code: "codex_credential_persistence_failed"`、`accountId`、`needsReauth: true`,并在需要时包含 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 44ae9d7e805..c878c168669 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -525,6 +525,7 @@ "codebuddy-tool-bridge-turn.test.ts": "providers", "codebuddy-tool-bridge.test.ts": "providers", "codex-account-delete-atomicity.test.ts": "codex-integration", + "codex-account-selection-atomicity.test.ts": "codex-integration", "codex-account-label.test.ts": "codex-integration", "codex-account-mode-state.test.ts": "gui", "codex-account-namespaces.test.ts": "codex-integration", diff --git a/src/codex/auth-api/account-selection.ts b/src/codex/auth-api/account-selection.ts new file mode 100644 index 00000000000..5e33396d1fe --- /dev/null +++ b/src/codex/auth-api/account-selection.ts @@ -0,0 +1,107 @@ +import type { OcxConfig } from "../../types"; +import { getConfigPath, initializePersistedConfigIfMissing, mutatePersistedConfig } from "../../config"; +import { mergeConfigDefaults, readConfigFileSnapshot, validateConfigCandidate } from "../../config/diagnostics"; +import { adoptPersistedCodexAccountSelection, claudeCodeBaselineArmed } from "../../config/live-reconcile"; +import { deleteConfigTopLevelKey, fileBackedConfigPath, recordFileBackedConfig } from "../../config/rebase-provenance"; +import { MAIN_CODEX_ACCOUNT_ID, hasLegacyMainCodexPoolAccount, isSelectableCodexPoolAccount, isValidCodexAccountId } from "../account-id"; +import { isCodexAccountPaused } from "../account-pause"; +import { readCodexAccountRecord } from "../account-store"; +import { codexAccountPinDrainReason, resetCodexRoutingForManualSelection } from "../routing"; +import { jsonResponse } from "./http"; + +class SelectionRejected extends Error { + constructor(message: string, readonly status: number) { super(message); } +} + +function validateSelection(config: OcxConfig, accountId: string | null): void { + if (accountId === null) return; + if (accountId === MAIN_CODEX_ACCOUNT_ID && hasLegacyMainCodexPoolAccount(config.codexAccounts)) { + throw new SelectionRejected("Remove the legacy __main__ pool row before selecting the Desktop account", 409); + } + if (isCodexAccountPaused(config, accountId)) throw new SelectionRejected("Account is paused", 409); + if (accountId === MAIN_CODEX_ACCOUNT_ID) return; + if (!isValidCodexAccountId(accountId)) throw new SelectionRejected("Invalid account id format", 400); + if (!(config.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === accountId)) { + throw new SelectionRejected("Account not found", 400); + } + if (readCodexAccountRecord(accountId)?.codexValidationPending) { + throw new SelectionRejected("Account validation is pending. Refresh quota after recovery to validate it.", 409); + } +} + +function applySelection(config: OcxConfig, accountId: string | null): boolean { + const changed = config.activeCodexAccountId !== (accountId ?? undefined) + || config.activeCodexAccountPinned !== (accountId ?? undefined); + if (accountId === null) { + deleteConfigTopLevelKey(config, "activeCodexAccountId"); + deleteConfigTopLevelKey(config, "activeCodexAccountPinned"); + } else { + config.activeCodexAccountId = accountId; + config.activeCodexAccountPinned = accountId; + } + return changed; +} + +/** A manual selection is an explicit command, even when equal to the live merge baseline. */ +export function persistCodexAccountSelection(config: OcxConfig, accountId: string | null): Response { + const unknown = () => jsonResponse({ + error: "Account selection could not be confirmed; reload settings before retrying", + code: "account_selection_unavailable", + }, 409); + const configPath = getConfigPath(); + const sourcePath = fileBackedConfigPath(config); + if (sourcePath !== undefined && sourcePath !== configPath) return unknown(); + let committed: OcxConfig; + let warning = false; + try { + // Do not activate a newly disk-only account that the running proxy has not adopted. + validateSelection(config, accountId); + const before = readConfigFileSnapshot(); + if (before.diagnostics.source === "default" && !fileBackedConfigPath(config) && !claudeCodeBaselineArmed(config)) { + // Preserve transient/initial callers without recreating a deleted live configuration. + // The create-only owner cannot replace a competing writer's newly published config. + const normalized = validateConfigCandidate(mergeConfigDefaults(config)); + if (!normalized.ok) return unknown(); + const initial = normalized.config; + applySelection(initial, accountId); + initializePersistedConfigIfMissing(initial); + } + const result = mutatePersistedConfig(persisted => { + // This callback is re-run under the mutation lock immediately before publication. + // A removed or paused account must not be resurrected by a stale UI snapshot. + validateSelection(config, accountId); + validateSelection(persisted, accountId); + return { changed: applySelection(persisted, accountId), value: persisted }; + }); + if (result.status === "unavailable") return unknown(); + committed = result.value; + } catch (error) { + if (error instanceof SelectionRejected) return jsonResponse({ error: error.message }, error.status); + // Atomic publication may succeed before generation/bookkeeping fails. Confirm the exact + // durable selection instead of rolling live state back behind an already published write. + const snapshot = readConfigFileSnapshot(); + if (snapshot.raw === undefined) return unknown(); + try { + const strict = validateConfigCandidate(JSON.parse(snapshot.raw.replace(/^\uFEFF/, ""))); + if (!strict.ok) return unknown(); + validateSelection(config, accountId); + validateSelection(strict.config, accountId); + if (strict.config.activeCodexAccountId !== (accountId ?? undefined) + || strict.config.activeCodexAccountPinned !== (accountId ?? undefined)) return unknown(); + committed = strict.config; + warning = true; + } catch { return unknown(); } + } + adoptPersistedCodexAccountSelection(config, committed); + recordFileBackedConfig(config, configPath); + // No failed save may clear affinity, spend the manual preference, or move the RR cursor. + resetCodexRoutingForManualSelection(accountId ?? MAIN_CODEX_ACCOUNT_ID); + const pinDrainReason = accountId === null ? undefined : codexAccountPinDrainReason(config, accountId); + return jsonResponse({ + ok: true, + activeCodexAccountId: config.activeCodexAccountId ?? null, + appliesImmediately: true, + ...(pinDrainReason !== undefined ? { pinDrained: true, pinDrainReason } : {}), + ...(warning ? { warning: "config_bookkeeping_failed" } : {}), + }); +} diff --git a/src/codex/auth-api/routes.ts b/src/codex/auth-api/routes.ts index f28c2382477..94742053385 100644 --- a/src/codex/auth-api/routes.ts +++ b/src/codex/auth-api/routes.ts @@ -1,3 +1,4 @@ +import { persistCodexAccountSelection } from "./account-selection"; import { CODEX_ACCOUNT_LOG_LABEL_RE, codexAccountLogLabel } from "../account-label"; import { poolQuotaHistoryIdentity, readCodexAccountRecord } from "../account-store"; import { estimateCodexQuotaCapacity, insufficientCodexCapacity } from "../quota-capacity"; @@ -7,13 +8,13 @@ import { getAccountQuotaHistory, listAccountQuotas } from "../quota"; import { deleteCodexAccount } from "../account-lifecycle"; import { isCodexAccountPaused, setCodexAccountPaused } from "../account-pause"; import { setAllCodexAccountsCreditsAfterLimit, setCodexAccountCreditsAfterLimit } from "../account-credit-use"; -import { clearCodexAccountPin, isCodexAccountPriorityKey, pinnedCodexAccountId, setCodexAccountPin, setCodexAccountPriority } from "../account-priority"; -import { codexAccountPinDrainReason, codexQuotaScopeForModel, clearCodexAccountCooldown, clearThreadAccountMapForAccount, getEffectiveActiveCodexAccountId, isEffectiveCodexAccountPinned, resetCodexRoutingForManualSelection } from "../routing"; +import { clearCodexAccountPin, isCodexAccountPriorityKey, pinnedCodexAccountId, setCodexAccountPriority } from "../account-priority"; +import { codexQuotaScopeForModel, clearCodexAccountCooldown, clearThreadAccountMapForAccount, getEffectiveActiveCodexAccountId, isEffectiveCodexAccountPinned } from "../routing"; import { DEFAULT_ACCOUNT_PRIORITY, MAX_ACCOUNT_PRIORITY, MIN_ACCOUNT_PRIORITY, normalizeAccountPoolStickyLimit, normalizeCodexAccountPoolStrategy, parseAccountPoolStickyLimit, parseCodexAccountPoolStrategy, parseAccountPriority } from "../pool-rotation"; import { MAIN_CODEX_ACCOUNT_ID } from "../main-account"; import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; import type { OcxConfig } from "../../types"; -import { CODEX_ACCOUNT_ID_RE, hasLegacyMainCodexPoolAccount, isSelectableCodexPoolAccount, isValidCodexAccountId } from "../account-id"; +import { CODEX_ACCOUNT_ID_RE, isSelectableCodexPoolAccount, isValidCodexAccountId } from "../account-id"; import { isCodexResetCreditOperationId } from "../reset-credit-recovery"; import { listCodexAuthAccounts, selectFallbackAfterPause, pauseExhaustedCodexAccounts } from "./account-list"; import { jsonResponse, manualImportDisabledResponse } from "./http"; @@ -247,53 +248,13 @@ export async function handleCodexAuthAPI( } if (url.pathname === "/api/codex-auth/active" && req.method === "PUT") { - let body: { accountId: string | null }; - try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } - const runtimeConfig = getRuntimeConfig(config); - const targetAccountId = body.accountId ?? MAIN_CODEX_ACCOUNT_ID; - if (body.accountId === MAIN_CODEX_ACCOUNT_ID && hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) { - return jsonResponse({ error: "Remove the legacy __main__ pool row before selecting the Desktop account" }, 409); - } - if (body.accountId != null && isCodexAccountPaused(runtimeConfig, targetAccountId)) { - return jsonResponse({ error: "Account is paused" }, 409); - } - if (body.accountId != null && body.accountId !== MAIN_CODEX_ACCOUNT_ID) { - if (!isValidCodexAccountId(body.accountId)) return jsonResponse({ error: "Invalid account id format" }, 400); - const exists = (runtimeConfig.codexAccounts ?? []) - .some(account => isSelectableCodexPoolAccount(account) && account.id === body.accountId); - if (!exists) return jsonResponse({ error: "Account not found" }, 400); - if (readCodexAccountRecord(body.accountId)?.codexValidationPending) { - return jsonResponse({ error: "Account validation is pending. Refresh quota after recovery to validate it." }, 409); - } + let body: unknown; + try { body = await req.json(); } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } + if (!body || typeof body !== "object" || Array.isArray(body) + || !("accountId" in body) || (body.accountId !== null && typeof body.accountId !== "string")) { + return jsonResponse({ error: "accountId must be an account id or null" }, 400); } - runtimeConfig.activeCodexAccountId = body.accountId ?? undefined; - // "Use this account now" outranks selection order until the account is spent: - // persisted here rather than in resetCodexRoutingForManualSelection, which is - // runtime state only. A null id clears the selection instead of making one, so it - // must release the pin rather than record one: pinning the `targetAccountId` - // fallback would leave a pin that no effective active account matches, which - // `isEffectiveCodexAccountPinned` reports as unpinned while the tier filter still - // honours it as a ceiling — invisibly capping the pool at the main account's tier. - if (body.accountId == null) clearCodexAccountPin(runtimeConfig); - else setCodexAccountPin(runtimeConfig, targetAccountId); - resetCodexRoutingForManualSelection(targetAccountId); - saveRuntimeConfig(config, runtimeConfig); - // A pin this route accepts can still be dropped by the very next resolve, and saying - // nothing about that is what made the setting look ignored (#4521). The checks above - // refuse an account that cannot be selected at all; this reports the one remaining - // outcome they do not cover, from the same predicate routing releases on, so the two - // cannot drift. Absent means the pin survives — additive for existing clients. - // `appliesImmediately` is unchanged: it answers whether thread affinity was cleared, - // not whether the pin is durable. - const pinDrainReason = body.accountId == null - ? undefined - : codexAccountPinDrainReason(runtimeConfig, targetAccountId); - return jsonResponse({ - ok: true, - activeCodexAccountId: body.accountId, - appliesImmediately: true, - ...(pinDrainReason !== undefined ? { pinDrained: true, pinDrainReason } : {}), - }); + return persistCodexAccountSelection(getRuntimeConfig(config), body.accountId); } if (url.pathname === "/api/codex-auth/active" && req.method === "GET") { diff --git a/src/config/live-reconcile.ts b/src/config/live-reconcile.ts index d33aa9746b0..0eb2b7ec0e2 100644 --- a/src/config/live-reconcile.ts +++ b/src/config/live-reconcile.ts @@ -8,6 +8,7 @@ import { clearPendingConfigObjectChildDeletions, prepareConfigObjectChildDeletionRebase, clearPendingConfigTopLevelDeletions, + clearConfigTopLevelDeletionIntent, configHasRebaseProvenance, configRebaseDeletionKeys, CONFIG_REBASE_PROVENANCE_KEY, @@ -42,6 +43,22 @@ const claudeCodeBaseline = new WeakMap(); */ const liveConfigBaseline = new WeakMap(); +/** Adopt a committed manual selection without advancing unrelated live merge baselines. */ +export function adoptPersistedCodexAccountSelection( + config: OcxConfig, + persisted: Pick, +): void { + const keys = ["activeCodexAccountId", "activeCodexAccountPinned"] as const; + const baseline = liveConfigBaseline.get(config); + for (const key of keys) { + config[key] = persisted[key]; + // Retain known absent keys: a later disk-only selection is a new edit, not stale intent. + if (baseline) baseline[key] = persisted[key]; + } + clearConfigTopLevelDeletionIntent(config, keys); + if (baseline) clearConfigTopLevelDeletionIntent(baseline, keys); +} + /** * Adopt a committed discovery decision and its merge baseline as one synchronous step. * Visibility writers assign and save synchronously; any future writer yielding between diff --git a/src/config/rebase-provenance.ts b/src/config/rebase-provenance.ts index 96a35fd181b..d198ffc064b 100644 --- a/src/config/rebase-provenance.ts +++ b/src/config/rebase-provenance.ts @@ -141,6 +141,17 @@ export function clearPendingConfigTopLevelDeletions(config: OcxConfig): void { pendingTopLevelDeletions.delete(config); } +/** A scoped durable adoption consumes only those fields' earlier deletion intent. */ +export function clearConfigTopLevelDeletionIntent(config: OcxConfig, keys: readonly (keyof OcxConfig)[]): void { + const pending = pendingTopLevelDeletions.get(config); + for (const key of keys) pending?.delete(key); + const parsed = parsedConfigRebaseDeletionKeys(config); + if (parsed === null) return; + for (const key of keys) parsed.delete(key); + if (parsed.size === 0) delete config.configRebaseProvenance; + else config.configRebaseProvenance = { version: 1, deletedTopLevelKeys: [...parsed].sort() }; +} + export function clearPendingConfigObjectChildDeletions(config: OcxConfig): void { pendingObjectChildDeletions.delete(config); } diff --git a/structure/config.md b/structure/config.md index e2e1e04b32c..378811247a3 100644 --- a/structure/config.md +++ b/structure/config.md @@ -54,7 +54,7 @@ as the supported-location recovery path; uncertain publication and cleanup warni the CLI. The quickstart documents inspection before retry, private-permission requirements, and fresh-location examples. Diagnostics do not introduce a fallback or alter file I/O ordering. -`src/config/persisted-mutation.ts` owns schema-valid on-disk mutations under the shared lock. +`src/config/persisted-mutation.ts` owns schema-valid on-disk mutations under the shared lock. Manual Codex selection uses that transaction rather than an inferred whole-config diff. `src/config/live-reconcile.ts` adopts the committed active/pin fields and only their merge baselines; their consumed deletion intent is cleared without consuming unrelated pending deletions. It rechecks the file before committing, retries a changed snapshot up to three times, and returns unavailable for missing, invalid, or persistently conflicting config. Its one-shot test seam and the mutation types remain re-exported through `src/config.ts`. Successful, repaired, and salvaged file loads record their source path in the private WeakMap owned by `src/config/rebase-provenance.ts`; defaults and synthetic objects do not acquire it. This metadata is never serialized. Inventory drift cannot turn a file-backed instance into a synthetic discovery writer. Read publication may use the detached policy projection described by the [catalog contract](catalog.md#shared-catalog), without changing the live merge baseline; changing the resolved config home refuses publication. diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index c408ce0629e..e3ac724871f 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -192,6 +192,14 @@ Pool mode needs stable public names and a store that survives concurrent refresh ## Sidecars, management, and UI +`src/codex/auth-api/account-selection.ts` commits manual active-account and pin changes against +the latest config under the mutation coordinator. Re-selecting the same account and explicitly +clearing selection retain their command intent; unrelated saved fields remain untouched. Both +the live roster and the current persisted roster must admit a selected account. Removed, paused, +or validation-pending targets are rejected before changing routing. Confirmed publication precedes +live selection/baseline adoption and affinity reset; an unavailable saved config leaves prior routing +intact. A post-publication bookkeeping failure adopts only a strictly verified matching selection. + The desktop restart adapter uses [Windows process ownership and installation membership](../runtime.md#codex-desktop-process-membership), independently of Pool/Direct credential selection. HTTP/SSE, Responses WebSocket, compact, images, search, and vision resolve the same account mode. diff --git a/tests/codex-integration/codex-account-selection-atomicity.test.ts b/tests/codex-integration/codex-account-selection-atomicity.test.ts new file mode 100644 index 00000000000..4867965aab1 --- /dev/null +++ b/tests/codex-integration/codex-account-selection-atomicity.test.ts @@ -0,0 +1,430 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + armClaudeCodeBaseline, deleteConfigTopLevelKey, getConfigPath, getDefaultConfig, + loadConfig, saveConfig, saveConfigPreservingClaudeCode, +} from "../../src/config"; +import { setPersistedConfigMutationBeforeCommitForTests } from "../../src/config/persisted-mutation"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import * as atomicWrite from "../../src/config/atomic-write"; +import * as derivedRegistries from "../../src/config/derived-registries"; +import * as mutationLock from "../../src/config/mutation-lock"; +import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes"; +import { materializeCodexUpstreamAuth, resolveCodexAuthContext } from "../../src/codex/auth-context"; +import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/account-id"; +import { clearAccountNeedsReauth } from "../../src/codex/account-runtime-state"; +import { getCodexAccountCredential, readCodexAccountRecord, saveCodexAccountCredential } from "../../src/codex/account-store"; +import { clearPoolRotationState } from "../../src/codex/pool-rotation"; +import { clearAccountQuota, getAccountQuota, updateAccountQuota } from "../../src/codex/quota"; +import { + clearCodexUpstreamHealth, clearThreadAccountMap, getEffectiveActiveCodexAccountId, + resolveCodexAccountForThread, +} from "../../src/codex/routing"; +import { rememberActiveCodexAccount } from "../../src/codex/routing/active-account"; +import { bindThreadAffinity, getThreadAffinity } from "../../src/codex/routing/thread-affinity"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +// These are deliberately old, flat credential records. Account order and display aliases must +// never become credential identity while an upgraded installation changes its active selection. +const ACCOUNT_A = "selection-old-a"; +const ACCOUNT_B = "selection-old-b"; +const MAIN = MAIN_CODEX_ACCOUNT_ID; +const IDS = [MAIN, ACCOUNT_A, ACCOUNT_B]; +const url = new URL("http://127.0.0.1/api/codex-auth/active"); +let home: string; +let previousOcxHome: string | undefined; +let previousCodexHome: string | undefined; +let fetchSpy: ReturnType>; + +function credential(id: string) { + return { + accessToken: `fixture-access-${id}`, + refreshToken: `fixture-refresh-${id}`, + expiresAt: Date.now() + 3_600_000, + chatgptAccountId: `fixture-workspace-${id}`, + }; +} + +function clearState(): void { + clearThreadAccountMap(); + clearCodexUpstreamHealth(); + clearPoolRotationState(); + clearAccountQuota(); + for (const id of IDS) clearAccountNeedsReauth(id); +} + +beforeEach(() => { + previousOcxHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-selection-atomicity-")); + process.env.OPENCODEX_HOME = join(home, "ocx"); + process.env.CODEX_HOME = join(home, "codex"); + mkdirSync(process.env.OPENCODEX_HOME); + mkdirSync(process.env.CODEX_HOME); + clearState(); + writeFileSync(join(process.env.CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: credential(MAIN).accessToken, account_id: credential(MAIN).chatgptAccountId }, + })); + writeFileSync(join(process.env.OPENCODEX_HOME, "codex-accounts.json"), JSON.stringify({ + [ACCOUNT_A]: credential(ACCOUNT_A), [ACCOUNT_B]: credential(ACCOUNT_B), + })); + fetchSpy = spyOn(globalThis, "fetch").mockImplementation(async () => { + throw new Error("Account selection fixture must not contact a provider"); + }); +}); + +afterEach(async () => { + setPersistedConfigMutationBeforeCommitForTests(null); + const requests = fetchSpy.mock.calls.length; + fetchSpy.mockRestore(); + clearState(); + await flushConfigDirHardeningForTests(); + if (previousOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOcxHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(home); + expect(requests).toBe(0); +}); + +function seedConfig(overrides: Partial = {}): OcxConfig { + saveConfig({ + port: 10100, + defaultProvider: "test", + providers: { test: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", apiKey: "fixture-key", allowPrivateNetwork: true } }, + codexAccounts: [ + { id: ACCOUNT_A, email: "selection-a@example.test", alias: "Old main", isMain: false }, + { id: ACCOUNT_B, email: "selection-b@example.test", alias: "Old backup", isMain: false }, + ], + activeCodexAccountId: MAIN, + activeCodexAccountPinned: MAIN, + accountPoolStrategy: "quota", + autoSwitchThreshold: 0, + codexMainAccountHardLock: false, + ...overrides, + } as OcxConfig); + const live = loadConfig(); + armClaudeCodeBaseline(live); + return live; +} + +function diskConfig(): OcxConfig { + return JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; +} + +function changeDisk(patch: Partial): void { + writeFileSync(getConfigPath(), JSON.stringify({ ...diskConfig(), ...patch })); +} + +async function select(live: OcxConfig, accountId: string | null): Promise { + const req = new Request(url, { method: "PUT", headers: { "content-type": "application/json" }, + body: JSON.stringify({ accountId }) }); + const response = await handleCodexAuthAPI(req, url, live); + expect(response).not.toBeNull(); + return response!; +} + +async function expectSelection(live: OcxConfig, accountId: string | null): Promise { + const response = await handleCodexAuthAPI(new Request(url), url, live); + expect(await response!.json()).toMatchObject({ + activeCodexAccountId: accountId, pinnedAccountId: accountId, + }); + expect(live.activeCodexAccountId).toBe(accountId ?? undefined); + expect(live.activeCodexAccountPinned).toBe(accountId ?? undefined); + expect(diskConfig().activeCodexAccountId).toBe(accountId ?? undefined); + expect(diskConfig().activeCodexAccountPinned).toBe(accountId ?? undefined); +} + +async function expectUpstreamIdentity(live: OcxConfig, accountId: string): Promise { + expect(resolveCodexAccountForThread(null, live)).toBe(accountId); + const context = await resolveCodexAuthContext(new Headers(), live, "pool", { + primeCodexPoolQuotas: async () => {}, + }); + const upstream = materializeCodexUpstreamAuth(new Headers(), context, { config: live }); + expect(upstream.get("authorization")).toBe(`Bearer ${credential(accountId).accessToken}`); + expect(upstream.get("chatgpt-account-id")).toBe(credential(accountId).chatgptAccountId); +} + +function captureRouting(live: OcxConfig) { + rememberActiveCodexAccount(live, ACCOUNT_A); + bindThreadAffinity("existing-selection-thread", ACCOUNT_A, Date.now()); + updateAccountQuota(ACCOUNT_A, 37); + const configBefore = structuredClone(live); + const affinityBefore = structuredClone(getThreadAffinity("existing-selection-thread")); + const quotaBefore = structuredClone(getAccountQuota(ACCOUNT_A)); + return () => { + expect(live).toEqual(configBefore); + expect(getEffectiveActiveCodexAccountId(live)).toBe(ACCOUNT_A); + expect(getThreadAffinity("existing-selection-thread")).toEqual(affinityBefore); + expect(getAccountQuota(ACCOUNT_A)).toEqual(quotaBefore); + }; +} + +describe("Codex active selection transaction", () => { + test.each([MAIN, ACCOUNT_A])("same-value selection of %s overrides newer disk selection", async accountId => { + const live = seedConfig({ activeCodexAccountId: accountId, activeCodexAccountPinned: accountId }); + const credentialsBefore = readFileSync(join(process.env.OPENCODEX_HOME!, "codex-accounts.json"), "utf8"); + changeDisk({ activeCodexAccountId: ACCOUNT_B, activeCodexAccountPinned: ACCOUNT_B }); + const response = await select(live, accountId); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ activeCodexAccountId: accountId, appliesImmediately: true }); + await expectSelection(live, accountId); + expect(readFileSync(join(process.env.OPENCODEX_HOME!, "codex-accounts.json"), "utf8")).toBe(credentialsBefore); + await expectUpstreamIdentity(live, accountId); + // First materialization may attach quota-history metadata to a legacy record. It must + // retain its credential and generation, rather than forbidding that intended upgrade. + const legacy = JSON.parse(credentialsBefore); + expect(getCodexAccountCredential(ACCOUNT_A)).toEqual(legacy[ACCOUNT_A]); + expect(getCodexAccountCredential(ACCOUNT_B)).toEqual(legacy[ACCOUNT_B]); + expect(readCodexAccountRecord(ACCOUNT_A)?.generation).toBe(0); + }); + + test("reselecting an initially unpinned main cannot leave a main pin on another active account", async () => { + const live = seedConfig({ activeCodexAccountPinned: undefined }); + changeDisk({ activeCodexAccountId: ACCOUNT_B, activeCodexAccountPinned: ACCOUNT_B }); + expect((await select(live, MAIN)).status).toBe(200); + await expectSelection(live, MAIN); + await expectUpstreamIdentity(live, MAIN); + }); + + test("null clears newer disk selection when the baseline had no selection or pin", async () => { + const live = seedConfig({ activeCodexAccountId: undefined, activeCodexAccountPinned: undefined }); + changeDisk({ activeCodexAccountId: ACCOUNT_B, activeCodexAccountPinned: ACCOUNT_B }); + expect((await select(live, null)).status).toBe(200); + await expectSelection(live, null); + // Clear means automatic selection; a subsequent route is free to choose an account. + live.upstreamFailoverThreshold = 4; + saveConfigPreservingClaudeCode(live); + await expectSelection(live, null); + }); + + test("a scoped selection preserves newer row order, aliases, and unrelated durable settings", async () => { + const live = seedConfig({ activeCodexAccountId: ACCOUNT_A, activeCodexAccountPinned: ACCOUNT_A }); + const rows = [...live.codexAccounts!].reverse().map(row => ({ ...row, alias: `Reordered ${row.id}` })); + const credentialsBefore = readFileSync(join(process.env.OPENCODEX_HOME!, "codex-accounts.json"), "utf8"); + changeDisk({ codexAccounts: rows, activeCodexAccountId: ACCOUNT_B, activeCodexAccountPinned: ACCOUNT_B, + port: 10102, showCodexCredits: true, claudeCode: { authMode: "proxy" } }); + expect((await select(live, ACCOUNT_A)).status).toBe(200); + expect(diskConfig()).toMatchObject({ codexAccounts: rows, port: 10102, showCodexCredits: true, + claudeCode: { authMode: "proxy" } }); + expect(live.port).toBe(10100); + expect(readFileSync(join(process.env.OPENCODEX_HOME!, "codex-accounts.json"), "utf8")).toBe(credentialsBefore); + await expectUpstreamIdentity(live, ACCOUNT_A); + expect(getCodexAccountCredential(ACCOUNT_A)).toEqual(JSON.parse(credentialsBefore)[ACCOUNT_A]); + }); + + test("selection adoption advances only its baseline and preserves pending live edits", async () => { + const live = seedConfig({ activeCodexAccountId: ACCOUNT_A, activeCodexAccountPinned: ACCOUNT_A }); + live.injectionPrompt = "Pending local fixture edit"; + expect((await select(live, MAIN)).status).toBe(200); + expect(live.injectionPrompt).toBe("Pending local fixture edit"); + changeDisk({ activeCodexAccountId: ACCOUNT_B, activeCodexAccountPinned: ACCOUNT_B }); + live.upstreamFailoverThreshold = 4; + saveConfigPreservingClaudeCode(live); + expect(live.activeCodexAccountId).toBe(ACCOUNT_B); + expect(live.activeCodexAccountPinned).toBe(ACCOUNT_B); + expect(diskConfig().injectionPrompt).toBe("Pending local fixture edit"); + }); + + test("a completed clear cannot erase a later selection or consume an unrelated pending deletion", async () => { + const live = seedConfig({ injectionPrompt: "Old fixture prompt" }); + deleteConfigTopLevelKey(live, "injectionPrompt"); + expect((await select(live, null)).status).toBe(200); + await expectSelection(live, null); + // The scoped write does not publish a pending unrelated edit. + expect(diskConfig().injectionPrompt).toBe("Old fixture prompt"); + expect(live.injectionPrompt).toBeUndefined(); + changeDisk({ activeCodexAccountId: ACCOUNT_B, activeCodexAccountPinned: ACCOUNT_B, + injectionPrompt: "Newer disk fixture prompt" }); + live.upstreamFailoverThreshold = 4; + saveConfigPreservingClaudeCode(live); + await expectSelection(live, ACCOUNT_B); + expect(diskConfig().injectionPrompt).toBeUndefined(); + expect(live.injectionPrompt).toBeUndefined(); + }); + + for (const timing of ["before-request", "before-commit"] as const) { + test.each(["deleted", "paused", "validation-pending"] as const)( + `a target that becomes %s ${timing} is rejected without changing runtime routing`, async reason => { + const live = seedConfig(); + const assertRoutingUnchanged = captureRouting(live); + const invalidate = () => { + if (reason === "deleted") changeDisk({ codexAccounts: live.codexAccounts!.filter(row => row.id !== ACCOUNT_B) }); + else if (reason === "paused") changeDisk({ pausedCodexAccountIds: [ACCOUNT_B] }); + else saveCodexAccountCredential(ACCOUNT_B, getCodexAccountCredential(ACCOUNT_B)!, { validationPending: true }); + }; + if (timing === "before-request") invalidate(); + else setPersistedConfigMutationBeforeCommitForTests(invalidate); + const response = await select(live, ACCOUNT_B); + expect(response.status).toBeGreaterThanOrEqual(400); + expect(response.status).toBeLessThan(500); + assertRoutingUnchanged(); + expect(diskConfig().activeCodexAccountId).toBe(MAIN); + expect(diskConfig().activeCodexAccountPinned).toBe(MAIN); + }, + ); + } + + test.each(["missing", "invalid", "unreadable"] as const)( + "a %s durable config fails closed without rebuilding it or changing runtime", async state => { + const live = seedConfig(); + const assertRoutingUnchanged = captureRouting(live); + if (state === "invalid") writeFileSync(getConfigPath(), "{invalid-fixture"); + else { + unlinkSync(getConfigPath()); + if (state === "unreadable") mkdirSync(getConfigPath()); + } + let rejected = false; + try { rejected = !(await select(live, ACCOUNT_B)).ok; } catch { rejected = true; } + expect(rejected).toBe(true); + assertRoutingUnchanged(); + if (state === "missing") expect(existsSync(getConfigPath())).toBe(false); + if (state === "invalid") expect(readFileSync(getConfigPath(), "utf8")).toBe("{invalid-fixture"); + }, + ); + + test.each(["atomic-write", "derived-registry", "generation-bump"] as const)( + "a %s failure respects the durable publication boundary", async phase => { + const live = seedConfig(); + changeDisk({ activeCodexAccountId: ACCOUNT_B, activeCodexAccountPinned: ACCOUNT_B }); + const before = readFileSync(getConfigPath(), "utf8"); + const assertRoutingUnchanged = captureRouting(live); + const fail = () => { throw new Error("Injected account-selection persistence failure"); }; + const fault = phase === "atomic-write" + ? spyOn(atomicWrite, "atomicWriteFile").mockImplementation(fail) + : phase === "derived-registry" + ? spyOn(derivedRegistries, "refreshConfigDerivedRegistries").mockImplementation(fail) + : spyOn(mutationLock, "bumpGenerationForCooperatingConfigWrite").mockImplementation(fail); + let response: Response; + try { + response = await select(live, MAIN); + expect(fault.mock.calls.length).toBeGreaterThan(0); + } finally { + fault.mockRestore(); + } + if (phase === "atomic-write") { + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ code: "account_selection_unavailable" }); + expect(readFileSync(getConfigPath(), "utf8")).toBe(before); + assertRoutingUnchanged(); + } else { + // Publication already succeeded. Reverting only live state would send the next turn + // to a different account than the committed selection until the process restarted. + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ activeCodexAccountId: MAIN, + warning: "config_bookkeeping_failed", appliesImmediately: true }); + await expectSelection(live, MAIN); + expect(getThreadAffinity("existing-selection-thread")).toBeUndefined(); + expect(getEffectiveActiveCodexAccountId(live)).toBe(MAIN); + await expectUpstreamIdentity(live, MAIN); + } + }, + ); + + test.each([MAIN, ACCOUNT_A])("unchanged selection of %s remains a successful no-op on disk", async accountId => { + const live = seedConfig({ activeCodexAccountId: accountId, activeCodexAccountPinned: accountId }); + bindThreadAffinity("existing-selection-thread", ACCOUNT_B, Date.now()); + const before = readFileSync(getConfigPath(), "utf8"); + expect((await select(live, accountId)).status).toBe(200); + expect(readFileSync(getConfigPath(), "utf8")).toBe(before); + expect(getThreadAffinity("existing-selection-thread")).toBeUndefined(); + await expectSelection(live, accountId); + await expectUpstreamIdentity(live, accountId); + }); + + test("a newly selected value with no competing writer still reaches its credential", async () => { + const live = seedConfig({ activeCodexAccountId: ACCOUNT_A, activeCodexAccountPinned: ACCOUNT_A }); + expect((await select(live, ACCOUNT_B)).status).toBe(200); + await expectSelection(live, ACCOUNT_B); + await expectUpstreamIdentity(live, ACCOUNT_B); + }); + + test("an unpersisted initial config can still create and select an account", async () => { + const live = getDefaultConfig(); + live.codexAccounts = [{ id: ACCOUNT_A, email: "selection-a@example.test", isMain: false }]; + live.autoSwitchThreshold = 0; + live.codexMainAccountHardLock = false; + expect(existsSync(getConfigPath())).toBe(false); + expect((await select(live, ACCOUNT_A)).status).toBe(200); + await expectSelection(live, ACCOUNT_A); + await expectUpstreamIdentity(live, ACCOUNT_A); + }); + + test("an unpersisted initial config adopts its published selection when bookkeeping fails", async () => { + const live = getDefaultConfig(); + live.codexAccounts = [{ id: ACCOUNT_A, email: "selection-a@example.test", isMain: false }]; + live.autoSwitchThreshold = 0; + live.codexMainAccountHardLock = false; + expect(existsSync(getConfigPath())).toBe(false); + const fault = spyOn(mutationLock, "bumpGenerationForCooperatingConfigWrite").mockImplementation(() => { + throw new Error("Injected initializer bookkeeping failure"); + }); + let response: Response; + try { + response = await select(live, ACCOUNT_A); + expect(fault.mock.calls.length).toBeGreaterThan(0); + } finally { + fault.mockRestore(); + } + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ activeCodexAccountId: ACCOUNT_A, + warning: "config_bookkeeping_failed", appliesImmediately: true }); + await expectSelection(live, ACCOUNT_A); + await expectUpstreamIdentity(live, ACCOUNT_A); + }); + + test("an initially transient config cannot recreate its durable file after a successful selection", async () => { + const live = getDefaultConfig(); + live.codexAccounts = [ACCOUNT_A, ACCOUNT_B].map(id => ({ id, email: `${id}@example.test`, isMain: false })); + live.autoSwitchThreshold = 0; + live.codexMainAccountHardLock = false; + expect((await select(live, ACCOUNT_A)).status).toBe(200); + const assertRoutingUnchanged = captureRouting(live); + unlinkSync(getConfigPath()); + expect((await select(live, ACCOUNT_B)).ok).toBe(false); + expect(existsSync(getConfigPath())).toBe(false); + assertRoutingUnchanged(); + }); + + test("a live config from another home cannot mutate the current home's selection", async () => { + const live = seedConfig(); + const assertRoutingUnchanged = captureRouting(live); + const originalHome = process.env.OPENCODEX_HOME!; + const originalPath = getConfigPath(); + const originalBytes = readFileSync(originalPath, "utf8"); + const otherHome = join(home, "other-ocx"); + mkdirSync(otherHome); + try { + process.env.OPENCODEX_HOME = otherHome; + const otherBytes = JSON.stringify({ ...JSON.parse(originalBytes), + activeCodexAccountId: ACCOUNT_B, activeCodexAccountPinned: ACCOUNT_B }); + writeFileSync(getConfigPath(), otherBytes); + expect((await select(live, MAIN)).ok).toBe(false); + expect(readFileSync(getConfigPath(), "utf8")).toBe(otherBytes); + expect(readFileSync(originalPath, "utf8")).toBe(originalBytes); + } finally { + process.env.OPENCODEX_HOME = originalHome; + } + assertRoutingUnchanged(); + }); + + test.each([ + { label: "null", body: null }, { label: "array", body: [] }, { label: "missing accountId", body: {} }, + { label: "boolean accountId", body: { accountId: false } }, { label: "numeric accountId", body: { accountId: 0 } }, + ])( + "malformed selection body $label cannot clear routing", async ({ body }) => { + const live = seedConfig(); + const assertRoutingUnchanged = captureRouting(live); + const before = readFileSync(getConfigPath(), "utf8"); + const req = new Request(url, { method: "PUT", headers: { "content-type": "application/json" }, + body: JSON.stringify(body) }); + const response = await handleCodexAuthAPI(req, url, live); + expect(response?.status).toBe(400); + assertRoutingUnchanged(); + expect(readFileSync(getConfigPath(), "utf8")).toBe(before); + }, + ); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d3d7bfd0cb2..ae346b3e556 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -348,6 +348,7 @@ "codebuddy-live-acceptance.test.ts": "providers", "codebuddy-mcp-server.test.ts": "providers", "codebuddy-protocol.test.ts": "providers", "codebuddy-tool-bridge-turn.test.ts": "providers", "codebuddy-tool-bridge.test.ts": "providers", "codex-account-delete-atomicity.test.ts": "codex-integration", + "codex-account-selection-atomicity.test.ts": "codex-integration", "codex-account-label.test.ts": "codex-integration", "codex-account-mode-state.test.ts": "gui", "codex-account-namespaces.test.ts": "codex-integration", "codex-account-selection-preferences.test.ts": "codex-integration", From 4504a5636d9fb4cd2ef5c9acbd3a48e746203378 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 9 Oct 2026 22:19:26 +0900 Subject: [PATCH 37/59] fix(claude): expose the durable OCX request id as request-id on Messages (#6831) Claude Code persists requestId from the standard request-id header, but /v1/messages only carried the OCX ledger id in x-opencodex-request-id, so transcripts could not be joined to request history. When the proxy owns a request-log row, set request-id and x-opencodex-request-id to that id across native, translated, streaming and logged-refusal responses. The native Anthropic upstream id moves to x-opencodex-upstream-request-id, restricted to a bounded req_ token. Unlogged responses get no ledger id. Closes #6815 --- .../content/docs/reference/proxy-formats.md | 19 +- scripts/test-layout/layout.json | 2 + src/server/claude-messages.ts | 6 +- src/server/inbound-body-admission.ts | 1 + src/server/index/serve-options.ts | 10 +- src/server/index/startup-warnings.ts | 19 ++ src/server/messages-native.ts | 15 +- src/server/messages-response-headers.ts | 14 + src/server/workflow-refusal.ts | 3 + structure/data-planes/inbound-compat.md | 19 ++ .../messages-request-id-endpoint.test.ts | 295 ++++++++++++++++++ .../messages-request-id-headers.test.ts | 68 ++++ tests/fixtures/test-layout-expected.json | 2 + 13 files changed, 461 insertions(+), 12 deletions(-) create mode 100644 src/server/messages-response-headers.ts create mode 100644 tests/claude-integration/messages-request-id-endpoint.test.ts create mode 100644 tests/claude-integration/messages-request-id-headers.test.ts diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index e16a7823988..876ef6f54d7 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -372,8 +372,23 @@ named in `Access-Control-Expose-Headers`, which is what lets browser JavaScript cross-origin — a custom `x-` header is otherwise invisible to `response.headers.get()` even when it is on the wire. -Responses rejected at authentication or origin admission never reach this wrapper and carry no id, -so a missing header means the request was refused before it was logged. +Responses rejected at authentication or origin admission never reach this wrapper and carry no id. + +Logged `POST /v1/messages` replies carry the same OCX request-history id in **both** +`request-id` and `x-opencodex-request-id`. This applies to native and translated replies, +streaming and JSON, and logged refusals. Claude Code can use its persisted `requestId` +metadata from `request-id` to join future transcripts to that OCX history row. + +On native delivered replies and upstream HTTP errors, an opaque Anthropic upstream +`request-id` matching `req_[A-Za-z0-9_-]{1,128}` is preserved separately as +`x-opencodex-upstream-request-id`. It is a provider diagnostic id, not an OCX ledger key. +Missing or nonconforming upstream ids, and translated adapter ids, are omitted. These +response headers are readable through CORS alongside existing exposed headers. + +No OCX id is added to authentication, origin, drain or active-turn refusals that have no +request-log owner, or to count_tokens. Streaming headers identify the turn's eventual +history row; they do not attest that final usage has already been saved. Old transcripts +without a shared key remain unlinked; timestamp or token similarity is not an exact join. ### WebSocket upgrade on the same path diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index c878c168669..bb3d0bce73f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,6 +2,8 @@ "version": 1, "root": "tests", "explicit": { + "messages-request-id-headers.test.ts": "claude-integration", + "messages-request-id-endpoint.test.ts": "claude-integration", "azure-vendor-metadata.test.ts": "providers", "anthropic-instance-isolation.test.ts": "adapters/anthropic", "anthropic-instance-pool-parity.test.ts": "adapters/anthropic", "anthropic-instance-quota.test.ts": "adapters/anthropic", "anthropic-instance-recovery.test.ts": "adapters/anthropic", diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index dcb03b38906..e9787d79b82 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -42,6 +42,7 @@ import { } from "../lib/upstream-retry"; import { resolveClientRetryAfter } from "../lib/retry-after"; import { anthropicRateLimitHeaders } from "./anthropic-rate-limit-headers"; +import { upstreamMessagesRequestIdHeaders } from "./messages-response-headers"; import { anthropicErrorBody, anthropicErrorResponse, @@ -635,7 +636,10 @@ async function anthropicNativePassthrough( const upstream = result.upstream; const contentType = upstream.headers.get("content-type") ?? "application/json"; - const rateLimitHeaders = anthropicRateLimitHeaders(upstream.headers); + const rateLimitHeaders = { + ...anthropicRateLimitHeaders(upstream.headers), + ...(pathname === "/v1/messages" ? upstreamMessagesRequestIdHeaders(upstream.headers) : {}), + }; const bodyGuard = resolvePassthroughBodyGuard(config, req.signal); if (upstream.ok && contentType.includes("text/event-stream") && upstream.body) { return new Response(tapAnthropicSseForLog(upstream.body, logCtx, finalize, bodyGuard), { diff --git a/src/server/inbound-body-admission.ts b/src/server/inbound-body-admission.ts index 792b3b73719..8f4ac6b7c80 100644 --- a/src/server/inbound-body-admission.ts +++ b/src/server/inbound-body-admission.ts @@ -181,6 +181,7 @@ export function runAdmittedBodyWork( addFinalRequestLog(refusalLog.requestId, refusalLog.start, refusalLog.logCtx, refused.status, { closeReason: "terminal", }); + refusalLog.onLogged?.(); } return withCors(refused, req, policy); }); diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index faa319f7014..78705b5782a 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -21,6 +21,7 @@ import { } from "./live-sideband"; import { withRequestLogId, + withMessagesRequestLogId, } from "./startup-warnings"; import { remoteWorkspaceEnabled } from "../../remote-control/workspace-activation"; @@ -1556,11 +1557,16 @@ export function createServeOptions(ctx: ServeOptionsContext) { // pre-translation stream + native passthrough callbacks) — do not re-wrap the // translated Anthropic stream here. const sessionReq = withCallerSessionIdentity(req, admission); - return runAdmittedHttpTurn(sessionReq, policy, async turnAdmissionLease => withCors( + // Entering the handler, or a logged refusal, means the final request log owns requestId. + let ownsRequestLog = false; + const claimRequestLog = (work: (lease: ActiveTurnLease) => Promise) => + (lease: ActiveTurnLease) => { ownsRequestLog = true; return work(lease); }; + const response = await runAdmittedHttpTurn(sessionReq, policy, claimRequestLog(async turnAdmissionLease => withCors( await handleClaudeMessages(sessionReq, config, logCtx, { requestId, start, turnAdmissionLease, admission }, policy, { claudeIntercept: ingress === "claude-intercept" }), req, policy, - ), { requestId, start, logCtx }); + )), { requestId, start, logCtx, onLogged: () => { ownsRequestLog = true; } }); + return ownsRequestLog ? withMessagesRequestLogId(response, requestId) : response; } // OpenAI Chat Completions inbound (GitHub Copilot App / OpenAI-compatible clients). diff --git a/src/server/index/startup-warnings.ts b/src/server/index/startup-warnings.ts index 5ebbb04805e..090112bef23 100644 --- a/src/server/index/startup-warnings.ts +++ b/src/server/index/startup-warnings.ts @@ -100,6 +100,25 @@ import { const REQUEST_LOG_ID_RESPONSE_HEADER = "x-opencodex-request-id"; +/** Messages clients persist request-id; the upstream diagnostic id has a separate namespace. */ +export function withMessagesRequestLogId(response: Response, requestId: string): Response { + const upstreamId = response.headers.get("request-id"); + const correlated = withRequestLogId(response, requestId); + correlated.headers.delete("x-opencodex-upstream-request-id"); + if (upstreamId && /^req_[A-Za-z0-9_-]{1,128}$/.test(upstreamId)) { + correlated.headers.set("x-opencodex-upstream-request-id", upstreamId); + } + correlated.headers.set("request-id", requestId); + const exposed = correlated.headers.get("Access-Control-Expose-Headers") ?? ""; + const names = exposed.split(",").map(name => name.trim().toLowerCase()); + const additions = ["request-id", ...(correlated.headers.has("x-opencodex-upstream-request-id") + ? ["x-opencodex-upstream-request-id"] : [])].filter(name => !names.includes(name)); + if (additions.length) { + correlated.headers.set("Access-Control-Expose-Headers", [exposed, ...additions].filter(Boolean).join(", ")); + } + return correlated; +} + export function withRequestLogId(response: Response, requestId: string): Response { const headers = new Headers(response.headers); headers.set(REQUEST_LOG_ID_RESPONSE_HEADER, requestId); diff --git a/src/server/messages-native.ts b/src/server/messages-native.ts index 5569092674c..79b1d90f901 100644 --- a/src/server/messages-native.ts +++ b/src/server/messages-native.ts @@ -121,6 +121,7 @@ import { workflowRefusalResponse } from "./workflow-refusal"; import { sseFieldValue } from "../lib/sse-decoder"; import { admissionModelDeniedResponse, type AdmissionModelScope } from "./admission-model-scope"; import { nativeMessagesToolScopeDenial } from "./messages-native-scope"; +import { retainUpstreamMessagesRequestId } from "./messages-response-headers"; export { isNativeMessagesRouteEligible, @@ -751,7 +752,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) upstream.abort(); return fail(499, "Client cancelled request", "api_error"); } - return nativeMessagesErrorResponse(response, bodyText, finishLog); + return retainUpstreamMessagesRequestId(nativeMessagesErrorResponse(response, bodyText, finishLog), response.headers); } const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; @@ -781,10 +782,10 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) // rewrite buffers whole frames, so it sits after the tap: stall detection keeps timing raw // upstream bytes. Real Anthropic pools refuse pre-stream in that wording and keep one relay. const clientStream = nativeInstance ? relayed : relaySseWithPayloadRewrite(relayed, claudeOverflowSsePayload, translatorBudget); - return new Response(clientStream, { + return retainUpstreamMessagesRequestId(new Response(clientStream, { status: 200, headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache", Connection: "keep-alive" }, - }); + }), response.headers); } // A non-streaming caller whose upstream streamed anyway: fold the stream into one message. const tapState: { closeReason?: FinalRequestLogMeta["closeReason"]; meta?: FinalRequestLogMeta } = {}; @@ -810,7 +811,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) return fail(502, text, "api_error"); } finishLog(200); - return Response.json(message); + return retainUpstreamMessagesRequestId(Response.json(message), response.headers); } catch (error) { cleanupAbort(); upstream.abort(); @@ -868,12 +869,12 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) const serialized = JSON.stringify(message); finishLog(200); if (requestedStream) { - return new Response(messageAsSse(message), { + return retainUpstreamMessagesRequestId(new Response(messageAsSse(message), { status: 200, headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache" }, - }); + }), response.headers); } - return new Response(serialized, { status: 200, headers: { "Content-Type": "application/json" } }); + return retainUpstreamMessagesRequestId(new Response(serialized, { status: 200, headers: { "Content-Type": "application/json" } }), response.headers); } /** A folded stream error that refuses an oversized input (same gate as the SSE rewrite). */ diff --git a/src/server/messages-response-headers.ts b/src/server/messages-response-headers.ts new file mode 100644 index 00000000000..a1d3df3ae99 --- /dev/null +++ b/src/server/messages-response-headers.ts @@ -0,0 +1,14 @@ +/** Only an opaque Anthropic request id is carried; arbitrary upstream headers stay private. */ +export function upstreamMessagesRequestIdHeaders(upstream: Headers): Record { + const requestId = upstream.get("request-id"); + return requestId && /^req_[A-Za-z0-9_-]{1,128}$/.test(requestId) + ? { "request-id": requestId } + : {}; +} + +/** The response is newly constructed by the Messages handler; preserve its body and markers. */ +export function retainUpstreamMessagesRequestId(response: Response, upstream: Headers): Response { + const requestId = upstreamMessagesRequestIdHeaders(upstream)["request-id"]; + if (requestId) response.headers.set("request-id", requestId); + return response; +} diff --git a/src/server/workflow-refusal.ts b/src/server/workflow-refusal.ts index 712e0b367f0..58dab886a53 100644 --- a/src/server/workflow-refusal.ts +++ b/src/server/workflow-refusal.ts @@ -41,6 +41,8 @@ export interface WorkflowRefusalLog { readonly requestId: string; readonly start: number; readonly logCtx: RequestLogContext; + /** Receiving HTTP route's notification after this refusal's final-log call. */ + readonly onLogged?: () => void; } /** @@ -84,6 +86,7 @@ export function workflowRefusalResponse( addFinalRequestLog(refusalLog.requestId, refusalLog.start, refusalLog.logCtx, 429, { closeReason: "terminal", }); + refusalLog.onLogged?.(); } const refusal = formatErrorResponse( 429, diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 0559cdc768c..f8313518d12 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -317,6 +317,25 @@ its defaults and exclusions are owned by [Responses transport](../transports/res The provider summary default applies at Responses ingress; native Chat and Anthropic inbound preferences keep their existing handling. Raw content is never renamed to a summary. See [bridge contract](../providers/chat-compat.md). +## Messages request-log correlation + +The Messages HTTP boundary in `src/server/index/serve-options.ts` publishes its generated +request-log id in both `request-id` and `x-opencodex-request-id`. Native, translated, streamed, +collected and logged refusal responses use the same id as the final request-history row. +Stream headers precede final accounting; they identify the row owned by the turn, rather than +attest to successful persistence. Authentication, origin, drain and active-turn rejections +without a request-log owner, and count_tokens, receive no OCX correlation id. + +`src/server/messages-response-headers.ts` retains only an upstream `request-id` matching +`req_[A-Za-z0-9_-]{1,128}` on native delivered results and upstream HTTP errors. At the final +HTTP boundary, `src/server/index/startup-warnings.ts` moves this diagnostic value into +`x-opencodex-upstream-request-id` and assigns the OCX id to `request-id`. Missing or nonconforming +upstream ids are omitted; translated adapter ids are not inferred. The standard/custom OCX +headers and the optional upstream header are exposed through CORS alongside existing names. +`tests/claude-integration/messages-request-id-endpoint.test.ts` covers the protocol boundary; +`tests/claude-integration/messages-request-id-headers.test.ts` covers header and stream identity. +Responses retains its existing custom-header contract. + ## Claude context rejection Claude Messages preserves the classified `context_length_exceeded` error (`src/claude/outbound.ts`, `src/protocols/encoders/messages.ts`, `src/server/claude-messages.ts`): one streaming `invalid_request_error` terminal, or HTTP 400 without a retry hint when collected or failed-JSON. diff --git a/tests/claude-integration/messages-request-id-endpoint.test.ts b/tests/claude-integration/messages-request-id-endpoint.test.ts new file mode 100644 index 00000000000..de63062a740 --- /dev/null +++ b/tests/claude-integration/messages-request-id-endpoint.test.ts @@ -0,0 +1,295 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { startServer } from "../../src/server"; +import { MAX_ACTIVE_TURNS, tryAdmitTurn } from "../../src/server/lifecycle"; +import { MAX_CONCURRENT_INBOUND_BODY_BYTES, withRaisedInboundBodyAdmission } from "../../src/server/inbound-body-admission"; +import { readRecentUsageEntries } from "../../src/usage/log"; +import { admitWorkflowTurn, chargeWorkflowSends, DEFAULT_WORKFLOW_BUDGET_POLICY, resetWorkflowBudgetsForTest } from "../../src/lib/workflow-budget"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { drainAndRemoveFixtureRoots } from "../helpers/fixture-teardown"; +import type { OcxConfig } from "../../src/types"; + +type Mode = "caller-forward" | "managed-native" | "translated"; +let fixtureHome = ""; +let previousHome: string | undefined; +let codexHome: IsolatedCodexHome | undefined; +let proxy: ReturnType | undefined; +let upstream: ReturnType | undefined; +let upstreamCancelled = false; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + fixtureHome = mkdtempSync(join(tmpdir(), "ocx-messages-request-id-")); + process.env.OPENCODEX_HOME = fixtureHome; + codexHome = installIsolatedCodexHome("ocx-messages-id-codex-"); + resetWorkflowBudgetsForTest(); + upstreamCancelled = false; +}); + +afterEach(async () => { + await proxy?.stop(true); + proxy = undefined; + await upstream?.stop(true); + upstream = undefined; + resetWorkflowBudgetsForTest(); + await drainAndRemoveFixtureRoots({ + roots: [{ path: fixtureHome }, ...(codexHome ? [{ path: codexHome.path }] : [])], + restoreEnvironment() { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + codexHome?.restoreEnvironment(); + }, + }); + codexHome = undefined; +}); + +const message = { id: "msg_fixture", type: "message", role: "assistant", model: "claude-x", + content: [{ type: "text", text: "fixture answer" }], stop_reason: "end_turn", stop_sequence: null, + usage: { input_tokens: 4, output_tokens: 2 } }; +const messageSse = [ + ["message_start", { type: "message_start", message: { ...message, content: [], stop_reason: null, usage: { input_tokens: 4, output_tokens: 0 } } }], + ["content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }], + ["content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "fixture answer" } }], + ["content_block_stop", { type: "content_block_stop", index: 0 }], + ["message_delta", { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 2 } }], + ["message_stop", { type: "message_stop" }], +].map(([event, data]) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`).join(""); +const chatSse = [ + { id: "chat_fixture", choices: [{ index: 0, delta: { role: "assistant", content: "fixture answer" } }] }, + { id: "chat_fixture", choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 4, completion_tokens: 2 } }, +].map(data => `data: ${JSON.stringify(data)}\n\n`).join("") + "data: [DONE]\n\n"; + +function launch(mode: Mode, wire: "json" | "sse", status = 200, override: Partial = {}, hang = false) { + upstream = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(req) { + if (new URL(req.url).pathname.endsWith("count_tokens")) return Response.json({ input_tokens: 4 }); + const body = status !== 200 ? JSON.stringify({ type: "error", error: { type: "invalid_request_error", message: "fixture refusal" } }) + : mode === "translated" ? chatSse : wire === "sse" ? messageSse : JSON.stringify(message); + const partial = body.split("\n\n").slice(0, mode === "translated" ? 1 : 3).join("\n\n") + "\n\n"; + const delivered = hang ? new ReadableStream({ + start(controller) { controller.enqueue(new TextEncoder().encode(partial)); }, + cancel() { upstreamCancelled = true; }, + }, { highWaterMark: 0 }) : body; + return new Response(delivered, { status, headers: { + "content-type": status !== 200 || (mode !== "translated" && wire === "json") ? "application/json" : "text/event-stream", + "request-id": "req_fixture", "x-opencodex-request-id": "upstream-controlled", "set-cookie": "fixture", + } }); + } }); + const baseUrl = upstream.url.toString().replace(/\/$/, ""); + saveConfig({ port: 0, defaultProvider: "mock", connectTimeoutMs: 1000, + providers: { mock: { adapter: mode === "translated" ? "openai-chat" : "anthropic", + baseUrl: mode === "translated" ? `${baseUrl}/v1` : baseUrl, apiKey: "fixture-key", + allowPrivateNetwork: true, liveModels: false, models: ["claude-x"] } }, + claudeCode: { anthropicBaseUrl: baseUrl }, + ...(mode === "managed-native" ? { protocols: { rollout: { managedMessagesNative: true } } } : {}), + ...override, + } as OcxConfig); + proxy = startServer(0); +} + +function send(mode: Mode, stream: boolean, extraHeaders: Record = {}, path = "/v1/messages?beta=true", signal?: AbortSignal) { + return fetch(new URL(path, proxy!.url), { method: "POST", signal, headers: { + "content-type": "application/json", + ...(mode === "caller-forward" ? { authorization: "Bearer sk-ant-oat01-fixture" } : {}), + "request-id": "caller-controlled", "x-opencodex-request-id": "caller-controlled", ...extraHeaders, + }, body: JSON.stringify({ model: mode === "caller-forward" ? "claude-x" : "mock/claude-x", + max_tokens: 16, stream, messages: [{ role: "user", content: "fixture question" }] }) }); +} + +async function expectDurableId(response: Response) { + const requestId = response.headers.get("request-id"); + expect(requestId).toMatch(/^ocx-[a-f0-9]{32}$/); + expect(response.headers.get("x-opencodex-request-id")).toBe(requestId); + await response.text(); + return durableRowFor(requestId!); +} + +async function durableRowFor(requestId: string) { + let rows = readRecentUsageEntries(100, fixtureHome).filter(row => row.requestId === requestId); + for (let i = 0; i < 100 && rows.length === 0; i++) { + await Bun.sleep(10); + rows = readRecentUsageEntries(100, fixtureHome).filter(row => row.requestId === requestId); + } + expect(rows).toHaveLength(1); + expect(rows[0]!.inboundProtocol).toBe("messages"); + return rows[0]!; +} + +for (const mode of ["caller-forward", "managed-native", "translated"] as const) { + for (const stream of [false, true]) { + test(`${mode} ${stream ? "SSE" : "JSON"} has the same standard/custom/durable request id`, async () => { + launch(mode, stream ? "sse" : "json"); + const response = await send(mode, stream); + expect(response.status).toBe(200); + expect(response.headers.get("x-opencodex-upstream-request-id")).toBe(mode === "translated" ? null : "req_fixture"); + expect(response.headers.get("set-cookie")).toBeNull(); + await expectDurableId(response); + }); + } +} + +for (const [stream, wire] of [[false, "sse"], [true, "json"]] as const) { + test(`managed native converts ${wire} and retains its upstream id`, async () => { + launch("managed-native", wire); + const response = await send("managed-native", stream); + expect(response.headers.get("x-opencodex-upstream-request-id")).toBe("req_fixture"); + await expectDurableId(response); + }); +} + +for (const mode of ["caller-forward", "managed-native"] as const) { + test(`${mode} logged upstream refusal has both ids`, async () => { + launch(mode, "json", 400); + const response = await send(mode, false); + expect(response.status).toBe(400); + expect(response.headers.get("x-opencodex-upstream-request-id")).toBe("req_fixture"); + await expectDurableId(response); + }); +} + +test("disabled Messages is a logged refusal", async () => { + launch("translated", "json", 200, { apiSurfaces: { messages: { enabled: false } } }); + const response = await send("translated", false); + expect(response.status).toBe(403); + expect(response.headers.has("x-opencodex-upstream-request-id")).toBe(false); + await expectDurableId(response); +}); + +test("pre-handler workflow refusal is logged and receives the correlation id", async () => { + launch("translated", "json"); + const admitted = admitWorkflowTurn("fixture-root", "interactive"); + expect(admitted?.admitted).toBe(true); + if (admitted?.admitted) admitted.lease.release(); + chargeWorkflowSends("fixture-root", DEFAULT_WORKFLOW_BUDGET_POLICY.maxPhysicalSends); + const response = await send("translated", false, { "x-codex-parent-thread-id": "fixture-root" }); + expect(response.status).toBe(429); + await expectDurableId(response); +}); + +test("active-turn refusal has no fabricated ledger association", async () => { + launch("translated", "json"); + const leases = Array.from({ length: MAX_ACTIVE_TURNS }, () => tryAdmitTurn()); + try { + expect(leases.every(Boolean)).toBe(true); + const before = readRecentUsageEntries(100, fixtureHome).length; + const response = await send("translated", false); + expect(response.status).toBe(503); + expect(response.headers.has("request-id")).toBe(false); + expect(response.headers.has("x-opencodex-request-id")).toBe(false); + await response.text(); + expect(readRecentUsageEntries(100, fixtureHome)).toHaveLength(before); + } finally { for (const lease of leases) lease?.release(); } +}); + +test("count_tokens has no OCX ledger id", async () => { + launch("caller-forward", "json"); + const response = await send("caller-forward", false, {}, "/v1/messages/count_tokens"); + expect(response.headers.has("x-opencodex-request-id")).toBe(false); + expect(response.headers.has("request-id")).toBe(false); + expect(await response.json()).toEqual({ input_tokens: 4 }); +}); + +for (const mode of ["caller-forward", "managed-native", "translated"] as const) { + test(`${mode} client cancellation retains the same durable id and releases the turn`, async () => { + launch(mode, "sse", 200, {}, true); + const client = new AbortController(); + const response = await send(mode, true, {}, "/v1/messages?beta=true", client.signal); + const requestId = response.headers.get("request-id")!; + expect(requestId).toMatch(/^ocx-[a-f0-9]{32}$/); + expect(response.headers.get("x-opencodex-request-id")).toBe(requestId); + expect(readRecentUsageEntries(100, fixtureHome).some(row => row.requestId === requestId)).toBe(false); + const reader = response.body!.getReader(); + try { + expect((await reader.read()).done).toBe(false); + client.abort(new DOMException("fixture client left", "AbortError")); + try { while (!(await reader.read()).done) {} } catch { /* expected fetch cancellation */ } + const row = await durableRowFor(requestId); + // The translated bridge's disconnect classification depends on the Bun runtime + // (terminal 502 on 1.4.0, client_cancel 499 on 1.4.2). This change only owns the + // header-to-row correlation, so either existing classification is accepted there. + if (mode === "translated") { + expect([[502, "terminal"], [499, "client_cancel"]]).toContainEqual([row.status, row.closeReason]); + } else { + expect(row.status).toBe(499); + expect(row.closeReason).toBe("client_cancel"); + } + for (let i = 0; i < 100 && !upstreamCancelled; i++) await Bun.sleep(10); + expect(upstreamCancelled).toBe(true); + // No transferred turn remains: all admission slots are available again. + const leases = Array.from({ length: MAX_ACTIVE_TURNS }, () => tryAdmitTurn()); + try { expect(leases.every(Boolean)).toBe(true); } + finally { for (const lease of leases) lease?.release(); } + } finally { + client.abort(); + try { await reader.cancel(); } catch { /* aborted reader */ } + reader.releaseLock(); + } + }); +} + +test("pre-handler body-capacity refusal receives the persisted OCX id and releases capacity", async () => { + launch("translated", "json", 200, { maxInboundBodyBytes: MAX_CONCURRENT_INBOUND_BODY_BYTES }); + const held = await withRaisedInboundBodyAdmission(new Request("http://localhost/v1/messages", { + method: "POST", body: "{}", + }), "/v1/messages", MAX_CONCURRENT_INBOUND_BODY_BYTES, + async () => new Response(new ReadableStream({}, { highWaterMark: 0 }))); + try { + const response = await send("translated", false); + expect(response.status).toBe(503); + expect(response.headers.has("x-opencodex-upstream-request-id")).toBe(false); + const row = await expectDurableId(response); + expect(row.status).toBe(503); + expect(row.errorCode).toBe("server_busy"); + } finally { await held.body!.cancel("fixture released"); } + const admitted = await withRaisedInboundBodyAdmission(new Request("http://localhost/v1/messages", { + method: "POST", body: "{}", + }), "/v1/messages", MAX_CONCURRENT_INBOUND_BODY_BYTES, async () => new Response(null, { status: 204 })); + expect(admitted.status).toBe(204); +}); + +const allowedOrigin = "http://fixture.example"; +function expectCors(response: Response, hasUpstream: boolean) { + expect(response.headers.get("access-control-allow-origin")).toBe(allowedOrigin); + const names = response.headers.get("access-control-expose-headers")!.toLowerCase().split(",").map(value => value.trim()); + for (const name of ["request-id", "x-opencodex-request-id"]) { + expect(names.filter(value => value === name)).toHaveLength(1); + } + expect(names.includes("x-opencodex-upstream-request-id")).toBe(hasUpstream); +} + +for (const mode of ["caller-forward", "managed-native", "translated"] as const) { + for (const stream of [false, true]) { + test(`${mode} ${stream ? "SSE" : "JSON"} exposes correlation headers on the real listener`, async () => { + launch(mode, stream ? "sse" : "json", 200, { corsAllowOrigins: [allowedOrigin] }); + const response = await send(mode, stream, { origin: allowedOrigin }); + expect(response.status).toBe(200); + expectCors(response, mode !== "translated"); + await expectDurableId(response); + }); + } +} + +for (const mode of ["caller-forward", "managed-native"] as const) { + test(`${mode} upstream refusal exposes correlation headers on the real listener`, async () => { + launch(mode, "json", 400, { corsAllowOrigins: [allowedOrigin] }); + const response = await send(mode, false, { origin: allowedOrigin }); + expect(response.status).toBe(400); + expectCors(response, true); + await expectDurableId(response); + }); +} + +test("real-listener workflow CORS retains its existing exposed refusal header", async () => { + launch("translated", "json", 200, { corsAllowOrigins: [allowedOrigin] }); + const admitted = admitWorkflowTurn("fixture-root", "interactive"); + expect(admitted?.admitted).toBe(true); + if (admitted?.admitted) admitted.lease.release(); + chargeWorkflowSends("fixture-root", DEFAULT_WORKFLOW_BUDGET_POLICY.maxPhysicalSends); + const response = await send("translated", false, { origin: allowedOrigin, "x-codex-parent-thread-id": "fixture-root" }); + expect(response.status).toBe(429); + expectCors(response, false); + expect(response.headers.get("access-control-expose-headers")).toContain("x-opencodex-local-refusal"); + await expectDurableId(response); +}); diff --git a/tests/claude-integration/messages-request-id-headers.test.ts b/tests/claude-integration/messages-request-id-headers.test.ts new file mode 100644 index 00000000000..d995302d067 --- /dev/null +++ b/tests/claude-integration/messages-request-id-headers.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from "bun:test"; +import { withMessagesRequestLogId, withRequestLogId } from "../../src/server/index/startup-warnings"; +import { retainUpstreamMessagesRequestId, upstreamMessagesRequestIdHeaders } from "../../src/server/messages-response-headers"; + +const ocxId = `ocx-${"1".repeat(32)}`; + +test("Messages uses the ledger id and preserves the upstream id and existing exposure", async () => { + const original = new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("fixture")); + controller.close(); + }, + }), { status: 429, statusText: "Fixture Refusal", headers: { + "request-id": "req_fixture", + "x-opencodex-request-id": "caller-controlled", + "x-opencodex-upstream-request-id": "caller-controlled", + "Access-Control-Expose-Headers": "X-Existing, ReQuEsT-Id", + "retry-after": "3", + } }); + const body = original.body; + const response = withMessagesRequestLogId(original, ocxId); + expect(response.body).toBe(body); + expect(response.status).toBe(429); + expect(response.statusText).toBe("Fixture Refusal"); + expect(response.headers.get("request-id")).toBe(ocxId); + expect(response.headers.get("x-opencodex-request-id")).toBe(ocxId); + expect(response.headers.get("x-opencodex-upstream-request-id")).toBe("req_fixture"); + expect(response.headers.get("retry-after")).toBe("3"); + const exposed = response.headers.get("Access-Control-Expose-Headers")!.toLowerCase().split(",").map(value => value.trim()); + expect(exposed).toContain("x-existing"); + expect(exposed.filter(value => value === "request-id")).toHaveLength(1); + expect(exposed).toContain("x-opencodex-request-id"); + expect(exposed).toContain("x-opencodex-upstream-request-id"); + expect(await response.text()).toBe("fixture"); +}); + +test("Responses keeps its existing request-id semantics", () => { + const response = withRequestLogId(new Response(null, { headers: { "request-id": "req_fixture" } }), ocxId); + expect(response.headers.get("request-id")).toBe("req_fixture"); + expect(response.headers.get("x-opencodex-request-id")).toBe(ocxId); + expect(response.headers.has("x-opencodex-upstream-request-id")).toBe(false); +}); + +test("upstream metadata is bounded, allowlisted and never invented", () => { + expect(upstreamMessagesRequestIdHeaders(new Headers({ "request-id": "req_fixture", "set-cookie": "fixture" }))) + .toEqual({ "request-id": "req_fixture" }); + for (const value of ["", "account_fixture", "req_", `req_${"x".repeat(129)}`, "req_has space"]) { + expect(upstreamMessagesRequestIdHeaders(new Headers({ "request-id": value }))).toEqual({}); + const response = withMessagesRequestLogId(new Response(null, { headers: { + "request-id": value, "x-opencodex-upstream-request-id": "untrusted", + } }), ocxId); + expect(response.headers.has("x-opencodex-upstream-request-id")).toBe(false); + expect(response.headers.get("request-id")).toBe(ocxId); + } + expect(upstreamMessagesRequestIdHeaders(new Headers())).toEqual({}); +}); + +test("native retention preserves Response identity and cancellation without reading or teeing", async () => { + let cancelled: unknown; + const original = new Response(new ReadableStream({ + cancel(reason) { cancelled = reason; }, + }, { highWaterMark: 0 })); + expect(retainUpstreamMessagesRequestId(original, new Headers({ "request-id": "req_fixture" }))).toBe(original); + const response = withMessagesRequestLogId(original, ocxId); + expect(response.body).toBe(original.body); + await response.body!.cancel("fixture-cancel"); + expect(cancelled).toBe("fixture-cancel"); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index ae346b3e556..281a3729757 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,6 @@ { + "messages-request-id-headers.test.ts": "claude-integration", + "messages-request-id-endpoint.test.ts": "claude-integration", "azure-vendor-metadata.test.ts": "providers", "anthropic-instance-isolation.test.ts": "adapters/anthropic", "anthropic-instance-pool-parity.test.ts": "adapters/anthropic", "anthropic-instance-quota.test.ts": "adapters/anthropic", "anthropic-instance-recovery.test.ts": "adapters/anthropic", From 60a973642ec03a40eb0c39680f9d21762789c758 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 9 Oct 2026 22:31:07 +0900 Subject: [PATCH 38/59] docs(devlog): close the N3 test-infra unit (#6840) * docs(devlog): record the #6777 fixture implementation * docs(devlog): record the #6776 fixture implementation * docs(devlog): close the N3 test-infra unit (#6775, #6777, #6776) Moves devlog/_plan/261009_n3_test_infra to devlog/_fin with an Outcome section recording #6835 (7f642e30e8), #6833 (6d7e5f48ad) and #6834 (c4baa30e09), their exact-head CI, the union check, and the scope left as follow-ups. * docs(devlog): say which N3 CI jobs were skipped --- .../261009_n3_test_infra/000_plan.md | 28 +++++++++++++++++++ .../010_6775_client_home_isolation.md | 11 ++++++++ .../020_6777_combo_fixture.md | 11 ++++++++ .../030_6776_toggle_ownership_fixture.md | 9 ++++++ 4 files changed, 59 insertions(+) rename devlog/{_plan => _fin}/261009_n3_test_infra/000_plan.md (55%) rename devlog/{_plan => _fin}/261009_n3_test_infra/010_6775_client_home_isolation.md (94%) rename devlog/{_plan => _fin}/261009_n3_test_infra/020_6777_combo_fixture.md (87%) rename devlog/{_plan => _fin}/261009_n3_test_infra/030_6776_toggle_ownership_fixture.md (85%) diff --git a/devlog/_plan/261009_n3_test_infra/000_plan.md b/devlog/_fin/261009_n3_test_infra/000_plan.md similarity index 55% rename from devlog/_plan/261009_n3_test_infra/000_plan.md rename to devlog/_fin/261009_n3_test_infra/000_plan.md index 1680e798145..9ea06449a1b 100644 --- a/devlog/_plan/261009_n3_test_infra/000_plan.md +++ b/devlog/_fin/261009_n3_test_infra/000_plan.md @@ -39,3 +39,31 @@ home. Focused files per doc plus `bun run typecheck`, run with a temporary home at process start. The full suite runs in hosted CI on each PR's exact head. + +## Outcome + +**DONE**, 2026-10-09. All three items merged into `dev` with squash admin merges, after +exact-head CI concluded `success` and independent gpt-6.1-sol reviews passed: + +| Doc | Issue | PR | Merge commit | Exact-head CI | +| --- | --- | --- | --- | --- | +| 010 | #6775 | #6835 | `7f642e30e8` | all executed jobs passed at `9ac1a95263`, including the nine Windows shards, `npm-global windows-latest` and `keyring windows` | +| 020 | #6777 | #6833 | `6d7e5f48ad` | all executed jobs passed at `0fa7ee87df`; Windows shards and other conditional jobs skipped (not selected for this diff) | +| 030 | #6776 | #6834 | `c4baa30e09` | all executed jobs passed at `c6addbb97e`; Windows shards and other conditional jobs skipped (not selected for this diff) | + +Before merging, the three heads were combined on `dev` `b89bbfb083` (#6835 shares +`layout.json` and `test-layout-expected.json` with changes that landed meanwhile): 180 +focused tests, typecheck and `structure:check` passed. The three issues were closed with +a comment naming the PR and merge commit. + +010 went through four review rounds. The plan's two guarded writers grew to four (agent +sync, gateway cache, served-catalog invalidation, intercept `settings.json`), and the guard +now judges every touched path by where it resolves (directory and file links), resolves its +roots at check time, compares case-insensitively on macOS and Windows, splits containment +on the platform separator, and covers removals with the same comparison while keeping the +checkout-content lift. + +Not done here: other client homes that still default to `os.homedir()` (Claude Desktop +config library, several client-integration writers, Kiro, XDG-based clients) remain +follow-up candidates. Native Windows was exercised only through hosted CI. + diff --git a/devlog/_plan/261009_n3_test_infra/010_6775_client_home_isolation.md b/devlog/_fin/261009_n3_test_infra/010_6775_client_home_isolation.md similarity index 94% rename from devlog/_plan/261009_n3_test_infra/010_6775_client_home_isolation.md rename to devlog/_fin/261009_n3_test_infra/010_6775_client_home_isolation.md index a5b0bd30c76..23d9a52c339 100644 --- a/devlog/_plan/261009_n3_test_infra/010_6775_client_home_isolation.md +++ b/devlog/_fin/261009_n3_test_infra/010_6775_client_home_isolation.md @@ -182,3 +182,14 @@ bun run structure:check plus the issue's probe: plant a generated `ocx-probe.md` in `$FH/.claude/agents`, start Bun with `CLAUDE_CONFIG_DIR` unset, and confirm it survives. + +## Implementation record + +Merged as PR #6835 (`7f642e30e8`). Beyond this doc, review added: guarded served-catalog +invalidation (`src/claude/intercept/cli-catalog.ts`) and the intercept `settings.json` +writer (`src/claude/intercept/settings.ts`); every touched path, including +`cache/gateway-models.json` and each agent definition's temporary file, is passed to the +guard; roots resolve at check time with case folding on macOS and Windows; removal +protection uses the same comparison and keeps the checkout-content lift. The regression file +has eight cases, all failing on `dev` before the change. + diff --git a/devlog/_plan/261009_n3_test_infra/020_6777_combo_fixture.md b/devlog/_fin/261009_n3_test_infra/020_6777_combo_fixture.md similarity index 87% rename from devlog/_plan/261009_n3_test_infra/020_6777_combo_fixture.md rename to devlog/_fin/261009_n3_test_infra/020_6777_combo_fixture.md index 50167fcafca..b51339725ca 100644 --- a/devlog/_plan/261009_n3_test_infra/020_6777_combo_fixture.md +++ b/devlog/_fin/261009_n3_test_infra/020_6777_combo_fixture.md @@ -97,3 +97,14 @@ FH=$(mktemp -d); env -u HTTPS_PROXY -u NODE_EXTRA_CA_CERTS HOME="$FH" USERPROFIL bun test tests/routing/combo-management-api.test.ts ``` + +## Implementation record + +Implemented as written in PR #6833 (branch `codex/n3-combo-rename-fixture`, commit `0fa7ee87df`), +test-only, in `tests/routing/combo-management-api.test.ts` (1,913 lines, under the ratchet cap, so +no sibling file was needed). Before the change the three regressions fail (rename: 3 discovery +calls; serialization: the first owner observes the second's `OPENCODEX_HOME`; poison: the drain +resolves). After: 41/41 in the file under a fresh startup home; independent review also ran it with +CI's `--isolate --timeout 60000` flags (77/77 across four files) and confirmed the timeout probe +drains the owner before the next case. + diff --git a/devlog/_plan/261009_n3_test_infra/030_6776_toggle_ownership_fixture.md b/devlog/_fin/261009_n3_test_infra/030_6776_toggle_ownership_fixture.md similarity index 85% rename from devlog/_plan/261009_n3_test_infra/030_6776_toggle_ownership_fixture.md rename to devlog/_fin/261009_n3_test_infra/030_6776_toggle_ownership_fixture.md index 700834a823f..e1dd36a1577 100644 --- a/devlog/_plan/261009_n3_test_infra/030_6776_toggle_ownership_fixture.md +++ b/devlog/_fin/261009_n3_test_infra/030_6776_toggle_ownership_fixture.md @@ -53,3 +53,12 @@ FH=$(mktemp -d); env HOME="$FH" USERPROFILE="$FH" CLAUDE_CONFIG_DIR="$FH/.claude bun test tests/codex-integration/native-codex-toggle.test.ts ``` + +## Implementation record + +Implemented as written in PR #6834 (branch `codex/n3-codex-toggle-fixture`, commit `c6addbb97e`), +test-only. With a simulated registered service manager the round-trip case fails on dev (12 pass / +1 fail, `service-home` refusal, `state: "absent"`) and passes with the stub (14/14). The new call +assertion fails when the production probe call is bypassed; the spy is restored before fixture +cleanup. + From 3b9fc865fadde5fd31b2c3b8ddf36baaa8b7f81a Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 9 Oct 2026 22:36:19 +0900 Subject: [PATCH 39/59] fix(service): preflight the selected Bun before baking it into Windows launchers (replaces #6695) (#6812) * fix(service): preflight the selected Bun before baking it into Windows launchers Under a Windows application-control policy the same Bun bytes can write the profile from one path and get EPERM from another. A durable launcher that bakes a denied path crash-loops. Before a Windows service install, repair, or Codex shim install/refresh changes anything, run the already selected runtime itself to create and remove a probe entry in the config directory. On failure refuse with OCX_RUNTIME_PREFLIGHT_FAILED and guidance (reinstall through a permitted npm global install, or select a trusted Bun with OPENCODEX_BUN_PATH). No alternate runtime is discovered or persisted. An orchestrated install admits the runtime once before the existing service is stopped and passes that frozen selection to rendering and the install record. Direct installer calls keep their own admission. The preflight never deletes the config root. Shim auto-restore defers with guidance and startup continues. Other platforms are untouched. Co-authored-by: ismell0992-afk * fix(service): explain the probe child's deliberate catch blocks Co-authored-by: ismell0992-afk * test(service): allow the preflight's empty config root during fresh scheduler staging On Windows the runtime preflight runs before staging and may create the absent config root. It never deletes it and leaves it empty, so ownership initialization still claims it; assert emptiness instead of absence. Co-authored-by: ismell0992-afk * fix(service): claim fresh scheduler ownership before runtime preflight Admit the frozen runtime after registration and ownership initialization, before preparation, so fresh installs clear cached ownership refusals and denied probes roll back the new registration. Co-authored-by: ismell0992-afk * docs(service): document the fresh scheduler preflight order and the published package name A first-time scheduler install registers and claims the config root before the runtime check, and a refusal rolls back that registration. The reinstall guidance now names the published @bitkyc08/opencodex package. Co-authored-by: ismell0992-afk * test(service): cover Desktop refusal before runtime admission Keep repair admission after ownership and auth checks, preserve the admitted runtime through install and repair, and verify Desktop refusal never spawns the runtime probe. Co-authored-by: ismell0992-afk * docs(structure): fit the restart and runtime preflight contracts in the service doc budget Both the guarded update restart section (#6808) and the Windows selected-runtime preflight section landed in structure/ops/service-and-sidecars.md. Keep both contracts and reflow them so the doc stays within the 600-line structure budget. Co-authored-by: ismell0992-afk --------- Co-authored-by: ismell0992-afk --- .../docs/fr/troubleshooting/windows-memory.md | 8 +- .../docs/ja/troubleshooting/windows-memory.md | 9 +- .../docs/ko/troubleshooting/windows-memory.md | 8 +- .../docs/ru/troubleshooting/windows-memory.md | 13 +- .../docs/tr/troubleshooting/windows-memory.md | 15 +- .../docs/troubleshooting/windows-memory.md | 14 +- .../zh-cn/troubleshooting/windows-memory.md | 8 +- .../zh-tw/troubleshooting/windows-memory.md | 8 +- scripts/test-layout/layout.json | 3 + src/codex/shim.ts | 64 ++-- src/lib/bun-runtime-preflight.ts | 140 +++++++++ src/lib/winsw.ts | 5 +- src/service/orchestration.ts | 46 +-- src/service/repair.ts | 25 +- src/service/state.ts | 7 +- src/service/windows-ops.ts | 83 ++++-- structure/ops/service-and-sidecars.md | 24 +- structure/runtime.md | 2 +- .../codex-shim-runtime-preflight.test.ts | 115 +++++++ tests/fixtures/test-layout-expected.json | 3 + tests/lib/bun-runtime-preflight.test.ts | 228 ++++++++++++++ .../service-desktop-runtime-preflight.test.ts | 116 +++++++ .../service-ownership-handover.test.ts | 7 +- .../service/service-runtime-preflight.test.ts | 282 ++++++++++++++++++ tests/service/service.test.ts | 14 +- 25 files changed, 1125 insertions(+), 122 deletions(-) create mode 100644 src/lib/bun-runtime-preflight.ts create mode 100644 tests/codex-integration/codex-shim-runtime-preflight.test.ts create mode 100644 tests/lib/bun-runtime-preflight.test.ts create mode 100644 tests/service/service-desktop-runtime-preflight.test.ts create mode 100644 tests/service/service-runtime-preflight.test.ts diff --git a/docs-site/src/content/docs/fr/troubleshooting/windows-memory.md b/docs-site/src/content/docs/fr/troubleshooting/windows-memory.md index 38fd2b99294..cae4dce2521 100644 --- a/docs-site/src/content/docs/fr/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/fr/troubleshooting/windows-memory.md @@ -34,8 +34,14 @@ Le redémarrage automatique fondé sur un seuil n’est délibérément **pas** 1. **Attendre une mise à jour du runtime intégré.** Dès qu’une version de Bun inclura les correctifs de manière vérifiable, opencodex mettra à niveau son runtime intégré et activera automatiquement le chemin de flux plus sûr sous Windows (macOS continuera d’exiger l’activation explicite ci-dessous). -2. **Utiliser un runtime Bun de confiance avec `OPENCODEX_BUN_PATH`.** Cette configuration n’est pas validée : vous exécutez opencodex avec un runtime que nous n’avons pas testé, à vos risques et périls. Point important pour les installations en tant que service : la valeur de remplacement est lue **lors de la génération de l’artefact du service**, et non au démarrage de celui-ci. Définissez la variable d’environnement, puis réexécutez `ocx service repair` depuis le même shell afin d’inscrire ce chemin dans la définition persistante du service. Définir uniquement la variable d’environnement ne change rien pour un service déjà installé. +2. **Utilisez un runtime Bun de confiance avec `OPENCODEX_BUN_PATH`.** Définissez la variable avant de lancer `ocx` dans le même shell, puis exécutez `ocx service repair` pour enregistrer le runtime sélectionné dans la définition du service. Un `.env` de projet chargé ensuite ne modifie pas ce choix. Changer uniquement la variable ne met pas à jour un service installé. 3. **Activer le relais borné avec `streamMode: "eager-relay"`.** Deux méthodes sont possibles : modifiez `config.json` (ajoutez `"streamMode": "eager-relay"`) ou appelez l’API de gestion — une requête `PUT /api/settings` avec `{"streamMode":"eager-relay"}` s’applique aux nouveaux tours sans redémarrage. **Avertissement relatif au risque de plantage :** avec Bun 1.3.14, ce réglage utilise la forme de flux concernée par #32111, qui peut faire planter le processus en cours de diffusion (sur tous les systèmes d’exploitation, pas uniquement Windows). Le gestionnaire de services le redémarrera, mais les requêtes en cours échoueront. `"legacy-tee"` impose le mode par défaut actuel. Sous Windows, `"auto"` (valeur par défaut) laisse le runtime décider. Sous macOS, `"auto"` conserve toujours le mode tee ; `"eager-relay"` constitue l’activation explicite. Si vous essayez l’une de ces solutions avec une charge Windows réelle, veuillez publier les sections de mémoire de `ocx doctor` avant et après dans [#314](https://github.com/lidge-jun/opencodex/issues/314) — c’est précisément la vérification attendue pour cette mesure d’atténuation. + +## Refus d’écriture du runtime sélectionné sous Windows + +L’installation et la réparation du service Windows ainsi que l’installation et l’actualisation du shim Codex vérifient que l’exécutable sélectionné peut créer puis supprimer un répertoire dans la racine de configuration OpenCodex. Ce contrôle de politique d’écriture ne corrige pas la mémoire. Un échec refuse l’opération avant tout arrêt, téléchargement, préparation ou écriture de jeton ou de lanceur. La réparation automatique du shim est différée avec des instructions et le démarrage continue. Les shims sains ou désactivés ne sont pas testés. Aucun autre runtime n’est recherché. Une première installation du planificateur enregistre sa tâche et revendique la racine de configuration avant ce contrôle ; en cas de refus, cet enregistrement est annulé et la racine revendiquée est conservée pour une nouvelle tentative. + +> Le runtime Bun sélectionné ne peut pas créer puis supprimer un répertoire de configuration. La politique d’application Windows peut interdire les écritures depuis cet emplacement. Avant de lancer `ocx`, définissez `OPENCODEX_BUN_PATH` vers un exécutable Bun de confiance autorisé par votre politique, ou réinstallez avec `npm install -g @bitkyc08/opencodex`, puis réessayez. Aucun autre runtime n’a été sélectionné. diff --git a/docs-site/src/content/docs/ja/troubleshooting/windows-memory.md b/docs-site/src/content/docs/ja/troubleshooting/windows-memory.md index 6d25a176336..774933a39f3 100644 --- a/docs-site/src/content/docs/ja/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/ja/troubleshooting/windows-memory.md @@ -39,10 +39,15 @@ Windows では、#32111 クラッシュを回避するために、opencodex は 1. **バンドルされたランタイム更新を待ちます。** Bun が検証可能にリリースされたら 修正が適用され、opencodex はバンドルされたランタイムを強化し、より安全なストリーム パスが Windows 上で自動的にオンになります (macOS では引き続き以下の明示的なオプトインが必要です)。 -2. **`OPENCODEX_BUN_PATH` を使用して信頼できる Bun ランタイムを実行します。** これは -未検証の領域 — 私たちがテストしていないランタイムで opencodex を実行しています。自己責任で。サービスのインストールにとって重要: オーバーライドは、サービスの開始時ではなく、**サービス アーティファクトの生成時に**読み込まれます。環境変数を設定し、同じシェルから `ocx service repair` を再実行すると、パスが永続サービス定義に組み込まれます。 env を設定するだけでは、すでにインストールされているサービスには何も影響しません。 +2. **`OPENCODEX_BUN_PATH` で信頼できる Bun ランタイムを使用します。** 同じシェルで `ocx` を起動する前に変数を設定し、`ocx service repair` を実行すると選択したランタイムがサービス定義に保存されます。後から読み込むプロジェクトの `.env` は選択を変更しません。環境変数の変更だけでは既存サービスは更新されません。 3. **`streamMode: "eager-relay"` を使用して有界リレーにオプトインします。** 2 つの方法: `config.json` を編集する (`"streamMode": "eager-relay"` を追加する) か、管理 API を呼び出します。`PUT /api/settings` と `{"streamMode":"eager-relay"}` は、再起動せずに新しいターンに適用されます。 **クラッシュのリスク警告:** Bun 1.3.14 では、#32111 の影響を受けるストリーム形状が使用されており、ストリームの途中でプロセスがクラッシュする可能性があります (Windows に限らず、どの OS でも)。サービス マネージャーはサービスを再起動しますが、実行中のリクエストは失敗します。 `"legacy-tee"` は現在のデフォルトを固定します。 Windows では、`"auto"` (デフォルト) によりランタイム ゲートが決定します。 macOS では、`"auto"` は常に T 上にあります。明示的な `"eager-relay"` はオプトインです。 これらのいずれかを実際の Windows ワークロードで試した場合は、[#314](https://github.com/lidge-jun/opencodex/issues/314) の `ocx doctor` メモリ セクションの前後を報告してください。これがまさにこの軽減策が待っている検証です。 + +## Windows の選択ランタイムの書き込み拒否 + +Windows のサービスのインストール・修復と Codex shim のインストール・更新は、選択した実行ファイルが OpenCodex 設定ルート内でディレクトリを作成・削除できるか確認します。これはメモリ修正ではなく書き込みポリシーの確認です。失敗するとサービス停止、ダウンロード、ステージング、トークンやランチャーの書き込み前に拒否します。shim の自動修復は案内付きで延期され、起動は続行します。正常または無効な shim は検査しません。別のランタイムを自動探索しません。 初回のスケジューラーインストールでは、この確認の前にタスクを登録し設定ルートを確保します。拒否された場合は新しい登録を取り消し、確保したルートは再試行用に残します。 + +> 選択した Bun ランタイムが設定ディレクトリ内でディレクトリを作成・削除できませんでした。Windows のアプリケーションポリシーがこの実行ファイルの位置からの書き込みを拒否している可能性があります。`ocx` 起動前にポリシーが許可する信頼できる Bun のパスを `OPENCODEX_BUN_PATH` に設定するか、`npm install -g @bitkyc08/opencodex` で再インストールして再試行してください。別のランタイムは選択していません。 diff --git a/docs-site/src/content/docs/ko/troubleshooting/windows-memory.md b/docs-site/src/content/docs/ko/troubleshooting/windows-memory.md index 3feb6b682a3..1534ef83f4d 100644 --- a/docs-site/src/content/docs/ko/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/ko/troubleshooting/windows-memory.md @@ -34,8 +34,14 @@ Windows에서는 opencodex가 #32111 충돌을 피하기 위해 스트리밍 응 1. **번들된 런타임 업데이트를 기다립니다.** Bun 릴리스가 수정 사항을 실제로 포함함이 확인되면 opencodex가 번들 런타임을 올리고, Windows에서는 더 안전한 스트림 경로가 자동으로 켜집니다(macOS는 아래의 명시적 opt-in을 계속 요구합니다). -2. **`OPENCODEX_BUN_PATH`로 신뢰하는 Bun 런타임을 사용합니다.** 이 경로는 검증되지 않은 영역입니다. opencodex를 아직 테스트하지 않은 런타임에서 실행하는 것이므로, 위험은 사용자에게 있습니다. 서비스 설치에서 특히 중요한 점은 이 override가 서비스 시작 시가 아니라 **서비스 아티팩트를 생성할 때** 읽힌다는 것입니다. 환경 변수를 설정한 뒤, 같은 셸에서 `ocx service repair`를 다시 실행해야 경로가 영구적인 서비스 정의에 반영됩니다. 환경 변수만 설정하면 이미 설치된 서비스에는 아무 영향이 없습니다. +2. **`OPENCODEX_BUN_PATH`로 신뢰하는 Bun 런타임을 사용합니다.** 환경 변수를 같은 셸에서 `ocx`를 실행하기 전에 설정하고 `ocx service repair`를 실행하면 선택한 런타임이 서비스 정의에 반영됩니다. 나중에 읽은 프로젝트 `.env`는 선택을 바꾸지 않습니다. 환경 변수만 바꾸면 기존 서비스는 갱신되지 않습니다. 3. **`streamMode: "eager-relay"`로 bounded relay를 opt-in합니다.** 방법은 두 가지입니다. `config.json`을 수정해 `"streamMode": "eager-relay"`를 추가하거나, 관리 API에 `PUT /api/settings`와 `{"streamMode":"eager-relay"}`를 보내 새 턴에 재시작 없이 적용합니다. **충돌 위험 경고:** Bun 1.3.14에서는 이 방식이 #32111의 영향을 받는 스트림 형태를 사용하므로, 어떤 OS에서든 스트림 중간에 프로세스가 충돌할 수 있습니다. 서비스 관리자가 다시 시작하겠지만, 진행 중이던 요청은 실패합니다. `"legacy-tee"`는 현재 기본값을 고정합니다. Windows에서는 `"auto"`(기본값)가 런타임 게이트 판단을 따르게 합니다. macOS에서는 `"auto"`가 항상 tee를 유지하고, 명시적 `"eager-relay"`만 opt-in입니다. 이 중 어떤 방법이든 실제 Windows 워크로드에 적용해 보셨다면, 변경 전후의 `ocx doctor` 메모리 섹션을 [#314](https://github.com/lidge-jun/opencodex/issues/314)에 남겨 주세요. 이것이 바로 이 완화책이 기다리고 있는 검증입니다. + +## Windows 선택 런타임 쓰기 검사 실패 + +Windows 서비스 설치·복구와 Codex shim 설치·갱신은 선택한 실행 파일이 OpenCodex 설정 루트 안에 디렉터리를 만들고 제거할 수 있는지 검사합니다. 메모리 수정이 아닌 쓰기 정책 검사입니다. 실패하면 서비스 중지, 다운로드, 스테이징, 토큰·실행 파일 쓰기 전에 거부합니다. shim 자동 복구는 안내와 함께 지연되며 시작은 계속됩니다. 정상 상태 또는 비활성화된 shim은 검사하지 않습니다. 다른 런타임을 자동으로 찾지 않습니다. 처음 하는 스케줄러 설치는 이 검사 전에 작업을 등록하고 설정 루트를 확보합니다. 거부되면 새 등록은 되돌리고, 확보한 루트는 재시도를 위해 남겨 둡니다. + +> 선택한 Bun 런타임이 OpenCodex 설정 디렉터리 안에 디렉터리를 만들고 제거하지 못했습니다. Windows 애플리케이션 정책이 이 실행 파일 위치의 쓰기를 차단할 수 있습니다. `ocx` 실행 전에 정책이 허용하는 신뢰할 수 있는 Bun 실행 파일로 `OPENCODEX_BUN_PATH`를 설정하거나, `npm install -g @bitkyc08/opencodex`로 다시 설치한 뒤 재시도하세요. 다른 런타임은 선택하지 않았습니다. diff --git a/docs-site/src/content/docs/ru/troubleshooting/windows-memory.md b/docs-site/src/content/docs/ru/troubleshooting/windows-memory.md index 7dd47d8da15..67a7ea9c7c7 100644 --- a/docs-site/src/content/docs/ru/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/ru/troubleshooting/windows-memory.md @@ -70,12 +70,7 @@ opencodex поставляет рантайм Bun (сейчас это **1.3.14* эти исправления, opencodex обновит bundled runtime, и более безопасный stream path автоматически включится на Windows (на macOS он всё равно останется только явным opt-in). -2. **Запустить Bun, которому вы доверяете, через `OPENCODEX_BUN_PATH`.** Это непроверенная - территория — вы запускаете opencodex на рантайме, который мы не тестировали, на свой риск. - Важно для service-установок: override считывается **при генерации артефакта службы**, а не при - её старте. Задайте переменную окружения и заново выполните `ocx service repair` из той же - оболочки, чтобы путь оказался зашит в долговременное определение службы. Одной только - переменной для уже установленной службы недостаточно. +2. **Используйте доверенную среду Bun через `OPENCODEX_BUN_PATH`.** Установите переменную до запуска `ocx` в той же оболочке, затем выполните `ocx service repair`, чтобы сохранить выбранную среду в определении службы. Загруженный позже файл проекта `.env` не меняет выбор. Изменение только переменной не обновляет установленную службу. 3. **Явно перейти на bounded relay через `streamMode: "eager-relay"`.** Есть два пути: отредактировать `config.json` (добавить `"streamMode": "eager-relay"`) или вызвать management @@ -91,3 +86,9 @@ opencodex поставляет рантайм Bun (сейчас это **1.3.14* разделы памяти из `ocx doctor` до и после в [#314](https://github.com/lidge-jun/opencodex/issues/314) — именно такой верификации эта мера и ждёт. + +## Отказ записи выбранной среды в Windows + +Установка и восстановление службы Windows, а также установка и обновление shim Codex проверяют, может ли выбранный исполняемый файл создать и удалить каталог внутри корня конфигурации OpenCodex. Это проверка политики записи, а не исправление памяти. При неудаче операция отклоняется до остановки службы, загрузки, подготовки или записи токена и запускающего файла. Автовосстановление shim откладывается с рекомендацией, запуск продолжается. Исправный или отключённый shim не проверяется. Поиск другой среды не выполняется. При первой установке через планировщик задача регистрируется и корень конфигурации закрепляется до этой проверки; при отказе новая регистрация откатывается, а закреплённый корень остаётся для повторной попытки. + +> Выбранная среда Bun не смогла создать и удалить каталог конфигурации. Политика приложений Windows может запрещать запись из этого расположения. До запуска `ocx` задайте `OPENCODEX_BUN_PATH` на доверенный исполняемый файл Bun, разрешённый политикой, либо переустановите через `npm install -g @bitkyc08/opencodex` и повторите попытку. Другая среда не выбиралась. diff --git a/docs-site/src/content/docs/tr/troubleshooting/windows-memory.md b/docs-site/src/content/docs/tr/troubleshooting/windows-memory.md index dee7fcdc31d..260b35816b8 100644 --- a/docs-site/src/content/docs/tr/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/tr/troubleshooting/windows-memory.md @@ -86,14 +86,7 @@ onu zaten yeniden başlatır. zamanını yükseltecektir ve daha güvenli akış yolu Windows'ta otomatik olarak açılacaktır (macOS aşağıdaki açık katılımı gerektirmeye devam eder). -2. **`OPENCODEX_BUN_PATH` ile güvendiğiniz bir Bun çalışma zamanını - çalıştırın.** Bu doğrulanmamış bir alandır — opencodex'i test etmediğimiz bir - çalışma zamanında kendi sorumluluğunuzda çalıştırıyorsunuz. Servis - yüklemeleri için önemli: geçersiz kılma servis başlangıcında değil, **servis - varlığı oluşturulduğunda** okunur. Ortam değişkenini ayarlayın, ardından - yolun dayanıklı servis tanımına yerleştirilmesi için aynı kabuktan `ocx - service repair`'ı yeniden çalıştırın. Ortamı tek başına ayarlamak zaten - kurulu bir servis için hiçbir şey yapmaz. +2. **`OPENCODEX_BUN_PATH` ile güvendiğiniz bir Bun çalışma zamanı kullanın.** Aynı kabukta `ocx` başlatılmadan önce değişkeni ayarlayın, ardından seçilen çalışma zamanını hizmet tanımına yazmak için `ocx service repair` çalıştırın. Sonradan yüklenen proje `.env` dosyası seçimi değiştirmez. Yalnızca ortam değişkenini değiştirmek kurulu hizmeti güncellemez. 3. **`streamMode: "eager-relay"` ile sınırlı aktarıma katılın.** İki yol vardır: `config.json` dosyasını düzenleyin (`"streamMode": "eager-relay"` ekleyin) @@ -113,3 +106,9 @@ sonraki `ocx doctor` bellek bölümlerini bildirin — bu azaltmanın beklediği doğrulama tam olarak budur. + +## Windows seçilen çalışma zamanı yazma reddi + +Windows hizmet kurulumu ve onarımı ile Codex shim kurulumu ve yenilemesi, seçilen yürütülebilir dosyanın OpenCodex yapılandırma kökünde bir dizin oluşturup kaldırabildiğini denetler. Bu bir yazma politikası denetimidir; bellek düzeltmesi değildir. Başarısızlık hizmet durdurma, indirme, hazırlama, belirteç veya başlatıcı yazma işlemlerinden önce reddedilir. Otomatik shim onarımı yönlendirmeyle ertelenir ve başlangıç devam eder. Sağlıklı veya devre dışı shim denetlenmez. Başka çalışma zamanı aranmaz. İlk zamanlayıcı kurulumu bu denetimden önce görevini kaydeder ve yapılandırma kökünü sahiplenir; ret durumunda yeni kayıt geri alınır ve sahiplenilen kök yeniden deneme için kalır. + +> Seçilen Bun çalışma zamanı yapılandırma dizininde bir dizin oluşturup kaldıramadı. Windows uygulama politikası bu yürütülebilir konumundan yazmayı engelleyebilir. `ocx` başlatılmadan önce `OPENCODEX_BUN_PATH` değerini politikanızın izin verdiği güvenilir Bun yürütülebilir dosyasına ayarlayın veya `npm install -g @bitkyc08/opencodex` ile yeniden kurup tekrar deneyin. Başka çalışma zamanı seçilmedi. diff --git a/docs-site/src/content/docs/troubleshooting/windows-memory.md b/docs-site/src/content/docs/troubleshooting/windows-memory.md index f27aaea4cde..51ad6b0ea90 100644 --- a/docs-site/src/content/docs/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/troubleshooting/windows-memory.md @@ -116,13 +116,7 @@ restart it. stream path turns on automatically on Windows (macOS keeps requiring the explicit opt-in below). -2. **Run a Bun runtime you trust with `OPENCODEX_BUN_PATH`.** This is - unvalidated territory — you are running opencodex on a runtime we have not - tested; at your own risk. Important for service installs: the override is - read **when the service artifact is generated**, not at service start. Set - the environment variable, then re-run `ocx service repair` from that same - shell so the path is baked into the durable service definition. Setting - the env alone does nothing for an already-installed service. +2. **Run a Bun runtime you trust with `OPENCODEX_BUN_PATH`.** Set the variable before launching `ocx` from that shell, then run `ocx service repair` to bake the selected runtime into the service definition. A later project `.env` does not change selection; changing the environment alone does not update an installed service. 3. **Opt into the bounded relay with `streamMode: "eager-relay"`.** Two ways: edit `config.json` (add `"streamMode": "eager-relay"`), or call the @@ -138,3 +132,9 @@ If you try any of these on a real Windows workload, please report the before and after `ocx doctor` memory sections on [#314](https://github.com/lidge-jun/opencodex/issues/314) — that is exactly the verification this mitigation is waiting on. + +## Windows selected-runtime write refusal + +Windows service installation, repair and Codex shim installation/refresh check whether the selected executable can create and remove a directory inside the OpenCodex config root. This is a write-policy check, not a memory fix. Failure refuses before service stops, downloads, staging, token or launcher writes. Automatic shim repair is deferred with guidance and startup continues. Healthy or disabled shims do not probe. No alternate runtime is discovered. A first-time scheduler install registers its task and claims the config root before this check; on refusal that new registration is rolled back and the claimed root stays for a retry. + +> The selected Bun runtime could not create and remove a directory in the OpenCodex config directory. Windows application policy may deny writes from this executable location. Set OPENCODEX_BUN_PATH to a trusted Bun executable allowed by your policy before launching ocx, or reinstall opencodex with npm install -g @bitkyc08/opencodex, then retry. No alternate runtime was selected. diff --git a/docs-site/src/content/docs/zh-cn/troubleshooting/windows-memory.md b/docs-site/src/content/docs/zh-cn/troubleshooting/windows-memory.md index 7734ebe23da..b77e5f2d7c1 100644 --- a/docs-site/src/content/docs/zh-cn/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/zh-cn/troubleshooting/windows-memory.md @@ -34,8 +34,14 @@ opencodex 打包了 Bun 运行时(当前为 **1.3.14**)。这类内存增长 1. **等待捆绑运行时更新。** 一旦某个 Bun 版本可验证地包含这些修复,opencodex 就会升级捆绑运行时,并在 Windows 上自动启用更安全的流路径(macOS 仍然需要下面的显式启用)。 -2. **通过 `OPENCODEX_BUN_PATH` 运行你信任的 Bun 运行时。** 这属于未验证区域,你是在一个我们没有测试过的运行时上运行 opencodex,风险自负。对服务安装而言,这个覆盖值是在生成服务产物时读取的,而不是在服务启动时读取的。先设置环境变量,然后在同一个 shell 中重新运行 `ocx service repair`,这样路径才会被写入持久化的服务定义。只设置环境变量对已经安装好的服务没有任何作用。 +2. **通过 `OPENCODEX_BUN_PATH` 使用可信的 Bun 运行时。** 在同一 shell 启动 `ocx` 前设置变量,再运行 `ocx service repair` 将所选运行时写入服务定义。之后加载的项目 `.env` 不会改变选择。仅修改环境变量不会更新已安装的服务。 3. **通过 `streamMode: "eager-relay"` 显式启用有界中继。** 有两种方式:编辑 `config.json`(添加 `"streamMode": "eager-relay"`),或调用管理 API - `PUT /api/settings` 携带 `{"streamMode":"eager-relay"}`,即可对新轮次生效,无需重启。**崩溃风险警告:** 在 Bun 1.3.14 上,这会使用受 #32111 影响的流形态,可能在流中途使进程崩溃(任何操作系统都会受影响,不只是 Windows)。服务管理器会把它重启,但正在进行的请求会失败。`"legacy-tee"` 会固定在当前默认路径。Windows 上,`"auto"`(默认值)会交给运行时门控决定。macOS 上,`"auto"` 始终保持 tee;显式 `"eager-relay"` 才是显式启用选项。 如果你在真实的 Windows 工作负载上尝试这些方案,请把变更前后 `ocx doctor` 的内存部分发到 [#314](https://github.com/lidge-jun/opencodex/issues/314)——这正是这个缓解措施在等待的验证。 + +## Windows 所选运行时写入被拒绝 + +Windows 服务安装、修复及 Codex shim 安装、刷新会检查所选可执行文件能否在 OpenCodex 配置根目录内创建并删除目录。这是写入策略检查,并非内存修复。失败时会在停止服务、下载、暂存以及写入令牌或启动器前拒绝操作。shim 自动修复会延后并显示指引,启动继续。正常或禁用的 shim 不会检查。不自动寻找其他运行时。 首次计划任务安装会在此检查前注册任务并认领配置根目录;被拒绝时会回滚新注册,已认领的根目录保留以便重试。 + +> 所选 Bun 运行时无法在配置目录内创建并删除目录。Windows 应用策略可能禁止来自此可执行文件位置的写入。在启动 `ocx` 前,将 `OPENCODEX_BUN_PATH` 设置为策略允许的可信 Bun 可执行文件,或通过 `npm install -g @bitkyc08/opencodex` 重新安装后重试。未选择其他运行时。 diff --git a/docs-site/src/content/docs/zh-tw/troubleshooting/windows-memory.md b/docs-site/src/content/docs/zh-tw/troubleshooting/windows-memory.md index 3467ff099f2..58f1d32c023 100644 --- a/docs-site/src/content/docs/zh-tw/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/zh-tw/troubleshooting/windows-memory.md @@ -34,8 +34,14 @@ opencodex 內嵌 Bun runtime(目前為 **1.3.14**)。記憶體成長由已 1. **等待內嵌 runtime 更新。** 一旦某個 Bun 發行版可驗證承載這些修復,opencodex 會升級內嵌 runtime,並自動啟用較安全的串流路徑。 -2. **以 `OPENCODEX_BUN_PATH` 執行你信任的 Bun runtime。** 這是未驗證領域——你是在我們尚未測試的 runtime 上執行 opencodex;風險自負。對服務安裝很重要:覆寫是在**服務產物產生時**讀取,而非服務啟動時。請設定環境變數,然後從同一個 shell 重新執行 `ocx service repair`,讓路徑寫入持久的服務定義。僅設定環境變數對已安裝的服務無效。 +2. **以 `OPENCODEX_BUN_PATH` 使用可信的 Bun runtime。** 在同一 shell 啟動 `ocx` 前設定變數,再執行 `ocx service repair` 將所選 runtime 寫入服務定義。之後載入的專案 `.env` 不會改變選擇。僅修改環境變數不會更新已安裝的服務。 3. **以 `streamMode: "eager-relay"` 選擇加入有界 relay。** 兩種方式:編輯 `config.json`(加入 `"streamMode": "eager-relay"`),或呼叫管理 API——`PUT /api/settings` 搭配 `{"streamMode":"eager-relay"}` 會套用到新回合且無需重啟。**當機風險警告:** 在 Bun 1.3.14 上,這會使用受 #32111 影響的串流形態,可能在串流中途讓程序當機(任何 OS,不限 Windows)。服務管理員會重啟它,但進行中的請求會失敗。`"legacy-tee"` 會釘住目前預設;`"auto"`(預設)讓 runtime 閘門決定。 若你在真實 Windows 工作負載上嘗試上述任一作法,請在 [#314](https://github.com/lidge-jun/opencodex/issues/314) 回報前後的 `ocx doctor` 記憶體區段——這正是此緩解等待的驗證。 + +## Windows 所選 runtime 寫入被拒絕 + +Windows 服務安裝、修復及 Codex shim 安裝、更新會檢查所選執行檔能否在 OpenCodex 設定根目錄內建立並移除目錄。這是寫入政策檢查,而非記憶體修復。失敗時會在停止服務、下載、暫存以及寫入權杖或啟動器前拒絕操作。shim 自動修復會延後並顯示指引,啟動繼續。正常或停用的 shim 不會檢查。不自動尋找其他 runtime。 首次排程安裝會在此檢查前登錄工作並認領設定根目錄;被拒絕時會復原新登錄,已認領的根目錄保留以便重試。 + +> 所選 Bun runtime 無法在設定目錄內建立並移除目錄。Windows 應用程式政策可能禁止來自此執行檔位置的寫入。在啟動 `ocx` 前,將 `OPENCODEX_BUN_PATH` 設為政策允許的可信 Bun 執行檔,或以 `npm install -g @bitkyc08/opencodex` 重新安裝後重試。未選擇其他 runtime。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index bb3d0bce73f..04ebce80766 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -11,6 +11,7 @@ "oauth-anthropic-instance-health.test.ts": "oauth", "anthropic2-responses-parity.test.ts": "responses", "oauth-anthropic-instance-multiprocess.test.ts": "oauth", "anthropic2-discovery-ownership.test.ts": "oauth", + "codex-shim-runtime-preflight.test.ts": "codex-integration", "cli-anthropic2-account.test.ts": "cli", "anthropic2-catalog.test.ts": "codex-integration", "provider-anthropic-instance-quota.test.ts": "providers", "anthropic2-management.test.ts": "server", "management-sidecar-anthropic-instance.test.ts": "server", "anthropic2-usage-attribution.test.ts": "usage", "vision-anthropic-instance-sidecar.test.ts": "vision", "web-search-anthropic-instance.test.ts": "web-search", "provider-anthropic-instance.test.ts": "providers", "config-anthropic-instance-pool.test.ts": "config", "oauth-anthropic-instance-registration.test.ts": "oauth", "oauth-anthropic-instance-refresh.test.ts": "oauth", @@ -80,6 +81,7 @@ "codex-pool-credit-policy.test.ts": "codex-integration", "codex-sidecar-credit-policy.test.ts": "codex-integration", "codex-credits-after-limit-main.test.ts": "codex-integration", + "service-runtime-preflight.test.ts": "service", "service-desktop-runtime-preflight.test.ts": "service", "service-desktop-startup-health.test.ts": "service", "service-desktop-startup-linux.test.ts": "service", "service-desktop-startup.test.ts": "service", "startup-health-packaged-probe.test.ts": "server", "discovered-native-models.test.ts": "codex-integration", @@ -90,6 +92,7 @@ "codex-quota-query-backoff.test.ts": "codex-integration", "pnpm-command-isolation.test.ts": "update", "provider-antigravity-quota-retry.test.ts": "providers", "project-config-warning-snapshot.test.ts": "codex-integration", "codex-quota-auto-refresh-generation.test.ts": "codex-integration", "codex-account-clear-paused.test.ts": "codex-integration", "low-quota-protection.test.ts": "codex-integration", + "bun-runtime-preflight.test.ts": "lib", "responses-compaction-recovery.test.ts": "responses", "responses-canonical-nonstream.test.ts": "responses", "compaction-recovery-settings.test.ts": "config", "responses-compaction-recovery-policy.test.ts": "responses", "plugin-loader.test.ts": "lib", "plugin-upstream-hooks.test.ts": "lib", "cli-kiro-auto-selection.test.ts": "cli", "codebuddy-live-models.test.ts": "providers", "kiro-auto-selection.test.ts": "providers/kiro", "kiro-quota-metrics.test.ts": "providers/kiro", "management-provider-request-pacing.test.ts": "server", "desktop-supervised-restart.test.ts": "clients", "cli-restart-handoff.test.ts": "cli", diff --git a/src/codex/shim.ts b/src/codex/shim.ts index 25638f231a9..9003db306ac 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -9,7 +9,9 @@ import { writeFileSync, } from "node:fs"; import { basename, delimiter, dirname, extname, join, posix, win32 } from "node:path"; -import { durableBunRuntime } from "../lib/bun-runtime"; +import { durableBunRuntime, type DurableBunRuntime } from "../lib/bun-runtime"; +import { assertSelectedRuntimeWritable, RuntimePreflightError, type RuntimePreflightDeps } from "../lib/bun-runtime-preflight"; +import { getConfigDir } from "../config"; import type { BunRuntimeSource } from "../lib/bun-runtime"; import { serviceApiTokenFilePath } from "../lib/service-secrets"; import { isWslRuntime, wslAutomountRoot } from "./home"; @@ -83,7 +85,9 @@ export function lastCodexDiscoveryError(): string | null { return lastShimDiscoveryError; } -interface InstallCodexShimInternalOptions { +interface InstallCodexShimInternalOptions extends RuntimePreflightDeps { + runtime?: DurableBunRuntime; + runtimeAdmitted?: boolean; expectedReplacements?: ReadonlyMap; allowFreshInstall: boolean; beforeGuardedRefresh?: (wrapperPath: string, index: number) => void; @@ -105,12 +109,11 @@ export type CodexShimAutoRestoreResult = | { status: "ineligible" | "deferred"; message?: string } | { status: "restored"; message: string }; -function cliEntry(): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: string } { +function cliEntry(runtime: DurableBunRuntime = durableBunRuntime()): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: string } { // Bundled Bun path (survives `ocx update`); all three shim builders // (Unix / Windows cmd / Windows PowerShell) receive it via this entry. // This module lives in src/codex/, the CLI entry in src/cli/index.ts. // Path and provenance resolve together so the marker always describes this binary. - const runtime = durableBunRuntime(); return { bun: runtime.path, bunRuntimeSource: runtime.source, cli: join(import.meta.dir, "..", "cli", "index.ts") }; } @@ -345,8 +348,8 @@ function rollbackFreshShimInstall(journal: readonly FreshShimInstallJournalEntry * updater's wrapper can carry them, and a replacement landing between the write * and the observation is otherwise indistinguishable from our own file. */ -function writeShim(wrapperPath: string, realCodexPath: string): { dev: number; ino: number } | undefined { - const { bun, bunRuntimeSource, cli } = cliEntry(); +function writeShim(wrapperPath: string, realCodexPath: string, runtime?: DurableBunRuntime): { dev: number; ino: number } | undefined { + const { bun, bunRuntimeSource, cli } = cliEntry(runtime); if (process.platform === "win32") { const lower = wrapperPath.toLowerCase(); if (lower.endsWith(".ps1")) { @@ -458,7 +461,7 @@ function replaceOwnedBackup(sourcePath: string, backupPath: string): void { } } -function refreshShimFile(file: ShimFileState): boolean { +function refreshShimFile(file: ShimFileState, runtime: DurableBunRuntime): boolean { if (file.preserveOnly) { if (existsSync(file.originalPath) && !isShim(file.originalPath)) { replaceOwnedBackup(file.originalPath, file.backupPath); @@ -474,10 +477,10 @@ function refreshShimFile(file: ShimFileState): boolean { file, expectedReplacement: replacement.fingerprint, sourcePath: file.wrapperPath, - }]); + }], undefined, undefined, runtime); } if (!existsSync(file.wrapperPath) && existsSync(file.backupPath)) { - writeShim(file.wrapperPath, file.realPath ?? file.backupPath); + writeShim(file.wrapperPath, file.realPath ?? file.backupPath, runtime); const writtenWrapper = stableShimPathProbe(file.wrapperPath); if (!writtenWrapper || !writtenWrapper.prefix.includes(SHIM_MARKER)) { return false; @@ -503,7 +506,7 @@ function refreshShimFile(file: ShimFileState): boolean { } if (file.originalPath !== file.wrapperPath && existsSync(file.originalPath) && existsSync(file.wrapperPath) && isShim(file.wrapperPath)) { replaceOwnedBackup(file.originalPath, file.backupPath); - writeShim(file.wrapperPath, file.realPath ?? file.backupPath); + writeShim(file.wrapperPath, file.realPath ?? file.backupPath, runtime); return true; } return false; @@ -610,6 +613,7 @@ function applyGuardedRefreshTransaction( operations: readonly GuardedRefreshOperation[], beforeGuardedRefresh?: (wrapperPath: string, index: number) => void, commitState?: () => void, + runtime?: DurableBunRuntime, ): boolean { const journal: GuardedRefreshJournalEntry[] = []; let applyError: Error | null = null; @@ -643,7 +647,7 @@ function applyGuardedRefreshTransaction( if (!movedReplacement) throw new Error("Codex shim guarded refresh could not fingerprint the staged launcher"); entry.movedReplacementFingerprint = movedReplacement.fingerprint; entry.wrapperWriteStarted = true; - const writtenInode = writeShim(operation.file.wrapperPath, operation.file.realPath ?? operation.file.backupPath); + const writtenInode = writeShim(operation.file.wrapperPath, operation.file.realPath ?? operation.file.backupPath, runtime); // Claim our own partial write before the hook can fail (see fresh install). entry.writtenWrapperFingerprint = ownedWrapperFingerprint(operation.file.wrapperPath, writtenInode); codexShimGuardedWriteHookForTests?.(); @@ -747,7 +751,7 @@ type ObsoleteUnixShimRefreshResult = | { installed: true; message: string } | { installed: false; deferred: boolean; message: string }; -function refreshObsoleteUnixShims(files: readonly ShimFileState[]): ObsoleteUnixShimRefreshResult { +function refreshObsoleteUnixShims(files: readonly ShimFileState[], runtime?: DurableBunRuntime): ObsoleteUnixShimRefreshResult { if (process.platform === "win32") { return { installed: false, deferred: false, message: "Codex autostart shim is already current." }; } @@ -786,7 +790,7 @@ function refreshObsoleteUnixShims(files: readonly ShimFileState[]): ObsoleteUnix throw new Error("Codex autostart shim upgrade could not fingerprint the staged wrapper"); } entry.wrapperWriteStarted = true; - const writtenInode = writeShim(file.wrapperPath, file.realPath ?? file.backupPath); + const writtenInode = writeShim(file.wrapperPath, file.realPath ?? file.backupPath, runtime); const writtenWrapper = stableShimPathProbe(file.wrapperPath); if (!writtenWrapper || !isCurrentUnixShimProbe(writtenWrapper)) { throw new Error("Codex autostart shim upgrade could not fingerprint the regenerated wrapper"); @@ -876,6 +880,19 @@ function refreshObsoleteUnixShims(files: readonly ShimFileState[]): ObsoleteUnix function installCodexShimInternal(options: InstallCodexShimInternalOptions): { installed: boolean; message: string } { const existing = readState(); + const filesBefore = existing ? stateFiles(existing) : []; + if (filesBefore.length > 0 && filesBefore.every(file => { + if (file.preserveOnly) return existsSync(file.backupPath) && !existsSync(file.originalPath); + const probe = stableShimPathProbe(file.wrapperPath); + return hasUsableBackingPath(file) && probe !== null && isHealthyShimProbe(probe, process.platform); + })) { + return { installed: false, message: "Codex autostart shim is already installed." }; + } + const runtime = options.runtime ?? Object.freeze({ ...(options.selectRuntime ?? durableBunRuntime)() }); + if (!options.runtimeAdmitted) { + const configDir = (options.configDir ?? getConfigDir)(); + (options.assertRuntimeWritable ?? assertSelectedRuntimeWritable)(runtime, configDir, { rootWasAbsent: !existsSync(configDir) }); + } if (existing) { const files = stateFiles(existing); if (!options.expectedReplacements && process.platform !== "win32") { @@ -907,7 +924,7 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i throw writeError; } }; - if (!applyGuardedRefreshTransaction(operations, options.beforeGuardedRefresh, commitState)) { + if (!applyGuardedRefreshTransaction(operations, options.beforeGuardedRefresh, commitState, runtime)) { return { installed: false, message: "Codex shim auto-restore deferred because tracked launchers changed." }; } return { @@ -916,7 +933,7 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i }; } let refreshed = false; - for (const file of files) refreshed = refreshShimFile(file) || refreshed; + for (const file of files) refreshed = refreshShimFile(file, runtime) || refreshed; const allInstalled = files.every(file => file.preserveOnly ? existsSync(file.backupPath) && !existsSync(file.originalPath) : existsSync(file.wrapperPath) @@ -988,7 +1005,7 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i } if (!target.preserveOnly) { entry.wrapperWriteStarted = true; - const writtenInode = writeShim(target.wrapperPath, target.realPath ?? target.backupPath); + const writtenInode = writeShim(target.wrapperPath, target.realPath ?? target.backupPath, runtime); entry.writtenWrapperInode = writtenInode; codexShimFreshWriteHookForTests?.(); if (process.platform !== "win32") { @@ -1061,12 +1078,12 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i }; } -export function installCodexShim(): { installed: boolean; message: string; refused?: boolean; runnable?: boolean } { +export function installCodexShim(deps: RuntimePreflightDeps = {}): { installed: boolean; message: string; refused?: boolean; runnable?: boolean } { if (process.platform !== "win32") return installUnixOverlay(findCodexOnPath); - return installCodexShimInternal({ allowFreshInstall: true }); + return installCodexShimInternal({ ...deps, allowFreshInstall: true }); } -export function autoRestoreCodexShim(options: { +export function autoRestoreCodexShim(options: RuntimePreflightDeps & { enabled: () => boolean; stabilitySleep?: (ms: number) => void; /** Narrow deterministic seam used to hold the interprocess lock in tests. */ @@ -1133,6 +1150,14 @@ export function autoRestoreCodexShim(options: { }; } + const runtime = Object.freeze({ ...(options.selectRuntime ?? durableBunRuntime)() }); + try { + const configDir = (options.configDir ?? getConfigDir)(); + (options.assertRuntimeWritable ?? assertSelectedRuntimeWritable)(runtime, configDir, { rootWasAbsent: !existsSync(configDir) }); + } catch (error) { + if (error instanceof RuntimePreflightError) return { status: "deferred", message: error.message }; + throw error; + } const lock = tryAcquireShimRestoreLock(options.beforeStaleRestoreLockDelete); if (!lock) return { status: "deferred" }; try { @@ -1159,6 +1184,7 @@ export function autoRestoreCodexShim(options: { expectedReplacements.set(path, secondProbe.fingerprint); } const result = installCodexShimInternal({ + runtime, runtimeAdmitted: true, allowFreshInstall: false, expectedReplacements, beforeGuardedRefresh: options.beforeGuardedRefresh, diff --git a/src/lib/bun-runtime-preflight.ts b/src/lib/bun-runtime-preflight.ts new file mode 100644 index 00000000000..19b9ca3ca09 --- /dev/null +++ b/src/lib/bun-runtime-preflight.ts @@ -0,0 +1,140 @@ +import { spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { lstatSync, mkdirSync, readdirSync, rmdirSync } from "node:fs"; +import { join } from "node:path"; +import type { DurableBunRuntime } from "./bun-runtime"; + +export type RuntimePreflightReason = "spawn" | "timeout" | "create" | "remove" | "protocol"; +export const RUNTIME_PREFLIGHT_TIMEOUT_MS = 5_000; +export const RUNTIME_PREFLIGHT_GUIDANCE = "The selected Bun runtime could not create and remove a directory in the OpenCodex config directory. " + + "Windows application policy may deny writes from this executable location. " + + "Set OPENCODEX_BUN_PATH to a trusted Bun executable allowed by your policy before launching ocx, " + + "or reinstall opencodex with npm install -g @bitkyc08/opencodex, then retry. No alternate runtime was selected."; + +export class RuntimePreflightError extends Error { + readonly code = "OCX_RUNTIME_PREFLIGHT_FAILED"; + constructor(readonly reason: RuntimePreflightReason) { + super(`${RUNTIME_PREFLIGHT_GUIDANCE} (preflight: ${reason})`); + this.name = "RuntimePreflightError"; + } +} + +type DirectoryIdentity = { dev: number; ino: number; isDirectory(): boolean; isSymbolicLink(): boolean }; +export interface RuntimePreflightFs { + lstatSync: (path: string) => DirectoryIdentity; + mkdirSync: (path: string, mode: number) => unknown; + rmdirSync: (path: string) => void; + readdirSync: (path: string) => readonly string[]; +} +export interface RuntimePreflightOptions { + platform?: NodeJS.Platform; + rootWasAbsent?: boolean; + spawnSync?: typeof spawnSync; + fs?: RuntimePreflightFs; + nonce?: () => string; +} +/** Caller seams keep one selection through admission, rendering and state publication. */ +export interface RuntimePreflightDeps { + selectRuntime?: () => DurableBunRuntime; + assertRuntimeWritable?: typeof assertSelectedRuntimeWritable; + configDir?: () => string; + platform?: NodeJS.Platform; +} + +const probeFs: RuntimePreflightFs = { lstatSync, mkdirSync, rmdirSync, readdirSync }; + +function sameDirectory(fs: RuntimePreflightFs, path: string, owned: DirectoryIdentity): boolean { + const current = fs.lstatSync(path); + return current.isDirectory() && !current.isSymbolicLink() + && current.dev === owned.dev && current.ino === owned.ino; +} + +function removeOwnedEmptyDirectory(fs: RuntimePreflightFs, path: string, owned: DirectoryIdentity): void { + try { + if (sameDirectory(fs, path, owned) && fs.readdirSync(path).length === 0 + && sameDirectory(fs, path, owned)) fs.rmdirSync(path); + } catch { /* Never delete contents or a replacement; cleanup does not mask refusal. */ } +} + +function prepareRoot(fs: RuntimePreflightFs, root: string, rootWasAbsent?: boolean): void { + try { + if (rootWasAbsent === undefined) { + try { fs.lstatSync(root); rootWasAbsent = false; } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + rootWasAbsent = true; + } + } + if (rootWasAbsent) { + try { + fs.mkdirSync(root, 0o700); // Exclusive, non-recursive: a missing parent refuses. + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + // Use a concurrently created root, subject to the directory check below. + } + } + const current = fs.lstatSync(root); + if (!current.isDirectory() || current.isSymbolicLink()) throw new RuntimePreflightError("create"); + } catch { + throw new RuntimePreflightError("create"); + } +} + +function probeInProcess(fs: RuntimePreflightFs, path: string): void { + let owned: DirectoryIdentity | undefined; + try { + try { fs.mkdirSync(path, 0o700); owned = fs.lstatSync(path); } + catch { throw new RuntimePreflightError("create"); } + try { fs.rmdirSync(path); } + catch { throw new RuntimePreflightError("remove"); } + } finally { + if (owned) removeOwnedEmptyDirectory(fs, path, owned); + } +} + +function probeChild(runtime: DurableBunRuntime, path: string, nonce: string, spawn: typeof spawnSync): void { + // The child emits only this invocation's nonce and a fixed result, never paths/errors. + const script = `const fs = require("node:fs"); const path = ${JSON.stringify(path)}; +let result = "create", owned; +try { fs.mkdirSync(path, 0o700); owned = fs.lstatSync(path); result = "remove"; + fs.rmdirSync(path); result = "ok"; +} catch { /* result already names the failed step; details never leave the child */ } finally { + if (owned) { try { const now = fs.lstatSync(path); + if (now.isDirectory() && !now.isSymbolicLink() && now.dev === owned.dev && now.ino === owned.ino + && fs.readdirSync(path).length === 0) fs.rmdirSync(path); + } catch { /* leave any probe entry that cannot be proven ours */ } } +} +process.stdout.write(${JSON.stringify(nonce)} + ":" + result); +process.exitCode = result === "ok" ? 0 : 1;`; + let child: ReturnType; + try { + child = spawn(runtime.path, ["-e", script], { + shell: false, windowsHide: true, timeout: RUNTIME_PREFLIGHT_TIMEOUT_MS, + encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 1024, + }); + } catch { throw new RuntimePreflightError("spawn"); } + if (child.error) { + throw new RuntimePreflightError((child.error as NodeJS.ErrnoException).code === "ETIMEDOUT" ? "timeout" : "spawn"); + } + const ack = String(child.stdout ?? ""); + if (ack === `${nonce}:create` && child.status === 1) throw new RuntimePreflightError("create"); + if (ack === `${nonce}:remove` && child.status === 1) throw new RuntimePreflightError("remove"); + if (ack !== `${nonce}:ok` || child.status !== 0) throw new RuntimePreflightError("protocol"); +} + +/** Windows admission of ONLY the already selected runtime. No discovery or memoization. */ +export function assertSelectedRuntimeWritable( + runtime: DurableBunRuntime, + configDir: string, + deps: RuntimePreflightOptions = {}, +): void { + if ((deps.platform ?? process.platform) !== "win32") return; + if (runtime.source === "standalone" && runtime.path !== process.execPath) throw new RuntimePreflightError("protocol"); + const fs = deps.fs ?? probeFs; + // The config root is durable: never delete it by pathname, even on refusal. + prepareRoot(fs, configDir, deps.rootWasAbsent); + const nonce = (deps.nonce ?? randomUUID)(); + const path = join(configDir, `.ocx-runtime-probe-${nonce}`); + if (runtime.source === "standalone") probeInProcess(fs, path); + else probeChild(runtime, path, nonce, deps.spawnSync ?? spawnSync); +} diff --git a/src/lib/winsw.ts b/src/lib/winsw.ts index 3ab8a947f1d..5e4de978811 100644 --- a/src/lib/winsw.ts +++ b/src/lib/winsw.ts @@ -20,7 +20,7 @@ import { homedir } from "node:os"; import { join, resolve, win32 } from "node:path"; import { expandUserPath, getConfigDir, loadConfig } from "../config"; import { recordOwnedConfigPath } from "./config-ownership"; -import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from "./bun-runtime"; +import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime, type DurableBunRuntime } from "./bun-runtime"; import type { BunRuntimeSource } from "./bun-runtime"; import { serviceApiTokenFilePath } from "./service-secrets"; import { filterTransientServicePath } from "./transient-service-path"; @@ -408,7 +408,6 @@ export function winswStatusSummary(): string { } /** Default entry mirrors the Task Scheduler baking: durable Bun + cli.ts. */ -export function defaultWinswEntry(cliDir: string): WinswEntry { - const runtime = durableBunRuntime(); +export function defaultWinswEntry(cliDir: string, runtime: DurableBunRuntime = durableBunRuntime()): WinswEntry { return { bun: runtime.path, bunRuntimeSource: runtime.source, cli: join(cliDir, "cli", "index.ts") }; } diff --git a/src/service/orchestration.ts b/src/service/orchestration.ts index 907c90051ca..6ca03ba1372 100644 --- a/src/service/orchestration.ts +++ b/src/service/orchestration.ts @@ -1,5 +1,7 @@ import { findLiveProxy, SERVICE_STOP_LIVENESS } from "../server/proxy-liveness"; import { existsSync, unlinkSync } from "node:fs"; +import { durableBunRuntime, type DurableBunRuntime } from "../lib/bun-runtime"; +import { assertSelectedRuntimeWritable, type RuntimePreflightDeps } from "../lib/bun-runtime-preflight"; import { getConfigDir } from "../config"; import { readPid, removePid, removeRuntimePort, verifyPidIdentity } from "../config/process-state"; import { isWslRuntime } from "../codex/home"; @@ -14,7 +16,7 @@ import { assertSchedulerRegistrationBeforeStart } from "./repair"; import { SERVICE_MANAGED_ENV, TASK, plistPath, removeServiceInstallStateRecords, writeServiceInstallState } from "./state"; import type { ServiceBackend } from "./state"; import { unitPath, isSystemd, installSystemd, startSystemd, stopSystemd, statusSystemd, uninstallSystemd, systemdServiceInstallCleanupOps } from "./systemd"; -import { writeWindowsSchedulerAssets, stageWindowsSchedulerRegistrationXml, removeWindowsSchedulerRegistrationStage, registerFreshWindowsSchedulerTask, recordWindowsSchedulerOwnership, removeNativeWindowsServiceForScheduler, installWindows, installWindowsNative, startWindows, isWindowsSchedulerEndBenign, stopWindows, stopWindowsChecked, statusWindows, statusWindowsXml, killWindowsServiceWrapperProcesses, uninstallWindows, classifyWindowsServiceStop } from "./windows-ops"; +import { writeWindowsSchedulerAssets, stageWindowsSchedulerRegistrationXml, removeWindowsSchedulerRegistrationStage, registerFreshWindowsSchedulerTask, recordWindowsSchedulerOwnership, removeNativeWindowsServiceForScheduler, commitWindowsInstall, commitWindowsNativeInstall, type WindowsServiceInstallDeps, startWindows, isWindowsSchedulerEndBenign, stopWindows, stopWindowsChecked, statusWindows, statusWindowsXml, killWindowsServiceWrapperProcesses, uninstallWindows, classifyWindowsServiceStop } from "./windows-ops"; import { schtasks, probeWindowsSchedulerTask, rollbackWindowsSchedulerTaskOwnedByAttempt, settleDelay } from "./windows-scheduler"; import type { WindowsSchedulerTaskProbe } from "./windows-scheduler"; import { windowsTaskRegistrationOwnedByAttempt, windowsTaskRegistrationHealthy } from "./windows-taskxml"; @@ -24,7 +26,7 @@ import { LABEL } from "./state"; import { assertNoDesktopSupervision, createSupervisionLatch, type SupervisionInspector, type SupervisionLatch } from "./desktop-command-guard"; type ServiceOps = { - install: () => void | Promise; start: () => void; stop: () => void; + install: (runtime: DurableBunRuntime) => void | Promise; start: () => void; stop: () => void; status: () => string; uninstall: () => void; }; @@ -33,18 +35,19 @@ export type ServiceInstallCleanupOps = { stop: () => void; }; -export function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null { - if (process.platform === "darwin") +export function platformOps(backend: ServiceBackend = "scheduler", deps: { platform?: NodeJS.Platform; windowsInstall?: WindowsServiceInstallDeps } = {}): ServiceOps | null { + const platform = deps.platform ?? process.platform; + if (platform === "darwin") // Wrapped, not passed: `installLaunchd` reports whether it reloaded launchd, and only // `repairService` (for the `restart` verb) has any use for that. `ServiceOps.install` is // the generic install seam and deliberately promises nothing about a return value. return { install: () => { installLaunchd(); }, start: startLaunchd, stop: stopLaunchd, status: statusLaunchd, uninstall: uninstallLaunchd }; - if (process.platform === "win32") { + if (platform === "win32") { if (backend === "native") - return { install: installWindowsNative, start: startWinswService, stop: stopWinswService, status: winswStatusSummary, uninstall: uninstallWinswService }; - return { install: installWindows, start: startWindows, stop: stopWindows, status: statusWindows, uninstall: uninstallWindows }; + return { install: runtime => commitWindowsNativeInstall(runtime, deps.windowsInstall), start: startWinswService, stop: stopWinswService, status: winswStatusSummary, uninstall: uninstallWinswService }; + return { install: runtime => commitWindowsInstall(runtime, deps.windowsInstall), start: startWindows, stop: stopWindows, status: statusWindows, uninstall: uninstallWindows }; } - if (process.platform === "linux") { + if (platform === "linux") { if (existsSync("/.dockerenv")) { console.error("Docker detected. Run 'ocx start' directly instead of using the service manager."); process.exit(1); @@ -230,7 +233,7 @@ export async function stopTrackedProxyForServiceCommand(): Promise ServiceDiagnostic; @@ -281,16 +284,20 @@ export async function prepareServiceInstall( export async function installServiceSafely( requestedBackend: ServiceBackend, - install: () => void | Promise, + install: (runtime: DurableBunRuntime) => void | Promise, deps: ServiceInstallPreparationDeps = {}, ): Promise { const latch = deps.supervisionLatch ?? createSupervisionLatch(); + assertNoDesktopSupervision(deps.inspectSupervision, latch); + const runtime = Object.freeze({ ...(deps.selectRuntime ?? durableBunRuntime)() }); + const configDir = (deps.configDir ?? getConfigDir)(); + (deps.assertRuntimeWritable ?? assertSelectedRuntimeWritable)(runtime, configDir, { platform: deps.platform, rootWasAbsent: !existsSync(configDir) }); await prepareServiceInstall(requestedBackend, { ...deps, supervisionLatch: latch }); assertNoDesktopSupervision(deps.inspectSupervision, latch); - await install(); + await install(runtime); } -export interface FreshWindowsSchedulerInstallDeps { +export interface FreshWindowsSchedulerInstallDeps extends RuntimePreflightDeps { inspectSupervision?: SupervisionInspector; supervisionLatch?: SupervisionLatch; stageRegistrationXml?: (attemptNonce: string) => string; @@ -298,14 +305,14 @@ export interface FreshWindowsSchedulerInstallDeps { recordOwnership?: () => boolean; prepare?: () => Promise; removeNativeService?: () => void; - publishAssets?: () => void; + publishAssets?: (runtime: DurableBunRuntime) => void; verifyBeforeRun?: (attemptNonce: string) => void | Promise; /** Reads the newly registered task; empty or throwing reads are retried before rollback. */ readSchedulerXml?: () => string; /** Bounded wait before retrying an unreadable fresh-install registration. */ settleSchedulerRead?: (delayMs: number) => void | Promise; runTask?: () => void; - writeState?: () => void; + writeState?: (runtime: DurableBunRuntime) => void; rollbackTask?: (attemptNonce: string) => Promise; removeStagedXml?: (xmlPath: string) => void; } @@ -344,7 +351,7 @@ export async function installFreshWindowsSchedulerSafely( ) )); const runTask = deps.runTask ?? startWindows; - const writeState = deps.writeState ?? (() => writeServiceInstallState("scheduler")); + const writeState = deps.writeState ?? ((runtime: DurableBunRuntime) => writeServiceInstallState("scheduler", undefined, {}, runtime)); const rollbackTask = deps.rollbackTask ?? ((attemptNonce: string) => ( rollbackWindowsSchedulerTaskOwnedByAttempt(attemptNonce, TASK) )); @@ -354,7 +361,9 @@ export async function installFreshWindowsSchedulerSafely( let stagedXml: string | null = null; const attemptNonce = randomUUID(); - const configRootWasAbsent = !existsSync(getConfigDir()); + const configDir = (deps.configDir ?? getConfigDir)(); + const configRootWasAbsent = !existsSync(configDir); + const runtime = Object.freeze({ ...(deps.selectRuntime ?? durableBunRuntime)() }); let registered = false; let started = false; try { @@ -375,15 +384,16 @@ export async function installFreshWindowsSchedulerSafely( + "aborting before service-manager cleanup or asset publication.", ); } + (deps.assertRuntimeWritable ?? assertSelectedRuntimeWritable)(runtime, configDir, { platform: deps.platform, rootWasAbsent: false }); await prepare(); assertNoDesktopSupervision(deps.inspectSupervision, latch); removeNativeService(); - publishAssets(); + publishAssets(runtime); await verifyBeforeRun(attemptNonce); assertNoDesktopSupervision(deps.inspectSupervision, latch); runTask(); started = true; - writeState(); + writeState(runtime); } catch (error) { const detail = error instanceof Error ? error.message : String(error); if (registered && !started) { diff --git a/src/service/repair.ts b/src/service/repair.ts index 7dd8ed54db1..5b8e4fe544d 100644 --- a/src/service/repair.ts +++ b/src/service/repair.ts @@ -1,3 +1,7 @@ +import { existsSync } from "node:fs"; +import { getConfigDir } from "../config"; +import { durableBunRuntime, type DurableBunRuntime } from "../lib/bun-runtime"; +import { assertSelectedRuntimeWritable, type RuntimePreflightDeps } from "../lib/bun-runtime-preflight"; import { randomUUID } from "node:crypto"; import { defaultWinswEntry, installWinswService } from "../lib/winsw"; import { resolveWindowsTaskDiagnosticUserId, diagnoseService } from "./diagnostics"; @@ -23,18 +27,18 @@ import { assertNoDesktopSupervision, createSupervisionLatch, type SupervisionIns */ export type ServiceRepairVerb = "repair" | "restart"; -export interface RepairServiceDeps { +export interface RepairServiceDeps extends RuntimePreflightDeps { inspectSupervision?: SupervisionInspector; supervisionLatch?: SupervisionLatch; diagnose?: () => ServiceDiagnostic; assertEnv?: () => void; assertAuth?: () => void; - writeSchedulerAssets?: () => void; + writeSchedulerAssets?: (runtime: DurableBunRuntime) => void; stopScheduler?: () => void; startScheduler?: () => void; - writeSchedulerState?: () => void; - writeNativeState?: () => void; - repairNative?: () => void | Promise; + writeSchedulerState?: (runtime: DurableBunRuntime) => void; + writeNativeState?: (runtime: DurableBunRuntime) => void; + repairNative?: (runtime: DurableBunRuntime) => void | Promise; repairLaunchd?: () => LaunchdInstallOutcome | void; repairSystemd?: () => void; /** Restarts a launchd job the install path deliberately left alone. `restart` only. */ @@ -181,15 +185,18 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise (deps.assertEnv ?? assertServiceEnvironmentMatchesInstall)(); (deps.assertAuth ?? assertServiceAuthEnvironment)(); + const runtime = Object.freeze({ ...(deps.selectRuntime ?? durableBunRuntime)() }); + const configDir = (deps.configDir ?? getConfigDir)(); + (deps.assertRuntimeWritable ?? assertSelectedRuntimeWritable)(runtime, configDir, { platform, rootWasAbsent: !existsSync(configDir) }); const temporaryLauncher = (deps.launcherPathDiagnostic ?? (() => serviceLauncherPathDiagnostic(undefined, platform, true)))(); if (temporaryLauncher) console.warn(`⚠️ ${temporaryLauncher}`); if (platform === "win32") { if (diag.backend === "native") { - await (deps.repairNative ?? (() => installWinswService(defaultWinswEntry(serviceSourceDir))))(); + await (deps.repairNative ?? ((selected: DurableBunRuntime) => installWinswService(defaultWinswEntry(serviceSourceDir, selected))))(runtime); assertNoDesktopSupervision(deps.inspectSupervision, latch); - (deps.writeNativeState ?? (() => writeServiceInstallState("native")))(); + (deps.writeNativeState ?? ((selected: DurableBunRuntime) => writeServiceInstallState("native", undefined, {}, selected)))(runtime); return; } const readSchedulerXml = deps.readSchedulerXml ?? statusWindowsXml; @@ -249,7 +256,7 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise ); } try { (deps.stopScheduler ?? stopWindows)(); } catch { /* not running */ } - (deps.writeSchedulerAssets ?? writeWindowsSchedulerAssets)(); + (deps.writeSchedulerAssets ?? writeWindowsSchedulerAssets)(runtime); // Rewriting the on-disk assets does not touch the definition Task Scheduler holds, so a // task registered by an older version keeps its old triggers forever: status reports it // stale, tells the user to run repair, and repair changes nothing it complains about. @@ -368,7 +375,7 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise ); assertNoDesktopSupervision(deps.inspectSupervision, latch); (deps.startScheduler ?? startWindows)(); - (deps.writeSchedulerState ?? (() => writeServiceInstallState("scheduler")))(); + (deps.writeSchedulerState ?? ((selected: DurableBunRuntime) => writeServiceInstallState("scheduler", undefined, {}, selected)))(runtime); return; } if (platform === "darwin") { diff --git a/src/service/state.ts b/src/service/state.ts index 8f2eda5a6cc..c48f7bcb5b2 100644 --- a/src/service/state.ts +++ b/src/service/state.ts @@ -360,8 +360,8 @@ export function parseServiceInstallState(value: unknown): ServiceInstallState | * it describes the install that just ran. {@link ServiceInstallState.ownership} deliberately * is not part of it. */ -function installProvenanceRecord(backend: ServiceBackend, launcherPath?: string | null): ServiceInstallState { - const { bun, cli } = cliEntry(); +function installProvenanceRecord(backend: ServiceBackend, launcherPath: string | null | undefined, runtime: DurableBunRuntime): ServiceInstallState { + const { bun, cli } = cliEntry(runtime); const codexHome = currentCodexHome(); return { version: 2, @@ -391,9 +391,10 @@ export function writeServiceInstallState( backend: ServiceBackend = "scheduler", launcherPath?: string | null, deps: ServiceStateSwapDeps = {}, + runtime: DurableBunRuntime = durableBunRuntime(), ): void { swapServiceInstallState(current => ({ - ...installProvenanceRecord(backend, launcherPath), + ...installProvenanceRecord(backend, launcherPath, runtime), ...preservedConsent(current), }), deps); } diff --git a/src/service/windows-ops.ts b/src/service/windows-ops.ts index f8a3a31fe44..f75674884d1 100644 --- a/src/service/windows-ops.ts +++ b/src/service/windows-ops.ts @@ -3,6 +3,8 @@ import { createHash } from "node:crypto"; import { win32 } from "node:path"; import { winswXmlPath } from "../lib/winsw"; import { hardenSecretPath } from "../lib/windows-secret-acl"; +import { durableBunRuntime, type DurableBunRuntime } from "../lib/bun-runtime"; +import { assertSelectedRuntimeWritable, type RuntimePreflightDeps } from "../lib/bun-runtime-preflight"; import { parseBakedListenPort } from "./health"; import { windowsServiceScriptPath } from "./state"; import { execFileSync } from "node:child_process"; @@ -18,7 +20,7 @@ import { killWindowsSchedulerWrappers } from "../lib/windows-service-wrappers"; import { isTestHomeGuardArmed } from "../lib/test-home-guard"; import { writeServiceApiTokenFile } from "./guards"; import type { ServiceStopOutcome } from "./orchestration"; -import { TASK, windowsLauncherVbsPath, windowsTaskXmlPath, serviceStatePath, writeServiceInstallState, serviceSourceDir } from "./state"; +import { TASK, windowsLauncherVbsPath, windowsTaskXmlPath, serviceStatePath, writeServiceInstallState, serviceSourceDir, cliEntry } from "./state"; import { decodeSchtasksOutput, querySchtasks, schtasks, type WindowsSchedulerTaskProbe, schtasksErrorDetail, probeWindowsSchedulerTask, rollbackWindowsSchedulerTaskOwnedByAttempt } from "./windows-scheduler"; import { buildWindowsServiceScript, buildWindowsSchtasksCreateArgs, buildWindowsSchtasksCreateArgsForXml, buildWindowsLauncherVbs, resolvedWindowsTaskSid, buildWindowsTaskXmlDocument, windowsTaskRegistrationOwnedByAttempt, windowsTaskRegistrationHealthy } from "./windows-taskxml"; @@ -108,11 +110,11 @@ function writeServiceAssetWithRetry(path: string, content: string, encoding: "ut * Rewrite on-disk scheduler assets (script/VBS/XML) without itself registering the task. * Fresh install creates it afterwards; repair does so only when the live definition is stale. */ -export function writeWindowsSchedulerAssets(): void { +export function writeWindowsSchedulerAssets(runtime: DurableBunRuntime = durableBunRuntime()): void { if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true }); writeServiceApiTokenFile(); const script = windowsServiceScriptPath(); - writeServiceAssetWithRetry(script, buildWindowsServiceScript(), "utf8"); + writeServiceAssetWithRetry(script, buildWindowsServiceScript(cliEntry(runtime)), "utf8"); // UTF-16LE + BOM: a BOM-less UTF-8 VBS mis-decodes non-ASCII (e.g. Korean) profile // paths on some WSH/codepage combinations — same contract as the task XML below. writeServiceAssetWithRetry(windowsLauncherVbsPath(), `\uFEFF${buildWindowsLauncherVbs(script)}`, "utf16le"); @@ -597,16 +599,40 @@ export function removeNativeWindowsServiceForScheduler( } } -export function installWindows(): void { - recordWindowsSchedulerOwnership(); - removeNativeWindowsServiceForScheduler(); +/** External Windows effects shared by the gated entrypoints and admitted commit paths. */ +export interface WindowsServiceInstallDeps extends RuntimePreflightDeps { + recordSchedulerOwnership?: () => boolean; + removeNativeService?: () => void; + stopScheduler?: () => void; + writeSchedulerAssets?: (runtime: DurableBunRuntime) => void; + schedulerCommand?: typeof schtasks; + writeState?: (backend: "scheduler" | "native", runtime: DurableBunRuntime) => void; + assertNativeAccount?: () => void; + prepareNativeConfig?: () => void; + uninstallScheduler?: () => void; + installNativeService?: typeof installWinswService; +} + +export function installWindows(selected?: DurableBunRuntime, deps: WindowsServiceInstallDeps = {}): void { + const runtime = Object.freeze({ ...(selected ?? (deps.selectRuntime ?? durableBunRuntime)()) }); + const configDir = (deps.configDir ?? getConfigDir)(); + (deps.assertRuntimeWritable ?? assertSelectedRuntimeWritable)(runtime, configDir, { platform: deps.platform, rootWasAbsent: !existsSync(configDir) }); + commitWindowsInstall(runtime, deps); +} + +/** Internal commit boundary: the caller has admitted this frozen runtime before cleanup. */ +export function commitWindowsInstall(runtime: DurableBunRuntime, deps: WindowsServiceInstallDeps = {}): void { + (deps.recordSchedulerOwnership ?? recordWindowsSchedulerOwnership)(); + (deps.removeNativeService ?? removeNativeWindowsServiceForScheduler)(); // End a running task BEFORE rewriting the assets it is executing — cmd.exe reading the // script mid-rewrite runs a torn batch file, and its open handle can fail the write. - try { stopWindows(); } catch { /* not running */ } - writeWindowsSchedulerAssets(); - schtasks(buildWindowsSchtasksCreateArgs(windowsServiceScriptPath())); - schtasks(["/run", "/tn", TASK]); - writeServiceInstallState("scheduler"); + try { (deps.stopScheduler ?? stopWindows)(); } catch { /* not running */ } + (deps.writeSchedulerAssets ?? writeWindowsSchedulerAssets)(runtime); + const command = deps.schedulerCommand ?? schtasks; + command(buildWindowsSchtasksCreateArgs(windowsServiceScriptPath())); + command(["/run", "/tn", TASK]); + if (deps.writeState) deps.writeState("scheduler", runtime); + else writeServiceInstallState("scheduler", undefined, {}, runtime); } /** @@ -748,26 +774,38 @@ function readWindowsPrincipalSource(): string | null { } } -export async function installWindowsNative(): Promise { - assertWindowsNativeServiceAccountSupported(); - recordOwnedConfigPath(getConfigDir(), serviceStatePath()); - if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true }); - writeServiceApiTokenFile(); +export async function installWindowsNative(selected?: DurableBunRuntime, deps: WindowsServiceInstallDeps = {}): Promise { + const runtime = Object.freeze({ ...(selected ?? (deps.selectRuntime ?? durableBunRuntime)()) }); + const configDir = (deps.configDir ?? getConfigDir)(); + (deps.assertRuntimeWritable ?? assertSelectedRuntimeWritable)(runtime, configDir, { platform: deps.platform, rootWasAbsent: !existsSync(configDir) }); + await commitWindowsNativeInstall(runtime, deps); +} + +/** Internal commit boundary; direct native installs go through the public admission gate. */ +export async function commitWindowsNativeInstall(runtime: DurableBunRuntime, deps: WindowsServiceInstallDeps = {}): Promise { + (deps.assertNativeAccount ?? assertWindowsNativeServiceAccountSupported)(); + if (deps.prepareNativeConfig) deps.prepareNativeConfig(); + else { + recordOwnedConfigPath(getConfigDir(), serviceStatePath()); + if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true }); + writeServiceApiTokenFile(); + } + const command = deps.schedulerCommand ?? schtasks; let hadScheduler = false; try { - hadScheduler = schtasks(["/query", "/tn", TASK]).includes(TASK); + hadScheduler = command(["/query", "/tn", TASK]).includes(TASK); } catch { /* task absent */ } if (hadScheduler) { console.log("🔁 Removing the Task Scheduler backend before installing the native (WinSW) service..."); - try { stopWindows(); } catch { /* not running */ } + try { (deps.stopScheduler ?? stopWindows)(); } catch { /* not running */ } try { - uninstallWindows(); + (deps.uninstallScheduler ?? uninstallWindows)(); } catch (err) { throw new Error(`Cannot remove the Task Scheduler backend before switching to native: ${err instanceof Error ? err.message : String(err)}`); } // Verify removal — schtasks /delete can silently fail if UAC or policy blocks it. try { - if (schtasks(["/query", "/tn", TASK]).includes(TASK)) { + if (command(["/query", "/tn", TASK]).includes(TASK)) { throw new Error("Task Scheduler backend still present after removal — aborting switch."); } } catch (e) { @@ -776,12 +814,13 @@ export async function installWindowsNative(): Promise { } } try { - await installWinswService(defaultWinswEntry(serviceSourceDir)); + await (deps.installNativeService ?? installWinswService)(defaultWinswEntry(serviceSourceDir, runtime)); } catch (err) { if (hadScheduler) console.error("⚠️ Native install failed AFTER removing the Task Scheduler backend — no service is installed now. Run `ocx service install` to restore the scheduler backend, or retry `--native`."); throw err; } - writeServiceInstallState("native"); + if (deps.writeState) deps.writeState("native", runtime); + else writeServiceInstallState("native", undefined, {}, runtime); } export function startWindows(): void { schtasks(["/run", "/tn", TASK]); } diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 5d81e4d8c54..135e86b092d 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -53,16 +53,11 @@ without running handlers is not covered by this forwarding mechanism. ## Guarded CLI update restart -`src/cli/system-restart-client.ts` freezes the newer-CLI candidate's runtime identity and -physical homes. `src/cli/update-restart-home.ts` captures the service-record digest from -`src/cli/update-restart-service-record.ts`: all ordered `serviceStatePaths()` candidates, -including confirmed absences and the authoritative position, plus the launchd plist or -systemd unit. State and ownership resolve from those captured bytes. Each present regular -file contributes SHA-256 content and bigint device, inode, size, nanosecond mtime/ctime, -mode, uid and gid. The opened descriptor must be regular and match device/inode before any -read. Each record is limited to 1 MiB, read in bounded chunks with growth beyond that limit -refused. Descriptor stats bracket the read; a final pathname stat must agree. -Symlinks, inconsistent reads, malformed state, unreadable candidates and membership drift +`src/cli/system-restart-client.ts` freezes the newer-CLI candidate's runtime identity and physical homes. `src/cli/update-restart-home.ts` captures the service-record digest from +`src/cli/update-restart-service-record.ts`: all ordered `serviceStatePaths()` candidates, including confirmed absences and the authoritative position, plus the launchd plist or +systemd unit. State and ownership resolve from those captured bytes. Each present regular file contributes SHA-256 content and bigint device, inode, size, nanosecond mtime/ctime, +mode, uid and gid. The opened descriptor must be regular and match device/inode before any read. Each record is limited to 1 MiB, read in bounded chunks with growth beyond that limit +refused. Descriptor stats bracket the read; a final pathname stat must agree. Symlinks, inconsistent reads, malformed state, unreadable candidates and membership drift refuse. Canonical directory aliases remain equivalent. Lock, PID and runtime records are excluded. `src/cli/update-restart.ts` requires known versions and a detached POSIX target with PID-1 @@ -96,6 +91,15 @@ deadline before and under that lease, before/after bind, before PID and runtime and at completion. Rollback retains custody until exit; the deadline ends an unfinished child. Windows, claimed, supervised and uncertain targets remain ineligible for this update path. +## Windows selected-runtime write preflight + +Before service install/repair or a Windows Codex shim mutation, the selected Bun executable must create and remove an exclusive nonce-named directory inside the config root: admission runs that exact lexical path with `-e`, no shell, a hidden window and a five-second timeout, and only a matching nonce acknowledgment admits. Failure refuses with `OCX_RUNTIME_PREFLIGHT_FAILED` (`spawn`, `timeout`, `create`, `remove` or `protocol`) and never discovers another runtime; the Node launcher may pick a validated PATH Bun before CLI startup when bundled Bun is unusable, and durable admission only probes that resulting selection. +The frozen selection feeds scheduler rendering, the WinSW entry and the install-state writer. `installServiceSafely` refuses live Desktop supervision before selection or admission, then admits once before cleanup and uses internal commit functions; repair refuses Desktop before diagnosis, checks ownership and auth before admission, and admits before native repair or scheduler stop; direct public installers keep their own admission gate. +The fresh scheduler path captures root absence and freezes the runtime at entry, stages and registers its definition, removes staging and claims config ownership, then probes with `rootWasAbsent: false` immediately before `prepare()`; a refused probe rolls back the new registration before service-manager cleanup or asset/state publication. Healthy or disabled shims do not probe; refused automatic restore defers with guidance and startup continues. Non-Windows admission performs no spawn or filesystem work, selection stays pre-dotenv and `cliEntry` stays I/O-free. + +An absent root reaching admission is created with an exclusive non-recursive mode-0700 mkdir under an existing parent and left empty for later ownership claiming; the preflight never deletes the config root (refusal, concurrent creation, contents and replacements are all preserved). Existing roots must be real directories, not files or symlinks/junctions, and probe cleanup is non-recursive. +A standalone selection is admitted in-process only when its path equals `process.execPath` exactly (standalone is executable packaging, not Desktop ownership); that path has neither child isolation nor a timeout, so synchronous filesystem operations may block, and other standalone selections refuse. Recovery is an operator-selected trusted `OPENCODEX_BUN_PATH` before launching ocx, or an `npm install -g @bitkyc08/opencodex` reinstall followed by retry; no runtime discovery or probe memo is added. + ## Windows npm tray update badge The npm Windows tray owns six installed ICOs: online, warning, and offline base safety glyphs plus one blue-dot variant of each. Its hidden `ocx __update-badge` child reads the package cache without refreshing or writing it. The tray samples no more often than every 60 seconds, caps stdout and stderr at 16 KiB each, requests termination after 12 seconds or a pipe overflow, and reaps the child on later Windows Forms ticks before allowing another launch. A successful badge observation expires after 180 seconds; failed reads do not extend it. The **Update available** item opens the dashboard and never installs a package. Shutdown requests child termination, waits at most 500 ms, and disposes the probe before tray UI disposal. diff --git a/structure/runtime.md b/structure/runtime.md index 4523babfcb5..432f7aef7c7 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -306,7 +306,7 @@ atomic OS guarantee against unobserved PID reuse. An installed Codex shim is checked on eligible CLI startup with a direct-regular-file/1 MiB state bound and bounded metadata/prefix reads. Changing launchers defer; failures warn without changing the requested command’s exit behavior. All mutations hold the shared atomic-mkdir interprocess lock and reread state before final revalidation, publication and state commit. Unique token-named owner records prevent release from naming a successor. Aged locks are reclaimed only when the owner PID is dead and the same token, directory identity and owner fingerprint remain immediately before deletion; age alone never evicts a live holder. -Windows repair retains complete sibling-set preflight, identical fingerprints/prefixes across a 100 ms observation interval, and reverse-order rollback if a later sibling changes. It restores stable external replacements without PATH rediscovery. +Windows repair retains complete sibling-set preflight, identical fingerprints/prefixes across a 100 ms observation interval, and reverse-order rollback if a later sibling changes. It restores stable external replacements without PATH rediscovery. Windows service/shim writes first admit the frozen selected runtime under the [write-preflight contract](ops/service-and-sidecars.md#windows-selected-runtime-write-preflight); orchestrated installs refuse Desktop supervision before selection and admit once before cleanup. Repair refuses Desktop before diagnosis, then checks ownership and auth before admission and any native repair or scheduler stop. The Node launcher’s validated PATH Bun fallback precedes CLI startup; durable admission never discovers a replacement. The fresh scheduler path claims config ownership after registration and staging removal, then admits before `prepare()`; refusal rolls back the new registration and preserves the claimed config root. Standalone admission uses the current executable in-process, without child isolation or a timeout. Unix installation owns only the private home/bin/codex wrapper and codex-shell-env.sh. The native manager-owned launcher stays in place; its recorded durable entry follows package upgrades and version rollback. `src/codex/shim-state-file.ts` decodes schema 2, mode `path-overlay`, supported Unix platform, exact private `wrapperPath`, and distinct absolute `launcherPath`; contradictory compatibility fields are invalid. State can record wrapper/env inode identities and a transaction id. Its compatibility view exposes one read-only backing tuple with original/backup/real paths all equal to `launcherPath`; mutation dispatches by mode/platform before interpreting that tuple as legacy backup data. State writes use exclusive same-directory 0600 staging and atomic publication. diff --git a/tests/codex-integration/codex-shim-runtime-preflight.test.ts b/tests/codex-integration/codex-shim-runtime-preflight.test.ts new file mode 100644 index 00000000000..d5f6db035b1 --- /dev/null +++ b/tests/codex-integration/codex-shim-runtime-preflight.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { autoRestoreCodexShim, buildWindowsCodexShim, installCodexShim } from "../../src/codex/shim"; +import { RuntimePreflightError, type RuntimePreflightReason } from "../../src/lib/bun-runtime-preflight"; +import type { DurableBunRuntime } from "../../src/lib/bun-runtime"; +import { maybeAutoRestoreCodexShim } from "../../src/cli/codex-shim-autorestore"; +import { getDefaultConfig } from "../../src/config"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const runtime: DurableBunRuntime = { path: process.execPath, source: "override", overrideEnv: "OPENCODEX_BUN_PATH" }; +/** Exercise the Windows shim state machine on every host with only temporary launchers. */ +function fixture(run: (paths: { home: string; wrapper: string; backup: string; state: string }) => void): void { + const root = mkdtempSync(join(tmpdir(), "ocx-shim-runtime-")); + const home = join(root, "home"); const bin = join(root, "bin"); + mkdirSync(home); mkdirSync(bin); + const wrapper = join(bin, "codex.cmd"); const backup = join(bin, "codex.opencodex-real.cmd"); const state = join(home, "codex-shim.json"); + const descriptor = Object.getOwnPropertyDescriptor(process, "platform")!; + const keys = ["HOME", "OPENCODEX_HOME", "CODEX_HOME", "PATH"] as const; + const saved = keys.map(key => process.env[key]); + try { + Object.defineProperty(process, "platform", { ...descriptor, value: "win32" }); + process.env.HOME = home; process.env.OPENCODEX_HOME = home; process.env.CODEX_HOME = join(home, "codex-home"); process.env.PATH = bin; + writeFileSync(wrapper, "@echo replacement\r\n"); writeFileSync(backup, "@echo original\r\n"); + writeFileSync(state, JSON.stringify({ platform: "win32", wrapperPath: wrapper, originalPath: wrapper, backupPath: backup, + wrappers: [{ wrapperPath: wrapper, originalPath: wrapper, backupPath: backup }] })); + run({ home, wrapper, backup, state }); + } finally { + Object.defineProperty(process, "platform", descriptor); + keys.forEach((key, i) => { if (saved[i] === undefined) delete process.env[key]; else process.env[key] = saved[i]; }); + removeTreeWithRetry(root); + } +} + +function snapshot(paths: { home: string; wrapper: string; backup: string; state: string }): unknown { + return { wrapper: readFileSync(paths.wrapper, "utf8"), backup: readFileSync(paths.backup, "utf8"), state: readFileSync(paths.state, "utf8"), entries: readdirSync(paths.home) }; +} + +describe("Windows shim selected-runtime preflight", () => { + for (const reason of ["spawn", "timeout", "create", "remove", "protocol"] as RuntimePreflightReason[]) { + test(`${reason} stops explicit refresh before rename, wrapper or state writes`, () => fixture(paths => { + const before = snapshot(paths); + expect(() => installCodexShim({ selectRuntime: () => runtime, assertRuntimeWritable: () => { throw new RuntimePreflightError(reason); } })).toThrow(RuntimePreflightError); + expect(snapshot(paths)).toEqual(before); + })); + test(`${reason} defers automatic restore before acquiring a lock, and startup continues with guidance`, () => fixture(paths => { + const before = snapshot(paths); + let locked = false; + const result = autoRestoreCodexShim({ enabled: () => true, selectRuntime: () => runtime, + assertRuntimeWritable: () => { throw new RuntimePreflightError(reason); }, + afterRestoreLockAcquired: () => { locked = true; }, stabilitySleep: () => {}, + }); + expect(result.status).toBe("deferred"); + expect("message" in result && result.message).toContain("OPENCODEX_BUN_PATH"); + expect(locked).toBe(false); expect(snapshot(paths)).toEqual(before); + const warnings: string[] = []; + expect(maybeAutoRestoreCodexShim("start", ["start"], { env: {}, warn: message => warnings.push(message), restore: () => result, + readConfig: () => ({ config: getDefaultConfig(), source: "default", error: null }), + })).toBeUndefined(); + expect(warnings).toEqual([expect.stringContaining("npm install -g @bitkyc08/opencodex")]); + })); + } + + test("fresh install refuses before moving the native launcher or creating state", () => fixture(paths => { + // No valid prior installation: explicit install must still admit before its first move. + writeFileSync(paths.state, "{}"); + const before = snapshot(paths); + expect(() => installCodexShim({ selectRuntime: () => runtime, assertRuntimeWritable: () => { throw new RuntimePreflightError("create"); } })).toThrow(RuntimePreflightError); + expect(snapshot(paths)).toEqual(before); + })); + + test("fresh install admits once before the first move and freezes all wrapper rendering", () => fixture(paths => { + unlinkSync(paths.state); unlinkSync(paths.backup); + const original = readFileSync(paths.wrapper, "utf8"); + const selected = { ...runtime }; let probes = 0; + const result = installCodexShim({ selectRuntime: () => selected, + assertRuntimeWritable: admitted => { + probes++; expect(admitted).toEqual(runtime); expect(Object.isFrozen(admitted)).toBe(true); + expect(readFileSync(paths.wrapper, "utf8")).toBe(original); + expect(existsSync(paths.state)).toBe(false); expect(existsSync(paths.backup)).toBe(false); + selected.path = "unprobed.exe"; selected.source = "process"; + }, + }); + expect(result.installed).toBe(true); expect(probes).toBe(1); + expect(readFileSync(paths.wrapper, "utf8")).toContain(runtime.path); + expect(readFileSync(paths.wrapper, "utf8")).toContain('OCX_BUN_RUNTIME_SOURCE=override'); + expect(readFileSync(paths.wrapper, "utf8")).not.toContain("unprobed.exe"); + expect(readFileSync(paths.backup, "utf8")).toBe(original); + })); + + test("healthy and disabled shims perform zero runtime selections and zero probes", () => fixture(paths => { + const unexpected = () => { throw new Error("unexpected probe or selection"); }; + const disabled = autoRestoreCodexShim({ enabled: () => false, selectRuntime: unexpected, assertRuntimeWritable: unexpected }); + expect(disabled.status).toBe("disabled"); + writeFileSync(paths.wrapper, buildWindowsCodexShim(paths.backup, runtime.path, "cli.ts", runtime.source)); + const before = snapshot(paths); + expect(autoRestoreCodexShim({ enabled: unexpected, selectRuntime: unexpected, assertRuntimeWritable: unexpected }).status).toBe("healthy"); + expect(installCodexShim({ selectRuntime: unexpected, assertRuntimeWritable: unexpected }).installed).toBe(false); + expect(snapshot(paths)).toEqual(before); + })); + + test("automatic refresh bakes the frozen admitted path/source after selection changes", () => fixture(paths => { + const selected = { ...runtime }; let selections = 0; let probes = 0; + const result = autoRestoreCodexShim({ enabled: () => true, stabilitySleep: () => {}, + selectRuntime: () => { selections++; return selected; }, + assertRuntimeWritable: admitted => { probes++; expect(admitted).toEqual(runtime); selected.path = "unprobed.exe"; selected.source = "process"; }, + }); + expect(result.status).toBe("restored"); expect(selections).toBe(1); expect(probes).toBe(1); + const wrapper = readFileSync(paths.wrapper, "utf8"); + expect(wrapper).toContain(runtime.path); expect(wrapper).toContain('OCX_BUN_RUNTIME_SOURCE=override'); expect(wrapper).not.toContain("unprobed.exe"); + expect(readFileSync(paths.backup, "utf8")).toContain("replacement"); + expect(existsSync(join(paths.home, "codex-shim.autorestore.lock"))).toBe(false); + })); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 281a3729757..0c1fc226221 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -8,6 +8,7 @@ "oauth-anthropic-instance-health.test.ts": "oauth", "anthropic2-responses-parity.test.ts": "responses", "oauth-anthropic-instance-multiprocess.test.ts": "oauth", "anthropic2-discovery-ownership.test.ts": "oauth", + "codex-shim-runtime-preflight.test.ts": "codex-integration", "cli-anthropic2-account.test.ts": "cli", "anthropic2-catalog.test.ts": "codex-integration", "provider-anthropic-instance-quota.test.ts": "providers", "anthropic2-management.test.ts": "server", "management-sidecar-anthropic-instance.test.ts": "server", "anthropic2-usage-attribution.test.ts": "usage", "vision-anthropic-instance-sidecar.test.ts": "vision", "web-search-anthropic-instance.test.ts": "web-search", "provider-anthropic-instance.test.ts": "providers", "config-anthropic-instance-pool.test.ts": "config", "oauth-anthropic-instance-registration.test.ts": "oauth", "oauth-anthropic-instance-refresh.test.ts": "oauth", @@ -92,6 +93,7 @@ "codex-credits-after-limit.test.ts": "codex-integration", "codex-pool-credit-policy.test.ts": "codex-integration", "codex-sidecar-credit-policy.test.ts": "codex-integration", + "service-runtime-preflight.test.ts": "service", "service-desktop-runtime-preflight.test.ts": "service", "codex-credits-after-limit-main.test.ts": "codex-integration", "service-desktop-startup-health.test.ts": "service", "service-desktop-startup-linux.test.ts": "service", "service-desktop-startup.test.ts": "service", "startup-health-packaged-probe.test.ts": "server", "discovered-native-models.test.ts": "codex-integration", "responses-hosted-image-delivery.test.ts": "responses", @@ -103,6 +105,7 @@ "low-quota-protection.test.ts": "codex-integration", "provider-antigravity-quota-retry.test.ts": "providers", "responses-compaction-recovery.test.ts": "responses", "responses-canonical-nonstream.test.ts": "responses", "compaction-recovery-settings.test.ts": "config", "responses-compaction-recovery-policy.test.ts": "responses", + "bun-runtime-preflight.test.ts": "lib", "plugin-loader.test.ts": "lib", "plugin-upstream-hooks.test.ts": "lib", "cli-kiro-auto-selection.test.ts": "cli", "codebuddy-live-models.test.ts": "providers", "kiro-auto-selection.test.ts": "providers/kiro", "kiro-quota-metrics.test.ts": "providers/kiro", diff --git a/tests/lib/bun-runtime-preflight.test.ts b/tests/lib/bun-runtime-preflight.test.ts new file mode 100644 index 00000000000..353302af64d --- /dev/null +++ b/tests/lib/bun-runtime-preflight.test.ts @@ -0,0 +1,228 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmdirSync, symlinkSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { assertSelectedRuntimeWritable, RuntimePreflightError, RUNTIME_PREFLIGHT_TIMEOUT_MS, type RuntimePreflightFs, type RuntimePreflightOptions, type RuntimePreflightReason } from "../../src/lib/bun-runtime-preflight"; +import type { DurableBunRuntime } from "../../src/lib/bun-runtime"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const roots: string[] = []; +function temporary(): string { + const dir = mkdtempSync(join(tmpdir(), "ocx-runtime-preflight-")); + roots.push(dir); + return dir; +} +afterEach(() => { for (const dir of roots.splice(0)) removeTreeWithRetry(dir); }); +const runtime: DurableBunRuntime = { path: process.execPath, source: "process", overrideEnv: "OPENCODEX_BUN_PATH" }; +const standalone: DurableBunRuntime = { ...runtime, source: "standalone" }; +const fs: RuntimePreflightFs = { lstatSync, mkdirSync, rmdirSync, readdirSync }; +function refusal(run: () => void, reason: RuntimePreflightReason): void { + try { run(); throw new Error("expected refusal"); } + catch (error) { + expect(error).toBeInstanceOf(RuntimePreflightError); + expect(error).toMatchObject({ code: "OCX_RUNTIME_PREFLIGHT_FAILED", reason }); + expect((error as Error).message).toContain("OPENCODEX_BUN_PATH"); + expect((error as Error).message).toContain("npm install -g @bitkyc08/opencodex"); + } +} +function child(result: Partial>): typeof spawnSync { + return (() => ({ status: 0, stdout: "nonce:ok", ...result })) as typeof spawnSync; +} +const windows: RuntimePreflightOptions = { platform: "win32", nonce: () => "nonce" }; + +describe("selected Bun write preflight", () => { + test("real child successfully creates and removes only its probe on every host", () => { + const dir = temporary(); + assertSelectedRuntimeWritable(runtime, dir, { platform: "win32" }); + expect(readdirSync(dir)).toEqual([]); + }); + + test("reuses the blocker-file fixture from #6695 without probing an ancestor", () => { + const blocker = join(temporary(), "probe-blocker-file"); + writeFileSync(blocker, "x", "utf8"); + refusal(() => assertSelectedRuntimeWritable(runtime, join(blocker, "probe"), windows), "create"); + expect(readFileSync(blocker, "utf8")).toBe("x"); + }); + + test("executes the selected lexical path with argv, nonce, hidden window and 5 s timeout on every invocation", () => { + const dir = temporary(); + const selected = { ...runtime, path: "C:\\trusted bun\\bun.exe", source: "override" as const }; + let calls = 0; + const spawn = ((path: string, argv: string[], options: unknown) => { + calls++; + expect(path).toBe(selected.path); + expect(argv[0]).toBe("-e"); + expect(argv[1]).toContain(".ocx-runtime-probe-nonce"); + expect(argv[1]).not.toContain("recursive"); + expect(options).toMatchObject({ shell: false, windowsHide: true, timeout: RUNTIME_PREFLIGHT_TIMEOUT_MS }); + return { status: 0, stdout: "nonce:ok" }; + }) as typeof spawnSync; + for (let i = 0; i < 2; i++) assertSelectedRuntimeWritable(selected, dir, { ...windows, spawnSync: spawn }); + expect(calls).toBe(2); + }); + + test("refuses spawn, timeout, create, remove and bad protocol without leaking child diagnostics", () => { + const cases: Array<[RuntimePreflightReason, Partial>]> = [ + ["spawn", { error: Object.assign(new Error("private diagnostic"), { code: "ENOENT" }) }], + ["timeout", { error: Object.assign(new Error("private diagnostic"), { code: "ETIMEDOUT" }) }], + ["create", { status: 1, stdout: "nonce:create" }], + ["remove", { status: 1, stdout: "nonce:remove" }], + ["protocol", { status: 0, stdout: "another-nonce:ok" }], + ["protocol", { status: 1, stdout: "nonce:ok" }], + ["protocol", { status: null, stdout: "" }], + ]; + for (const [reason, result] of cases) { + refusal(() => assertSelectedRuntimeWritable(runtime, temporary(), { ...windows, spawnSync: child(result) }), reason); + } + refusal(() => assertSelectedRuntimeWritable(runtime, temporary(), { + ...windows, spawnSync: (() => { throw new Error("private diagnostic"); }) as typeof spawnSync, + }), "spawn"); + }); + + test("concurrent invocations use different nonces and cannot acknowledge each other", async () => { + const dir = temporary(); + const nonces: string[] = []; + await Promise.all([1, 2].map(async () => assertSelectedRuntimeWritable(runtime, dir, { + platform: "win32", spawnSync: ((path: string, argv: string[], options: object) => { + const match = /process\.stdout\.write\(("[^"]+")/.exec(argv[1]!); + nonces.push(JSON.parse(match![1]!)); + return spawnSync(path, argv, options); + }) as typeof spawnSync, + }))); + expect(new Set(nonces).size).toBe(2); + expect(readdirSync(dir)).toEqual([]); + }); + + test("non-Windows returns before any spawn, fs read, or fs mutation", () => { + const unexpected = () => { throw new Error("unexpected I/O"); }; + for (const platform of ["darwin", "linux"] as const) { + assertSelectedRuntimeWritable(runtime, "unused", { + platform, spawnSync: unexpected as typeof spawnSync, + fs: { lstatSync: unexpected, mkdirSync: unexpected, rmdirSync: unexpected, readdirSync: unexpected }, nonce: unexpected, + }); + } + }); + + test("refuses files, symlinks, dangling roots and unreadable roots before spawning", () => { + const dir = temporary(); + const file = join(dir, "file"); + writeFileSync(file, "x"); + const link = join(dir, "link"); + const dangling = join(dir, "dangling"); + symlinkSync(dir, link, "junction"); + symlinkSync(join(dir, "absent"), dangling, "junction"); + for (const root of [file, link, dangling]) { + refusal(() => assertSelectedRuntimeWritable(runtime, root, { + ...windows, spawnSync: (() => { throw new Error("must not spawn"); }) as typeof spawnSync, + }), "create"); + } + refusal(() => assertSelectedRuntimeWritable(runtime, dir, { + ...windows, fs: { ...fs, lstatSync: () => { throw Object.assign(new Error("denied"), { code: "EACCES" }); } }, + }), "create"); + expect(readFileSync(file, "utf8")).toBe("x"); + }); + + test("absent root is exclusively created, stays empty on success and refusal", () => { + const parent = temporary(); + for (const source of ["process", "standalone"] as const) { + const root = join(parent, source); + assertSelectedRuntimeWritable({ ...runtime, source }, root, { platform: "win32", rootWasAbsent: true }); + expect(readdirSync(root)).toEqual([]); + } + const denied = join(parent, "denied"); + refusal(() => assertSelectedRuntimeWritable(runtime, denied, { ...windows, rootWasAbsent: true, spawnSync: child({ status: 1, stdout: "nonce:create" }) }), "create"); + expect(existsSync(denied)).toBe(true); + expect(readdirSync(denied)).toEqual([]); + const missingParent = join(parent, "missing", "root"); + refusal(() => assertSelectedRuntimeWritable(runtime, missingParent, windows), "create"); + expect(existsSync(join(parent, "missing"))).toBe(false); + }); + + test("another invocation's concurrent root and contents survive refusal", () => { + const root = join(temporary(), "concurrent"); + refusal(() => assertSelectedRuntimeWritable(runtime, root, { + ...windows, rootWasAbsent: true, spawnSync: child({ status: 1, stdout: "nonce:create" }), + fs: { ...fs, mkdirSync: (path, mode) => { + mkdirSync(path, mode); writeFileSync(join(path, "other"), "preserve"); + throw Object.assign(new Error("exists"), { code: "EEXIST" }); + } }, + }), "create"); + expect(readFileSync(join(root, "other"), "utf8")).toBe("preserve"); + }); + + test("a concurrently created empty root also survives refusal", () => { + const root = join(temporary(), "empty"); + mkdirSync(root); + refusal(() => assertSelectedRuntimeWritable(runtime, root, { ...windows, rootWasAbsent: true, spawnSync: child({ status: 1, stdout: "nonce:create" }) }), "create"); + expect(existsSync(root)).toBe(true); + }); + + test("a created root becoming nonempty during the probe survives refusal", () => { + const root = join(temporary(), "nonempty"); + refusal(() => assertSelectedRuntimeWritable(runtime, root, { + ...windows, rootWasAbsent: true, spawnSync: (() => { + writeFileSync(join(root, "new"), "preserve"); return { status: 1, stdout: "nonce:create" }; + }) as typeof spawnSync, + }), "create"); + expect(readFileSync(join(root, "new"), "utf8")).toBe("preserve"); + }); + + test("a replacement empty root with a different inode survives refusal", () => { + const parent = temporary(); + const root = join(parent, "replaced"); + let originalIno = 0; + refusal(() => assertSelectedRuntimeWritable(runtime, root, { + ...windows, rootWasAbsent: true, spawnSync: (() => { + originalIno = lstatSync(root).ino; + renameSync(root, join(parent, "old")); mkdirSync(root); + return { status: 1, stdout: "nonce:create" }; + }) as typeof spawnSync, + }), "create"); + expect(lstatSync(root).ino).not.toBe(originalIno); + expect(readdirSync(root)).toEqual([]); + }); + + test("refusal never inspects emptiness or deletes the config root", () => { + const root = join(temporary(), "root"); + const cleanup: string[] = []; + refusal(() => assertSelectedRuntimeWritable(runtime, root, { + ...windows, rootWasAbsent: true, spawnSync: child({ status: 1, stdout: "nonce:create" }), + fs: { ...fs, + readdirSync: () => { cleanup.push("read"); return []; }, + rmdirSync: () => { cleanup.push("remove"); }, + }, + }), "create"); + expect(cleanup).toEqual([]); + expect(existsSync(root)).toBe(true); expect(readdirSync(root)).toEqual([]); + }); + + test("standalone exact executable probes in-process, and failures clean only empty owned probes", () => { + const root = temporary(); + const noSpawn = (() => { throw new Error("standalone must not spawn"); }) as typeof spawnSync; + assertSelectedRuntimeWritable(standalone, root, { ...windows, spawnSync: noSpawn }); + refusal(() => assertSelectedRuntimeWritable(standalone, root, { + ...windows, spawnSync: noSpawn, fs: { ...fs, mkdirSync: () => { throw new Error("denied"); } }, + }), "create"); + let removals = 0; + refusal(() => assertSelectedRuntimeWritable(standalone, root, { + ...windows, spawnSync: noSpawn, fs: { ...fs, rmdirSync: path => { + if (removals++ === 0) throw new Error("denied once"); rmdirSync(path); + } }, + }), "remove"); + expect(readdirSync(root)).toEqual([]); + refusal(() => assertSelectedRuntimeWritable({ ...standalone, path: "other.exe" }, join(root, "absent"), windows), "protocol"); + expect(readdirSync(root)).toEqual([]); + }); + + test("remove refusal preserves nonempty leftover entries instead of recursively deleting", () => { + const root = temporary(); + refusal(() => assertSelectedRuntimeWritable(runtime, root, { + ...windows, spawnSync: ((path: string, argv: string[], options: object) => { + const script = argv[1]!.replace('fs.rmdirSync(path); result = "ok";', 'fs.writeFileSync(path + "/leftover", "preserve"); throw new Error("remove blocked");'); + return spawnSync(path, ["-e", script], options); + }) as typeof spawnSync, + }), "remove"); + expect(readFileSync(join(root, ".ocx-runtime-probe-nonce", "leftover"), "utf8")).toBe("preserve"); + }); +}); diff --git a/tests/service/service-desktop-runtime-preflight.test.ts b/tests/service/service-desktop-runtime-preflight.test.ts new file mode 100644 index 00000000000..b3065bf13cd --- /dev/null +++ b/tests/service/service-desktop-runtime-preflight.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { spawnSync } from "node:child_process"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { assertSelectedRuntimeWritable, type RuntimePreflightDeps } from "../../src/lib/bun-runtime-preflight"; +import type { DurableBunRuntime } from "../../src/lib/bun-runtime"; +import type { DesktopSupervision } from "../../src/service/desktop-command-guard"; +import type { ServiceDiagnostic } from "../../src/service/diagnostics"; +import { installFreshWindowsSchedulerSafely, installServiceSafely } from "../../src/service/orchestration"; +import { repairService } from "../../src/service/repair"; +import { buildWindowsTaskXml } from "../../src/service/windows-taskxml"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) removeTreeWithRetry(root); }); +const selected: DurableBunRuntime = { path: process.execPath, source: "override", overrideEnv: "OPENCODEX_BUN_PATH" }; +const desktop: DesktopSupervision = { + kind: "desktop", runtimePid: 4242, supervisorPid: 4200, + app: "/Applications/OpenCodex.app/Contents/MacOS/opencodex-desktop", + proxy: "/Applications/OpenCodex.app/Contents/MacOS/ocx", +}; +const diagnostic: ServiceDiagnostic = { + supported: true, installed: true, enabled: true, running: true, viable: true, + startable: true, stale: false, conflict: false, backend: "scheduler", summary: "fixture", +}; +const sid = "S-1-5-21-111-222-333-1001"; +const launcher = "C:\\fixture\\launcher.vbs"; +const healthyXml = buildWindowsTaskXml("injected.cmd", launcher, undefined, sid); + +function fixture(supervised: boolean, passes: boolean) { + const root = mkdtempSync(join(tmpdir(), "ocx-desktop-runtime-")); roots.push(root); + const order: string[] = []; + const admitted: DurableBunRuntime[] = []; + const candidate = { ...selected }; + let spawns = 0, selections = 0; + const admission: RuntimePreflightDeps = { + platform: "win32", configDir: () => root, + selectRuntime: () => { selections++; return candidate; }, + assertRuntimeWritable: (runtime, configDir, options) => { + order.push("preflight"); admitted.push(runtime); + assertSelectedRuntimeWritable(runtime, configDir, { + ...options, nonce: () => "combined", + spawnSync: ((path: string) => { + spawns++; expect(path).toBe(selected.path); + return { status: passes ? 0 : 1, stdout: passes ? "combined:ok" : "combined:create" }; + }) as typeof spawnSync, + }); + candidate.path = "unprobed.exe"; + }, + }; + const inspectSupervision = (): DesktopSupervision => supervised ? desktop : { kind: "none" }; + const consume = (effect: string) => (runtime: DurableBunRuntime) => { + order.push(effect); admitted.push(runtime); + expect(runtime).toEqual(selected); expect(Object.isFrozen(runtime)).toBe(true); + }; + const run = (action: string) => { + if (action === "install") return installServiceSafely("scheduler", consume("install"), { + ...admission, inspectSupervision, + diagnose: () => { order.push("diagnose"); return diagnostic; }, + managerOps: () => ({ status: () => "present", stop: () => { order.push("stop"); } }), + stopTrackedProxy: async () => { order.push("proxy"); }, + }); + if (action === "fresh install") return installFreshWindowsSchedulerSafely({ + ...admission, inspectSupervision, + stageRegistrationXml: () => { order.push("stage"); return "fixture.xml"; }, + register: async () => { order.push("register"); }, removeStagedXml: () => { order.push("unstage"); }, + recordOwnership: () => { order.push("ownership"); return true; }, + prepare: async () => { order.push("stop"); }, removeNativeService: () => { order.push("remove"); }, + publishAssets: consume("assets"), verifyBeforeRun: () => {}, runTask: () => { order.push("start"); }, + writeState: consume("state"), rollbackTask: async () => { order.push("rollback"); return null; }, + }); + return repairService({ + ...admission, inspectSupervision, + diagnose: () => { order.push("diagnose"); return { ...diagnostic, backend: action === "native repair" ? "native" : "scheduler" }; }, + readOwnership: () => { order.push("ownership"); return { kind: "none", revision: 0 }; }, + assertEnv: () => { order.push("env"); }, assertAuth: () => { order.push("auth"); }, + launcherPathDiagnostic: () => null, + repairNative: consume("native"), writeNativeState: consume("state"), + readSchedulerXml: () => healthyXml, resolveExpectedUserId: () => sid, schedulerLauncher: launcher, + stopScheduler: () => { order.push("stop"); }, writeSchedulerAssets: consume("assets"), + startScheduler: () => { order.push("start"); }, writeSchedulerState: consume("state"), + }); + }; + return { run, order, admitted, spawns: () => spawns, selections: () => selections }; +} + +describe("Desktop supervision precedes selected-runtime service admission", () => { + for (const action of ["install", "fresh install", "native repair", "scheduler repair"]) { + test(`${action}: Desktop refuses before the refusing preflight can spawn`, async () => { + const f = fixture(true, false); + await expect(f.run(action)).rejects.toThrow("OpenCodex Desktop supervises the running proxy"); + expect(f.spawns()).toBe(0); expect(f.selections()).toBe(0); expect(f.order).toEqual([]); + }); + test(`${action}: preflight refusal precedes stop and runtime writes`, async () => { + const f = fixture(false, false); + if (action === "fresh install") { + await expect(f.run(action)).rejects.toThrow("preflight: create"); + expect(f.order).toEqual(["stage", "register", "unstage", "ownership", "preflight", "rollback"]); + } else { + await expect(f.run(action)).rejects.toMatchObject({ code: "OCX_RUNTIME_PREFLIGHT_FAILED", reason: "create" }); + expect(f.order).toEqual(action === "install" ? ["preflight"] : ["diagnose", "ownership", "env", "auth", "preflight"]); + } + expect(f.spawns()).toBe(1); expect(f.selections()).toBe(1); + }); + test(`${action}: passing admission uses one frozen runtime through commit`, async () => { + const f = fixture(false, true); + await f.run(action); + expect(f.spawns()).toBe(1); expect(f.selections()).toBe(1); + expect(f.admitted.length).toBeGreaterThan(1); + for (const runtime of f.admitted) { expect(runtime).toBe(f.admitted[0]); expect(runtime).toEqual(selected); } + if (action === "fresh install") expect(f.order.indexOf("ownership")).toBeLessThan(f.order.indexOf("preflight")); + expect(f.order.indexOf("preflight")).toBeLessThan(f.order.indexOf(action === "native repair" ? "native" : "stop")); + }); + } +}); diff --git a/tests/service/service-ownership-handover.test.ts b/tests/service/service-ownership-handover.test.ts index a16232cdba7..80c66ae76bb 100644 --- a/tests/service/service-ownership-handover.test.ts +++ b/tests/service/service-ownership-handover.test.ts @@ -313,14 +313,15 @@ describe("live Desktop service command guards", () => { test("safe install checks fresh supervision after asynchronous cleanup", async () => { for (const blocked of [SUPERVISION, SEEN_UNKNOWN]) { let reads = 0; + let current: DesktopSupervision = { kind: "none" }; const touched: string[] = []; await expect(installServiceSafely("scheduler", () => { touched.push("install"); }, { - inspectSupervision: () => reads++ === 0 ? { kind: "none" } : blocked, + inspectSupervision: () => { reads++; return current; }, platform: "darwin", diagnose: () => INSTALLED, managerOps: () => ({ status: () => null, stop: () => { touched.push("stop"); } }), - stopTrackedProxy: async () => { touched.push("tracked"); }, + stopTrackedProxy: async () => { touched.push("tracked"); current = blocked; }, })).rejects.toThrow(desktopServiceRefusal(blocked, true)!); - expect(reads).toBe(2); + expect(reads).toBe(3); expect(touched).toEqual(["tracked"]); } }); diff --git a/tests/service/service-runtime-preflight.test.ts b/tests/service/service-runtime-preflight.test.ts new file mode 100644 index 00000000000..7f10a87e609 --- /dev/null +++ b/tests/service/service-runtime-preflight.test.ts @@ -0,0 +1,282 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { assertSelectedRuntimeWritable, RuntimePreflightError, type RuntimePreflightReason } from "../../src/lib/bun-runtime-preflight"; +import { durableBunRuntime, BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, type DurableBunRuntime } from "../../src/lib/bun-runtime"; +import { installServiceSafely, installFreshWindowsSchedulerSafely, platformOps } from "../../src/service/orchestration"; +import { installWindows, installWindowsNative, type WindowsServiceInstallDeps } from "../../src/service/windows-ops"; +import { repairService } from "../../src/service/repair"; +import { cliEntry, writeServiceInstallState } from "../../src/service/state"; +import { buildWindowsServiceScript } from "../../src/service/windows-taskxml"; +import { defaultWinswEntry } from "../../src/lib/winsw"; +import { recordOwnedConfigPath, CONFIG_OWNER_FILE, CONFIG_UNINSTALL_MANIFEST } from "../../src/lib/config-ownership"; +import type { ServiceDiagnostic } from "../../src/service/diagnostics"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath } from "../helpers/repo-root"; + +const runtime: DurableBunRuntime = { path: process.execPath, source: "override", overrideEnv: "OPENCODEX_BUN_PATH" }; +const roots: string[] = []; +const envKeys = ["OPENCODEX_HOME", "CODEX_HOME", BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV] as const; +const originalEnv = envKeys.map(key => process.env[key]); +function temporary(): string { + const dir = mkdtempSync(join(tmpdir(), "ocx-service-runtime-")); roots.push(dir); return dir; +} +afterEach(() => { + for (const [index, key] of envKeys.entries()) { + if (originalEnv[index] === undefined) delete process.env[key]; else process.env[key] = originalEnv[index]; + } + for (const dir of roots.splice(0)) removeTreeWithRetry(dir); +}); +const diagnostic: ServiceDiagnostic = { + supported: true, installed: true, enabled: true, running: true, viable: true, + startable: true, stale: false, conflict: false, backend: "scheduler", summary: "test", +}; + +describe("service selected-runtime admission", () => { + for (const reason of ["spawn", "timeout", "create", "remove", "protocol"] as RuntimePreflightReason[]) { + test(`${reason} refuses before disruption and rolls back fresh registration after ownership`, async () => { + const effects: string[] = []; + const deny = () => { throw new RuntimePreflightError(reason); }; + const deps = { platform: "win32" as const, selectRuntime: () => runtime, configDir: () => temporary(), assertRuntimeWritable: deny }; + await expect(installServiceSafely("scheduler", () => { effects.push("install"); }, { + ...deps, diagnose: () => { effects.push("diagnose"); return diagnostic; }, + managerOps: () => ({ status: () => "present", stop: () => { effects.push("stop"); } }), + stopTrackedProxy: async () => { effects.push("proxy"); }, + })).rejects.toMatchObject({ code: "OCX_RUNTIME_PREFLIGHT_FAILED", reason }); + const home = join(temporary(), "absent-home"); + const freshOrder: string[] = []; + await expect(installFreshWindowsSchedulerSafely({ + ...deps, configDir: () => home, + assertRuntimeWritable: (_selected, _root, options) => { + expect(options?.rootWasAbsent).toBe(false); freshOrder.push("preflight"); deny(); + }, + stageRegistrationXml: () => { expect(existsSync(home)).toBe(false); freshOrder.push("stage"); return "stage"; }, + register: async () => { freshOrder.push("register"); }, removeStagedXml: () => {}, + recordOwnership: () => { freshOrder.push("ownership"); return recordOwnedConfigPath(home, join(home, "service-state.json")); }, + prepare: async () => { freshOrder.push("prepare"); effects.push("stop"); }, + removeNativeService: () => { freshOrder.push("remove"); }, publishAssets: () => { freshOrder.push("assets"); }, + verifyBeforeRun: () => { freshOrder.push("verify"); }, runTask: () => { freshOrder.push("run"); }, + writeState: () => { freshOrder.push("state"); }, + rollbackTask: async () => { freshOrder.push("rollback"); return null; }, + })).rejects.toThrow(`${new RuntimePreflightError(reason).message}\nThe new Task Scheduler registration was rolled back.`); + expect(freshOrder).toEqual(["stage", "register", "ownership", "preflight", "rollback"]); + expect(existsSync(join(home, CONFIG_OWNER_FILE))).toBe(true); + expect(() => installWindows(undefined, deps)).toThrow(RuntimePreflightError); + await expect(installWindowsNative(undefined, deps)).rejects.toThrow(RuntimePreflightError); + for (const backend of ["scheduler", "native"] as const) { + await expect(repairService({ + ...deps, diagnose: () => ({ ...diagnostic, backend }), readOwnership: () => ({ kind: "none", revision: 0 }), + assertEnv: () => { effects.push("env"); }, assertAuth: () => { effects.push("token"); }, + writeSchedulerAssets: () => { effects.push("assets"); }, stopScheduler: () => { effects.push("stop"); }, + repairNative: () => { effects.push("download/xml"); }, writeNativeState: () => { effects.push("state"); }, + })).rejects.toMatchObject({ code: "OCX_RUNTIME_PREFLIGHT_FAILED", reason }); + } + expect(effects).toEqual(["env", "token", "env", "token"]); + }); + } + + test("generic install freezes the admitted selection before stopping any manager", async () => { + const selected = { ...runtime }; + const order: string[] = []; + let selections = 0; + await installServiceSafely("scheduler", admitted => { + order.push("install"); expect(admitted).toEqual(runtime); expect(Object.isFrozen(admitted)).toBe(true); + }, { + platform: "win32", configDir: temporary, selectRuntime: () => { selections++; return selected; }, + assertRuntimeWritable: admitted => { order.push("preflight"); expect(admitted).toEqual(runtime); selected.path = "unprobed.exe"; }, + diagnose: () => diagnostic, + managerOps: () => ({ status: () => "present", stop: () => { order.push("stop"); } }), + stopTrackedProxy: async () => { order.push("proxy"); }, + }); + expect(selections).toBe(1); + expect(order).toEqual(["preflight", "stop", "proxy", "install"]); + }); + + for (const backend of ["scheduler", "native"] as const) { + for (const refuses of [true, false]) { + test(`${backend} composed install ${refuses ? "refuses before stop" : "never admits again after stop"}`, async () => { + const home = temporary(); process.env.OPENCODEX_HOME = home; + const selected = { ...runtime }; + const order: string[] = []; + const seen: DurableBunRuntime[] = []; + let probes = 0, selections = 0; + const admission = { + platform: "win32" as const, configDir: () => home, + selectRuntime: () => { selections++; return selected; }, + assertRuntimeWritable: (admitted: DurableBunRuntime) => { + order.push("preflight"); probes++; seen.push(admitted); + if (refuses || probes > 1) throw new RuntimePreflightError("create"); + selected.path = "unprobed.exe"; + }, + }; + const effects: WindowsServiceInstallDeps = { + ...admission, + recordSchedulerOwnership: () => { order.push("ownership"); return true; }, + removeNativeService: () => { order.push("remove-native"); }, + stopScheduler: () => { order.push("installer-stop"); }, + writeSchedulerAssets: admitted => { + seen.push(admitted); order.push("assets"); + expect(buildWindowsServiceScript(cliEntry(admitted), 12345, [])).toContain(runtime.path); + }, + schedulerCommand: args => { order.push(args[0]!); return ""; }, + assertNativeAccount: () => { order.push("account"); }, + prepareNativeConfig: () => { order.push("native-config"); }, + uninstallScheduler: () => { throw new Error("no scheduler to uninstall"); }, + installNativeService: async entry => { + order.push("winsw"); expect(entry.bun).toBe(runtime.path); expect(entry.bunRuntimeSource).toBe(runtime.source); + }, + writeState: (installedBackend, admitted) => { + expect(installedBackend).toBe(backend); seen.push(admitted); order.push("state"); + writeServiceInstallState(installedBackend, undefined, { paths: [join(home, "state.json")] }, admitted); + }, + }; + const ops = platformOps(backend, { platform: "win32", windowsInstall: effects })!; + const result = installServiceSafely(backend, ops.install, { + ...admission, diagnose: () => ({ ...diagnostic, backend }), + managerOps: () => ({ status: () => "present", stop: () => { order.push("stop"); } }), + stopTrackedProxy: async () => { order.push("proxy"); }, + }); + if (refuses) { + await expect(result).rejects.toMatchObject({ code: "OCX_RUNTIME_PREFLIGHT_FAILED", reason: "create" }); + expect(order).toEqual(["preflight"]); expect(readdirSync(home)).toEqual([]); + } else { + await result; + expect(order).toEqual(backend === "scheduler" + ? ["preflight", "stop", "proxy", "ownership", "remove-native", "installer-stop", "assets", "/create", "/run", "state"] + : ["preflight", "stop", "proxy", "account", "native-config", "/query", "winsw", "state"]); + expect(JSON.parse(readFileSync(join(home, "state.json"), "utf8"))).toMatchObject({ backend, bunPath: runtime.path }); + } + expect(probes).toBe(1); expect(selections).toBe(1); + for (const admitted of seen) { + expect(admitted).toBe(seen[0]); expect(admitted).toEqual(runtime); expect(Object.isFrozen(admitted)).toBe(true); + } + }); + } + + test(`${backend} direct public installer admits before committing`, async () => { + const home = temporary(); process.env.OPENCODEX_HOME = home; + const order: string[] = []; + let probes = 0; + const deps: WindowsServiceInstallDeps = { + platform: "win32", configDir: () => home, selectRuntime: () => runtime, + assertRuntimeWritable: admitted => { probes++; order.push("preflight"); expect(Object.isFrozen(admitted)).toBe(true); }, + recordSchedulerOwnership: () => true, removeNativeService: () => {}, stopScheduler: () => { order.push("stop"); }, + writeSchedulerAssets: () => { order.push("assets"); }, schedulerCommand: () => "", + assertNativeAccount: () => {}, prepareNativeConfig: () => { order.push("config"); }, + installNativeService: async () => { order.push("winsw"); }, writeState: () => { order.push("state"); }, + }; + if (backend === "scheduler") installWindows(undefined, deps); + else await installWindowsNative(undefined, deps); + expect(probes).toBe(1); + expect(order).toEqual(backend === "scheduler" ? ["preflight", "stop", "assets", "state"] : ["preflight", "config", "winsw", "state"]); + }); + } + + test("fresh absent root clears stale ownership refusal before preflight and preparation", async () => { + const home = join(temporary(), "absent-home"); + mkdirSync(home); writeFileSync(join(home, "legacy.txt"), "keep"); + expect(recordOwnedConfigPath(home, join(home, "service-state.json"))).toBe(false); + removeTreeWithRetry(home); + const order: string[] = []; + const selected = { ...runtime }; + let selections = 0; + await installFreshWindowsSchedulerSafely({ + platform: "win32", configDir: () => home, selectRuntime: () => { selections++; return selected; }, + assertRuntimeWritable: (admitted, root, options) => { + expect(options?.rootWasAbsent).toBe(false); + expect(existsSync(join(home, CONFIG_OWNER_FILE))).toBe(true); + expect(admitted).toEqual(runtime); expect(Object.isFrozen(admitted)).toBe(true); + assertSelectedRuntimeWritable(admitted, root, options); order.push("preflight"); + }, + stageRegistrationXml: () => { + expect(existsSync(home)).toBe(false); selected.path = "unprobed.exe"; order.push("stage"); return "stage"; + }, + register: async () => { order.push("register"); }, removeStagedXml: () => {}, + recordOwnership: () => { order.push("ownership"); return recordOwnedConfigPath(home, join(home, "service-state.json")); }, + prepare: async () => { order.push("prepare"); }, removeNativeService: () => { order.push("remove"); }, + publishAssets: admitted => { expect(admitted).toEqual(runtime); order.push("assets"); }, + verifyBeforeRun: () => { order.push("verify"); }, runTask: () => { order.push("run"); }, writeState: () => { order.push("state"); }, + rollbackTask: async () => { order.push("rollback"); return null; }, + }); + expect(selections).toBe(1); + expect(order).toEqual(["stage", "register", "ownership", "preflight", "prepare", "remove", "assets", "verify", "run", "state"]); + expect(existsSync(join(home, CONFIG_OWNER_FILE))).toBe(true); + }); + + test("fresh denied absent root retains ownership and rolls back registration before preparation", async () => { + const home = join(temporary(), "absent-home"); + const order: string[] = []; + let registeredNonce = "", rolledBackNonce = ""; + await expect(installFreshWindowsSchedulerSafely({ + platform: "win32", configDir: () => home, selectRuntime: () => ({ ...runtime, path: join(home, "missing.exe") }), + assertRuntimeWritable: (selected, root, options) => { + order.push("preflight"); expect(options?.rootWasAbsent).toBe(false); + assertSelectedRuntimeWritable(selected, root, options); + }, + stageRegistrationXml: () => { expect(existsSync(home)).toBe(false); order.push("stage"); return "stage"; }, + register: async (_path, nonce) => { registeredNonce = nonce; order.push("register"); }, removeStagedXml: () => {}, + recordOwnership: () => { order.push("ownership"); return recordOwnedConfigPath(home, join(home, "service-state.json")); }, + prepare: async () => { order.push("prepare"); order.push("stop"); }, removeNativeService: () => { order.push("remove"); }, + publishAssets: () => { order.push("assets"); }, verifyBeforeRun: () => { order.push("verify"); }, + runTask: () => { order.push("run"); }, writeState: () => { order.push("state"); }, + rollbackTask: async nonce => { rolledBackNonce = nonce; order.push("rollback"); return null; }, + })).rejects.toThrow(`${new RuntimePreflightError("spawn").message}\nThe new Task Scheduler registration was rolled back.`); + expect(order).toEqual(["stage", "register", "ownership", "preflight", "rollback"]); + expect(registeredNonce).not.toBe(""); expect(rolledBackNonce).toBe(registeredNonce); + expect(existsSync(join(home, CONFIG_OWNER_FILE))).toBe(true); + expect(JSON.parse(readFileSync(join(home, CONFIG_UNINSTALL_MANIFEST), "utf8")).paths).toContain("service-state.json"); + expect(existsSync(join(home, "service-state.json"))).toBe(false); + }); + + test("selection changes after admission cannot change scheduler rendering, WinSW entry or install state", async () => { + const home = temporary(); + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = join(home, "codex"); + process.env[BUN_RUNTIME_PATH_ENV] = runtime.path; + process.env[BUN_RUNTIME_SOURCE_ENV] = runtime.source; + let selections = 0; + const state = join(home, "state.json"); + await installFreshWindowsSchedulerSafely({ + platform: "win32", configDir: () => home, selectRuntime: () => { selections++; return durableBunRuntime(); }, + assertRuntimeWritable: () => {}, + stageRegistrationXml: () => "stage", register: async () => {}, removeStagedXml: () => {}, recordOwnership: () => true, + prepare: async () => { process.env[BUN_RUNTIME_SOURCE_ENV] = "process"; }, removeNativeService: () => {}, + publishAssets: selected => { + expect(durableBunRuntime().source).toBe("process"); + expect(selected).toEqual(runtime); + expect(buildWindowsServiceScript(cliEntry(selected), 12345, [])).toContain('set "OCX_BUN_RUNTIME_SOURCE=override"'); + expect(defaultWinswEntry("source", selected)).toMatchObject({ bun: runtime.path, bunRuntimeSource: "override" }); + }, verifyBeforeRun: () => {}, runTask: () => {}, + writeState: selected => writeServiceInstallState("scheduler", undefined, { paths: [state] }, selected), + }); + expect(selections).toBe(1); + expect(JSON.parse(readFileSync(state, "utf8"))).toMatchObject({ bunPath: runtime.path, backend: "scheduler" }); + // A distinct selected path also survives the state writer's default selector. + const distinct = { ...runtime, path: join(home, "selected.exe") }; + writeServiceInstallState("native", undefined, { paths: [state] }, distinct); + expect(JSON.parse(readFileSync(state, "utf8")).bunPath).toBe(distinct.path); + }); + + test("native repair uses one frozen admitted runtime for both rendering and state", async () => { + const selected = { ...runtime }; + const seen: DurableBunRuntime[] = []; + await repairService({ + platform: "win32", configDir: temporary, selectRuntime: () => selected, + diagnose: () => ({ ...diagnostic, backend: "native" }), readOwnership: () => ({ kind: "none", revision: 0 }), + assertRuntimeWritable: admitted => { seen.push(admitted); selected.path = "unprobed.exe"; }, + assertEnv: () => {}, assertAuth: () => {}, launcherPathDiagnostic: () => null, + repairNative: admitted => { seen.push(admitted); }, writeNativeState: admitted => { seen.push(admitted); }, + }); + expect(seen).toEqual([runtime, runtime, runtime]); + expect(seen[0]).toBe(seen[1]); expect(seen[1]).toBe(seen[2]); + }); + + test("preflight stays outside cliEntry and CLI reports refusal as exit 1", () => { + const state = readFileSync(repoPath("src/service/state.ts"), "utf8"); + const entry = state.slice(state.indexOf("export function cliEntry("), state.indexOf("export function serviceLauncherPathDiagnostic")); + expect(entry).not.toContain("assertSelectedRuntimeWritable"); + const cli = readFileSync(repoPath("src/service/cli.ts"), "utf8"); + expect(cli).toContain("process.exitCode = 1"); + }); +}); diff --git a/tests/service/service.test.ts b/tests/service/service.test.ts index acaa867d865..c7eb3daaa2b 100644 --- a/tests/service/service.test.ts +++ b/tests/service/service.test.ts @@ -2443,13 +2443,13 @@ describe("service lifecycle cleanup ordering", () => { test("Windows service install ends the running task before rewriting its assets, with write retry", async () => { const service = await readText("src/service/windows-ops.ts"); const assetsHelper = service.slice( - service.indexOf("function writeWindowsSchedulerAssets()"), - service.indexOf("function installWindows()"), + service.indexOf("function writeWindowsSchedulerAssets("), + service.indexOf("function installWindows("), ); - const installWindows = service.slice(service.indexOf("function installWindows()"), service.indexOf("async function installWindowsNative()")); + const installWindows = service.slice(service.indexOf("function installWindows("), service.indexOf("async function installWindowsNative(")); - const stopAt = installWindows.indexOf("stopWindows();"); - const assetsAt = installWindows.indexOf("writeWindowsSchedulerAssets();"); + const stopAt = installWindows.indexOf("(deps.stopScheduler ?? stopWindows)();"); + const assetsAt = installWindows.indexOf("(deps.writeSchedulerAssets ?? writeWindowsSchedulerAssets)(runtime);"); const createAt = installWindows.indexOf("buildWindowsSchtasksCreateArgs"); expect(stopAt).toBeGreaterThan(-1); expect(assetsAt).toBeGreaterThan(-1); @@ -2533,8 +2533,8 @@ describe("service lifecycle cleanup ordering", () => { test("native install refuses Microsoft-account logins before removing the scheduler backend", async () => { const service = await readText("src/service/windows-ops.ts"); - const installNative = service.slice(service.indexOf("async function installWindowsNative()"), service.indexOf("function startWindows()")); - expect(installNative.indexOf("assertWindowsNativeServiceAccountSupported()")).toBeLessThan(installNative.indexOf("uninstallWindows()")); + const installNative = service.slice(service.indexOf("async function installWindowsNative("), service.indexOf("function startWindows()")); + expect(installNative.indexOf("(deps.assertNativeAccount ?? assertWindowsNativeServiceAccountSupported)()")).toBeLessThan(installNative.indexOf("(deps.uninstallScheduler ?? uninstallWindows)()")); expect(service).toContain("Microsoft-account Windows login"); }); From 2cd5f38db88bf8af9618029bf7129dbb35c58567 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 9 Oct 2026 22:38:49 +0900 Subject: [PATCH 40/59] fix(quota): record main-login responses under the observed main credential (#6830) Quota headers from upstream responses were recorded only for pool and main-pool auth contexts, so traffic served with the main login as a plain main context never refreshed __main__ in codex-quota-cache.json (#6800). Materialization now captures a process-local dispatch proof when the bearer sent upstream is the main credential the proxy already observed. HTTP delivery and the WebSocket observer record headers under __main__ only for the canonical OpenAI forward provider and only while that proof is live: same identity, credential generation and credential mutation epoch. Ownership is per physical attempt; a WebSocket exchange publishes only through its observer, prelude projections and claimed dispatches never republish, and delivery uses the proof captured when the response arrived. Pool and main-pool behaviour is unchanged. Fixes #6800 --- .../docs/reference/cli/providers-accounts.md | 6 + scripts/test-layout/layout.json | 1 + src/codex/auth-context.ts | 21 +- src/codex/main-account-cache.ts | 43 + src/server/responses/codex-ws-exchange.ts | 7 +- src/server/responses/codex-ws-wire.ts | 9 + src/server/responses/core-codex-account.ts | 23 +- src/server/responses/passthrough-delivery.ts | 24 +- src/server/responses/passthrough-dispatch.ts | 4 + src/server/responses/ws-upstream.ts | 2 +- structure/providers/openai-accounts.md | 1 + structure/providers/openai-tiers.md | 19 + structure/transports/responses-failover.md | 2 +- structure/transports/responses.md | 2 +- tests/fixtures/test-layout-expected.json | 1 + .../responses-main-quota-observation.test.ts | 963 ++++++++++++++++++ 16 files changed, 1119 insertions(+), 9 deletions(-) create mode 100644 tests/responses/responses-main-quota-observation.test.ts diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 88f997b2e87..efd8f00d9a5 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -407,6 +407,12 @@ with the lock status; Direct provider quota omits an unpublished response and it identities and stale 401/403 replies retain the current cached info and cannot clear or set the current account's reauthentication state. +Responses to requests sent with the identified main credential refresh its cached +usage from their quota headers, whether the proxy substituted the stored credential or +the caller sent the same credential itself. A response is applied only if that +credential is still the observed main credential when it arrives; a caller-owned +credential for another account or workspace never updates the main account's usage. + The persisted option is `"codexMainAccountHardLock"` in OpenCodex's `config.json`. An absent key or `true` means on; only an explicit `false` turns it off, and that is what switching the setting off stores. The default changed here: the policy used to be opt-in and the old switch removed the key diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 04ebce80766..f4c797ed734 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1322,6 +1322,7 @@ "openai-provider-option.test.ts": "adapters/openai", "responses-forward-client-headers.test.ts": "responses", "openai-responses-passthrough.test.ts": "responses", + "responses-main-quota-observation.test.ts": "responses", "external-task-input-repair.test.ts": "responses", "openai-responses-summary-none.test.ts": "responses", "responses-forward-output-cap.test.ts": "responses", diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 84929653161..3903e295fde 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -81,11 +81,13 @@ import { import { captureMainAccountIdentityGeneration, captureMainQuotaWriter, + captureMainQuotaDispatch, getObservedMainQuotaIdentityKey, isMainQuotaWriterLive, matchesMainQuotaCredential, observeMainQuotaCredential, type MainQuotaWriter, + type MainQuotaDispatch, } from "./main-account-cache"; import { CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, isCodexReserveHelperUnsupported, isCodexReserveRequestEligible } from "./loopback-target"; import type { DataPlaneAdmission } from "../server/auth-cors"; @@ -275,7 +277,11 @@ export function previewCodexPoolLineage( } export type CodexAuthContext = - | { kind: "main"; accountId: null; reserveAuthorization?: MainReserveAuthorization } + | { + kind: "main"; accountId: null; reserveAuthorization?: MainReserveAuthorization; + /** Captured for the observed main credential actually selected for upstream. */ + mainQuotaDispatch?: MainQuotaDispatch; + } | { kind: "pool"; accountId: string; @@ -1635,6 +1641,14 @@ export class CodexMainSubstitutionUnavailableError extends Error { } } +/** Dispatch identity comes only from an owned observation of the selected credential. */ +function selectedMainQuotaDispatch(selected: Headers): MainQuotaDispatch | undefined { + const bearer = selected.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + if (!bearer) return undefined; + const accountId = selected.get("chatgpt-account-id") ?? extractAccountId(undefined, bearer); + return captureMainQuotaDispatch(bearer, accountId, captureConfigGeneration()); +} + /** * Build the upstream auth headers for one Codex turn. * @@ -1654,6 +1668,7 @@ export function materializeCodexUpstreamAuth( ctx: CodexAuthContext, options: CodexAuthMaterializationOptions = {}, ): Headers { + if (ctx.kind === "main") ctx.mainQuotaDispatch = undefined; const selected = new Headers(); for (const name of FORWARD_HEADERS) { const value = headers.get(name); @@ -1695,10 +1710,12 @@ export function materializeCodexUpstreamAuth( observeSelectedMainCredential(stored, writer); assertMainAccountPolicy(options.config); assertMaterializedReserve(selected, ctx, options); + ctx.mainQuotaDispatch = selectedMainQuotaDispatch(selected); return selected; } if (callerMatchesObservedMain(selected)) assertMainAccountPolicy(options.config); assertMaterializedReserve(selected, ctx, options); + if (ctx.kind === "main") ctx.mainQuotaDispatch = selectedMainQuotaDispatch(selected); return selected; } @@ -1749,6 +1766,7 @@ export async function materializeCodexUpstreamAuthAsync( ctx: CodexAuthContext, options: CodexAuthMaterializationOptions = {}, ): Promise { + if (ctx.kind === "main") ctx.mainQuotaDispatch = undefined; if (requiresReserveAuthorization(options.config, options.modelId, options.admission)) { return materializeReserveUpstreamAuth(headers, ctx, options); } @@ -1776,6 +1794,7 @@ export async function materializeCodexUpstreamAuthAsync( assertMainAccountPolicy(options.config); // An opt-in enabled during token refresh must not turn a proof-less context into Reserve. assertMaterializedReserve(selected, ctx, options); + ctx.mainQuotaDispatch = selectedMainQuotaDispatch(selected); return selected; } diff --git a/src/codex/main-account-cache.ts b/src/codex/main-account-cache.ts index 81b93dd5289..3a46180e672 100644 --- a/src/codex/main-account-cache.ts +++ b/src/codex/main-account-cache.ts @@ -1,4 +1,5 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { codexCredentialMutationEpoch } from "./credential-mutation-epoch"; import type { StoredAccountQuota } from "./quota-types"; import { truncateRetainedUtf8 } from "../lib/admission"; @@ -73,6 +74,48 @@ export function isMainQuotaWriterLive(writer: MainQuotaWriter): boolean { && writer.identityGeneration === mainAccountIdentityGeneration; } +/** Proof that a dispatch used the observed main credential; process-local, never persisted. */ +export type MainQuotaDispatch = Readonly<{ + writer: MainQuotaWriter; + credentialGeneration: number; + credentialMutationEpoch: number; + configGeneration: number; +}>; + +// WS quota frames publish through their observer; prelude quota can only come from those frames. +// A real HTTP fallback after a failed upgrade never invokes the observer and stays unclaimed. +const wsObservedMainDispatches = new WeakSet(); + +export function claimMainQuotaDispatchForWs(dispatch: MainQuotaDispatch): void { + wsObservedMainDispatches.add(dispatch); +} + +export function isMainQuotaDispatchWsClaimed(dispatch: MainQuotaDispatch): boolean { + return wsObservedMainDispatches.has(dispatch); +} + +/** Give a replacement physical attempt its own quota ownership without recapturing credential fences. */ +export function renewMainQuotaDispatchForAttempt(dispatch: MainQuotaDispatch): MainQuotaDispatch { + return { ...dispatch }; +} + +export function captureMainQuotaDispatch( + accessToken: string, accountId: string | undefined, configGeneration: number, +): MainQuotaDispatch | undefined { + if (!accountId || !matchesMainQuotaCredential(accessToken, accountId)) return undefined; + const writer = captureMainQuotaWriter(accountId); + return writer ? { writer, credentialGeneration: mainQuotaCredentialGeneration, + credentialMutationEpoch: codexCredentialMutationEpoch(), configGeneration } : undefined; +} + +export function isMainQuotaDispatchLive(dispatch: MainQuotaDispatch): boolean { + // Other OpenCodex-owned credential publications also advance this epoch; + // dropping a main quota update after any such publication is the intended safe direction. + return isMainQuotaWriterLive(dispatch.writer) + && dispatch.credentialGeneration === mainQuotaCredentialGeneration + && dispatch.credentialMutationEpoch === codexCredentialMutationEpoch(); +} + export function getObservedMainQuotaIdentityKey(): string | undefined { return observedMainQuotaIdentityKey; } diff --git a/src/server/responses/codex-ws-exchange.ts b/src/server/responses/codex-ws-exchange.ts index ddb587a1a4d..849c7184198 100644 --- a/src/server/responses/codex-ws-exchange.ts +++ b/src/server/responses/codex-ws-exchange.ts @@ -9,7 +9,7 @@ import { CODEX_RESPONSES_HTTP_URL, type PreparedCodexWsRequest } from "./codex-w import { CodexWsCorrelation } from "./codex-ws-correlation"; import type { CodexWsSession } from "./codex-ws-session"; import { UPGRADE_DEADLINE_MS, CODEX_WS_LIVENESS_PING_INTERVAL_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES, - MAX_CODEX_WS_QUEUE_BYTES, markCodexWsResponse, normalizeResponsesWsRelayEvent, closedBeforeTerminalMessage, + MAX_CODEX_WS_QUEUE_BYTES, markCodexWsResponse, markCodexWsPreludeProjection, normalizeResponsesWsRelayEvent, closedBeforeTerminalMessage, codexWsCreateFrameExceedsLimit, codexWsFailureDetail, codexWsPreResponseFailure, markCodexWsStage, codexWsOcxVersion, markCodexWsSocketDeath, type CodexWsFailureStage, type CodexWsStageRecord } from "./codex-ws-wire"; @@ -45,7 +45,8 @@ function rejectionHeaders(source: Record, prelude: Headers): He } } // Reuse the metadata owner's count/value/family budgets and window freshness - // rules, without publishing quota twice. The unmarked HTTP response owns it. + // rules, without publishing quota twice. Pool bookkeeping consumes the HTTP + // projection; plain-main publication belongs only to the WS observer. const projected = new CodexWsMetadata(); try { for (const values of [Object.fromEntries(prelude), source]) { @@ -241,6 +242,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise { // are left out: their channel may already have sent continuation frames on this socket, so // the create frame alone no longer describes the turn. if (socketDied && !nativeControl) markCodexWsSocketDeath(failureResponse, stage); + markCodexWsPreludeProjection(failureResponse); resolve(failureResponse); return; } @@ -513,6 +515,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise { cleanup(); try { controller.close(); } catch { /* unused stream already closed */ } session.dispose(); + markCodexWsPreludeProjection(rejection); resolve(rejection); return; } diff --git a/src/server/responses/codex-ws-wire.ts b/src/server/responses/codex-ws-wire.ts index af8539e93d3..592d2ef32ad 100644 --- a/src/server/responses/codex-ws-wire.ts +++ b/src/server/responses/codex-ws-wire.ts @@ -49,6 +49,7 @@ const WS_CLOSE_MESSAGE_TOO_BIG = 1009; const codexWsUpstreamResponses = new WeakSet(); const quotaObservedResponses = new WeakSet(); +const codexWsPreludeProjections = new WeakSet(); /** Quota arrived directly at its captured account; do not replay old HTTP prelude headers. */ export function isCodexWsQuotaObservedResponse(response: Response): boolean { @@ -60,6 +61,14 @@ export function isCodexWsUpstreamResponse(response: Response): boolean { return codexWsUpstreamResponses.has(response); } +/** Pre-response projection; its headers retain the exchange's prelude snapshot. */ +export function markCodexWsPreludeProjection(response: Response): void { + codexWsPreludeProjections.add(response); +} + +export function isCodexWsPreludeProjection(response: Response): boolean { + return codexWsPreludeProjections.has(response); +} export function markCodexWsResponse(response: Response, observed: boolean): void { codexWsUpstreamResponses.add(response); diff --git a/src/server/responses/core-codex-account.ts b/src/server/responses/core-codex-account.ts index 1864a7f6535..8fa6dafb1ed 100644 --- a/src/server/responses/core-codex-account.ts +++ b/src/server/responses/core-codex-account.ts @@ -6,6 +6,7 @@ import { computeQuotaCooldown, formatCodexProviderForLog, } from "../../codex/routing"; +import { claimMainQuotaDispatchForWs, isMainQuotaDispatchLive, renewMainQuotaDispatchForAttempt, type MainQuotaDispatch } from "../../codex/main-account-cache"; import type { CodexWsQuotaObserver } from "./codex-ws-metadata"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { isCodexAccountGenerationLive } from "../../codex/account-store"; @@ -121,8 +122,28 @@ export function usesCodexForwardPoolAuth( } +/** Live proof that this plain-main response used the observed main credential. */ +export function liveMainQuotaDispatch( + authCtx: CodexAuthContext, provider: OcxProviderConfig, +): MainQuotaDispatch | undefined { + if (authCtx.kind !== "main" || !authCtx.mainQuotaDispatch) return undefined; + if (!isCanonicalOpenAiForwardProvider(provider) + || provider.authMode !== "forward" || provider.adapter !== "openai-responses") return undefined; + return isMainQuotaDispatchLive(authCtx.mainQuotaDispatch) ? authCtx.mainQuotaDispatch : undefined; +} + export function codexWsQuotaObserver(authCtx: CodexAuthContext, provider: OcxProviderConfig, modelId?: string): CodexWsQuotaObserver | undefined { - if (!isCanonicalOpenAiForwardProvider(provider) || !usesCodexForwardPoolAuth(authCtx, provider)) return undefined; + if (!isCanonicalOpenAiForwardProvider(provider)) return undefined; + if (!usesCodexForwardPoolAuth(authCtx, provider)) { + const captured = liveMainQuotaDispatch(authCtx, provider); + if (!captured || authCtx.kind !== "main") return undefined; + const dispatch = authCtx.mainQuotaDispatch = renewMainQuotaDispatchForAttempt(captured); + return headers => { + claimMainQuotaDispatchForWs(dispatch); + if (!isMainQuotaDispatchLive(dispatch)) return; + applyCapturedCodexQuota(MAIN_CODEX_ACCOUNT_ID, headers, dispatch.configGeneration, dispatch.writer, { modelId }); + }; + } const { accountId, writerGeneration } = authCtx; const credentialGeneration = authCtx.kind === "pool" ? authCtx.generation : undefined; const mainWriter = authCtx.kind === "main-pool" ? authCtx.mainQuotaWriter : undefined; diff --git a/src/server/responses/passthrough-delivery.ts b/src/server/responses/passthrough-delivery.ts index c0b86b87595..89cdd6c8533 100644 --- a/src/server/responses/passthrough-delivery.ts +++ b/src/server/responses/passthrough-delivery.ts @@ -24,13 +24,16 @@ import { teeWithBoundedInspection } from "../inspection-tee"; import { codexForwardTerminalOutcomeRecorder, usesCodexForwardPoolAuth, + liveMainQuotaDispatch, codexQuotaOutcomeMeta, codexDenialOutcomeMeta, isFixedCodexAccount, shouldDeferCodexResetDerivedCooldown, } from "./core-codex-account"; +import { isMainQuotaDispatchLive, isMainQuotaDispatchWsClaimed } from "../../codex/main-account-cache"; +import { MAIN_CODEX_ACCOUNT_ID } from "../../codex/account-id"; import type { ResponsesTerminalStatus } from "../../bridge"; -import { isCodexWsQuotaObservedResponse, isCodexWsUpstreamResponse } from "./ws-upstream"; +import { isCodexWsQuotaObservedResponse, isCodexWsUpstreamResponse, isCodexWsPreludeProjection } from "./ws-upstream"; import { recordSubagentQuotaFailureForThreadSpawn } from "../../codex/subagent-model-fallback"; import { recordCodexUpstreamOutcome } from "../../codex/routing"; import { codexProbeLeaseId, codexProbeQuotaScope, codexTransientProbeGrant, releaseCodexAuthContextProbeLease } from "../../codex/auth-context"; @@ -406,6 +409,9 @@ export async function deliverPassthroughResponse( | "localUpstream" >, ): Promise { + const { route } = requestState; + // The proof must belong to the response whose headers are published. + const arrivalMainDispatch = liveMainQuotaDispatch(admissionState.authCtx, route.provider); const { logCtx, config, options, req } = requestContext; const { codexSafetyBufferingOptions, @@ -429,7 +435,7 @@ export async function deliverPassthroughResponse( normalizeFunctionCompletionJson, } = nativeExchange; const { commitReasoningReplayServingRoute, recordTerminalOutcomes } = responseEffects; - const { parsed, route, subagentQuotaFailureModel, clientRequestedStream, translatorBudget, inboundWire } = requestState; + const { parsed, subagentQuotaFailureModel, clientRequestedStream, translatorBudget, inboundWire } = requestState; const enforceDeclaredToolNames = inboundWire !== "chat" && inboundWire !== "anthropic"; const { openAiSidecar } = sidecarState; const { requestBindings } = transportState; @@ -535,6 +541,20 @@ export async function deliverPassthroughResponse( ...(admissionState.authCtx.kind === "pool" ? { credentialGeneration: admissionState.authCtx.generation } : {}), }); } + } else { + // The WS observer is the only plain-main publisher for a WebSocket exchange; + // a prelude projection carries the prelude snapshot, not fresh evidence. + // The dispatch claim is authoritative because downstream wrappers can replace the Response. + if (arrivalMainDispatch && !isMainQuotaDispatchWsClaimed(arrivalMainDispatch) + && !(isCodexWsUpstreamResponse(upstreamResponse) || isCodexWsPreludeProjection(upstreamResponse))) { + const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/auth-api"); + // Import yields; same-account token replacement leaves the identity writer live. + // Re-check the credential fence with no await before publication. + if (isMainQuotaDispatchLive(arrivalMainDispatch)) { + applyAccountQuotaFromUpstreamHeaders(MAIN_CODEX_ACCOUNT_ID, upstreamResponse.headers, + arrivalMainDispatch.configGeneration, arrivalMainDispatch.writer, { modelId: route.modelId }); + } + } } // Non-2xx passthrough failures must never reach Codex as an empty body — diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index a2b423b0a0e..bdca37aed80 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -12,6 +12,7 @@ import type { ResponsesTransport } from "./request-transport"; import type { ResponsesEffects } from "./response-effects"; import type { ResponsesSendBudget } from "./request-send-budget"; import { transientSendCapFor } from "./request-send-budget"; +import { renewMainQuotaDispatchForAttempt } from "../../codex/main-account-cache"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { isLocalUpstream } from "../../lib/local-upstream"; import { codexSafetyBufferingFilterOptions, terminalStatusFromParsed } from "../relay"; @@ -802,6 +803,9 @@ export async function preparePassthroughExchange( const sendAmbiguousReplacement = ( signal: AbortSignal = upstream.signal, ): Promise => { + if (admissionState.authCtx.kind === "main" && admissionState.authCtx.mainQuotaDispatch) { + admissionState.authCtx.mainQuotaDispatch = renewMainQuotaDispatchForAttempt(admissionState.authCtx.mainQuotaDispatch); + } const report = transientSendReporter(); let started = false; const run = () => fetchWithHeaderTimeout( diff --git a/src/server/responses/ws-upstream.ts b/src/server/responses/ws-upstream.ts index b51f66abc1a..186302d2f1b 100644 --- a/src/server/responses/ws-upstream.ts +++ b/src/server/responses/ws-upstream.ts @@ -26,7 +26,7 @@ import { codexWsCreateFrameExceedsLimit } from "./codex-ws-wire"; import { isLoopbackUrl, rewriteWebSocketDial } from "../../plugins/upstream-hooks"; export { CODEX_WS_LIVENESS_PING_INTERVAL_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES, MAX_CODEX_WS_QUEUE_BYTES, MAX_CODEX_WS_CREATE_FRAME_BYTES, CODEX_WS_CREATE_FRAME_LIMIT_BYTES, codexWsCreateFrameExceedsLimit, - isCodexWsQuotaObservedResponse, isCodexWsUpstreamResponse } from "./codex-ws-wire"; + isCodexWsQuotaObservedResponse, isCodexWsUpstreamResponse, isCodexWsPreludeProjection } from "./codex-ws-wire"; export const MIN_BOUNDED_CODEX_WS_BUN_VERSION = "1.4.0"; /** diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index e3ac724871f..c3888bc4706 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -219,6 +219,7 @@ request-scoped: a translated Claude turn resolves through Pool selection like an main keeps its health, quarantine and refresh-and-classify handling. Only a bearer the client itself supplied is caller-owned and exempt from stored state. Both synchronous and asynchronous stored-main substitution in `src/codex/auth-context.ts` remove a caller account header before copying the stored identity; an absent stored account ID leaves no account header. Caller-owned native Direct authentication retains its existing passthrough behavior. +Plain-main HTTP and WebSocket Responses refresh `__main__` only on the canonical OpenAI forward provider when the sent bearer and effective workspace match the main credential already observed under native ownership, the same equality rule used by the hard lock. `src/codex/auth-context.ts` captures a process-local dispatch proof after materialization; `src/server/responses/passthrough-delivery.ts` captures the response-arrival proof before any awaited body classification and retains it for that response's header publication; it and `src/server/responses/core-codex-account.ts` recheck credential/identity generations before publishing. The dispatch also captures the process-wide credential mutation epoch; any OpenCodex-owned credential publication, including native main refresh or same-account reauth before a new quota credential observation, rejects an older dispatch. Publications for other credentials conservatively drop the main update as well. Same-account token rotation and A→B→A changes reject old responses; Each WebSocket observer renews the live dispatch object with every captured fence unchanged, retains that copy across frames, and claims it on every invocation before checking liveness. A later observer starts unclaimed, so its failed-upgrade HTTP fallback can publish even if the prior WS attempt observed quota. This process-local claim belongs to one physical attempt and prevents plain-main HTTP publication even when a downstream stream wrapper replaces the Response; response markers remain an additional guard, including separately marked pre-response prelude projections (4xx refusals and 502/504 gateway failures). Prelude headers remain available to Pool replay; real HTTP fallback responses still publish through HTTP delivery. An operator-granted HTTP replacement gets a new unclaimed dispatch object with every captured credential and config fence copied unchanged; the failed WS observer retains the old object. Caller-owned requests acquire no physical-main read or Pool health state. `src/providers/openai-sidecar.ts` releases quota-probe ownership on every materialization or usability failure before transferring a resolved context to its caller. Audio reports one terminal upstream outcome after validating the response body; redirects remain diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 889bfd3c656..3e54593d065 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -429,6 +429,25 @@ invalidates old evidence. Request-owned bearers are matched only against a crede workspace already observed under native ownership; an unrelated or unmatched keyring credential is not attributed to stored main and introduces no physical-main read. Credential equality tags remain process-local and never enter disk, logs, or management DTOs. +Plain-main HTTP and WebSocket Responses on the canonical OpenAI forward provider refresh cached +main usage under that same credential/workspace match, including stored-main substitution and +an identical caller-owned credential. Materialization captures a process-local dispatch proof; +HTTP delivery captures the response-arrival proof before any body await and retains it for those +headers even if deferred recovery renews the context. Publication rechecks identity and credential +generations, including after an awaited HTTP import +and for every WebSocket frame. A replaced credential, unmatched workspace, or custom destination +cannot publish main usage. Pool health/failover handling stays scoped to Pool contexts. +The dispatch additionally fences the process-wide credential mutation epoch, so native main +refresh and same-account reauth commits reject an older response before quota observation catches +up. Publications for other credentials also conservatively drop the main update. +Each plain-main WS observer renews its live dispatch object with every captured fence unchanged. +Every invocation claims that observer's own copy before checking liveness, preventing +HTTP publication from stream-wrapper replacement Responses. A failed-upgrade HTTP fallback with no WS quota +frames remains unclaimed and publishes normally, including after an earlier attempt observed quota; +response markers remain an additional guard. +An operator-granted HTTP replacement renews only the dispatch object identity, copying all captured +credential and config fences unchanged. Its unclaimed attempt can publish only while those original +fences remain live; the failed WS observer retains its old claimed object. `src/codex/auth-api/main-account-probe.ts` re-reads the bounded stored main credential and rechecks its writer, bearer and generation after body/retry awaits, before publishing main usage, credits, plan, reauth or Reserve state, including terminal 401/403 mutations. An unreadable file diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index cc26a8ed31d..7fe0ff326b2 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -409,7 +409,7 @@ the public server reference already documents. The 504 and a drop after the resp are never replaced. Only the 502 of a socket that closed or errored before any Responses event may be replaced over HTTP, when the provider opted into `retryOnReset` (#4191). That replacement claims from the request's one allowance; if it resets before its head, that is the pre-header row -again and may use a configured second replacement, otherwise it settles as the refusal. +again and may use a configured second replacement, otherwise it settles as the refusal. For plain-main quota, each physical HTTP replacement renews the dispatch object identity while copying every original credential and config fence unchanged. The failed WS observer retains its claimed object, so only the live replacement attempt may publish fresh HTTP headers. Each rebuilt WS observer also renews its own live dispatch copy; an upgrade failure with no quota frames leaves that attempt unclaimed for HTTP fallback publication. HTTP delivery retains the arrival dispatch for the arrival headers across deferred body recovery, even when that recovery renews the auth context's dispatch. This reclassification is the recorded behaviour change: before it, the pre-header refusal borrowed `upstream_closed_before_response` and its 502, which multiplied the duplicate send diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 3c6b5c7025b..e6fcd0b6981 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -23,7 +23,7 @@ Canonical forward auth retains its separate fixed credential/metadata allowlist; Retired Codex Spark has no model-specific tool or Responses Lite override; general Lite handling and namespace scrubbing remain shared compatibility behavior. Codex quota/reset evidence follows the -[shared/Reserve policy](../providers/openai-tiers.md#public-provider-contract), including suppression of retired model-derived evidence before shared recovery. +[shared/Reserve policy](../providers/openai-tiers.md#public-provider-contract), including suppression of retired model-derived evidence before shared recovery. Plain-main HTTP/WS quota headers refresh `__main__` only on the canonical OpenAI forward provider when the materialized bearer and workspace match the owned main observation, using the hard-lock credential-match rule. `src/server/responses/passthrough-delivery.ts` captures the response-arrival dispatch before any await and keeps that proof for the arrival headers across body classification and deferred replacement, rechecking its credential generation after the awaited import; `src/server/responses/core-codex-account.ts` renews a live dispatch object for each WS observer without recapturing its credential or config fences, retains that copy across frames, and rechecks it for each frame. Rotated credentials and unmatched workspaces publish nothing; dispatch proofs also reject any OpenCodex-owned credential publication epoch change, including native main refresh or same-account reauth before quota re-observation; each plain-main WS observer invocation claims its captured dispatch before liveness checks, and this claim is the authoritative HTTP publication guard across Response replacement; WS upstream responses and separately marked pre-response prelude projections (4xx refusals and 502/504 gateway failures) skip plain-main HTTP quota writes; prelude headers remain available to Pool replay, real HTTP fallbacks still publish; each operator-granted HTTP replacement renews the dispatch object with its original credential and config fences unchanged, and Pool health/failover gates remain unchanged. ### Credential-bearing HTTP redirects diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 0c1fc226221..8035b43451e 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -817,6 +817,7 @@ "openai-provider-option-startup.test.ts": "adapters/openai", "openai-provider-option-tooling.test.ts": "adapters/openai", "openai-provider-option.test.ts": "adapters/openai", "responses-forward-client-headers.test.ts": "responses", "openai-responses-passthrough.test.ts": "responses", + "responses-main-quota-observation.test.ts": "responses", "external-task-input-repair.test.ts": "responses", "openai-responses-summary-none.test.ts": "responses", "responses-forward-output-cap.test.ts": "responses", "opencode-cli.test.ts": "providers", "opencode-free-provider.test.ts": "providers", diff --git a/tests/responses/responses-main-quota-observation.test.ts b/tests/responses/responses-main-quota-observation.test.ts new file mode 100644 index 00000000000..45d0bf6e3d9 --- /dev/null +++ b/tests/responses/responses-main-quota-observation.test.ts @@ -0,0 +1,963 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { MAIN_CODEX_ACCOUNT_ID as MAIN } from "../../src/codex/account-id"; +import * as mainCache from "../../src/codex/main-account-cache"; +import { + CodexReserveUnavailableError, materializeCodexUpstreamAuth, materializeCodexUpstreamAuthAsync, resolveCodexAuthContext, + type CodexAuthContext, +} from "../../src/codex/auth-context"; +import { beginNativeMainReauth, forceRefreshMainAccountToken } from "../../src/codex/main-account"; +import { advanceCodexCredentialMutationEpoch, codexCredentialMutationEpoch } from "../../src/codex/credential-mutation-epoch"; +import { resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; +import { clearAccountNeedsReauth } from "../../src/codex/account-runtime-state"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { clearAccountQuota, getAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; +import { clearCodexUpstreamHealth, clearThreadAccountMap } from "../../src/codex/routing"; +import { clearPoolRotationState } from "../../src/codex/pool-rotation"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import { captureConfigGeneration } from "../../src/lib/state-store-sweeper"; +import { parseRequest } from "../../src/responses/parser"; +import { codexAccountSelectionForTurn, tryAdmitTurn } from "../../src/server/lifecycle"; +import { handleResponses } from "../../src/server/responses"; +import { BOUNDED_WS_RUNTIME } from "../helpers/ws-upstream-fixtures"; +import { deliverPassthroughResponse } from "../../src/server/responses/passthrough-delivery"; +import { codexWsQuotaObserver, retryCodexPoolOnAlternateAccount } from "../../src/server/responses/core-codex-account"; +import { CodexWsMetadata } from "../../src/server/responses/codex-ws-metadata"; +import { codexWsExchange } from "../../src/server/responses/codex-ws-exchange"; +import { CodexWsSession } from "../../src/server/responses/codex-ws-session"; +import { CODEX_RESPONSES_HTTP_URL, CODEX_RESPONSES_WS_URL, prepareCodexWsRequest } from "../../src/server/responses/codex-ws-request"; +import { NATIVE_RESERVE_MODEL } from "../../src/codex/catalog/native-models"; +import { getMainAccountHardLockStatus } from "../../src/codex/main-account-hard-lock"; +import { CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, isCodexWsPreludeProjection } from "../../src/server/responses/ws-upstream"; +import { UPGRADE_DEADLINE_MS, markCodexWsResponse, isCodexWsQuotaObservedResponse, isCodexWsUpstreamResponse } from "../../src/server/responses/codex-ws-wire"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { fakeChatGptJwt } from "../helpers/agent-task-recovery"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath } from "../helpers/repo-root"; + +const ACCOUNT = "fixture-observed-main"; +const POOL = "fixture-observed-pool"; +const provider: OcxProviderConfig = { + adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex", +}; +const originalFetch = globalThis.fetch; +let root: string; +let oldOcxHome: string | undefined; +let oldCodexHome: string | undefined; +let bearer: string; +let config: OcxConfig; +let releaseSpend: (() => void) | undefined; +let pendingPersist: { run: () => void; timer: ReturnType } | undefined; +let clock: ReturnType; +let pendingPreludeTimeout: (() => void) | undefined; +let pendingUpgradeTimeout: (() => void) | undefined; + +// Exercise the real serializer without a wall-clock race, as main-quota-provenance does. +function installPersistenceClock() { + const nativeTimeout = globalThis.setTimeout; + return spyOn(globalThis, "setTimeout").mockImplementation((( + callback: (...args: unknown[]) => void, delay?: number, ...args: unknown[] + ) => { + if (delay === UPGRADE_DEADLINE_MS) { + pendingUpgradeTimeout = () => callback(...args); + return nativeTimeout(() => {}, delay); + } + if (delay === CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS) { + pendingPreludeTimeout = () => callback(...args); + return nativeTimeout(() => {}, delay); + } + if (delay !== 250) return nativeTimeout(callback, delay, ...args); + const timer = nativeTimeout(() => {}, 60_000); + pendingPersist = { run: () => callback(...args), timer }; + return timer; + }) as typeof setTimeout); +} + +function observe(token = bearer, account = ACCOUNT): void { + mainCache.observeMainQuotaIdentity(account); + expect(mainCache.observeMainQuotaCredential(token, account)).toBeDefined(); +} +function caller(token = bearer, account: string | undefined = ACCOUNT): Headers { + const headers = new Headers({ authorization: `Bearer ${token}` }); + if (account !== undefined) headers.set("chatgpt-account-id", account); + return headers; +} +function materialized(headers = caller()): Extract { + const ctx: Extract = { kind: "main", accountId: null }; + materializeCodexUpstreamAuth(headers, ctx, { config, modelId: "gpt-5.5" }); + return ctx; +} +function quotaHeaders(percent = "23"): Headers { + return new Headers({ "x-codex-primary-used-percent": percent, "x-codex-primary-window-minutes": "10080" }); +} +function quotaResponse(headers = quotaHeaders()): Response { + // A real upstream redirect takes the early relay return after quota publication. This keeps + // this delivery fixture independent of unrelated success-body repair and continuation state. + headers = new Headers(headers); + headers.set("location", "https://chatgpt.com/backend-api/codex/responses"); + return new Response(null, { status: 307, headers }); +} +async function deliver(ctx: CodexAuthContext, response = quotaResponse(), selectedProvider = provider): Promise { + type Args = Parameters; + const budget = createTranslatorBudget(); + try { + const result = await deliverPassthroughResponse( + { config, logCtx: { model: "", provider: "" }, options: {}, req: new Request("http://localhost/v1/responses") }, + { authCtx: ctx } as Args[1], + { parsed: parseRequest({ model: "gpt-5.5", input: "hi", stream: false }), + route: { providerName: "openai", modelId: "gpt-5.5", provider: selectedProvider }, + clientRequestedStream: false, translatorBudget: budget, inboundWire: "responses" }, + { requestBindings: undefined } as Args[3], {}, + { plaintextV2AgentMessageToolNames: new Set(), routedMuseToolNameAliases: new Map(), + routedNamespaceToolAliases: new Map(), plaintextV2AgentMessageAliasedToolNames: new Set(), + commitReasoningReplayServingRoute: () => {}, recordTerminalOutcomes: () => {}, + responseCompletionCancelled: () => false } as Args[5], + { upstreamResponse: response, upstream: new AbortController(), connectMs: 1000 } as Args[6], + ); + expect(result.status).toBe(response.status); + await result.body?.cancel(); + } finally { budget.dispose(); } +} +function frame(metadata: CodexWsMetadata, percent: number): void { + const event = { type: "codex.rate_limits", rate_limits: { primary: { used_percent: percent, window_minutes: 10080 } } }; + expect(metadata.consume(event, JSON.stringify(event).length)).not.toBeNull(); +} + +beforeEach(() => { + mkdirSync(repoPath(".tmp"), { recursive: true }); + root = mkdtempSync(repoPath(".tmp/main-quota-observation-")); + oldOcxHome = process.env.OPENCODEX_HOME; + oldCodexHome = process.env.CODEX_HOME; + process.env.OPENCODEX_HOME = root; + process.env.CODEX_HOME = root; + releaseSpend = acquireOwnedSpendHome(); + clearAccountQuota(); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearPoolRotationState(); + clearAccountNeedsReauth(MAIN); + clearAccountNeedsReauth(POOL); + resetMainCodexAccountIdentityTrackingForTests(); + mainCache.clearMainAccountInfoCache(); + mainCache.observeMainQuotaIdentity("fixture-unobserved"); + bearer = fakeChatGptJwt(ACCOUNT); + config = { providers: { openai: provider }, codexAccounts: [], codexMainAccountHardLock: false } as OcxConfig; + pendingPersist = undefined; + pendingPreludeTimeout = undefined; + pendingUpgradeTimeout = undefined; + clock = installPersistenceClock(); + globalThis.fetch = (async () => { throw new Error("unexpected network call"); }) as typeof fetch; +}); +afterEach(() => { + releaseSpend?.(); releaseSpend = undefined; + globalThis.fetch = originalFetch; + clearAccountQuota(); + if (pendingPersist) clearTimeout(pendingPersist.timer); + pendingPersist = undefined; + pendingPreludeTimeout = undefined; + pendingUpgradeTimeout = undefined; + clock.mockRestore(); + clearCodexUpstreamHealth(); clearThreadAccountMap(); clearPoolRotationState(); + clearAccountNeedsReauth(MAIN); clearAccountNeedsReauth(POOL); + resetMainCodexAccountIdentityTrackingForTests(); mainCache.clearMainAccountInfoCache(); + if (oldOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldOcxHome; + if (oldCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = oldCodexHome; + removeTreeWithRetry(root); +}); + +describe("credential-bound plain-main Responses quota", () => { + test("1: observed caller bearer updates main through HTTP delivery", async () => { + observe(); + const ctx = materialized(); + expect(ctx.mainQuotaDispatch).toBeDefined(); + await deliver(ctx); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(23); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(23); + // Materialization resets an old proof when this context is reused for another bearer. + materializeCodexUpstreamAuth(caller("fixture-unmatched"), ctx, { config }); + expect(ctx.mainQuotaDispatch).toBeUndefined(); + await deliver(ctx, quotaResponse(quotaHeaders("31"))); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(23); + }); + + for (const mode of ["sync", "async"] as const) { + test(`2: ${mode} stored-main substitution captures the sent credential`, async () => { + observe(); + writeFileSync(join(root, "auth.json"), JSON.stringify({ tokens: { access_token: bearer, account_id: ACCOUNT } })); + const ctx: Extract = { kind: "main", accountId: null }; + const options = { config, substituteMainCredential: true }; + const selected = mode === "sync" + ? materializeCodexUpstreamAuth(caller("fixture-admission", "fixture-wrong-workspace"), ctx, options) + : await materializeCodexUpstreamAuthAsync(caller("fixture-admission", "fixture-wrong-workspace"), ctx, options); + // Compare without putting credential material in a failed assertion's output. + expect(selected.get("authorization") === `Bearer ${bearer}`).toBe(true); + expect(selected.get("chatgpt-account-id") === ACCOUNT).toBe(true); + expect(ctx.mainQuotaDispatch).toBeDefined(); + await deliver(ctx); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(23); + }); + } + + test("3: different account, different token and unobserved caller cannot publish", async () => { + const unobserved = materialized(); + expect(unobserved.mainQuotaDispatch).toBeUndefined(); + await deliver(unobserved); + observe(); + for (const headers of [caller(fakeChatGptJwt("fixture-other"), "fixture-other"), caller("fixture-other-token")]) { + const ctx = materialized(headers); + expect(ctx.mainQuotaDispatch).toBeUndefined(); + await deliver(ctx); + } + expect(getAccountQuota(MAIN)).toBeNull(); + expect(getMainPolicyQuota()).toBeNull(); + }); + + test("4: same-account rotation and A to B to A reject old HTTP dispatches", async () => { + observe(); + const rotation = materialized(); + observe("fixture-rotated"); + expect(mainCache.isMainQuotaWriterLive(rotation.mainQuotaDispatch!.writer)).toBe(true); + expect(mainCache.isMainQuotaDispatchLive(rotation.mainQuotaDispatch!)).toBe(false); + await deliver(rotation); + observe(); + const aba = materialized(); + observe("fixture-b", "fixture-account-b"); + observe(); + await deliver(aba); + expect(getAccountQuota(MAIN)).toBeNull(); + expect(getMainPolicyQuota()).toBeNull(); + }); + + for (const commit of ["refresh", "reauth"] as const) { + for (const transport of ["HTTP", "WS"] as const) { + test(`real native main ${commit} commit fences an older ${transport} quota dispatch`, async () => { + writeFileSync(join(root, "auth.json"), JSON.stringify({ tokens: { + access_token: bearer, refresh_token: "fixture-main-before-publication", account_id: ACCOUNT, + } })); + observe(); + const ctx = materialized(); + expect(ctx.mainQuotaDispatch).toBeDefined(); + const observer = transport === "WS" ? codexWsQuotaObserver(ctx, provider, "gpt-5.5") : undefined; + if (transport === "WS") expect(observer).toBeDefined(); + const epoch = codexCredentialMutationEpoch(); + expect(ctx.mainQuotaDispatch!.credentialMutationEpoch).toBe(epoch); + const generation = mainCache.getMainQuotaCredentialGeneration(); + const replacement = fakeChatGptJwt(ACCOUNT, { fixture_publication: commit }); + if (commit === "refresh") { + let refreshes = 0; + const result = await forceRefreshMainAccountToken(bearer, { + refreshToken: async () => { + refreshes++; + return { access: replacement, refresh: "fixture-main-after-refresh", expires: Date.now() + 3600_000, accountId: ACCOUNT }; + }, + }); + expect(refreshes).toBe(1); + expect(result?.accessToken === replacement).toBe(true); + } else { + const result = await beginNativeMainReauth().commit({ accessToken: replacement, + refreshToken: "fixture-main-after-reauth", idToken: "fixture-main-identity-token", chatgptAccountId: ACCOUNT }); + expect(result.chatgptAccountId).toBe(ACCOUNT); + } + const stored = JSON.parse(readFileSync(join(root, "auth.json"), "utf8")); + expect(stored.tokens.access_token === replacement).toBe(true); + expect(codexCredentialMutationEpoch()).toBeGreaterThan(epoch); + // No subsequent credential observation warmed the main quota generation. + expect(mainCache.getMainQuotaCredentialGeneration()).toBe(generation); + expect(mainCache.isMainQuotaWriterLive(ctx.mainQuotaDispatch!.writer)).toBe(true); + if (transport === "HTTP") await deliver(ctx); + else observer!(quotaHeaders()); + expect(getAccountQuota(MAIN)).toBeNull(); + expect(getMainPolicyQuota()).toBeNull(); + expect(mainCache.isMainQuotaDispatchLive(ctx.mainQuotaDispatch!)).toBe(false); + }); + } + } + + test("5: non-canonical, key-auth and other-adapter providers cannot publish", async () => { + observe(); + const ctx = materialized(); + for (const other of [{ ...provider, baseUrl: "https://fixture.test/v1" }, + { ...provider, authMode: "key" as const }, { ...provider, adapter: "openai-chat" }]) { + await deliver(ctx, quotaResponse(), other); + expect(codexWsQuotaObserver(ctx, other)).toBeUndefined(); + } + expect(getAccountQuota(MAIN)).toBeNull(); + }); + + test("6: WS metadata publishes once and its projected HTTP response cannot duplicate", async () => { + observe(); + const ctx = materialized(); + const observer = codexWsQuotaObserver(ctx, provider, "gpt-5.5"); + expect(observer).toBeDefined(); + const metadata = new CodexWsMetadata(observer); + frame(metadata, 19); + const quota = getAccountQuota(MAIN); + expect(quota?.weeklyPercent).toBe(19); + // A distinct projected value detects a second write even within the same millisecond. + const projected = quotaResponse(quotaHeaders("47")); + markCodexWsResponse(projected, true); + expect(isCodexWsQuotaObservedResponse(projected)).toBe(true); + await deliver(ctx, projected); + expect(getAccountQuota(MAIN)).toEqual(quota); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(19); + metadata.finish(); + }); + + test("a WS-marked unobserved refusal cannot overwrite newer main display or policy quota", async () => { + observe(); + const ctx = materialized(); + const metadata = new CodexWsMetadata(codexWsQuotaObserver(ctx, provider, "gpt-5.5")); + frame(metadata, 17); + frame(metadata, 99); + const display = getAccountQuota(MAIN); + const policy = getMainPolicyQuota(); + const refusal = Response.json({ error: { type: "invalid_request_error", message: "fixture refused create" } }, + { status: 400, headers: quotaHeaders("17") }); + markCodexWsResponse(refusal, false); + expect(isCodexWsUpstreamResponse(refusal)).toBe(true); + expect(isCodexWsQuotaObservedResponse(refusal)).toBe(false); + await deliver(ctx, refusal); + expect(getAccountQuota(MAIN)).toEqual(display); + expect(getMainPolicyQuota()).toEqual(policy); + expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: true }).state).toBe("blocked"); + metadata.finish(); + }); + + for (const failure of ["4xx refusal", "socket closure", "prelude timeout", "connect timeout"] as const) { + test(`WS ${failure} through the real exchange cannot republish stale prelude quota`, async () => { + observe(); + const ctx = materialized(); + const observer = codexWsQuotaObserver(ctx, provider, "gpt-5.5"); + expect(observer).toBeDefined(); + const originalSocket = globalThis.WebSocket; + const controller = new AbortController(); + class RefusalSocket extends EventTarget { + readyState = 0; + constructor() { + super(); + queueMicrotask(() => { this.readyState = 1; this.dispatchEvent(new Event("open")); }); + } + send(_text: string): void { + queueMicrotask(() => { + this.dispatchEvent(new MessageEvent("message", { data: JSON.stringify({ + type: "codex.rate_limits", rate_limits: { primary: { used_percent: 17, window_minutes: 10080 } }, + }) })); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(17); + // A concurrent main response publishes newer usage before this exchange fails. + observer!(quotaHeaders("99")); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(99); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); + if (failure === "socket closure") this.close(); + else if (failure === "prelude timeout") { + expect(pendingPreludeTimeout).toBeDefined(); + pendingPreludeTimeout!(); + } else if (failure === "connect timeout") controller.abort(new DOMException("fixture deadline", "TimeoutError")); + else this.dispatchEvent(new MessageEvent("message", { data: JSON.stringify({ + type: "error", status_code: 400, error: { type: "invalid_request_error", message: "fixture refused create" }, + }) })); + }); + } + close(): void { + if (this.readyState === 3) return; + this.readyState = 3; + this.dispatchEvent(new Event("close")); + } + } + globalThis.WebSocket = RefusalSocket as unknown as typeof WebSocket; + const session = new CodexWsSession(CODEX_RESPONSES_WS_URL, {}); + try { + expect(session.reserve()).toBe(true); + const init = { method: "POST", signal: controller.signal, headers: caller(), body: JSON.stringify({ model: "gpt-5.5", stream: true, input: "hi" }) }; + const prepared = prepareCodexWsRequest(CODEX_RESPONSES_HTTP_URL, init); + expect(prepared).not.toBeNull(); + const refusal = await codexWsExchange({ session, url: CODEX_RESPONSES_HTTP_URL, init, prepared: prepared!, + sseFallback: globalThis.fetch, onQuota: observer, bunVersion: "1.4.0" }); + expect(refusal.status).toBe(failure === "4xx refusal" ? 400 : failure === "socket closure" ? 502 : 504); + expect(isCodexWsUpstreamResponse(refusal)).toBe(false); + expect(isCodexWsPreludeProjection(quotaResponse())).toBe(false); + expect(isCodexWsQuotaObservedResponse(refusal)).toBe(false); + expect(refusal.headers.get("x-codex-primary-used-percent")).toBe("17"); + expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: true }).state).toBe("blocked"); + await deliver(ctx, refusal); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(99); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); + expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: true }).state).toBe("blocked"); + expect(isCodexWsPreludeProjection(refusal)).toBe(true); + } finally { + session.dispose(); + globalThis.WebSocket = originalSocket; + } + }); + } + + for (const failure of ["close", "error", "upgrade timeout", "send exception"] as const) { + test(`real HTTP fallback after WS ${failure} still publishes main quota`, async () => { + observe(); + const ctx = materialized(); + const originalSocket = globalThis.WebSocket; + class UpgradeFailureSocket extends EventTarget { + readyState = 0; + constructor() { + super(); + queueMicrotask(() => { + if (failure === "upgrade timeout") { expect(pendingUpgradeTimeout).toBeDefined(); pendingUpgradeTimeout!(); } + else if (failure === "send exception") { this.readyState = 1; this.dispatchEvent(new Event("open")); } + else if (failure === "close") this.close(); + else this.dispatchEvent(new Event("error")); + }); + } + send(): void { throw new Error("fixture unsent create"); } + close(): void { + if (this.readyState === 3) return; + this.readyState = 3; + this.dispatchEvent(new Event("close")); + } + } + globalThis.WebSocket = UpgradeFailureSocket as unknown as typeof WebSocket; + const session = new CodexWsSession(CODEX_RESPONSES_WS_URL, {}); + let fallbackCalls = 0; + try { + expect(session.reserve()).toBe(true); + const init = { method: "POST", headers: caller(), body: JSON.stringify({ model: "gpt-5.5", stream: true, input: "hi" }) }; + const prepared = prepareCodexWsRequest(CODEX_RESPONSES_HTTP_URL, init); + expect(prepared).not.toBeNull(); + const response = await codexWsExchange({ session, url: CODEX_RESPONSES_HTTP_URL, init, prepared: prepared!, + sseFallback: (async () => { fallbackCalls++; return quotaResponse(quotaHeaders("37")); }) as typeof fetch, + onQuota: codexWsQuotaObserver(ctx, provider, "gpt-5.5"), bunVersion: "1.4.0" }); + expect(fallbackCalls).toBe(1); + expect(mainCache.isMainQuotaDispatchWsClaimed(ctx.mainQuotaDispatch!)).toBe(false); + expect(isCodexWsUpstreamResponse(response)).toBe(false); + expect(isCodexWsPreludeProjection(response)).toBe(false); + expect(isCodexWsQuotaObservedResponse(response)).toBe(false); + await deliver(ctx, response); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(37); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(37); + expect(mainCache.isMainQuotaDispatchWsClaimed(ctx.mainQuotaDispatch!)).toBe(false); + } finally { + session.dispose(); + globalThis.WebSocket = originalSocket; + } + }); + } + + test("full handler HTTP replacement after WS quota publishes under a new physical attempt", async () => { + observe(); + const originalSocket = globalThis.WebSocket; + const sockets: FailedAttemptSocket[] = []; + class FailedAttemptSocket extends EventTarget { + readyState = 0; + sent: string[] = []; + constructor() { + super(); + sockets.push(this); + queueMicrotask(() => { this.readyState = 1; this.dispatchEvent(new Event("open")); }); + } + send(text: string): void { + this.sent.push(text); + queueMicrotask(() => { + this.dispatchEvent(new MessageEvent("message", { data: JSON.stringify({ + type: "codex.rate_limits", rate_limits: { primary: { used_percent: 17, window_minutes: 10080 } }, + }) })); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(17); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(17); + this.close(); + }); + } + close(): void { + if (this.readyState === 3) return; + this.readyState = 3; + this.dispatchEvent(new Event("close")); + } + } + globalThis.WebSocket = FailedAttemptSocket as unknown as typeof WebSocket; + let httpCalls = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + httpCalls++; + expect(JSON.parse(String(init?.body))).toMatchObject({ model: "gpt-5.5", store: false }); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(17); + const headers = quotaHeaders("99"); + headers.set("content-type", "text/event-stream"); + return new Response(`event: response.completed\ndata: ${JSON.stringify({ + type: "response.completed", response: { id: "fixture-http-replacement", status: "completed", output: [] }, + })}\n\n`, { status: 200, headers }); + }) as typeof fetch; + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: new Headers({ ...Object.fromEntries(caller()), "content-type": "application/json" }), + body: JSON.stringify({ model: "gpt-5.5", stream: true, store: false, input: "fixture self-contained turn" }), + }), { ...config, defaultProvider: "openai", + providers: { openai: { ...provider, codexAccountMode: "direct", retryOnReset: {} } } }, + { model: "", provider: "" }, { codexWsRuntimeIdentity: BOUNDED_WS_RUNTIME }); + expect(response.status).toBe(200); + expect(await response.text()).toContain("fixture-http-replacement"); + expect(sockets).toHaveLength(1); + expect(sockets[0]!.sent).toHaveLength(1); + expect(sockets[0]!.readyState).toBe(3); + expect(httpCalls).toBe(1); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(99); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); + expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: true }).state).toBe("blocked"); + } finally { + for (const socket of sockets) socket.close(); + globalThis.WebSocket = originalSocket; + } + }); + + for (const mutation of ["credential observation", "credential publication epoch"] as const) { + test(`attempt renewal preserves retired fences after ${mutation}`, async () => { + observe(); + const ctx = materialized(); + const observer = codexWsQuotaObserver(ctx, provider, "gpt-5.5"); + const original = ctx.mainQuotaDispatch!; + expect(observer).toBeDefined(); + observer!(quotaHeaders("17")); + expect(mainCache.isMainQuotaDispatchWsClaimed(original)).toBe(true); + if (mutation === "credential observation") mainCache.observeMainQuotaCredential("fixture-next-credential", ACCOUNT); + else advanceCodexCredentialMutationEpoch(); + const renewed = mainCache.renewMainQuotaDispatchForAttempt(original); + expect(renewed).not.toBe(original); + expect(renewed).toEqual(original); + expect(renewed.writer).toBe(original.writer); + expect(mainCache.isMainQuotaDispatchWsClaimed(renewed)).toBe(false); + expect(mainCache.isMainQuotaDispatchWsClaimed(original)).toBe(true); + expect(mainCache.isMainQuotaDispatchLive(renewed)).toBe(false); + ctx.mainQuotaDispatch = renewed; + observer!(quotaHeaders("99")); + expect(mainCache.isMainQuotaDispatchWsClaimed(renewed)).toBe(false); + await deliver(ctx, quotaResponse(quotaHeaders("99"))); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(17); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(17); + }); + } + + test("plaintext V2 deferred reset keeps original headers bound to their arrival dispatch", async () => { + observe(); + const renewalSpy = spyOn(mainCache, "renewMainQuotaDispatchForAttempt"); + const claimSpy = spyOn(mainCache, "isMainQuotaDispatchWsClaimed"); + let httpCalls = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + httpCalls++; + expect(JSON.parse(String(init?.body))).toMatchObject({ stream: true, store: false }); + expect(getAccountQuota(MAIN)).toBeNull(); + expect(getMainPolicyQuota()).toBeNull(); + if (httpCalls === 1) { + return new Response(new ReadableStream({ + async pull(controller) { + await new Promise(resolve => setTimeout(resolve, 0)); + controller.error(Object.assign(new Error("fixture pre-output reset"), { code: "ECONNRESET" })); + }, + }), { status: 200, headers: quotaHeaders("17") }); + } + return new Response(new TextEncoder().encode(`event: response.completed\ndata: ${JSON.stringify({ + type: "response.completed", response: { id: "fixture-plaintext-reset", status: "completed", output: [] }, + })}\n\n`), { status: 200, headers: quotaHeaders("99") }); + }) as typeof fetch; + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: new Headers({ ...Object.fromEntries(caller()), "content-type": "application/json" }), + body: JSON.stringify({ model: "gpt-5.5", stream: true, store: false, input: "fixture delegate request", + tools: [{ type: "namespace", name: "collaboration", tools: [{ type: "function", name: "spawn_agent", + parameters: { type: "object", properties: { message: { type: "string", encrypted: true } } } }] }], + }), + }), { ...config, defaultProvider: "openai", plaintextV2AgentMessages: true, + providers: { openai: { ...provider, codexAccountMode: "direct", upstreamWebsocket: false, retryOnReset: {} } } }, + { model: "", provider: "" }, { codexWsRuntimeIdentity: BOUNDED_WS_RUNTIME }); + expect(response.status).toBe(200); + expect(await response.text()).toContain("fixture-plaintext-reset"); + expect(httpCalls).toBe(2); + expect(renewalSpy).toHaveBeenCalledTimes(2); + const arrival = renewalSpy.mock.results[0]!.value as mainCache.MainQuotaDispatch; + const successor = renewalSpy.mock.results[1]!.value as mainCache.MainQuotaDispatch; + expect(successor).not.toBe(arrival); + expect(successor).toEqual(arrival); + // The prefix probe awaits the replacement before publication; the original proof stays live. + // Its 17% may publish only under that proof. Replacement-header publication is a follow-up. + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(17); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(17); + expect(claimSpy).toHaveBeenCalledTimes(1); + expect(claimSpy.mock.calls[0]![0]).toBe(arrival); + expect(claimSpy.mock.calls[0]![0]).not.toBe(successor); + } finally { + claimSpy.mockRestore(); + renewalSpy.mockRestore(); + } + }); + + test("full handler sanitized recovery with failed WS upgrade publishes fresh HTTP quota", async () => { + observe(); + const originalSocket = globalThis.WebSocket; + const sockets: RecoverySocket[] = []; + const opaqueBytes = Buffer.alloc(73, 1); + opaqueBytes[0] = 0x80; + const opaqueOutput = opaqueBytes.toString("base64").replace(/\+/g, "-").replace(/\//g, "_"); + class RecoverySocket extends EventTarget { + readyState = 0; + sent: string[] = []; + constructor() { + super(); + sockets.push(this); + const attempt = sockets.length; + queueMicrotask(() => { + if (attempt === 1) { this.readyState = 1; this.dispatchEvent(new Event("open")); } + else this.close(); + }); + } + send(text: string): void { + this.sent.push(text); + queueMicrotask(() => { + const emit = (payload: unknown) => this.dispatchEvent(new MessageEvent("message", { data: JSON.stringify(payload) })); + emit({ type: "codex.rate_limits", rate_limits: { primary: { used_percent: 17, window_minutes: 10080 } } }); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(17); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(17); + emit({ type: "error", status_code: 400, error: { type: "invalid_request_error", + code: "invalid_encrypted_content", message: "The encrypted content could not be verified." } }); + }); + } + close(): void { + if (this.readyState === 3) return; + this.readyState = 3; + this.dispatchEvent(new Event("close")); + } + } + globalThis.WebSocket = RecoverySocket as unknown as typeof WebSocket; + let httpCalls = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + httpCalls++; + const body = JSON.parse(String(init?.body)); + const output = body.input.find((item: { type?: string }) => item.type === "function_call_output"); + expect(output.output).toEqual([{ type: "input_text", text: "[encrypted content omitted]" }]); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(17); + const headers = quotaHeaders("99"); + headers.set("content-type", "text/event-stream"); + return new Response(`event: response.completed\ndata: ${JSON.stringify({ + type: "response.completed", response: { id: "fixture-sanitized-http", status: "completed", output: [] }, + })}\n\n`, { status: 200, headers }); + }) as typeof fetch; + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: new Headers({ ...Object.fromEntries(caller()), "content-type": "application/json" }), + body: JSON.stringify({ model: "gpt-5.5", stream: true, store: false, input: [ + { type: "function_call", call_id: "fixture-call", name: "fixture_tool", arguments: "{}" }, + { type: "function_call_output", call_id: "fixture-call", + output: [{ type: "encrypted_content", encrypted_content: opaqueOutput }] }, + ] }), + }), { ...config, defaultProvider: "openai", providers: { openai: { ...provider, codexAccountMode: "direct" } } }, + { model: "", provider: "" }, { codexWsRuntimeIdentity: BOUNDED_WS_RUNTIME }); + expect(response.status).toBe(200); + expect(await response.text()).toContain("fixture-sanitized-http"); + expect(sockets).toHaveLength(2); + expect(sockets[0]!.sent).toHaveLength(1); + const firstOutput = JSON.parse(sockets[0]!.sent[0]!).input.find((item: { type?: string }) => item.type === "function_call_output"); + expect(firstOutput.output[0].type).toBe("encrypted_content"); + expect(sockets[1]!.sent).toHaveLength(0); + expect(httpCalls).toBe(1); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(99); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); + expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: true }).state).toBe("blocked"); + } finally { + for (const socket of sockets) socket.close(); + globalThis.WebSocket = originalSocket; + } + }); + + test("full handler preflight for encrypted function output retains newer WS quota", async () => { + observe(); + const newerObserver = codexWsQuotaObserver(materialized(), provider, "gpt-5.5"); + expect(newerObserver).toBeDefined(); + const originalSocket = globalThis.WebSocket; + const sockets: PreflightSocket[] = []; + const opaqueBytes = Buffer.alloc(73, 1); + opaqueBytes[0] = 0x80; + const opaqueOutput = opaqueBytes.toString("base64").replace(/\+/g, "-").replace(/\//g, "_"); + class PreflightSocket extends EventTarget { + readyState = 0; + sent: string[] = []; + constructor() { + super(); + sockets.push(this); + queueMicrotask(() => { this.readyState = 1; this.dispatchEvent(new Event("open")); }); + } + send(text: string): void { + this.sent.push(text); + queueMicrotask(() => { + const emit = (payload: unknown) => this.dispatchEvent(new MessageEvent("message", { data: JSON.stringify(payload) })); + emit({ type: "codex.rate_limits", rate_limits: { primary: { used_percent: 17, window_minutes: 10080 } } }); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(17); + emit({ type: "response.created", response: { id: "fixture-preflight", status: "in_progress", output: [] } }); + // The committed response has a 17% prelude; another dispatch publishes 99%. + newerObserver!(quotaHeaders("99")); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); + emit({ type: "response.output_text.delta", delta: "fixture answer", output_index: 0, content_index: 0 }); + emit({ type: "response.completed", response: { id: "fixture-preflight", status: "completed", output: [] } }); + }); + } + close(): void { + if (this.readyState === 3) return; + this.readyState = 3; + this.dispatchEvent(new Event("close")); + } + } + globalThis.WebSocket = PreflightSocket as unknown as typeof WebSocket; + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: new Headers({ ...Object.fromEntries(caller()), "content-type": "application/json" }), + body: JSON.stringify({ model: "gpt-5.5", stream: true, input: [ + { type: "function_call", call_id: "fixture-call", name: "fixture_tool", arguments: "{}" }, + { type: "function_call_output", call_id: "fixture-call", + output: [{ type: "encrypted_content", encrypted_content: opaqueOutput }] }, + ] }), + }), { ...config, defaultProvider: "openai", streamMode: "legacy-tee", + providers: { openai: { ...provider, codexAccountMode: "direct" } } }, { model: "", provider: "" }, + { codexWsRuntimeIdentity: BOUNDED_WS_RUNTIME }); + expect(response.status).toBe(200); + expect(sockets).toHaveLength(1); + const sentOutput = JSON.parse(sockets[0]!.sent[0]!).input.find((item: { type?: string }) => item.type === "function_call_output"); + expect(sentOutput.output[0].type).toBe("encrypted_content"); + // The preflight replay is a fresh Response; no response-object marker survives. + expect(isCodexWsUpstreamResponse(response)).toBe(false); + expect(isCodexWsPreludeProjection(response)).toBe(false); + const text = await response.text(); + expect(text).toContain("fixture answer"); + expect(text).toContain("response.completed"); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(99); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); + expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: true }).state).toBe("blocked"); + } finally { + for (const socket of sockets) socket.close(); + globalThis.WebSocket = originalSocket; + } + }); + + test("an observed WS dispatch rejects an unmarked HTTP-shaped quota snapshot", async () => { + observe(); + const ctx = materialized(); + const observer = codexWsQuotaObserver(ctx, provider, "gpt-5.5"); + expect(observer).toBeDefined(); + observer!(quotaHeaders("17")); + observer!(quotaHeaders("99")); + expect(mainCache.isMainQuotaDispatchWsClaimed(ctx.mainQuotaDispatch!)).toBe(true); + const response = quotaResponse(quotaHeaders("17")); + expect(isCodexWsUpstreamResponse(response)).toBe(false); + expect(isCodexWsPreludeProjection(response)).toBe(false); + await deliver(ctx, response); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(99); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); + expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: true }).state).toBe("blocked"); + }); + + test("each WS observer owns a fresh dispatch and an old observer cannot claim its successor", async () => { + observe(); + const ctx = materialized(); + const materializedDispatch = ctx.mainQuotaDispatch!; + const firstObserver = codexWsQuotaObserver(ctx, provider, "gpt-5.5"); + const firstDispatch = ctx.mainQuotaDispatch!; + expect(firstObserver).toBeDefined(); + expect(firstDispatch).not.toBe(materializedDispatch); + expect(firstDispatch).toEqual(materializedDispatch); + firstObserver!(quotaHeaders("17")); + expect(mainCache.isMainQuotaDispatchWsClaimed(firstDispatch)).toBe(true); + const nextObserver = codexWsQuotaObserver(ctx, provider, "gpt-5.5"); + const nextDispatch = ctx.mainQuotaDispatch!; + expect(nextObserver).toBeDefined(); + expect(nextDispatch).not.toBe(firstDispatch); + expect(nextDispatch).toEqual(firstDispatch); + expect(nextDispatch.writer).toBe(firstDispatch.writer); + expect(mainCache.isMainQuotaDispatchWsClaimed(nextDispatch)).toBe(false); + // A callback retained by a previous attempt must never claim the later fallback. + firstObserver!(new Headers()); + expect(mainCache.isMainQuotaDispatchWsClaimed(nextDispatch)).toBe(false); + await deliver(ctx, quotaResponse(quotaHeaders("99"))); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(99); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); + // If this attempt's own observer runs, its HTTP-shaped projection is suppressed. + nextObserver!(quotaHeaders("99")); + expect(mainCache.isMainQuotaDispatchWsClaimed(nextDispatch)).toBe(true); + await deliver(ctx, quotaResponse(quotaHeaders("17"))); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(99); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); + }); + + test("every observer invocation claims its captured dispatch before checking liveness", async () => { + observe(); + const ctx = materialized(); + const observer = codexWsQuotaObserver(ctx, provider, "gpt-5.5"); + const dispatch = ctx.mainQuotaDispatch!; + expect(observer).toBeDefined(); + expect(mainCache.isMainQuotaDispatchWsClaimed(dispatch)).toBe(false); + mainCache.observeMainQuotaCredential("fixture-replaced-before-frame", ACCOUNT); + expect(mainCache.isMainQuotaDispatchLive(dispatch)).toBe(false); + observer!(new Headers()); + expect(mainCache.isMainQuotaDispatchWsClaimed(dispatch)).toBe(true); + expect(getAccountQuota(MAIN)).toBeNull(); + // Reusing the context cannot transfer an old observer's claim to a new dispatch. + observe(); + materializeCodexUpstreamAuth(caller(), ctx, { config, modelId: "gpt-5.5" }); + expect(ctx.mainQuotaDispatch).not.toBe(dispatch); + expect(mainCache.isMainQuotaDispatchWsClaimed(ctx.mainQuotaDispatch!)).toBe(false); + observer!(quotaHeaders("99")); + await deliver(ctx, quotaResponse(quotaHeaders("37"))); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(37); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(37); + }); + + test("async Reserve admission failure clears a reused main dispatch proof", async () => { + observe(); + const ctx = materialized(); + expect(ctx.mainQuotaDispatch).toBeDefined(); + expect(mainCache.isMainQuotaDispatchLive(ctx.mainQuotaDispatch!)).toBe(true); + await expect(materializeCodexUpstreamAuthAsync(caller(), ctx, { + config: { ...config, codexDesktopAuthless: true, pausedCodexAccountIds: [MAIN] }, + modelId: NATIVE_RESERVE_MODEL, admission: { source: "loopback" }, + })).rejects.toBeInstanceOf(CodexReserveUnavailableError); + expect(ctx.mainQuotaDispatch).toBeUndefined(); + }); + + test("7: stored pool HTTP and WS still update only their pool row", async () => { + const generation = saveCodexAccountCredential(POOL, { accessToken: "fixture-pool-token", + refreshToken: "fixture-pool-refresh", chatgptAccountId: "fixture-pool-workspace", expiresAt: Date.now() + 3600_000 }); + const ctx: Extract = { kind: "pool", accountId: POOL, + generation, writerGeneration: captureConfigGeneration(), accessToken: "fixture-pool-token", chatgptAccountId: "fixture-pool-workspace" }; + await deliver(ctx); + expect(getAccountQuota(POOL)?.weeklyPercent).toBe(23); + const observer = codexWsQuotaObserver(ctx, provider, "gpt-5.5"); + expect(observer).toBeDefined(); + observer!(quotaHeaders("29")); + expect(getAccountQuota(POOL)?.weeklyPercent).toBe(29); + expect(getAccountQuota(MAIN)).toBeNull(); + }); + + test("8: credential replacement during the HTTP import yield rejects publication", async () => { + observe(); + const ctx = materialized(); + const live = mainCache.isMainQuotaDispatchLive; + let checks = 0; + const fence = spyOn(mainCache, "isMainQuotaDispatchLive").mockImplementation(dispatch => { + const current = live(dispatch); + if (++checks === 1) queueMicrotask(() => observe("fixture-during-import")); + return current; + }); + try { + await deliver(ctx); + expect(checks).toBe(2); + expect(mainCache.isMainQuotaWriterLive(ctx.mainQuotaDispatch!.writer)).toBe(true); + expect(getAccountQuota(MAIN)).toBeNull(); + expect(getMainPolicyQuota()).toBeNull(); + } finally { fence.mockRestore(); } + }); + + test("9: WS closure rejects a second frame after rotation despite mutable context recapture", () => { + observe(); + const ctx = materialized(); + const metadata = new CodexWsMetadata(codexWsQuotaObserver(ctx, provider, "gpt-5.5")); + frame(metadata, 17); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(17); + observe("fixture-new-token"); + materializeCodexUpstreamAuth(caller("fixture-new-token"), ctx, { config }); + expect(mainCache.isMainQuotaDispatchLive(ctx.mainQuotaDispatch!)).toBe(true); + frame(metadata, 39); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(17); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(17); + metadata.finish(); + }); + + test("10: the same bearer for a different workspace cannot publish", async () => { + observe(); + const ctx = materialized(caller(bearer, "fixture-different-workspace")); + expect(ctx.mainQuotaDispatch).toBeUndefined(); + await deliver(ctx); + expect(getAccountQuota(MAIN)).toBeNull(); + expect(getMainPolicyQuota()).toBeNull(); + }); + + test("11: absent and nonnumeric headers do nothing; invalid ranges clamp display but retain policy", async () => { + observe(); + const ctx = materialized(); + for (const headers of [new Headers(), quotaHeaders("not-a-number")]) await deliver(ctx, quotaResponse(headers)); + expect(getAccountQuota(MAIN)).toBeNull(); + expect(getMainPolicyQuota()).toBeNull(); + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 44, shortPercent: 12, shortWindowSeconds: 18_000 }, + undefined, ctx.mainQuotaDispatch!.writer); + const before = getMainPolicyQuota(); + await deliver(ctx, quotaResponse(quotaHeaders("120"))); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(100); + expect(getMainPolicyQuota()).toEqual(before); + await deliver(ctx, quotaResponse(new Headers({ "x-codex-primary-used-percent": "-5", + "x-codex-primary-window-minutes": "300", "x-codex-secondary-used-percent": "25" }))); + expect(getAccountQuota(MAIN)).toMatchObject({ shortPercent: 0, weeklyPercent: 25 }); + // The existing main-pool consumer rejects the entire policy projection of a mixed set. + expect(getMainPolicyQuota()).toEqual(before); + }); + + test("12: real persistence saves fresh main usage without credential or dispatch proof", async () => { + observe(); + const before = Date.now(); + const ctx = materialized(); + await deliver(ctx); + expect(pendingPersist).toBeDefined(); + const pending = pendingPersist!; + pendingPersist = undefined; + clearTimeout(pending.timer); + pending.run(); + const body = readFileSync(join(root, "codex-quota-cache.json"), "utf8"); + const persisted = JSON.parse(body); + expect(persisted.quotas[MAIN].weeklyPercent).toBe(23); + expect(persisted.quotas[MAIN].updatedAt).toBeGreaterThanOrEqual(before); + expect(persisted.quotas[MAIN].updatedAt).toBeLessThanOrEqual(Date.now()); + for (const forbidden of [bearer, ACCOUNT, "bearerHmac", "mainQuotaDispatch", "credentialGeneration", "credentialMutationEpoch", "configGeneration", "identityGeneration"]) + expect(body.includes(forbidden)).toBe(false); + expect(Object.keys(persisted.mainPolicyQuota).sort()).toEqual(["identityKey", "quota"]); + }); + + test("13: actual pool to caller-main retry publishes only the final dispatch proof", async () => { + observe(); + saveCodexAccountCredential(POOL, { accessToken: "fixture-retry-pool", + refreshToken: "fixture-retry-refresh", chatgptAccountId: "fixture-pool-workspace", expiresAt: Date.now() + 3600_000 }); + config = { ...config, activeCodexAccountId: POOL, autoSwitchThreshold: 0, + providers: { openai: { ...provider, codexAccountMode: "pool" } }, codexAccounts: [{ id: POOL, label: "fixture pool" }] }; + const turn = tryAdmitTurn(); + expect(turn).not.toBeNull(); + const budget = createTranslatorBudget(); + let sends = 0; + globalThis.fetch = (async (_input, init) => { + sends++; + const headers = new Headers(init?.headers); + expect(headers.get("authorization") === `Bearer ${bearer}`).toBe(true); + expect(headers.get("chatgpt-account-id") === ACCOUNT).toBe(true); + return quotaResponse(quotaHeaders("32")); + }) as typeof fetch; + try { + const firstAuthCtx = await resolveCodexAuthContext(caller(), config, "pool", { + modelId: "gpt-5.5", requestScopedMainCredential: true, + beginCodexAccountSelection: codexAccountSelectionForTurn(turn!), + }); + expect(firstAuthCtx.kind).toBe("pool"); + if (firstAuthCtx.kind !== "pool") throw new Error("fixture did not select pool"); + const result = await retryCodexPoolOnAlternateAccount({ callerAuthHeaders: caller(), config, firstAuthCtx, + firstResponse: new Response(null, { status: 429, headers: { "retry-after": "60", ...Object.fromEntries(quotaHeaders("91")) } }), + outcomeStatus: 429, route: { providerName: "openai", modelId: "gpt-5.5", provider: config.providers.openai! }, + parsed: parseRequest({ model: "gpt-5.5", input: "hi", stream: false }), logCtx: { model: "", provider: "" }, + options: { translatorBudget: budget, turnAdmissionLease: turn! }, upstream: new AbortController(), + connectMs: 1000, stream: false, httpOnly: true }); + expect(result.kind).toBe("retried"); + if (result.kind !== "retried") throw new Error("fixture did not retry"); + expect(sends).toBe(1); + expect(result.authCtx.kind).toBe("main"); + if (result.authCtx.kind !== "main") throw new Error("fixture did not choose caller main"); + expect(result.authCtx.mainQuotaDispatch).toBeDefined(); + expect(getAccountQuota(POOL)?.weeklyPercent).toBe(91); + expect(getAccountQuota(MAIN)).toBeNull(); + await deliver(result.authCtx, result.upstreamResponse); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(32); + // An older completed dispatch cannot overwrite a newer credential's observation. + observe("fixture-final-replacement"); + const final = materialized(caller("fixture-final-replacement")); + await deliver(final, quotaResponse(quotaHeaders("41"))); + await deliver(result.authCtx, quotaResponse(quotaHeaders("79"))); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(41); + } finally { turn?.release(); budget.dispose(); } + }); +}); From 32a25c85ef8b61d6dc39ce817465468acd43270a Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 9 Oct 2026 22:42:16 +0900 Subject: [PATCH 41/59] fix(anthropic): eject an OAuth account whose access token was revoked (#6832) Account recovery handled only 429 and 403, so an exact revoked-token 401 was returned to the client while session affinity kept pinning the same account. Before any output, classify that exact envelope (bounded, fatal UTF-8, no error code), mark the sending account needsReauth under a credential-generation and send-ownership fence, clear its affinities, and allow an eligible same-pool account to take over within existing send limits. Covers native Messages (including pool-off admission order), translated dispatch and continuation, and the web-search and image sidecars. Other 401s and anything after output keep their existing behavior. Security review: independent sol security review PASS on the final patch; merged under the maintainer's admin-merge instruction. Closes #6749 --- .../src/content/docs/fr/guides/claude-code.md | 2 + .../src/content/docs/guides/claude-code.md | 6 + .../src/content/docs/ja/guides/claude-code.md | 2 + .../src/content/docs/ko/guides/claude-code.md | 2 + .../src/content/docs/ru/guides/claude-code.md | 2 + .../src/content/docs/tr/guides/claude-code.md | 2 + .../content/docs/zh-cn/guides/claude-code.md | 2 + .../content/docs/zh-tw/guides/claude-code.md | 2 + scripts/test-layout/layout.json | 5 + src/images/loop.ts | 2 +- src/oauth/anthropic-account-refusal.ts | 40 ++- src/oauth/anthropic-routing.ts | 2 +- src/oauth/anthropic-send-ownership.ts | 6 +- src/oauth/store.ts | 2 +- src/server/messages-native-oauth.ts | 17 +- src/server/messages-native.ts | 6 +- src/server/responses/adapter-continuation.ts | 4 +- src/server/responses/adapter-dispatch.ts | 4 +- src/server/responses/sidecar-execution.ts | 4 +- src/web-search/loop.ts | 2 + structure/data-planes/images.md | 2 + structure/data-planes/inbound-compat.md | 2 + structure/providers-and-adapters.md | 2 +- structure/providers/anthropic-account-pool.md | 22 ++ structure/transports/inventory.md | 2 +- structure/transports/responses-failover.md | 2 +- ...anthropic-revoked-token-boundaries.test.ts | 110 ++++++++ ...thropic-revoked-token-continuation.test.ts | 127 ++++++++++ .../anthropic-revoked-token-sidecars.test.ts | 139 +++++++++++ .../anthropic/anthropic-revoked-token.test.ts | 234 ++++++++++++++++++ .../messages-revoked-token.test.ts | 215 ++++++++++++++++ tests/fixtures/test-layout-expected.json | 5 + 32 files changed, 949 insertions(+), 27 deletions(-) create mode 100644 tests/adapters/anthropic/anthropic-revoked-token-boundaries.test.ts create mode 100644 tests/adapters/anthropic/anthropic-revoked-token-continuation.test.ts create mode 100644 tests/adapters/anthropic/anthropic-revoked-token-sidecars.test.ts create mode 100644 tests/adapters/anthropic/anthropic-revoked-token.test.ts create mode 100644 tests/claude-integration/messages-revoked-token.test.ts diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index f1fd2649697..22f57ac1954 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -775,3 +775,5 @@ Claude Code **2.1.257 or newer** is required for FORCE. Plugin and built-in agen The dashboard warns about old or unknown CLI versions, unavailable targets, and either variable already present in `settings.json` → `env` (which overrides launch env). Detection is read-only and server-local: it cannot inspect another launch shell, another machine, or project-local settings. An unknown result is not proof of force support. Explicit gateway selectors on a generated agent request take precedence over its legacy `ocx-route` fallback, even if the saved force setting changes after launch. For shell or settings overrides of generated roster agents, use an explicit gateway alias; bare Claude ids retain the older-client fallback behavior. Native aliases restore their bare model before the existing credential and model-map checks. Connected launches validate force targets against a fresh authenticated gateway catalog; failed discovery skips automatic force injection, and cached context windows alone never prove availability. + +Uniquement avant toute sortie : Un HTTP 401 authentication_error (sans error.code) portant exactement le message « OAuth access token has been revoked. » marque le compte OAuth ayant envoyé la requête comme nécessitant une nouvelle connexion et efface ses affinités de session. Avant toute sortie, un compte disponible du même pool peut prendre le relais dans les limites existantes. Sans remplaçant, le 401 original est renvoyé et le compte reste exclu jusqu’à une nouvelle connexion. Les autres 401 gardent leur traitement actuel. diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index ea1c6761289..ecdc703d3ce 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -81,6 +81,12 @@ Operational contract when enabled: access, policy and unrecognized errors stay terminal. Recovery respects model routes and send limits; if no replacement is eligible, the original 403 is returned. This also works with proactive pooling off. A 403 after assistant output starts never switches accounts. +- Before output, an exact structured **401** authentication_error with no error code and the message + “OAuth access token has been revoked.” marks the sending OAuth account as requiring + a new login and clears its session affinities. Before output, an eligible account in + the same pool may take over within existing send limits. With no eligible replacement, + the original 401 is returned; the refused account remains excluded until login. + Other 401 errors retain their existing behavior. - Token-refresh credential failures retain the existing `needsReauth` policy. Subscription renewal does not require reauthentication, but the account waits for its cooldown to expire. - If every eligible account is cooling, the proxy returns **429** (not 401) with `Retry-After` diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index faf0313c315..c6308ab24b8 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -630,3 +630,5 @@ Claude Code **2.1.257 or newer** is required for FORCE. Plugin and built-in agen The dashboard warns about old or unknown CLI versions, unavailable targets, and either variable already present in `settings.json` → `env` (which overrides launch env). Detection is read-only and server-local: it cannot inspect another launch shell, another machine, or project-local settings. An unknown result is not proof of force support. Explicit gateway selectors on a generated agent request take precedence over its legacy `ocx-route` fallback, even if the saved force setting changes after launch. For shell or settings overrides of generated roster agents, use an explicit gateway alias; bare Claude ids retain the older-client fallback behavior. Native aliases restore their bare model before the existing credential and model-map checks. Connected launches validate force targets against a fresh authenticated gateway catalog; failed discovery skips automatic force injection, and cached context windows alone never prove availability. + +出力前の応答に限り、HTTP 401 の authentication_error(error.code なし) が正確に “OAuth access token has been revoked.” を返した場合、送信した OAuth アカウントを再ログインが必要な状態にし、セッションの紐付けを解除します。出力前に限り、既存の送信上限内で同じプールの利用可能なアカウントに切り替えます。候補がなければ元の 401 を返し、再ログインまで選択から除外します。他の 401 の処理は変わりません。 diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index 4f530872b0c..1de6c56ba53 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -693,3 +693,5 @@ Claude Code **2.1.257 or newer** is required for FORCE. Plugin and built-in agen The dashboard warns about old or unknown CLI versions, unavailable targets, and either variable already present in `settings.json` → `env` (which overrides launch env). Detection is read-only and server-local: it cannot inspect another launch shell, another machine, or project-local settings. An unknown result is not proof of force support. Explicit gateway selectors on a generated agent request take precedence over its legacy `ocx-route` fallback, even if the saved force setting changes after launch. For shell or settings overrides of generated roster agents, use an explicit gateway alias; bare Claude ids retain the older-client fallback behavior. Native aliases restore their bare model before the existing credential and model-map checks. Connected launches validate force targets against a fresh authenticated gateway catalog; failed discovery skips automatic force injection, and cached context windows alone never prove availability. + +출력 전의 응답에서만 정확한 HTTP 401 authentication_error (error.code 없음) 메시지가 “OAuth access token has been revoked.”이면 요청을 보낸 OAuth 계정에 재로그인이 필요하다고 표시하고 세션 연결을 해제합니다. 출력 전에는 기존 전송 제한 안에서 같은 풀의 사용 가능한 계정으로 전환할 수 있습니다. 대체 계정이 없으면 원래 401을 반환하며, 해당 계정은 재로그인할 때까지 선택에서 제외됩니다. 다른 401의 처리는 바뀌지 않습니다. diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index 0499b4441c3..6bb458df309 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -665,3 +665,5 @@ Claude Code **2.1.257 or newer** is required for FORCE. Plugin and built-in agen The dashboard warns about old or unknown CLI versions, unavailable targets, and either variable already present in `settings.json` → `env` (which overrides launch env). Detection is read-only and server-local: it cannot inspect another launch shell, another machine, or project-local settings. An unknown result is not proof of force support. Explicit gateway selectors on a generated agent request take precedence over its legacy `ocx-route` fallback, even if the saved force setting changes after launch. For shell or settings overrides of generated roster agents, use an explicit gateway alias; bare Claude ids retain the older-client fallback behavior. Native aliases restore their bare model before the existing credential and model-map checks. Connected launches validate force targets against a fresh authenticated gateway catalog; failed discovery skips automatic force injection, and cached context windows alone never prove availability. + +Только до начала вывода: Если HTTP 401 содержит authentication_error (без error.code) с точным сообщением “OAuth access token has been revoked.”, отправившая запрос учётная запись OAuth помечается как требующая нового входа, а привязки сессий очищаются. До начала вывода возможен переход к доступной записи того же пула в пределах существующих лимитов отправки. Без замены возвращается исходный 401; запись исключается до нового входа. Обработка других 401 не меняется. diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index 87634321e60..b0015531c14 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -887,3 +887,5 @@ Claude Code **2.1.257 or newer** is required for FORCE. Plugin and built-in agen The dashboard warns about old or unknown CLI versions, unavailable targets, and either variable already present in `settings.json` → `env` (which overrides launch env). Detection is read-only and server-local: it cannot inspect another launch shell, another machine, or project-local settings. An unknown result is not proof of force support. Explicit gateway selectors on a generated agent request take precedence over its legacy `ocx-route` fallback, even if the saved force setting changes after launch. For shell or settings overrides of generated roster agents, use an explicit gateway alias; bare Claude ids retain the older-client fallback behavior. Native aliases restore their bare model before the existing credential and model-map checks. Connected launches validate force targets against a fresh authenticated gateway catalog; failed discovery skips automatic force injection, and cached context windows alone never prove availability. + +Yalnızca çıktı başlamadan önce: HTTP 401 authentication_error (error.code olmadan) iletisi tam olarak “OAuth access token has been revoked.” olduğunda, isteği gönderen OAuth hesabı yeniden oturum açılması gereken durumda işaretlenir ve oturum bağları temizlenir. Çıktı başlamadan önce mevcut gönderim sınırları içinde aynı havuzdaki uygun hesaba geçilebilir. Alternatif yoksa özgün 401 döndürülür; hesap yeniden giriş yapılana kadar seçim dışı kalır. Diğer 401 yanıtlarının işlenmesi değişmez. diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index 6559c1a8977..aafb1f310a7 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -596,3 +596,5 @@ Claude Code **2.1.257 or newer** is required for FORCE. Plugin and built-in agen The dashboard warns about old or unknown CLI versions, unavailable targets, and either variable already present in `settings.json` → `env` (which overrides launch env). Detection is read-only and server-local: it cannot inspect another launch shell, another machine, or project-local settings. An unknown result is not proof of force support. Explicit gateway selectors on a generated agent request take precedence over its legacy `ocx-route` fallback, even if the saved force setting changes after launch. For shell or settings overrides of generated roster agents, use an explicit gateway alias; bare Claude ids retain the older-client fallback behavior. Native aliases restore their bare model before the existing credential and model-map checks. Connected launches validate force targets against a fresh authenticated gateway catalog; failed discovery skips automatic force injection, and cached context windows alone never prove availability. + +仅在输出开始前的响应中,仅当 HTTP 401 的 authentication_error(无 error.code) 消息完全等于 “OAuth access token has been revoked.” 时,发送请求的 OAuth 账户会被标记为需要重新登录,并清除会话绑定。输出开始前,可在现有发送限制内切换到同一池的可用账户;没有替代账户时返回原始 401,该账户在重新登录前不会被选择。其他 401 的处理保持不变。 diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index 6e6936a0ea6..c6ec3d79339 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -672,3 +672,5 @@ Claude Code **2.1.257 or newer** is required for FORCE. Plugin and built-in agen The dashboard warns about old or unknown CLI versions, unavailable targets, and either variable already present in `settings.json` → `env` (which overrides launch env). Detection is read-only and server-local: it cannot inspect another launch shell, another machine, or project-local settings. An unknown result is not proof of force support. Explicit gateway selectors on a generated agent request take precedence over its legacy `ocx-route` fallback, even if the saved force setting changes after launch. For shell or settings overrides of generated roster agents, use an explicit gateway alias; bare Claude ids retain the older-client fallback behavior. Native aliases restore their bare model before the existing credential and model-map checks. Connected launches validate force targets against a fresh authenticated gateway catalog; failed discovery skips automatic force injection, and cached context windows alone never prove availability. + +僅在輸出開始前的回應中,僅當 HTTP 401 的 authentication_error(無 error.code) 訊息完全等於 “OAuth access token has been revoked.” 時,發送請求的 OAuth 帳戶會被標記為需要重新登入,並清除工作階段綁定。輸出開始前,可在既有發送限制內切換到同一池的可用帳戶;沒有替代帳戶時回傳原始 401,該帳戶在重新登入前不會被選取。其他 401 的處理保持不變。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f4c797ed734..5cba9f4397c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -4,6 +4,11 @@ "explicit": { "messages-request-id-headers.test.ts": "claude-integration", "messages-request-id-endpoint.test.ts": "claude-integration", + "anthropic-revoked-token.test.ts": "adapters/anthropic", + "messages-revoked-token.test.ts": "claude-integration", + "anthropic-revoked-token-continuation.test.ts": "adapters/anthropic", + "anthropic-revoked-token-sidecars.test.ts": "adapters/anthropic", + "anthropic-revoked-token-boundaries.test.ts": "adapters/anthropic", "azure-vendor-metadata.test.ts": "providers", "anthropic-instance-isolation.test.ts": "adapters/anthropic", "anthropic-instance-pool-parity.test.ts": "adapters/anthropic", "anthropic-instance-quota.test.ts": "adapters/anthropic", "anthropic-instance-recovery.test.ts": "adapters/anthropic", diff --git a/src/images/loop.ts b/src/images/loop.ts index 33df568ebaa..516b3cbc6a1 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -704,7 +704,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise= 3 })); } +async function isRevokedOAuthToken(response: Response, signal?: AbortSignal): Promise { + if (response.status !== 401) return false; + try { + const body = await readBoundedResponseBody(response.clone(), { signal, fatalUtf8: true }); + if (!body.displaySafe || body.truncated) return false; + const payload: unknown = JSON.parse(body.text); + if (!payload || typeof payload !== "object" || Array.isArray(payload) + || !("type" in payload) || payload.type !== "error" || !("error" in payload)) return false; + const error = payload.error; + return !!error && typeof error === "object" && !Array.isArray(error) + && "type" in error && error.type === "authentication_error" + && "message" in error && error.message === "OAuth access token has been revoked." + && (!("code" in error) || error.code == null); + } catch { return false; } +} + async function isAccountRefusal(response: Response, signal?: AbortSignal): Promise { try { const body = await readBoundedResponseBody(response.clone(), { signal }); @@ -91,6 +107,28 @@ export async function rotateAnthropicAccountOnResponseForInstance( && credentialGeneration(row.credential) === sent.generation && (!sent.checkProviderUuid || row.credential.accountId === sent.providerAccountUuid) ? row : undefined; }; + if (response.status === 401) { + if (options.allowAccountRefusal === false) return null; + let verdict = verdicts.get(response); + if (!verdict) { verdict = isRevokedOAuthToken(response, options.signal); verdicts.set(response, verdict); } + if (!await verdict || options.signal?.aborted || !ownedCurrent()) return null; + options.currentDecision?.(); + let marked: boolean; + try { + marked = await markAccountNeedsReauthIfGeneration(instance, sent.accountId, sent.generation, undefined, undefined, store => { + const row = store[instance]?.accounts.find(account => account.id === sent.accountId); + return !options.signal?.aborted && configuredAnthropicInstance(options.config, instance) === instance + && anthropicPhysicalSendOwnershipIsCurrent(sent, store) + && (!sent.checkProviderUuid || row?.credential.accountId === sent.providerAccountUuid); + }); + } catch { return null; } + if (!marked || configuredAnthropicInstance(options.config, instance) !== instance + || !anthropicPhysicalSendOwnershipIsCurrent(sent)) return null; + routing.clearAnthropicSessionAffinityForAccount(sent.accountId); + if (!options.canRetry || options.signal?.aborted) return null; + const decision = options.currentDecision ? options.currentDecision() : options.decision ?? null; + return pickAlternateAnthropicAccount(options.config, sent.accountId, Date.now(), decision, options.model, options.excludedAccountIds); + } if (response.status === 429) { const current = ownedCurrent(); if (!current) return null; diff --git a/src/oauth/anthropic-routing.ts b/src/oauth/anthropic-routing.ts index 5ee71f197d4..dfff1d80ffb 100644 --- a/src/oauth/anthropic-routing.ts +++ b/src/oauth/anthropic-routing.ts @@ -704,7 +704,7 @@ function createAnthropicRouting(instance: AnthropicInstanceId): AnthropicRouting excludedAccountIds?: ReadonlySet, ): string | null { if (!admitted(config)) return null; - const strategy = anthropicPoolStrategy(config); + const strategy = isAnthropicAccountPoolEnabled(config) ? anthropicPoolStrategy(config) : "quota"; const eligible = routeCandidates(getEligibleAnthropicAccounts(now, model), decision).filter(id => id !== excludeId && !excludedAccountIds?.has(id)); if (strategy === "round-robin") { return peekRoundRobinAccount(poolKey, eligible, stickyLimitForPool(config)); diff --git a/src/oauth/anthropic-send-ownership.ts b/src/oauth/anthropic-send-ownership.ts index 04db07545c4..3f589a68496 100644 --- a/src/oauth/anthropic-send-ownership.ts +++ b/src/oauth/anthropic-send-ownership.ts @@ -1,6 +1,6 @@ /** Ownership captured before a physical send, independent of subsequent cooldown observations. */ import type { OAuthAccessSnapshot } from "./index"; -import { credentialGeneration, getAccountSet } from "./store"; +import { credentialGeneration, getAccountSet, type AuthStore } from "./store"; import { isAnthropicInstanceId, type AnthropicInstanceId } from "../providers/anthropic-instance-id"; import { anthropicCooldownRecoveryFor } from "../providers/quota/anthropic-cooldown-recovery"; import { captureProviderAccountQuotaEpoch } from "../providers/quota/account-cache"; @@ -32,8 +32,8 @@ export function captureAnthropicPhysicalSendOwnership(snapshot: OAuthAccessSnaps } /** Pure ownership read: never adopt or reserve the replacement account's current incarnation. */ -export function anthropicPhysicalSendOwnershipIsCurrent(owner: AnthropicPhysicalSendOwnership): boolean { - const row = getAccountSet(owner.provider)?.accounts.find(account => account.id === owner.accountId); +export function anthropicPhysicalSendOwnershipIsCurrent(owner: AnthropicPhysicalSendOwnership, store?: AuthStore): boolean { + const row = (store ? store[owner.provider] : getAccountSet(owner.provider))?.accounts.find(account => account.id === owner.accountId); return !!row && row.loginId === owner.loginId && row.addedAt === owner.addedAt && row.credential.access === owner.accessToken && credentialGeneration(row.credential) === owner.generation && anthropicCooldownRecoveryFor(owner.provider).anthropicAccountIncarnation(owner.accountId) === owner.accountIncarnation diff --git a/src/oauth/store.ts b/src/oauth/store.ts index ac750fe0541..ef952bd9d40 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -1508,7 +1508,7 @@ export async function markAccountNeedsReauth( export async function mergeAccountCredential(provider:string,accountId:string,credential:OAuthCredentials,opts:{expectedGeneration?:string;afterPrePersistRead?:()=>void|Promise;assertOwnership?: (store: AuthStore) => void}={}):Promise<{superseded:false}|{superseded:true;stored:OAuthCredentials}>{const safe=normalizeCredential(credential);if(!safe)throw new Error("Refusing to persist invalid OAuth credential");if(isAnthropicInstanceId(provider))assertAnthropicCredentialSource(provider,safe);return await mutateStore(async store=>{await opts.afterPrePersistRead?.();const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account)throw new Error(`OAuth account disappeared before persist: ${provider}`);if(opts.expectedGeneration!==undefined&&credentialGeneration(account.credential)!==opts.expectedGeneration)return{superseded:true,stored:account.credential};opts.assertOwnership?.(store);account.credential=safe;delete account.refreshAttentionGeneration;if(account.needsReauthReason!=="verify_account"){delete account.needsReauth;delete account.needsReauthReason;}return{superseded:false};},[provider,accountId,safe,opts.expectedGeneration]);} // A late refresh failure must not change an operator-paused account's health. Check under // the mutation lock, not before awaiting it, so a concurrent pause cannot be overwritten. -export async function markAccountNeedsReauthIfGeneration(provider:string,accountId:string,generation:string,writerGeneration=captureConfigGeneration(),reason?:"verify_account"):Promise{const key=oauthAccountKey(provider,accountId);if(writerGeneration{const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account?.credential||account.paused||credentialGeneration(account.credential)!==generation)return false;if(writerGenerationboolean):Promise{const key=oauthAccountKey(provider,accountId);if(writerGeneration{const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account?.credential||account.paused||credentialGeneration(account.credential)!==generation)return false;if(writerGeneration => { + const send = async (recovery?: "rate-limit-429" | "oauth-401" | "oauth-account-403" | "key-429" | "key-401"): Promise => { const remaining = remainingTransientSends(); if (requestTransientPolicy && remaining <= 0) { throw new Error("native Messages transient send budget exhausted before recovery dispatch"); @@ -685,7 +685,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) const oauthRetryKey = {}; const triedAccountIds = new Set(); let oauthFailovers = 0; - while (oauthBinding && (response.status === 429 || response.status === 403)) { + while (oauthBinding && (response.status === 429 || response.status === 403 || response.status === 401)) { const sendingBinding = oauthBinding; const expectedRecoverySelection = sendingBinding.selection; triedAccountIds.add(sendingBinding.snapshot.accountId); @@ -703,7 +703,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) canRetry: transientSendAvailable() && oauthFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST, }); if (!nextAccountId) break; - const recovery = response.status === 403 ? "oauth-account-403" : "rate-limit-429"; + const recovery = response.status === 401 ? "oauth-401" : response.status === 403 ? "oauth-account-403" : "rate-limit-429"; discard(response); oauthBinding = await resolveNativeOAuthBindingForInstance(nativeInstance!, config, { routeTarget, sessionKey: options.sessionKey, model: route.modelId, candidateAccountId: nextAccountId, diff --git a/src/server/responses/adapter-continuation.ts b/src/server/responses/adapter-continuation.ts index 72e8e0c43d8..096b19db88f 100644 --- a/src/server/responses/adapter-continuation.ts +++ b/src/server/responses/adapter-continuation.ts @@ -401,7 +401,7 @@ export function createAdapterContinuations( } } if ( - (response.status === 429 || response.status === 403) + (response.status === 429 || response.status === 403 || response.status === 401) && anthropicInstance && transportState.anthropicPoolAccountId && !isNonReplayableResponse(response) @@ -428,7 +428,7 @@ export function createAdapterContinuations( ); sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel); recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name); - nextContinuationRecoveryKind = "anthropic-oauth-429"; + nextContinuationRecoveryKind = response.status === 401 ? "oauth-401" : "anthropic-oauth-429"; continue; } catch { // fall through to emit continuation error below diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index 4a7cf9490a1..9d7c7215c85 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -1097,7 +1097,7 @@ export async function prepareAdapterExchange( // Anthropic OAuth: recover a rate limit or proven account entitlement refusal // before output, within the shared request and account rotation limits. while ( - (upstreamResponse.status === 429 || upstreamResponse.status === 403) + (upstreamResponse.status === 429 || upstreamResponse.status === 403 || upstreamResponse.status === 401) && anthropicInstance && transportState.anthropicPoolAccountId ) { @@ -1122,7 +1122,7 @@ export async function prepareAdapterExchange( ); sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel); recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name); - const result = await rebuildAndRefetch("anthropic-oauth-429"); + const result = await rebuildAndRefetch(upstreamResponse.status === 401 ? "oauth-401" : "anthropic-oauth-429"); if ("failed" in result) return result.failed; upstreamResponse = result; if (isNonReplayableResponse(upstreamResponse)) continue recovery; diff --git a/src/server/responses/sidecar-execution.ts b/src/server/responses/sidecar-execution.ts index 831b298994f..be3105c50af 100644 --- a/src/server/responses/sidecar-execution.ts +++ b/src/server/responses/sidecar-execution.ts @@ -204,7 +204,7 @@ export async function executeResponsesSidecars( ): Promise<{ adapter: ProviderAdapter; recoveryKind: AttemptRecoveryKind } | null> => { const antigravityValidationResponse = canRunWebSearch && route.providerName === "google-antigravity" && route.provider.authMode === "oauth" && originalResponse?.status === 403; - if (route.providerName !== "kiro" && !(anthropicInstance && originalResponse?.status === 403) + if (route.providerName !== "kiro" && !(anthropicInstance && (originalResponse?.status === 403 || originalResponse?.status === 401)) && !antigravityValidationResponse && originalResponse && originalResponse.status !== 429) return null; let antigravityVerification = false; if (antigravityValidationResponse) { @@ -368,7 +368,7 @@ export async function executeResponsesSidecars( hop.permit?.release(); return null; } - recoveryKind = "anthropic-oauth-429"; + recoveryKind = originalResponse?.status === 401 ? "oauth-401" : "anthropic-oauth-429"; sidecarBudget.ownCredentialHop(hop.permit); } else { // No key pool, no generic OAuth roster, no Anthropic pool could produce a replacement diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 83164a004d8..8720e657411 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -577,6 +577,8 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise { + f = await createAnthropicInstanceFixture(); + await f.seed(); + refusal = await import("../../../src/oauth/anthropic-account-refusal"); + ownership = await import("../../../src/oauth/anthropic-send-ownership"); +}); +afterEach(async () => { await f?.dispose(); }); + +async function bound(instance: AnthropicInstanceId, response = Response.json(payload, { status: 401 })) { + const { snapshot } = await f.admit(instance, "session-one"); + expect(snapshot).not.toBeNull(); + const owner = ownership.captureAnthropicPhysicalSendOwnership(snapshot!); + expect(owner).not.toBeNull(); + refusal.bindAnthropicRefusalCredentialForSend(response, owner!); + return { response, snapshot: snapshot!, owner: owner! }; +} +function rotate(instance: AnthropicInstanceId, response: Response, canRetry = true, allowAccountRefusal = true) { + return refusal.rotateAnthropicAccountOnResponseForInstance(instance, response, { + config: f.config, accountId: f.ids[0], model: f.model, sessionKey: "session-one", + requestKey: {}, canRetry, allowAccountRefusal, + }); +} +for (const instance of instances) for (const mutation of ["uuid", "cancel", "epoch", "remove-readd"] as const) { + test(instance + ": independent queued " + mutation + " cannot acquire terminal authority", async () => { + const { response, snapshot } = await bound(instance); + refusal.bindAnthropicRefusalCredentialForSend(response, ownership.captureAnthropicPhysicalSendOwnership(snapshot)!, + f.store.getAccountCredential(instance, f.ids[0])!.accountId); + const abort = new AbortController(); + if (mutation === "epoch") f.quota.clearAccountQuotaCache(); + if (mutation === "remove-readd") { + const original = structuredClone(f.store.getAccountSet(instance)!.accounts[0]!); + await f.store.removeAccount(instance,f.ids[0]); + const cache=await import("../../../src/providers/quota/account-cache"); + const recovery=await import("../../../src/providers/quota/anthropic-cooldown-recovery"); + const sweeper=await import("../../../src/lib/state-store-sweeper"); + const context={generation:sweeper.captureConfigGeneration()+1,providerNames:new Set(instances), + oauthAccountKeys:new Set(instances.flatMap(i=>f.store.getAccountSet(i)!.accounts.map(a=>cache.accountCacheKey(i,a.id)))), + comboIds:new Set(),comboTargets:new Set(),codexAccountIds:new Set(),configRoots:new Set()}; + recovery.reconcileAllAnthropicCooldownGenerations(context); + await f.store.mutateStore(s=>{s[instance]!.accounts.unshift(original);}); + } + let release!:()=>void,entered!:()=>void; + const hold=new Promise(r=>release=r), ready=new Promise(r=>entered=r); + const blocker=f.store.mutateStore(async()=>{entered();await hold;}); await ready; + const newer=mutation === "uuid" ? f.store.mutateStore(s=>{s[instance]!.accounts[0]!.credential.accountId="55555555-5555-4555-8555-555555555555";}) : Promise.resolve(); + const pending=refusal.rotateAnthropicAccountOnResponseForInstance(instance,response,{ + config:f.config,accountId:f.ids[0],model:f.model,canRetry:true,signal:abort.signal}); + try {for(let i=0;i<100;i++) await new Promise(r=>setImmediate(r)); if(mutation==="cancel")abort.abort();} + finally {release();} + await blocker;await newer; + expect(await pending).toBeNull(); + expect(f.store.getAccountCredentialWithStatus(instance,f.ids[0])?.needsReauth).toBe(false); + }); +} +test("independent Pool 2 disable while terminal writer is queued",async()=>{ + const {response}=await bound("anthropic2"); + let release!:()=>void,entered!:()=>void; + const hold=new Promise(r=>release=r),ready=new Promise(r=>entered=r); + const blocker=f.store.mutateStore(async()=>{entered();await hold;});await ready; + const pending=rotate("anthropic2",response); + try {for(let i=0;i<100 && f.store.oauthMutationTailSnapshot().active<2;i++)await new Promise(r=>setImmediate(r)); + expect(f.store.oauthMutationTailSnapshot().active).toBe(2); f.config.providers.anthropic2!.disabled=true;} + finally {release();} + await blocker;expect(await pending).toBeNull(); + expect(f.store.getAccountCredentialWithStatus("anthropic2",f.ids[0])?.needsReauth).toBe(false); +}); + +for (const instance of instances) { + test(`${instance}: independent search loop does not offer revoked-401 recovery after routed output`, async () => { + const { runWithWebSearch } = await import("../../../src/web-search/loop"); + const { parseRequest } = await import("../../../src/responses/parser"); + const { createTranslatorBudget } = await import("../../../src/lib/translator-budget"); + const translatorBudget = createTranslatorBudget(); + globalThis.fetch = (async () => Response.json({ results: [{ title: "Fixture", url: "https://example.test/result", content: "Synthetic result" }] })) as typeof fetch; + let routedSends = 0; let rotations = 0; + const adapter: import("../../../src/adapters/base").ProviderAdapter = { + name: "mock-anthropic-output", + buildRequest: () => ({ url: "https://routed.example.test/messages", method: "POST", headers: {}, body: "{}" }), + fetchResponse: async () => ++routedSends === 1 ? new Response("ok") : Response.json(payload,{status:401}), + async *parseStream() { + yield { type: "text_delta", text: "I will check. " }; + yield { type: "tool_call_start", id: "search-1", name: "web_search" }; + yield { type: "tool_call_delta", arguments: '{"query":"fixture query"}' }; + yield { type: "tool_call_end" }; yield { type: "done" }; + }, + }; + try { + const response = await runWithWebSearch({ + parsed: parseRequest({ model: `${instance}/${f.model}`, input: "Answer briefly", stream: true, tools: [{ type: "web_search" }] }), + adapter, incomingMeta: { headers: new Headers(), providerName: instance, translatorBudget }, + backend: "exa", exaApiKey: "test-exa-key", hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers(), + settings: { model: "exa-fixture-model", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 1, streamRoutedModelOutput: true, + on429: () => { rotations++; return null; }, + }); + expect(await response.text()).toContain("I will check."); + expect(routedSends).toBe(2); expect(rotations).toBe(0); + } finally { translatorBudget.dispose(); } + }); +} diff --git a/tests/adapters/anthropic/anthropic-revoked-token-continuation.test.ts b/tests/adapters/anthropic/anthropic-revoked-token-continuation.test.ts new file mode 100644 index 00000000000..45eafc3465a --- /dev/null +++ b/tests/adapters/anthropic/anthropic-revoked-token-continuation.test.ts @@ -0,0 +1,127 @@ +/** Real Responses recovery with colliding account IDs and an instance-owned send ledger. */ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import type { OcxConfig, OcxProviderConfig } from "../../../src/types"; +import type { AnthropicInstanceId } from "../../../src/providers/anthropic-instance-id"; +import type { HandleResponsesOptions } from "../../../src/server/responses/core-options"; +import { createAnthropicInstanceFixture, instanceFixtureCredential, instanceFixtureUuid, type AnthropicInstanceFixture } from "../../helpers/anthropic-instance-fixture"; + +const instances = ["anthropic", "anthropic2"] as const; +let store: typeof import("../../../src/oauth/store"); +let routing: typeof import("../../../src/oauth/anthropic-routing"); +let resolver: typeof import("../../../src/server/adapter-resolve"); +let handleResponses: typeof import("../../../src/server/responses").handleResponses; +let fixture: AnthropicInstanceFixture; +let releaseSpend: (() => void) | undefined; +let ids: Record; +let config: OcxConfig; +let sends: Array<{ instance: AnthropicInstanceId; token: string | null; body: Record }>; +let reply: (instance: AnthropicInstanceId, index: number, body: Record) => Response | Promise; + +function answer(): Response { + return Response.json({ id: "msg_synthetic", type: "message", role: "assistant", model: "claude-sonnet-4-6", + content: [{ type: "text", text: "The answer is complete." }], stop_reason: "end_turn", usage: { input_tokens: 8, output_tokens: 6 } }); +} +function refusal(status: 403 | 429): Response { + return Response.json({ type: "error", error: status === 403 + ? { type: "permission_error", message: "Your account does not have access to Claude Code" } + : { type: "rate_limit_error", message: "Synthetic shared quota exhausted" } }, { + status, headers: { "retry-after": "30", ...(status === 429 ? { "anthropic-ratelimit-unified-5h-status": "rejected" } : {}) }, + }); +} +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} +function post(instance: AnthropicInstanceId, options: HandleResponsesOptions = {}, body: Record = {}) { + return handleResponses(new Request("http://localhost/v1/responses", { method: "POST", + headers: { "content-type": "application/json", "session-id": "equal-session", authorization: "Bearer access-token-value-test-caller-excluded" }, + body: JSON.stringify({ model: `${instance}/claude-sonnet-4-6`, input: "Answer briefly", stream: false, ...body }), + }), config, { model: "", provider: "" }, options); +} + +beforeEach(async () => { + fixture = await createAnthropicInstanceFixture({ anthropic: { enabled: false }, anthropic2: { enabled: false } }); + fixture.quota.resetProviderQuotaReconcileStateForTests(); + await fixture.seed(); + ({ store, routing, config } = fixture); + resolver = await import("../../../src/server/adapter-resolve"); + ({ handleResponses } = await import("../../../src/server/responses")); + const { acquireOwnedSpendHome } = await import("../../helpers/owned-spend-home"); + releaseSpend = acquireOwnedSpendHome(); + ids = { anthropic: [...fixture.ids], anthropic2: [...fixture.ids] }; + sends = []; + reply = () => answer(); + for (const instance of instances) { + Object.assign(config.providers[instance]!, { baseUrl: "https://instance-bridge.example.test", models: [fixture.model], + fetch: (async (_input: RequestInfo | URL, init?: RequestInit) => { + const token = new Headers(init?.headers).get("authorization"); + const row = store.getAccountSet(instance)?.accounts.find(account => `Bearer ${account.credential.access}` === token); + const body = JSON.parse(String(init?.body)) as Record; + expect(row).toBeDefined(); + // Source identity is separate evidence; the translated adapter sends no account UUID. + expect(row!.credential.anthropicIdentity?.accountUuid).toBe(instanceFixtureUuid(instance, fixture.ids.indexOf(row!.id as typeof fixture.ids[number]) + 1)); + fixture.ledger.record({ instance, accountId: row!.id, token: token!.replace(/^Bearer /, ""), model: String(body.model) }); + sends.push({ instance, token, body }); + return reply(instance, sends.length, body); + }) as typeof fetch, + }); + } + fixture.publishConfig(); +}); +afterEach(async () => { + try { + fixture.ledger.assertNoCrossSend(); + releaseSpend?.(); releaseSpend = undefined; + const { clearResponseStateForTests } = await import("../../../src/responses/state"); + clearResponseStateForTests(); + } finally { + try { await fixture.dispose(); } + finally { fixture.quota.resetProviderQuotaReconcileStateForTests(); } + } +}); + +function revoked() { + return Response.json({ type: "error", error: { type: "authentication_error", + message: "OAuth access token has been revoked." } }, { status: 401 }); +} +function answerStream(text = "The answer is complete.") { + const message = { id: "msg_synthetic", type: "message", role: "assistant", model: fixture.model, + content: [], stop_reason: null, usage: { input_tokens: 8, output_tokens: 0 } }; + const frames = [ + { type: "message_start", message }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 6 } }, + { type: "message_stop" }, + ]; + return new Response(frames.map(frame => "event: " + frame.type + "\ndata: " + JSON.stringify(frame) + "\n\n").join(""), + { headers: { "content-type": "text/event-stream" } }); +} + +for (const instance of instances) { + test(instance + ": pre-output empty continuation recovers revoked 401", async () => { + config.emptyCompletionRetry = true; + reply = (_instance, index, body) => index === 1 ? answerStream("") + : index === 2 ? revoked() : answerStream(); + const response = await post(instance, {}, { stream: true }); + expect(await response.text()).toContain("The answer is complete."); + expect(sends.map(send => send.token)).toEqual([ + "Bearer synthetic-" + instance + "-1-access", + "Bearer synthetic-" + instance + "-1-access", + "Bearer synthetic-" + instance + "-2-access", + ]); + expect(store.getAccountCredentialWithStatus(instance, fixture.ids[0])?.needsReauth).toBe(true); + }); + test(instance + ": continuation after assistant output cannot mark or replay", async () => { + reply = (_instance, index) => index === 1 ? answerStream("I will modify the file now.") : revoked(); + const response = await post(instance, {}, { stream: true, input: "Please modify the file now", + tools: [{ type: "function", name: "read_file", description: "read a file", parameters: { type: "object" } }] }); + expect(await response.text()).toContain("I will modify the file now."); + expect(sends.map(send => send.token)).toEqual([ + "Bearer synthetic-" + instance + "-1-access", "Bearer synthetic-" + instance + "-1-access", + ]); + expect(store.getAccountCredentialWithStatus(instance, fixture.ids[0])?.needsReauth).toBe(false); + }); +} diff --git a/tests/adapters/anthropic/anthropic-revoked-token-sidecars.test.ts b/tests/adapters/anthropic/anthropic-revoked-token-sidecars.test.ts new file mode 100644 index 00000000000..88238f393fa --- /dev/null +++ b/tests/adapters/anthropic/anthropic-revoked-token-sidecars.test.ts @@ -0,0 +1,139 @@ +/** Real Responses recovery with colliding account IDs and an instance-owned send ledger. */ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import type { OcxConfig, OcxProviderConfig } from "../../../src/types"; +import type { AnthropicInstanceId } from "../../../src/providers/anthropic-instance-id"; +import type { HandleResponsesOptions } from "../../../src/server/responses/core-options"; +import { createAnthropicInstanceFixture, instanceFixtureCredential, instanceFixtureUuid, type AnthropicInstanceFixture } from "../../helpers/anthropic-instance-fixture"; + +const instances = ["anthropic", "anthropic2"] as const; +let store: typeof import("../../../src/oauth/store"); +let routing: typeof import("../../../src/oauth/anthropic-routing"); +let resolver: typeof import("../../../src/server/adapter-resolve"); +let handleResponses: typeof import("../../../src/server/responses").handleResponses; +let fixture: AnthropicInstanceFixture; +let releaseSpend: (() => void) | undefined; +let ids: Record; +let config: OcxConfig; +let sends: Array<{ instance: AnthropicInstanceId; token: string | null; body: Record }>; +let reply: (instance: AnthropicInstanceId, index: number, body: Record) => Response | Promise; + +function answer(): Response { + return Response.json({ id: "msg_synthetic", type: "message", role: "assistant", model: "claude-sonnet-4-6", + content: [{ type: "text", text: "The answer is complete." }], stop_reason: "end_turn", usage: { input_tokens: 8, output_tokens: 6 } }); +} +function refusal(status: 403 | 429): Response { + return Response.json({ type: "error", error: status === 403 + ? { type: "permission_error", message: "Your account does not have access to Claude Code" } + : { type: "rate_limit_error", message: "Synthetic shared quota exhausted" } }, { + status, headers: { "retry-after": "30", ...(status === 429 ? { "anthropic-ratelimit-unified-5h-status": "rejected" } : {}) }, + }); +} +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} +function post(instance: AnthropicInstanceId, options: HandleResponsesOptions = {}, body: Record = {}) { + return handleResponses(new Request("http://localhost/v1/responses", { method: "POST", + headers: { "content-type": "application/json", "session-id": "equal-session", authorization: "Bearer access-token-value-test-caller-excluded" }, + body: JSON.stringify({ model: `${instance}/claude-sonnet-4-6`, input: "Answer briefly", stream: false, ...body }), + }), config, { model: "", provider: "" }, options); +} + +beforeEach(async () => { + fixture = await createAnthropicInstanceFixture({ anthropic: { enabled: false }, anthropic2: { enabled: false } }); + fixture.quota.resetProviderQuotaReconcileStateForTests(); + await fixture.seed(); + ({ store, routing, config } = fixture); + resolver = await import("../../../src/server/adapter-resolve"); + ({ handleResponses } = await import("../../../src/server/responses")); + const { acquireOwnedSpendHome } = await import("../../helpers/owned-spend-home"); + releaseSpend = acquireOwnedSpendHome(); + ids = { anthropic: [...fixture.ids], anthropic2: [...fixture.ids] }; + sends = []; + reply = () => answer(); + for (const instance of instances) { + Object.assign(config.providers[instance]!, { baseUrl: "https://instance-bridge.example.test", models: [fixture.model], + fetch: (async (_input: RequestInfo | URL, init?: RequestInit) => { + const token = new Headers(init?.headers).get("authorization"); + const row = store.getAccountSet(instance)?.accounts.find(account => `Bearer ${account.credential.access}` === token); + const body = JSON.parse(String(init?.body)) as Record; + expect(row).toBeDefined(); + // Source identity is separate evidence; the translated adapter sends no account UUID. + expect(row!.credential.anthropicIdentity?.accountUuid).toBe(instanceFixtureUuid(instance, fixture.ids.indexOf(row!.id as typeof fixture.ids[number]) + 1)); + fixture.ledger.record({ instance, accountId: row!.id, token: token!.replace(/^Bearer /, ""), model: String(body.model) }); + sends.push({ instance, token, body }); + return reply(instance, sends.length, body); + }) as typeof fetch, + }); + } + fixture.publishConfig(); +}); +afterEach(async () => { + try { + fixture.ledger.assertNoCrossSend(); + releaseSpend?.(); releaseSpend = undefined; + const { clearResponseStateForTests } = await import("../../../src/responses/state"); + clearResponseStateForTests(); + } finally { + try { await fixture.dispose(); } + finally { fixture.quota.resetProviderQuotaReconcileStateForTests(); } + } +}); + +function revoked() { + return Response.json({ type: "error", error: { type: "authentication_error", + message: "OAuth access token has been revoked." } }, { status: 401 }); +} +function answerStream(text = "The answer is complete.") { + const message = { id: "msg_synthetic", type: "message", role: "assistant", model: fixture.model, + content: [], stop_reason: null, usage: { input_tokens: 8, output_tokens: 0 } }; + const frames = [ + { type: "message_start", message }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 6 } }, + { type: "message_stop" }, + ]; + return new Response(frames.map(frame => "event: " + frame.type + "\ndata: " + JSON.stringify(frame) + "\n\n").join(""), + { headers: { "content-type": "text/event-stream" } }); +} + +for (const instance of instances) for (const kind of ["search", "image"] as const) { + test(instance + ": exact revoked 401 reaches real " + kind + " recovery", async () => { + if (kind === "search") config.webSearchSidecar = { backend: "anthropic", enabled: true }; + else { + config.images = { bridgeEnabled: true }; + config.providers.xai = { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", + authMode: "key", apiKey: "synthetic-image-key" }; + } + reply = (_instance, index) => index === 1 ? revoked() : answerStream(); + const response = await post(instance, {}, { stream: true, + tools: [{ type: kind === "search" ? "web_search" : "image_generation" }] }); + expect(response.status).toBe(200); + expect(await response.text()).toContain("The answer is complete."); + expect(sends).toHaveLength(2); + expect(fixture.ledger.sends.map(send => [send.instance, send.accountId])).toEqual([ + [instance, fixture.ids[0]], [instance, fixture.ids[1]], + ]); + expect(store.getAccountCredentialWithStatus(instance, fixture.ids[0])?.needsReauth).toBe(true); + const other = instance === "anthropic" ? "anthropic2" : "anthropic"; + expect(store.getAccountCredentialWithStatus(other, fixture.ids[0])?.needsReauth).toBe(false); + }); + test(instance + ": unknown 401 in " + kind + " cannot mark or fail over", async () => { + if (kind === "search") config.webSearchSidecar = { backend: "anthropic", enabled: true }; + else { + config.images = { bridgeEnabled: true }; + config.providers.xai = { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", + authMode: "key", apiKey: "synthetic-image-key" }; + } + reply = () => Response.json({ type: "error", error: { type: "authentication_error", + message: "OAuth access token has expired." } }, { status: 401 }); + const response = await post(instance, {}, { stream: true, + tools: [{ type: kind === "search" ? "web_search" : "image_generation" }] }); + await response.text(); + expect(sends).toHaveLength(1); + expect(store.getAccountCredentialWithStatus(instance, fixture.ids[0])?.needsReauth).toBe(false); + }); +} diff --git a/tests/adapters/anthropic/anthropic-revoked-token.test.ts b/tests/adapters/anthropic/anthropic-revoked-token.test.ts new file mode 100644 index 00000000000..247a775cb03 --- /dev/null +++ b/tests/adapters/anthropic/anthropic-revoked-token.test.ts @@ -0,0 +1,234 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import type { AnthropicInstanceId } from "../../../src/providers/anthropic-instance-id"; +import { createAnthropicInstanceFixture, type AnthropicInstanceFixture } from "../../helpers/anthropic-instance-fixture"; + +let f: AnthropicInstanceFixture; +let refusal: typeof import("../../../src/oauth/anthropic-account-refusal"); +let ownership: typeof import("../../../src/oauth/anthropic-send-ownership"); +const instances = ["anthropic", "anthropic2"] as const; +const message = "OAuth access token has been revoked."; +const payload = { type: "error", error: { type: "authentication_error", message } }; + +beforeEach(async () => { + f = await createAnthropicInstanceFixture(); + await f.seed(); + refusal = await import("../../../src/oauth/anthropic-account-refusal"); + ownership = await import("../../../src/oauth/anthropic-send-ownership"); +}); +afterEach(async () => { await f?.dispose(); }); + +async function bound(instance: AnthropicInstanceId, response = Response.json(payload, { status: 401 })) { + const { snapshot } = await f.admit(instance, "session-one"); + expect(snapshot).not.toBeNull(); + const owner = ownership.captureAnthropicPhysicalSendOwnership(snapshot!); + expect(owner).not.toBeNull(); + refusal.bindAnthropicRefusalCredentialForSend(response, owner!); + return { response, snapshot: snapshot!, owner: owner! }; +} +function rotate(instance: AnthropicInstanceId, response: Response, canRetry = true, allowAccountRefusal = true) { + return refusal.rotateAnthropicAccountOnResponseForInstance(instance, response, { + config: f.config, accountId: f.ids[0], model: f.model, sessionKey: "session-one", + requestKey: {}, canRetry, allowAccountRefusal, + }); +} +for (const instance of instances) { + const other = instance === "anthropic" ? "anthropic2" : "anthropic"; + for (const canRetry of [true, false]) { + test(instance + ": mark and clear all affinity with retry allowance " + canRetry, async () => { + const { response } = await bound(instance); + await f.admit(instance, "session-two"); + await f.admit(other, "other-session"); + const otherAffinity = f.routing.anthropicRoutingFor(other).anthropicSessionAffinitySizeForTests(); + expect(f.routing.anthropicRoutingFor(instance).anthropicSessionAffinitySizeForTests()).toBe(2); + expect(await rotate(instance, response, canRetry)).toBe(canRetry ? f.ids[1] : null); + expect(f.store.getAccountCredentialWithStatus(instance, f.ids[0])?.needsReauth).toBe(true); + expect(f.routing.anthropicRoutingFor(instance).anthropicSessionAffinitySizeForTests()).toBe(0); + expect(f.routing.anthropicRoutingFor(instance).getEligibleAnthropicAccounts()).toEqual([f.ids[1]]); + expect(f.store.getAccountCredentialWithStatus(other, f.ids[0])?.needsReauth).toBe(false); + expect(f.routing.anthropicRoutingFor(other).anthropicSessionAffinitySizeForTests()).toBe(otherAffinity); + expect(f.routing.anthropicRoutingFor(instance).getAnthropicAccountHealthSnapshot(f.ids[0])).toBeNull(); + expect(await response.json()).toEqual(payload); + }); + } + test(instance + ": no eligible sibling still marks reauthentication", async () => { + await f.store.setAccountPaused(instance, f.ids[1], true); + const { response } = await bound(instance); + expect(await rotate(instance, response)).toBeNull(); + expect(f.store.getAccountCredentialWithStatus(instance, f.ids[0])?.needsReauth).toBe(true); + expect(response.status).toBe(401); + expect(await response.json()).toEqual(payload); + }); + test(instance + ": output commitment disables account-refusal recovery", async () => { + const { response } = await bound(instance); + expect(await rotate(instance, response, true, false)).toBeNull(); + expect(f.store.getAccountCredentialWithStatus(instance, f.ids[0])?.needsReauth).toBe(false); + expect(await response.json()).toEqual(payload); + }); + for (const sameBytes of [false, true]) { + test(instance + ": relogin before late refusal is preserved " + sameBytes, async () => { + const { response } = await bound(instance); + const credential = f.store.getAccountCredential(instance, f.ids[0])!; + await f.store.saveAccountCredential(instance, f.ids[0], sameBytes ? credential + : { ...credential, access: "synthetic-" + instance + "-replacement-access" }, { rotateLoginId: true }); + expect(await rotate(instance, response)).toBeNull(); + expect(f.store.getAccountCredentialWithStatus(instance, f.ids[0])?.needsReauth).toBe(false); + }); + } + const negatives = [ + { ...payload, error: { type: "authentication_error", message: "OAuth access token has expired." } }, + { ...payload, error: { type: "permission_error", message } }, + { ...payload, error: { type: "authentication_error", message: "Quoted: " + message } }, + { ...payload, error: { type: "authentication_error", message: message.toLowerCase() } }, + { ...payload, error: { type: "authentication_error", message: " " + message + " " } }, + { ...payload, error: { type: "authentication_error", message, code: "invalid_api_key" } }, + { error: payload.error }, [], + ]; + for (const [index, body] of negatives.entries()) { + test(instance + ": unmatched 401 body " + index + " has no account effect", async () => { + const { response } = await bound(instance, Response.json(body, { status: 401 })); + expect(await rotate(instance, response)).toBeNull(); + expect(f.store.getAccountCredentialWithStatus(instance, f.ids[0])?.needsReauth).toBe(false); + expect(f.routing.anthropicRoutingFor(instance).anthropicSessionAffinitySizeForTests()).toBe(1); + expect(await response.json()).toEqual(body); + }); + } + for (const body of ["{", JSON.stringify({ ...payload, padding: "x".repeat(65_536) })]) { + test(instance + ": malformed or oversized refusal is not authority " + body.length, async () => { + const { response } = await bound(instance, new Response(body, { status: 401 })); + expect(await rotate(instance, response)).toBeNull(); + expect(f.store.getAccountCredentialWithStatus(instance, f.ids[0])?.needsReauth).toBe(false); + expect(await response.text()).toBe(body); + }); + } + for (const status of [400, 403, 429]) { + test(instance + ": exact sentence on other status is not revocation " + status, async () => { + const { response } = await bound(instance, Response.json(payload, { status })); + expect(await rotate(instance, response, false)).toBeNull(); + expect(f.store.getAccountCredentialWithStatus(instance, f.ids[0])?.needsReauth).toBe(false); + }); + } + test(instance + ": unbound matching response is ignored", async () => { + expect(await rotate(instance, Response.json(payload, { status: 401 }))).toBeNull(); + expect(f.store.getAccountCredentialWithStatus(instance, f.ids[0])?.needsReauth).toBe(false); + }); + test(instance + ": cancellation preserves the account", async () => { + const { response } = await bound(instance); + const abort = new AbortController(); abort.abort(); + expect(await refusal.rotateAnthropicAccountOnResponseForInstance(instance, response, { + config: f.config, accountId: f.ids[0], model: f.model, signal: abort.signal, canRetry: true, + })).toBeNull(); + expect(f.store.getAccountCredentialWithStatus(instance, f.ids[0])?.needsReauth).toBe(false); + }); + test(instance + ": explicit relogin clears terminal state", async () => { + const { response } = await bound(instance); + await rotate(instance, response, false); + expect(f.store.getAccountCredentialWithStatus(instance, f.ids[0])?.needsReauth).toBe(true); + const credential = f.store.getAccountCredential(instance, f.ids[0])!; + await f.store.saveAccountCredential(instance, f.ids[0], { ...credential, + access: "synthetic-" + instance + "-new-login-access" }, { rotateLoginId: true }); + expect(f.store.getAccountCredentialWithStatus(instance, f.ids[0])?.needsReauth).toBe(false); + expect(f.routing.anthropicRoutingFor(instance).getEligibleAnthropicAccounts()).toContain(f.ids[0]); + }); +} + +for (const instance of instances) { + test(instance + ": audit malformed UTF-8 is not terminal authority", async () => { + const prefix=new TextEncoder().encode(JSON.stringify({...payload,padding:"X"})); + const index=prefix.indexOf(88,prefix.length-6); expect(index).toBeGreaterThan(0); prefix[index]=255; + const {response}=await bound(instance,new Response(prefix,{status:401})); + expect(await rotate(instance,response)).toBeNull(); + expect(f.store.getAccountCredentialWithStatus(instance,f.ids[0])?.needsReauth).toBe(false); + }); + test(instance + ": audit delayed body relogin preserves same-byte replacement", async () => { + let release!:()=>void; const ready=new Promise(r=>release=r); + let entered!:()=>void; const begin=new Promise(r=>entered=r); + const response=new Response(new ReadableStream({ async pull(controller) { entered(); await ready; controller.enqueue(new TextEncoder().encode(JSON.stringify(payload))); controller.close(); } }),{status:401}); + await bound(instance,response); + const pending=rotate(instance,response); await begin; + await f.store.saveAccountCredential(instance,f.ids[0],f.store.getAccountCredential(instance,f.ids[0])!,{rotateLoginId:true}); + release(); expect(await pending).toBeNull(); + expect(f.store.getAccountCredentialWithStatus(instance,f.ids[0])?.needsReauth).toBe(false); + }); +} + +for (const instance of instances) for (const replacement of ["same-login", "pause"]) { + test(instance + ": audit locked snapshot fences queued " + replacement, async () => { + const {response}=await bound(instance); + let release!:()=>void; const hold=new Promise(r=>release=r); + let entered!:()=>void; const ready=new Promise(r=>entered=r); + const blocker=f.store.mutateStore(async()=>{entered();await hold;}); await ready; + const newer=replacement === "pause" ? f.store.setAccountPaused(instance,f.ids[0],true) + : f.store.saveAccountCredential(instance,f.ids[0],f.store.getAccountCredential(instance,f.ids[0])!,{rotateLoginId:true}); + const pending=rotate(instance,response); + try { + for (let i=0;i<100 && f.store.oauthMutationTailSnapshot().active<3;i++) await new Promise(r=>setImmediate(r)); + expect(f.store.oauthMutationTailSnapshot().active).toBe(3); + } finally {release();} + await blocker; await newer; expect(await pending).toBeNull(); + expect(f.store.getAccountCredentialWithStatus(instance,f.ids[0])?.needsReauth).toBe(false); + }); +} + + +for (const instance of instances) { + test(instance + ": exact null-code evidence is accepted", async () => { + const { response } = await bound(instance, Response.json({ ...payload, error: { ...payload.error, code: null } }, { status: 401 })); + expect(await rotate(instance, response)).toBe(f.ids[1]); + expect(f.store.getAccountCredentialWithStatus(instance, f.ids[0])?.needsReauth).toBe(true); + }); + test(instance + ": singleton is marked without a replay proposal", async () => { + await f.store.removeAccount(instance, f.ids[1]); + const { response } = await bound(instance); + expect(await rotate(instance, response)).toBeNull(); + expect(f.store.getAccountCredentialWithStatus(instance, f.ids[0])?.needsReauth).toBe(true); + expect(await response.json()).toEqual(payload); + }); + test(instance + ": rejected persistence offers no alternate and keeps the response", async () => { + const { response } = await bound(instance); + const failure = spyOn(f.store, "markAccountNeedsReauthIfGeneration").mockRejectedValue(new Error("synthetic write failure")); + try { + expect(await rotate(instance, response)).toBeNull(); + expect(f.store.getAccountCredentialWithStatus(instance, f.ids[0])?.needsReauth).toBe(false); + expect(await response.json()).toEqual(payload); + } finally { failure.mockRestore(); } + }); + for (const fallback of [false, true]) { + test(instance + ": revoked recovery retains model route fallback " + fallback, async () => { + const pool = { enabled: true, routes: [{ name: "strict", match: f.model, accounts: [f.ids[0]], fallback }] }; + if (instance === "anthropic") f.config.anthropicAccountPool = pool; + else f.config.providers.anthropic2!.anthropicAccountPool = pool; + const { response } = await bound(instance); + const decision = (await import("../../../src/oauth/anthropic-model-routes")).resolveAnthropicModelRouteForInstance(instance, f.config, f.model).decision; + expect(await refusal.rotateAnthropicAccountOnResponseForInstance(instance, response, { + config: f.config, accountId: f.ids[0], model: f.model, decision, canRetry: true, + })).toBe(fallback ? f.ids[1] : null); + expect(f.store.getAccountCredentialWithStatus(instance, f.ids[0])?.needsReauth).toBe(true); + }); + } + test(instance + ": response from the other instance is never authority", async () => { + const other = instance === "anthropic" ? "anthropic2" : "anthropic"; + const { response } = await bound(other); + expect(await rotate(instance, response)).toBeNull(); + expect(f.store.getAccountCredentialWithStatus(instance, f.ids[0])?.needsReauth).toBe(false); + expect(f.store.getAccountCredentialWithStatus(other, f.ids[0])?.needsReauth).toBe(false); + }); + test(instance + ": cancellation during body classification makes no durable change", async () => { + let release!: () => void; + const hold = new Promise(resolve => { release = resolve; }); + let entered!: () => void; + const ready = new Promise(resolve => { entered = resolve; }); + const response = new Response(new ReadableStream({ + async pull(controller) { entered(); await hold; controller.enqueue(new TextEncoder().encode(JSON.stringify(payload))); controller.close(); }, + }), { status: 401 }); + await bound(instance, response); + const abort = new AbortController(); + const pending = refusal.rotateAnthropicAccountOnResponseForInstance(instance, response, { + config: f.config, accountId: f.ids[0], model: f.model, canRetry: true, signal: abort.signal, + }); + await ready; + abort.abort(); + release(); + expect(await pending).toBeNull(); + expect(f.store.getAccountCredentialWithStatus(instance, f.ids[0])?.needsReauth).toBe(false); + }); +} diff --git a/tests/claude-integration/messages-revoked-token.test.ts b/tests/claude-integration/messages-revoked-token.test.ts new file mode 100644 index 00000000000..ea2a68c4736 --- /dev/null +++ b/tests/claude-integration/messages-revoked-token.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import type { AnthropicInstanceId } from "../../src/providers/anthropic-instance-id"; +import type { OcxConfig } from "../../src/types"; +import type { RouteResult } from "../../src/router"; +import type { GenerationContext } from "../../src/lib/state-store-sweeper"; +import { + anthropicInstanceBarrier, createAnthropicInstanceFixture, instanceFixtureCredential, instanceFixtureUuid, + type AnthropicInstanceFixture, +} from "../helpers/anthropic-instance-fixture"; + +type Rec = Record; +type Sent = { instance: AnthropicInstanceId; url: string; headers: Headers; body: Rec }; +const INSTANCES = ["anthropic", "anthropic2"] as const; +const CALLER = "sk-ant-fixture-caller"; +let f: AnthropicInstanceFixture; +let sent: Sent[]; +let ingress: typeof import("../../src/server/claude-messages"); +let native: typeof import("../../src/server/messages-native"); +let binding: typeof import("../../src/server/messages-native-oauth"); +let planner: typeof import("../../src/protocols/plan-snapshot"); +let settings: typeof import("../../src/protocols/settings"); +let identity: typeof import("../../src/oauth/anthropic-identity"); +let pacing: typeof import("../../src/providers/request-pacing"); +let logs: typeof import("../../src/server/request-log"); +let releaseSpend: (() => void) | undefined; +const restorations: Array<() => void> = []; + +beforeEach(async () => { + // The shared fixture creates all homes and blocks network before runtime imports. + f = await createAnthropicInstanceFixture({ anthropic: { enabled: false }, anthropic2: { enabled: false } }); + [ingress, native, binding, planner, settings, identity, pacing, logs] = await Promise.all([ + import("../../src/server/claude-messages"), import("../../src/server/messages-native"), + import("../../src/server/messages-native-oauth"), import("../../src/protocols/plan-snapshot"), + import("../../src/protocols/settings"), import("../../src/oauth/anthropic-identity"), + import("../../src/providers/request-pacing"), import("../../src/server/request-log"), + ]); + releaseSpend = (await import("../helpers/owned-spend-home")).acquireOwnedSpendHome(); + (await import("../../src/responses/reasoning-replay-cache")).clearReasoningReplayCacheForTests(); + sent = []; + f.config.protocols = { rollout: { managedMessagesNative: true, managedMessagesNativeOAuth: true } }; + for (const instance of INSTANCES) { + f.config.providers[instance]!.models = [f.model]; + } + f.publishConfig(); +}); + +afterEach(async () => { + for (const restore of restorations.splice(0).reverse()) restore(); + pacing?.resetProviderRequestPacingForTest(); + releaseSpend?.(); + releaseSpend = undefined; + await f?.dispose(); +}); + +async function seed(instances: readonly AnthropicInstanceId[] = INSTANCES) { + await f.seed(instances); + // Seed clones the pure config; attach in-process transports only after that operation. + for (const instance of INSTANCES) f.config.providers[instance]!.fetch = transport(instance); +} + +function answer(model: string): Rec { + return { id: "msg_instance_fixture", type: "message", role: "assistant", model, + content: [{ type: "text", text: "fixture reply" }], stop_reason: "end_turn", stop_sequence: null, + usage: { input_tokens: 9, output_tokens: 3 } }; +} + +/** Responses forces streaming upstream even for a non-streaming Messages caller. */ +function answerForWire(send: Sent): Response { + if (send.body.stream !== true) return Response.json(answer(f.model)); + const frames: Rec[] = [ + { type: "message_start", message: { ...answer(f.model), content: [], stop_reason: null, + usage: { input_tokens: 9, output_tokens: 0 } } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "fixture reply" } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 3 } }, + { type: "message_stop" }, + ]; + return new Response(frames.map(frame => `event: ${frame.type}\ndata: ${JSON.stringify(frame)}\n\n`).join(""), + { headers: { "content-type": "text/event-stream" } }); +} + +function transport(instance: AnthropicInstanceId, response?: (send: Sent) => Response | Promise): typeof fetch { + return (async (input, init) => { + const headers = new Headers(init?.headers); + const token = headers.get("authorization")?.replace(/^Bearer /, "") ?? ""; + const slot = [1, 2].find(candidate => token === instanceFixtureCredential(instance, candidate).access); + // Independent shared ledger detects wrong-instance physical bearer even for equal IDs. + const parsedBody = JSON.parse(String(init?.body)) as Rec; + const metadata = parsedBody.metadata as { user_id?: string } | undefined; + const uuid = metadata?.user_id ? (JSON.parse(metadata.user_id) as { account_uuid?: string }).account_uuid : undefined; + f.ledger.record({ instance, accountId: slot ? f.ids[slot - 1]! : f.ids[0], token, uuid }); + expect(headers.has("x-api-key")).toBe(false); + expect(token).not.toBe(CALLER); + const entry = { instance, url: String(input), headers, body: parsedBody }; + sent.push(entry); + return response ? await response(entry) : answerForWire(entry); + }) as typeof fetch; +} + +function body(model = `anthropic2/${f.model}`, extra: Rec = {}): Rec { + return { model, max_tokens: 64, stream: false, + metadata: { user_id: JSON.stringify({ account_uuid: instanceFixtureUuid("anthropic", 1), device_id: "fixture-device", session_id: f.sessionKey }) }, + messages: [{ role: "user", content: "fixture question" }], ...extra }; +} + +async function send(model = `anthropic2/${f.model}`, extra: Rec = {}, options: { nativeCaller?: boolean; sessionKey?: string } = {}) { + const requestId = crypto.randomUUID(); + const logCtx = { model: "", provider: "" }; + const requestBody = body(model, extra); + if (options.sessionKey) { + const metadata = requestBody.metadata as { user_id: string }; + metadata.user_id = JSON.stringify({ ...JSON.parse(metadata.user_id), session_id: options.sessionKey }); + } + const response = await ingress.handleClaudeMessages(new Request("http://localhost/v1/messages", { + // Managed parity cases use ordinary admission fixtures. Caller-forward exclusion cases + // explicitly supply a classified Anthropic caller bearer while passthrough stays enabled. + method: "POST", headers: { "content-type": "application/json", + authorization: `Bearer ${options.nativeCaller ? CALLER : "fixture-admission-token"}`, + "x-api-key": options.nativeCaller ? CALLER : "fixture-caller-key", "x-session-id": options.sessionKey ?? f.sessionKey }, + body: JSON.stringify(requestBody), + }), f.config, logCtx, { requestId, start: Date.now() }); + const text = await response.text(); + const rows = logs.getRequestLogEntries().filter(row => row.requestId === requestId); + expect(JSON.stringify(rows)).not.toContain(CALLER); + if (existsSync(f.store.getAuthStorePath())) expect(readFileSync(f.store.getAuthStorePath(), "utf8")).not.toContain(CALLER); + expect(JSON.stringify(f.config)).not.toContain(CALLER); + f.ledger.assertNoCrossSend(); + return { response, text, rows }; +} + +function revoked() { + return Response.json({ type: "error", error: { type: "authentication_error", + message: "OAuth access token has been revoked." } }, { status: 401 }); +} +for (const instance of INSTANCES) for (const enabled of [true, false]) { + for (const nativeMode of [true, false]) for (const stream of [true, false]) { + test("revoked recovery pool=" + enabled + " native=" + nativeMode + " stream=" + stream + " " + instance, async () => { + await seed(); + if (instance === "anthropic") f.config.anthropicAccountPool = { enabled }; + else f.config.providers.anthropic2!.anthropicAccountPool = { enabled }; + f.config.protocols = { rollout: { managedMessagesNative: nativeMode, managedMessagesNativeOAuth: nativeMode } }; + f.config.providers[instance]!.fetch = transport(instance, row => sent.length === 1 ? revoked() : answerForWire(row)); + const { response, text, rows } = await send(instance + "/" + f.model, { stream }); + expect(response.status, text).toBe(200); + expect(text).toContain("fixture reply"); + expect(sent).toHaveLength(2); + expect(f.ledger.sends.map(row => row.accountId)).toEqual([f.ids[0], f.ids[1]]); + expect(f.store.getAccountCredentialWithStatus(instance, f.ids[0])?.needsReauth).toBe(true); + const other = instance === "anthropic" ? "anthropic2" : "anthropic"; + expect(f.store.getAccountCredentialWithStatus(other, f.ids[0])?.needsReauth).toBe(false); + expect(f.store.getAccountSet(other)?.activeAccountId).toBe(f.ids[0]); + expect(JSON.stringify(rows)).toContain("oauth-401"); + expect(rows[0]?.protocolTrace?.mode).toBe(nativeMode ? "native" : "legacy-bridge"); + if (nativeMode) expect(f.ledger.sends.map(row => row.uuid)).toEqual([ + instanceFixtureUuid(instance, 1), instanceFixtureUuid(instance, 2), + ]); + const subsequent = await send(instance + "/" + f.model, { stream }); + expect(subsequent.response.status).toBe(200); + expect(sent).toHaveLength(3); + expect(f.ledger.sends[2]!.accountId).toBe(f.ids[1]); + }); + } + test(instance + ": both revoked accounts retain the final upstream 401", async () => { + await seed(); + if (instance === "anthropic") f.config.anthropicAccountPool = { enabled }; + else f.config.providers.anthropic2!.anthropicAccountPool = { enabled }; + f.config.providers[instance]!.fetch = transport(instance, () => revoked()); + const { response, text } = await send(instance + "/" + f.model); + expect(response.status, text).toBe(401); + expect(text).toContain("OAuth access token has been revoked."); + expect(sent).toHaveLength(2); + for (const id of f.ids) expect(f.store.getAccountCredentialWithStatus(instance, id)?.needsReauth).toBe(true); + await send(instance + "/" + f.model); + expect(sent).toHaveLength(2); + }); + test(instance + ": unavailable sibling preserves original 401", async () => { + await seed(); + if (instance === "anthropic") f.config.anthropicAccountPool = { enabled }; + else f.config.providers.anthropic2!.anthropicAccountPool = { enabled }; + await f.store.setAccountPaused(instance, f.ids[1], true); + f.config.providers[instance]!.fetch = transport(instance, () => revoked()); + const { response, text } = await send(instance + "/" + f.model); + expect(response.status, text).toBe(401); + expect(sent).toHaveLength(1); + expect(f.store.getAccountCredentialWithStatus(instance, f.ids[0])?.needsReauth).toBe(true); + }); +} +for (const instance of INSTANCES) { + test(instance + ": stale recovery selection does not override a newer manual choice", async () => { + await seed(); + const selection = f.store.captureOAuthAccountSelection(instance); + await f.store.setActiveAccount(instance, f.ids[1]); + const result = await binding.resolveNativeOAuthBindingForInstance(instance, f.config, { + model: f.model, candidateAccountId: f.ids[0], expectedRecoverySelection: selection, + expectedRecoveryRouteDecision: null, + }); + expect(result.snapshot.accountId).toBe(f.ids[1]); + }); + test(instance + ": recovery candidate still respects the current model route", async () => { + await seed(); + const selection = f.store.captureOAuthAccountSelection(instance); + const pool = { enabled: true, routes: [{ name: "strict", match: f.model, accounts: [f.ids[0]] }] }; + if (instance === "anthropic") f.config.anthropicAccountPool = pool; + else f.config.providers.anthropic2!.anthropicAccountPool = pool; + const route = (await import("../../src/oauth/anthropic-model-routes")).resolveAnthropicModelRouteForInstance(instance, f.config, f.model); + await expect(binding.resolveNativeOAuthBindingForInstance(instance, f.config, { + model: f.model, candidateAccountId: f.ids[1], expectedRecoverySelection: selection, + expectedRecoveryRouteDecision: route.decision, + })).rejects.toThrow(); + expect(sent).toHaveLength(0); + expect(f.store.getAccountSet(instance)?.activeAccountId).toBe(f.ids[0]); + }); +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 8035b43451e..6cde4bda791 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,6 +1,11 @@ { "messages-request-id-headers.test.ts": "claude-integration", "messages-request-id-endpoint.test.ts": "claude-integration", + "anthropic-revoked-token.test.ts": "adapters/anthropic", + "messages-revoked-token.test.ts": "claude-integration", + "anthropic-revoked-token-continuation.test.ts": "adapters/anthropic", + "anthropic-revoked-token-sidecars.test.ts": "adapters/anthropic", + "anthropic-revoked-token-boundaries.test.ts": "adapters/anthropic", "azure-vendor-metadata.test.ts": "providers", "anthropic-instance-isolation.test.ts": "adapters/anthropic", "anthropic-instance-pool-parity.test.ts": "adapters/anthropic", "anthropic-instance-quota.test.ts": "adapters/anthropic", "anthropic-instance-recovery.test.ts": "adapters/anthropic", From e6950e03b4adbe82ed167b80eab873b74bb6da5b Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 9 Oct 2026 22:53:18 +0900 Subject: [PATCH 42/59] fix(responses): reclaim spill headroom before refusing a continuation (#6829) Spill admission refused a continuation whenever the store sat under the 1 GiB ceiling but the new write would cross it: the shared eviction function returned as soon as accounted bytes were under the cap, so nothing was reclaimed and the newest continuation became a spill_failed tombstone (#6747). Admission and the shutdown fallback now pass the headroom their write needs. Eviction runs oldest-first down to the ceiling minus that headroom, re-reads accounting after every attempt and stops as soon as the publication can no longer fit, so an impossible publication, or one over the replay ceiling, never costs an unrelated continuation. A failed spill unlink stays charged in the unreclaimable ledger until the path is gone. The memory endpoint gains spillCapacityRefusals and spillHeadroomEvictions. Fixes #6747 Co-authored-by: kirin-bot139 <269198063+kirin-bot139@users.noreply.github.com> --- .../261009_n4_responses_quota/000_plan.md | 68 +++ .../010_spill_admission_headroom.md | 277 +++++++++ .../020_main_quota_observation.md | 246 ++++++++ .../content/docs/reference/management-api.md | 2 +- .../docs/troubleshooting/windows-memory.md | 9 + scripts/test-layout/layout.json | 1 + src/responses/spill-store.ts | 17 +- src/responses/state.ts | 28 +- src/responses/state/metrics.ts | 8 + src/responses/state/spill-failure.ts | 12 + src/responses/state/spill-queue.ts | 17 +- structure/INDEX.md | 4 +- structure/codex-home.md | 6 +- structure/manifest.json | 5 +- structure/transports/byte-accounting.md | 24 + tests/fixtures/test-layout-expected.json | 1 + tests/responses/continuation-dedup.test.ts | 2 +- ...responses-spill-admission-headroom.test.ts | 534 ++++++++++++++++++ tests/responses/responses-state.test.ts | 4 +- tests/server/memory-watchdog.test.ts | 9 +- 20 files changed, 1244 insertions(+), 30 deletions(-) create mode 100644 devlog/_plan/261009_n4_responses_quota/000_plan.md create mode 100644 devlog/_plan/261009_n4_responses_quota/010_spill_admission_headroom.md create mode 100644 devlog/_plan/261009_n4_responses_quota/020_main_quota_observation.md create mode 100644 tests/responses/responses-spill-admission-headroom.test.ts diff --git a/devlog/_plan/261009_n4_responses_quota/000_plan.md b/devlog/_plan/261009_n4_responses_quota/000_plan.md new file mode 100644 index 00000000000..72ed1f98c0f --- /dev/null +++ b/devlog/_plan/261009_n4_responses_quota/000_plan.md @@ -0,0 +1,68 @@ +# N4 responses/quota lane — roadmap + +Base: `origin/dev` 730d898457. Lane worktree: `opencodex-lanes/261009-N4-responses-quota`. + +## Loop spec + +- Archetype: satisfy-spec, two independent bug fixes. +- Trigger: sweep lane N4 dispatched from the coordinator chat for #6747 and #6800. +- Goal: each issue fixed by a merge-ready dev PR, or closed out as NEEDS_HUMAN with evidence. +- Non-goals: see "Out of scope"; no merge, release, or edits to other lanes' PRs. +- Verifier: focused `bun test` files named in 010/020 (pre-change run of the existing + ones: 382 pass, exit 0; they import `src/responses/state.ts` and the main-quota modules + directly) plus `bun run typecheck`; exact-head hosted CI for the rest. +- Stop: both PRs READY, or a recorded NEEDS_HUMAN/BLOCKED verdict per item. +- Memory artifact: this unit plus the PR descriptions. +- Resources: none stated; host limits apply. Writes limited to the lane worktrees and + `codex/n4-*` branches. +- SoT sync: `structure/transports/responses.md` (both), `structure/providers/openai-accounts.md` + and `openai-tiers.md` (wp2). + +## Objective + +Two independent fixes, each a separate dev-targeted PR: + +| Work phase | Issue | Doc | Branch | +|---|---|---|---| +| wp1 | #6747 durable spill admission refuses instead of reclaiming headroom | [010](010_spill_admission_headroom.md) | `codex/n4-responses-quota` | +| wp2 | #6800 `__main__` quota stops updating for caller-owned main traffic | [020](020_main_quota_observation.md) | `codex/n4-main-quota` | + +wp1 and wp2 share no source file and can land in either order. This unit (000 + 010) +rides the wp1 PR; the wp2 PR carries only 020 so the two PRs never add the same path. + +## Constraints + +- `src/responses/state.ts` sits at 1352 lines against a ratchet cap of 1371; wp1 may + spend at most 19 lines there. `tests/responses/responses-state.test.ts` is at 3982 of + 3983, so wp1 adds its regression cases in a new sibling file and edits existing + assertions in place only. +- `tests/responses/openai-responses-passthrough.test.ts` is at its cap (4809/4809); wp2 + adds a new test file instead. +- New test files are registered in both `scripts/test-layout/layout.json` and + `tests/fixtures/test-layout-expected.json`. +- `/api/system/memory` exposes a reviewed field set pinned by + `tests/responses/continuation-dedup.test.ts`; any new metric must be added there + deliberately and documented in `docs-site`. +- Local verification is limited to focused tests for changed behaviour and + `bun run typecheck`; the full suite runs in exact-head hosted CI. + +## Dependency order + +wp0 (this roadmap) → wp1 and wp2 in parallel. Each ends READY only with green +exact-head CI, an independent review PASS (wp2 also a security review PASS), and a +MERGEABLE PR against the latest `dev`. + +## Out of scope + +- A per-thread "newest continuation" pin for spill eviction (#6747 suggestion). It is a + retention-policy change, not the admission defect. +- Compact's final quota capture (`src/server/responses/compact.ts` admits only `pool`). + Adjacent gap, not the reporter's path. +- Raising the 1 GiB spill cap or adding a production knob for it. + +## Architect consultation + +| Doc | Architect handle | Proposal | Reflection | Main disposition | +|---|---|---|---|---| +| 010 | sol `01a1204b-a886` | D1–D8: headroom parameter, footprint+inherited at admission, superseded-only headroom at shutdown, unchanged eviction order, no per-thread pin, sibling test file, fail-closed, ratchet budget | MISALIGNED: impossible shutdown fallback still pruned unrelated spills; missing shutdown/deferred/reload tests; test reset is field-by-field; structure doc must be unconditional | All folded: infeasible headroom now evicts nothing; tests 6–9 added; reset lines budgeted; structure doc made mandatory. Counters added beyond the proposal and documented as logical events. | +| 020 | sol `01a1204b-a99a` | D1–D7: keep pool gate, process-local dispatch proof, capture from materialized credential, owned-credential equality only, separate canonical HTTP/WS observation, credential-generation fence, focused tests and docs | MISALIGNED: HTTP await between proof check and write; WS early return at :125 must be replaced and proof captured in the closure; tests for persistence, wrong workspace, invalid headers, retry path, WS replacement between frames; docs-site and two more structure owners | All folded: post-import re-check, closure capture, tests 8–13, docs list extended. Compact capture stays out of scope (recorded above). | diff --git a/devlog/_plan/261009_n4_responses_quota/010_spill_admission_headroom.md b/devlog/_plan/261009_n4_responses_quota/010_spill_admission_headroom.md new file mode 100644 index 00000000000..37ab28a6a23 --- /dev/null +++ b/devlog/_plan/261009_n4_responses_quota/010_spill_admission_headroom.md @@ -0,0 +1,277 @@ +# 010 — wp1: spill admission reclaims the headroom it asks for (#6747) + +## Root cause (verified at 730d898457) + +`queuePendingResponseSpill` (`src/responses/state/spill-queue.ts:288`) refuses a +publication when `accounted + footprint + inherited > cap`, and before refusing calls +`enforceSpilledResponseBudget()` to reclaim room. That function +(`src/responses/state.ts:879`) starts with: + +```ts +let spilledBytes = accountedResponseSpillBytes(); +if (spilledBytes <= spillByteCap()) return 0; +``` + +so whenever the store is under the cap but the new publication would cross it, nothing is +evicted, the recheck fails, and the candidate becomes a `spill_failed` tombstone +(`replaceWithSpillFailure`). The reporter's isolated reproduction (900,000-byte cap, +~600,867 bytes stored, ~400 KB peak footprint) shows exactly this. The same zero-headroom +call exists in the shutdown fallback (`spill-queue.ts:424`). The path runs on the Windows +ACL async queue (`windowsSecretAclApplies()`); other platforms publish synchronously and +prune afterwards. + +## Design + +`enforceSpilledResponseBudget(requiredHeadroomBytes?: number)` gains an admission mode. +Called with no argument (mutation pruning, lazy load, periodic sweep) it is unchanged. +Called with a number — including 0 — it is an admission reclaim: it brings the store to +`cap - headroom`, but only when eviction can actually produce that room. Bytes eviction +cannot touch — in-flight reservations (including the shutdown fallback's own pre-reserved +footprint), job-owned superseded generations and unreclaimable paths — are +`pinned = accounted - spilledResponseBytes()`. If `pinned + headroom > cap` the +publication cannot fit no matter what is evicted, so admission mode evicts nothing and +returns 0; the caller's recheck then refuses. Because the feasibility check applies to +every admission call, a shutdown fallback with no superseded generation (headroom 0) whose +own footprint plus cleanup debt exceeds the cap also evicts nothing (audit blocker 2). + +A reclaim is only as honest as its unlinks (audit blocker 1). `deleteResponseSpill` +swallows unlink failures while every caller has already dropped the entry from +accounting, so a locked victim would let admission publish against space that was never +freed. The existing per-path debt ledger in `spill-queue.ts` +(`chargeUnreclaimableSpillPath` / `reconcileUnreclaimableSpillPaths`) already models a +file a failed cleanup left behind and settles the charge once the path is gone. A failed +(non-ENOENT) unlink of a stable spill file now charges that path at its `payloadBytes`, +so the admission recheck — which reads `accountedResponseSpillBytes()` and therefore +`unreclaimableBytes` — sees the file and refuses. This applies to every deletion of a +stable spill, which closes the same hole for the existing zero-headroom pruning too. +The orphan sweep (`recoverOrphanedResponseSpills`) still removes such files after its +grace window, and the charge clears on the next accounting read. + +Eviction order is unchanged: deferred superseded generations first, then installed spills +by `createdAt` with response-id tie-break. Pending candidates and job-owned generations are +not in the eviction list, as today. + +### `src/responses/state.ts` (MODIFY) + +Final shape of the function (comments elided where unchanged): + +```ts +function enforceSpilledResponseBudget(requiredHeadroomBytes?: number): number { + // Price in-flight publications too: a file being created by + // `writeResponseSpillDurablyAsync` occupies the volume before it reaches `states`. + let spilledBytes = accountedResponseSpillBytes(); + // Admission (#6747) passes the room its publication needs, 0 included. Evict only while that + // room is still reachable: re-read accounting after each attempt, since a failed unlink stays + // charged, and stop the moment the publication cannot fit so no unrelated continuation pays. + const admission = requiredHeadroomBytes !== undefined; + const headroom = admission && requiredHeadroomBytes > 0 ? requiredHeadroomBytes : 0; + const feasible = (): boolean => !admission + || (spilledBytes = accountedResponseSpillBytes()) - spilledResponseBytes() + headroom <= spillByteCap(); + if (!feasible() || spilledBytes <= spillByteCap() - headroom) return 0; + const before = spilledBytes; + // (unchanged deferred-first comment) + while (spilledBytes > spillByteCap() - headroom && pendingSpillUnlinks.length > 0) { + const ref = pendingSpillUnlinks.shift()!; + spilledBytes -= ref.payloadBytes; + deleteResponseSpill(ref); + if (admission) spillCounters.headroomEvictions += 1; + if (!feasible()) return before - spilledBytes; + } + // (unchanged createdAt ordering comment + sort) + for (const [id, entry] of spilled) { + if (spilledBytes <= spillByteCap() - headroom) break; + spilledBytes -= entry.spill.payloadBytes; + deleteEntry(id); + if (admission) spillCounters.headroomEvictions += 1; + if (!feasible()) return before - spilledBytes; + } + return before - spilledBytes; +} +``` + +`return` (not `break`) on infeasibility exits both loops, so a failed deferred unlink can +never fall through to evicting an installed continuation (audit round 3). A NaN or +non-positive argument still selects admission mode with zero headroom; `Infinity` is +infeasible and evicts nothing. + +Metrics call (~line 1212), one added line: + +```diff + spillAclTimeoutMemoRefusals: spillCounters.aclTimeoutMemoRefusals, ++ spillCapacityRefusals: spillCounters.capacityRefusals, spillHeadroomEvictions: spillCounters.headroomEvictions, +``` + +`clearResponseStateMemoryForTests()` (lines 1324–1328) replaces its five field-by-field +counter resets with one call, so new counters cannot be forgotten: + +```diff +- spillCounters.writes = 0; +- spillCounters.writeFailures = 0; +- spillCounters.readFailures = 0; +- spillCounters.aclRetryReturnedTimeouts = 0; +- spillCounters.aclTimeoutMemoRefusals = 0; ++ resetSpillCountersForTests(); +``` + +Line budget for `state.ts` (1352 now, cap 1371): function +11, metrics +1, reset −4, +import of `resetSpillCountersForTests` folded into the existing `./state/spill-failure` +import line (0) → **1360**. The ratchet is not raised. + + +### `src/responses/state/spill-queue.ts` (MODIFY) + +```diff +- enforceSpilledResponseBudget(): number; ++ enforceSpilledResponseBudget(requiredHeadroomBytes?: number): number; + ... + if (requireStore().accountedResponseSpillBytes() + footprint + inheritedBytes > requireStore().spillByteCap()) { +- requireStore().enforceSpilledResponseBudget(); ++ requireStore().enforceSpilledResponseBudget(footprint + inheritedBytes); + if (requireStore().accountedResponseSpillBytes() + footprint + inheritedBytes > requireStore().spillByteCap()) { +- noteSpillWriteFailure(null, "ECAPACITY"); ++ noteSpillCapacityRefusal(); + ... + // shutdown fallback: footprint is already in reservedResponseSpillBytes, so only the + // superseded generation is additional headroom. +- requireStore().enforceSpilledResponseBudget(); ++ requireStore().enforceSpilledResponseBudget(supersededBytes); + if (...) { + if (requireStore().currentEntry(job.id) === candidate) { +- noteSpillWriteFailure(null, "ECAPACITY"); ++ noteSpillCapacityRefusal(); +``` + +The pending-RAM refusal at `spill-queue.ts:272` keeps `noteSpillWriteFailure(null, +"ECAPACITY")`: it is a different limit and is not counted as a disk-cap refusal. + +Both admission call sites always pass a number: normal admission passes +`footprint + inheritedBytes`, the shutdown fallback passes `supersededBytes` (which may +be 0). + +### `src/responses/spill-store.ts` (MODIFY) — failed unlinks stay accounted + +```diff ++type SpillUnlinkFailureObserver = (path: string, bytes: number) => void; ++let spillUnlinkFailureObserver: SpillUnlinkFailureObserver | null = null; ++/** The owner of spill accounting registers here; a file a failed unlink left behind must stay priced. */ ++export function setResponseSpillUnlinkFailureObserver(next: SpillUnlinkFailureObserver | null): void { ++ spillUnlinkFailureObserver = next; ++} + + export function deleteResponseSpill(ref: ResponseSpillRef): void { + if (!validSpillRef(ref)) return; + const dir = responseSpillDirectory(); ++ const path = join(dir, ref.fileName); + try { +- unlink(join(dir, ref.fileName)); ++ unlink(path); + fsyncDirectoryBestEffort(dir); +- } catch { /* best effort */ } ++ } catch (error) { ++ // Still best effort, but no longer invisible: the file occupies the volume. ++ if (!isErrno(error, "ENOENT")) spillUnlinkFailureObserver?.(path, ref.payloadBytes); ++ } + } +``` + +`src/responses/state/spill-queue.ts` registers the observer once at module load: +`setResponseSpillUnlinkFailureObserver(chargeUnreclaimableSpillPath)`, and +`resetSpillQueueForTests` keeps clearing `unreclaimableSpillPaths` (unchanged). +The existing `spillIoForTest.unlink` seam can force the failure in tests. + +### `src/responses/state/spill-failure.ts` (MODIFY) + +```diff + export const spillCounters = { + writes: 0, writeFailures: 0, readFailures: 0, + aclRetryReturnedTimeouts: 0, aclTimeoutMemoRefusals: 0, ++ capacityRefusals: 0, headroomEvictions: 0, + }; ++ ++/** Test-only: zero every spill counter, including ones added later. */ ++export function resetSpillCountersForTests(): void { ++ for (const key of Object.keys(spillCounters) as Array) spillCounters[key] = 0; ++} ++ ++/** A publication refused because the durable spill cap could not make room for it. */ ++export function noteSpillCapacityRefusal(): void { ++ spillCounters.capacityRefusals += 1; ++ noteSpillWriteFailure(null, "ECAPACITY"); ++} +``` + + +### `src/responses/state/metrics.ts` (MODIFY) + +Add `spillCapacityRefusals: number; spillHeadroomEvictions: number;` to +`ResponseStateMetrics` and to the `computeResponseStateMetrics` params, passed straight +through. Both are cumulative, process-local integers; they carry no path, size or id. + +### Tests + +- NEW `tests/responses/responses-spill-admission-headroom.test.ts` (register in both + layout files). Uses the Windows ACL lane seam (`forceWindowsAclLane`-style setup copied + from `responses-state.test.ts`) and `setSpilledResponseByteCapForTests`. Cases: + 1. under-cap store, publication that fits only after evicting the oldest spill → the + oldest is evicted, the new continuation spills, no tombstone, newest replay works, + `spillHeadroomEvictions >= 1`, `spillCapacityRefusals === 0`, disk stays ≤ cap; + 2. exact fit → no eviction; + 3. impossible footprint (two envelopes > cap) → no existing spill evicted, refusal + counted in `spillCapacityRefusals`, seeded continuation still replays; + 4. inherited (same-id) generation priced into the headroom; + 5. a second publication pinned in flight is not evictable and is counted as pinned. + 6. shutdown fallback with its footprint already reserved and a feasible superseded + headroom → the oldest unrelated spill is evicted and the fallback publishes; + 7. shutdown fallback whose footprint plus superseded bytes exceed the cap → no unrelated + spill is evicted, the candidate is tombstoned, reservations are released; + 8. deferred superseded generations are evicted before installed spills; + 9. after `flushResponseState()` and a reload, the newest continuation replays; + 10. shutdown fallback with **no** superseded generation whose footprint plus cleanup + debt exceeds the cap → no unrelated spill is evicted, the unrelated continuation + still replays; + 11. eviction victim whose unlink fails (`spillIoForTest.unlink` throws EBUSY/EPERM), + two shapes: (a) the failure makes the publication impossible (cap 900, headroom 800, + victims 500 then 300, the 500 fails) → the 300 continuation survives and still + replays, admission refuses; (b) the failed victim is small and further successful + reclamation still makes room → admission evicts the next victim and publishes. + Bytes on disk never exceed the cap. Recovery: once the seam stops failing, the + production orphan sweep (`recoverOrphanedResponseSpills`, advancing the test clock + past its grace) removes the file, the charge clears, and the next admission succeeds. + (c) a deferred superseded generation whose unlink fails, followed by an installed + continuation (cap 900, headroom 800, deferred 500 fails, installed 300) → the + installed continuation survives and admission refuses. + Publication is serialized: tests must not assume two writers run at once. +- MODIFY `tests/server/memory-watchdog.test.ts`: the reviewed response-state field count + (`toHaveLength(20)` at :232) becomes 22, the typed field list at :201 gains the two + counters, and an assertion checks both are finite non-negative integers. +- MODIFY in place (no added lines) `tests/responses/continuation-dedup.test.ts` reviewed + key list, and any exact-shape metric assertions that use `toEqual`. +- Existing `responses-state.test.ts` "refuses a publication whose peak footprint does not + fit the disk cap" must still pass unchanged (footprint 2×seed > 1.5×seed cap → headroom + collapses to 0). + +### Docs + +- `docs-site/src/content/docs/troubleshooting/windows-memory.md`: describe + `spillCapacityRefusals` / `spillHeadroomEvictions` and that admission now evicts the + oldest continuations to make room before refusing. +- `docs-site/src/content/docs/reference/management-api.md` memory row: name the two new + fields. +- `structure/transports/responses.md` (owner of `src/responses/` per + `structure/manifest.json`): state the headroom admission rule and the two counters. + Counters are logical eviction/refusal events, not proof of a physical unlink. + +## Verification + +`bun test tests/responses/responses-spill-admission-headroom.test.ts +tests/responses/responses-state.test.ts tests/responses/continuation-dedup.test.ts +tests/responses/responses-spill-acl-recovery.test.ts tests/server/memory-watchdog.test.ts`, +`bun run typecheck`, the layout and file-size guards; the rest in hosted CI. + +## Risks + +Headroom eviction trades the oldest stored continuations for the newest one, which is the +existing retention order. A failed unlink is now charged until the file is gone, which can +temporarily lower usable capacity while a Windows lock or permission failure persists; +cleanup is eventual through the orphan sweep once the unlink can succeed. The extra +accounting reads per eviction run only on admission paths that are already over target. diff --git a/devlog/_plan/261009_n4_responses_quota/020_main_quota_observation.md b/devlog/_plan/261009_n4_responses_quota/020_main_quota_observation.md new file mode 100644 index 00000000000..974f6b9d3c8 --- /dev/null +++ b/devlog/_plan/261009_n4_responses_quota/020_main_quota_observation.md @@ -0,0 +1,246 @@ +# 020 — wp2: credential-bound main quota observation (#6800) + +## Root cause (verified at 730d898457) + +Quota from upstream response headers is recorded only inside +`if (usesCodexForwardPoolAuth(authCtx, provider))` +(`src/server/responses/passthrough-delivery.ts:486`), and the WebSocket observer has the +same gate (`core-codex-account.ts:124`). `usesCodexForwardPoolAuth` admits auth-context +kinds `pool` and `main-pool`. A request served with the main login as a plain +`{ kind: "main" }` context — the caller's own ChatGPT bearer, or the stored main +substituted for an admission bearer — never records its headers, so `__main__` in +`codex-quota-cache.json` keeps its last WHAM/probe value while pool rows refresh on every +response. The reporter (Codex CLI, main Pro + two Plus pool accounts, HTTP fallback after a +WebSocket 426) describes exactly that asymmetry. + +## Design + +Record a plain-main response into `__main__` only when the credential actually sent +upstream is the main credential the proxy has already observed from its own read, and only +if that observation is still current when the response arrives. + +The process already has the matching primitive: `matchesMainQuotaCredential(accessToken, +accountId)` in `src/codex/main-account-cache.ts` compares a process-local HMAC of the +bearer plus the account identity key against the credential observed from an owned read. +It is what `callerMatchesObservedMain` uses today to decide that the hard-lock policy +applies to a caller-owned request, so quota recording follows the same trust rule. + +`usesCodexForwardPoolAuth` is not widened: its block also owns pool health, failover and +quarantine, none of which apply to a caller-owned credential. + +### `src/codex/main-account-cache.ts` (MODIFY) + +```ts +/** Proof that a dispatch used the observed main credential; process-local, never persisted. */ +export type MainQuotaDispatch = Readonly<{ + writer: MainQuotaWriter; + credentialGeneration: number; + configGeneration: number; +}>; + +export function captureMainQuotaDispatch( + accessToken: string, accountId: string | undefined, configGeneration: number, +): MainQuotaDispatch | undefined { + if (!accountId || !matchesMainQuotaCredential(accessToken, accountId)) return undefined; + const writer = captureMainQuotaWriter(accountId); + return writer ? { writer, credentialGeneration: mainQuotaCredentialGeneration, configGeneration } : undefined; +} + +export function isMainQuotaDispatchLive(dispatch: MainQuotaDispatch): boolean { + return isMainQuotaWriterLive(dispatch.writer) + && dispatch.credentialGeneration === mainQuotaCredentialGeneration; +} +``` + +The credential generation fence rejects a response that arrives after the main credential +was replaced (including same-account token rotation, which leaves the writer live) or after +an A→B→A identity change. + +### `src/codex/auth-context.ts` (MODIFY) + +```diff + import { + captureMainQuotaWriter, ++ captureMainQuotaDispatch, + ... ++ type MainQuotaDispatch, + } from "./main-account-cache"; + + export type CodexAuthContext = +- | { kind: "main"; accountId: null; reserveAuthorization?: MainReserveAuthorization } ++ | { ++ kind: "main"; accountId: null; reserveAuthorization?: MainReserveAuthorization; ++ /** Set by materialization when the bearer sent upstream is the observed main credential. */ ++ mainQuotaDispatch?: MainQuotaDispatch; ++ } + ++/** Dispatch proof for the credential actually selected; identity comes only from an owned observation. */ ++function selectedMainQuotaDispatch(selected: Headers): MainQuotaDispatch | undefined { ++ const bearer = selected.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); ++ if (!bearer) return undefined; ++ const accountId = selected.get("chatgpt-account-id") ?? extractAccountId(undefined, bearer); ++ return captureMainQuotaDispatch(bearer, accountId, captureConfigGeneration()); ++} + + export function materializeCodexUpstreamAuth(...): Headers { + const selected = new Headers(); + ... ++ if (ctx.kind === "main") ctx.mainQuotaDispatch = undefined; + if (ctx.kind === "pool" || ctx.kind === "main-pool") { ... unchanged ... } + ... + if (ctx.kind === "main" && options.substituteMainCredential === true) { + ... + observeSelectedMainCredential(stored, writer); + assertMainAccountPolicy(options.config); + assertMaterializedReserve(selected, ctx, options); ++ ctx.mainQuotaDispatch = selectedMainQuotaDispatch(selected); + return selected; + } + if (callerMatchesObservedMain(selected)) assertMainAccountPolicy(options.config); + assertMaterializedReserve(selected, ctx, options); ++ if (ctx.kind === "main") ctx.mainQuotaDispatch = selectedMainQuotaDispatch(selected); + return selected; + } + + export async function materializeCodexUpstreamAuthAsync(...): Promise { + ... ++ ctx.mainQuotaDispatch = undefined; // ctx.kind === "main" in the substitution branch + const selected = new Headers(); + ... + observeSelectedMainCredential(stored, writer); + assertMainAccountPolicy(options.config); + assertMaterializedReserve(selected, ctx, options); ++ ctx.mainQuotaDispatch = selectedMainQuotaDispatch(selected); + return selected; + } +``` + +The proof is captured after every policy/reserve assertion, so a request that throws never +carries one. `captureConfigGeneration` and `extractAccountId` are already imported in +this file. `materializeReserveUpstreamAuth` reaches these through +`materializeCodexUpstreamAuthAsync`. The stored-main substitution path matches only when +`observeSelectedMainCredential` has recorded that credential (it does so on a live +writer); a stale writer yields no proof, which is the safe direction. + +Check during build: the hard lock's "possible usage outside opencodex" advisory relies on +recent main-account activity through the proxy. Confirm caller-owned matched requests are +already recorded as activity (they pass `assertMainAccountPolicy`); if not, a fresh +header reading could be misread as outside usage — record the activity alongside the +write, or document the gap. + +### `src/server/responses/core-codex-account.ts` (MODIFY) + +```ts +/** Live proof that this plain-main response came back for the observed main credential. */ +export function liveMainQuotaDispatch( + authCtx: CodexAuthContext, provider: OcxProviderConfig, +): MainQuotaDispatch | undefined { + if (authCtx.kind !== "main" || !authCtx.mainQuotaDispatch) return undefined; + if (!isCanonicalOpenAiForwardProvider(provider) + || provider.authMode !== "forward" || provider.adapter !== "openai-responses") return undefined; + return isMainQuotaDispatchLive(authCtx.mainQuotaDispatch) ? authCtx.mainQuotaDispatch : undefined; +} +``` + +`codexWsQuotaObserver`: replace the single early return at `core-codex-account.ts:125` +(adding a branch below it would be unreachable). When the pool gate does not apply, +capture `liveMainQuotaDispatch(authCtx, provider)` once into the closure (never re-read +mutable `authCtx` per frame) and return an observer that re-checks +`isMainQuotaDispatchLive(dispatch)` per frame and then synchronously calls +`applyCapturedCodexQuota(MAIN_CODEX_ACCOUNT_ID, headers, dispatch.configGeneration, +dispatch.writer, { modelId })`. + +The canonical-provider check matters: a custom `openai-responses` provider pointed at +another host must never write `__main__`. + +### `src/server/responses/passthrough-delivery.ts` (MODIFY) + +After the pool block: + +```ts +} else { + const mainDispatch = liveMainQuotaDispatch(admissionState.authCtx, route.provider); + if (mainDispatch && !isCodexWsQuotaObservedResponse(upstreamResponse)) { + const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/auth-api"); + // The import yields; a credential replaced meanwhile leaves the writer live + // (quota.ts only checks identity), so re-check the dispatch proof with no await between. + if (isMainQuotaDispatchLive(mainDispatch)) { + applyAccountQuotaFromUpstreamHeaders(MAIN_CODEX_ACCOUNT_ID, upstreamResponse.headers, + mainDispatch.configGeneration, mainDispatch.writer, { modelId: route.modelId }); + } + } +} +``` + +Context identity (verified by the architect): initial materialization uses the context +returned at `core-auth.ts:303/340`, assigned unchanged at `request-prepare.ts:1387`; +account-retry materialization reaches the synchronous helper via +`core-codex-account.ts:752` and the new context replaces admission state at +`passthrough-dispatch.ts:1636`. The proof set during materialization is therefore the one +delivery reads. A second materialization at `core-auth.ts:314` re-captures it. + +No auth-file read, refresh, affinity, rotation or quarantine is added for caller-owned +requests. A missing proof, a different bearer or workspace, a non-canonical provider or +absent quota headers records nothing. + +### Tests + +NEW `tests/responses/responses-main-quota-observation.test.ts` (register in both layout +files), using existing main-quota test seams from `tests/codex-integration/ +main-quota-provenance.test.ts`: + +1. observed main credential sent as caller bearer → HTTP response headers update + `__main__`; +2. stored-main substitution → updates `__main__`; +3. caller bearer for a different account, or same account with a different token → no + write; +4. main credential replaced between dispatch and response (credential generation bump) → + no write; A→B→A identity change → no write; +5. non-canonical forward provider → no write; +6. WebSocket observer path for a matching main dispatch → updates once (no duplicate HTTP + write when `isCodexWsQuotaObservedResponse`); +7. pool request behaviour unchanged; +8. credential replaced during the HTTP path's awaited import → no write; +9. WS: credential replaced between two frames → first frame writes, second does not; +10. same bearer with a different `chatgpt-account-id` workspace → no write; +11. quota headers: a non-numeric or absent set parses to nothing → no write. Out-of-range + values follow the existing consumer contract shared with `main-pool` + (`src/codex/quota.ts:620`): the display reading is clamped while + `isInvalidPolicyUsagePercent` withholds policy evidence. Assert both halves (display + clamped, `mainPolicyQuota` unchanged) and a mixed valid/invalid window case; +12. persistence: the serialized `codex-quota-cache.json` carries a fresh + `quotas.__main__.updatedAt` and no dispatch proof or credential material; +13. pool→caller-main account retry (`core-codex-account.ts:752` path) records under the + proof of the final dispatch only. + +### Docs + +- `structure/providers/openai-accounts.md`, `structure/providers/openai-tiers.md` and + `structure/transports/responses.md`: plain-main responses record quota under the same + credential-match rule as the hard lock; canonical provider only. +- `docs-site/src/content/docs/reference/cli/providers-accounts.md`, section + "Main-account quota protection", after the paragraph ending "…cannot clear or set the + current account's reauthentication state.": add + "Responses to requests sent with the identified main credential refresh its cached + usage from their quota headers, whether the proxy substituted the stored credential or + the caller sent the same credential itself. A response is applied only if that + credential is still the observed main credential when it arrives; a caller-owned + credential for another account or workspace never updates the main account's usage." + Translated locales are left to the translation sync. + +Both work phases add one line to each layout manifest (`layout.json` at 1982 lines, +limit 2000); verify the combined result after the first PR lands. + +## Verification + +Focused: the new test file, `main-quota-provenance`, `main-quota-evidence-validation`, +`codex-auth-context`, `codex-pool-request-owned-main`, `tests/responses/ws-upstream.test.ts`; +`bun run typecheck`. Independent review plus a separate security review (credential +binding, misattribution, timing-safe comparison, no persisted credential material). + +## Risks + +Recording more main observations makes the hard lock react to live headers between WHAM +probes; that is the same data main-pool requests already feed it. If the reporter's +requests actually resolve to `main-pool`, this change does not explain their symptom and +the issue stays open with a request for a sampled request's resolved context. diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index a6849177e36..66312da9530 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -687,7 +687,7 @@ the 503 without reporting model capacity; provider overload errors retain their | Method and path | Purpose | Notable errors | | --- | --- | --- | -| `GET /api/system/memory` | Return scalar process, heap, stream, response-state, watchdog, and active-turn metrics. Response-state diagnostics include spill-write status, consecutive failures, fixed privacy-safe failure class, and last failure/success timestamps. `spillLastWriteFailureOrigin` is `retry_returned_timeout`, `timeout_memo_refusal`, or null; cumulative `spillAclRetryReturnedTimeouts` and `spillAclTimeoutMemoRefusals` count terminal failed publications. See [Windows spill diagnostics](/troubleshooting/windows-memory/) for process-local semantics. Raw errors and paths are never returned. | — | +| `GET /api/system/memory` | Return scalar process, heap, stream, response-state, watchdog, and active-turn metrics. Response-state diagnostics include spill-write status, consecutive failures, fixed privacy-safe failure class, and last failure/success timestamps. `spillLastWriteFailureOrigin` is `retry_returned_timeout`, `timeout_memo_refusal`, or null; cumulative `spillAclRetryReturnedTimeouts` and `spillAclTimeoutMemoRefusals` count terminal failed publications; cumulative `spillCapacityRefusals` and `spillHeadroomEvictions` count continuations refused at the spill ceiling and continuations evicted to make room for a newer one. See [Windows spill diagnostics](/troubleshooting/windows-memory/) for process-local semantics. Raw errors and paths are never returned. | — | | `POST /api/system/restart` | Begin a drain-aware process restart without removing client injection; optional JSON `drainGraceMs` explicitly shortens the default 60s grace | Returns 202; repeated calls report the existing grace; 400 invalid body or grace | | `POST /api/stop` | Stop the service, restore native Codex, remove managed Grok injection, and drain the proxy | 409 service ownership conflict; 409 `respawnable_service` when a Windows Task Scheduler wrapper could respawn the proxy and the caller is not `ocx stop` (nothing is changed); 409 `self_unload_service` when this proxy is running as the installed launchd/systemd service, because stopping the manager from inside it would end the process before native Codex is restored — run `ocx stop` instead (nothing is changed); 409 when the installed manager refuses to stop; 409 `service_state_unknown` when the Task Scheduler state cannot be read (nothing is changed; repair the query and retry) | | `GET /api/system/codex-app-server` | Report whether running Codex app-servers predate the current model catalog | — | diff --git a/docs-site/src/content/docs/troubleshooting/windows-memory.md b/docs-site/src/content/docs/troubleshooting/windows-memory.md index 51ad6b0ea90..65ee95c07ab 100644 --- a/docs-site/src/content/docs/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/troubleshooting/windows-memory.md @@ -63,6 +63,15 @@ runtime the leak itself remains an upstream problem: remembered timeout state. Other failures use null. The cumulative `spillAclRetryReturnedTimeouts` and `spillAclTimeoutMemoRefusals` count terminal failed publications, not individual ACL commands or transient first attempts. + When a new continuation does not fit under the 1 GiB spill ceiling, the proxy + first evicts the oldest stored continuations to make room, as long as that can + succeed; a continuation that cannot fit even then is refused. The cumulative + `spillHeadroomEvictions` counts continuations evicted to make room, and + `spillCapacityRefusals` counts continuations refused because the ceiling could + not make room (each one is also a failed publication with class `ECAPACITY`). + A rising `spillCapacityRefusals` while memory is healthy means the durable + continuation set is full of space the proxy cannot reclaim — in-flight + publications or files a failed delete left behind — rather than a memory problem. Success clears the failure streak but retains the last failure fields and cumulative counts; a later unrelated failure sets the last origin to null. These values are process-local, so compare snapshots from the same process. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 5cba9f4397c..ca73cb89802 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1599,6 +1599,7 @@ "responses-spend-anchor.test.ts": "responses", "responses-spend-capacity-guard.test.ts": "responses", "responses-spill-acl-recovery.test.ts": "responses", + "responses-spill-admission-headroom.test.ts": "responses", "responses-spill-inspection.test.ts": "responses", "responses-spill-orphan-sweep.test.ts": "responses", "responses-spill-shutdown-clock.test.ts": "responses", diff --git a/src/responses/spill-store.ts b/src/responses/spill-store.ts index b7813649ab6..538c3645ec5 100644 --- a/src/responses/spill-store.ts +++ b/src/responses/spill-store.ts @@ -765,10 +765,23 @@ export function readResponseSpill(responseId: string, ref: ResponseSpillRef): Re export function deleteResponseSpill(ref: ResponseSpillRef): void { if (!validSpillRef(ref)) return; const dir = responseSpillDirectory(); + const path = join(dir, ref.fileName); try { - unlink(join(dir, ref.fileName)); + unlink(path); fsyncDirectoryBestEffort(dir); - } catch { /* best effort */ } + } catch (error) { + // Still best effort, but no longer invisible: a file a failed unlink left behind occupies + // the volume, so the accounting owner keeps pricing it until the path is gone (#6747). + if (!isErrno(error, "ENOENT")) spillUnlinkFailureObserver?.(path, ref.payloadBytes); + } +} + +type SpillUnlinkFailureObserver = (path: string, bytes: number) => void; +let spillUnlinkFailureObserver: SpillUnlinkFailureObserver | null = null; + +/** The owner of spill accounting registers here so a failed unlink cannot under-report disk use. */ +export function setResponseSpillUnlinkFailureObserver(next: SpillUnlinkFailureObserver | null): void { + spillUnlinkFailureObserver = next; } type SpillDirNameKind = "spill" | "temp"; diff --git a/src/responses/state.ts b/src/responses/state.ts index 8594e2d1e40..7696f2d2414 100644 --- a/src/responses/state.ts +++ b/src/responses/state.ts @@ -31,7 +31,7 @@ import { recoverStaleResponseStateTemps } from "./state/temp-recovery"; export type { ResponseSpillWriteFailureCode, ResponseSpillWriteStatus, ResponseSpillWriteFailureOrigin } from "./state/spill-failure"; import type { ResponseSpillWriteFailureCode, ResponseSpillWriteStatus, ResponseSpillWriteFailureOrigin } from "./state/spill-failure"; export { responseAdmissionCountersForTests } from "./state/spill-failure"; -import { admissionCounters, noteSpillWriteFailure, noteSpillWriteSuccess, spillCounters, spillWriteHealth } from "./state/spill-failure"; +import { admissionCounters, noteSpillWriteFailure, noteSpillWriteSuccess, resetSpillCountersForTests, spillCounters, spillWriteHealth } from "./state/spill-failure"; import { loadSnapshotEntry } from "./state/snapshot-codec"; import { isBodyNonPersistable } from "./state/body-policy"; export { isBodyNonPersistable, markBodyNonPersistable } from "./state/body-policy"; @@ -876,20 +876,29 @@ export function replayOverlapSkipsForTests(): number { * This ceiling therefore bounds what the store owns, which is every file it can * account for, and not the directory as a whole. */ -function enforceSpilledResponseBudget(): number { +function enforceSpilledResponseBudget(requiredHeadroomBytes?: number): number { // Price in-flight publications too: a file being created by // `writeResponseSpillDurablyAsync` occupies the volume before it reaches `states`. let spilledBytes = accountedResponseSpillBytes(); - if (spilledBytes <= spillByteCap()) return 0; + // Admission (#6747) passes the room its publication needs, 0 included. Evict only while that + // room is still reachable: re-read accounting after each attempt, since a failed unlink stays + // charged, and stop the moment the publication cannot fit so no unrelated continuation pays. + const admission = requiredHeadroomBytes !== undefined; + const headroom = admission && requiredHeadroomBytes > 0 ? requiredHeadroomBytes : 0; + const feasible = (): boolean => !admission + || (spilledBytes = accountedResponseSpillBytes()) - spilledResponseBytes() + headroom <= spillByteCap(); + if (!feasible() || spilledBytes <= spillByteCap() - headroom) return 0; const before = spilledBytes; // Deferred generations go first. They are already superseded, so releasing one // costs only the crash window the queue exists to cover — the same trade // PENDING_SPILL_UNLINKS_MAX already makes against unbounded disk. Evicting a // live continuation to make room for a dead file would be the wrong order. - while (spilledBytes > spillByteCap() && pendingSpillUnlinks.length > 0) { + while (spilledBytes > spillByteCap() - headroom && pendingSpillUnlinks.length > 0) { const ref = pendingSpillUnlinks.shift()!; spilledBytes -= ref.payloadBytes; deleteResponseSpill(ref); + if (admission) spillCounters.headroomEvictions += 1; + if (!feasible()) return before - spilledBytes; } // Ordered by createdAt, not by map order. `states` is not an age index: // demotion and spill replacement delete and reinsert entries, and @@ -906,9 +915,11 @@ function enforceSpilledResponseBudget(): number { .sort((a, b) => a[1].createdAt - b[1].createdAt || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); for (const [id, entry] of spilled) { - if (spilledBytes <= spillByteCap()) break; + if (spilledBytes <= spillByteCap() - headroom) break; spilledBytes -= entry.spill.payloadBytes; deleteEntry(id); + if (admission) spillCounters.headroomEvictions += 1; + if (!feasible()) return before - spilledBytes; } return before - spilledBytes; } @@ -1210,6 +1221,7 @@ export function responseStateMetrics(): ResponseStateMetrics { spillLastSuccessAt: spillWriteHealth.lastSuccessAt, spillAclRetryReturnedTimeouts: spillCounters.aclRetryReturnedTimeouts, spillAclTimeoutMemoRefusals: spillCounters.aclTimeoutMemoRefusals, + spillCapacityRefusals: spillCounters.capacityRefusals, spillHeadroomEvictions: spillCounters.headroomEvictions, spillLastFailureAt: spillWriteHealth.lastFailureAt, spillReadFailures: spillCounters.readFailures, replayScopeMismatchDrops, @@ -1321,11 +1333,7 @@ export function clearResponseStateMemoryForTests(): void { oldestResidentAt = null; stateRevision = 0; pendingSpillUnlinks.length = 0; - spillCounters.writes = 0; - spillCounters.writeFailures = 0; - spillCounters.readFailures = 0; - spillCounters.aclRetryReturnedTimeouts = 0; - spillCounters.aclTimeoutMemoRefusals = 0; + resetSpillCountersForTests(); spillWriteHealth.consecutiveFailures = 0; spillWriteHealth.lastFailureCode = null; spillWriteHealth.lastFailureOrigin = null; diff --git a/src/responses/state/metrics.ts b/src/responses/state/metrics.ts index f43533d5cf0..9821b5fb1ce 100644 --- a/src/responses/state/metrics.ts +++ b/src/responses/state/metrics.ts @@ -18,6 +18,10 @@ export interface ResponseStateMetrics { spillLastWriteFailureOrigin: ResponseSpillWriteFailureOrigin | null; spillAclRetryReturnedTimeouts: number; spillAclTimeoutMemoRefusals: number; + /** Publications refused because the durable spill cap could not make room (cumulative). */ + spillCapacityRefusals: number; + /** Spills evicted by admission to make room for a newer publication (cumulative). */ + spillHeadroomEvictions: number; spillLastWriteFailureAt: number | null; spillLastWriteSuccessAt: number | null; spillReadFailures: number; @@ -37,6 +41,8 @@ export function computeResponseStateMetrics(params: { spillLastSuccessAt: number | null; spillAclRetryReturnedTimeouts: number; spillAclTimeoutMemoRefusals: number; + spillCapacityRefusals: number; + spillHeadroomEvictions: number; spillLastFailureAt: number | null; spillReadFailures: number; replayScopeMismatchDrops: number; @@ -79,6 +85,8 @@ export function computeResponseStateMetrics(params: { spillLastWriteFailureOrigin: params.spillLastFailureOrigin, spillAclRetryReturnedTimeouts: params.spillAclRetryReturnedTimeouts, spillAclTimeoutMemoRefusals: params.spillAclTimeoutMemoRefusals, + spillCapacityRefusals: params.spillCapacityRefusals, + spillHeadroomEvictions: params.spillHeadroomEvictions, spillLastWriteFailureAt: params.spillLastFailureAt, spillLastWriteSuccessAt: params.spillLastSuccessAt, spillReadFailures: params.spillReadFailures, diff --git a/src/responses/state/spill-failure.ts b/src/responses/state/spill-failure.ts index fbba5265900..e055243b0ec 100644 --- a/src/responses/state/spill-failure.ts +++ b/src/responses/state/spill-failure.ts @@ -1,8 +1,14 @@ export const spillCounters = { writes: 0, writeFailures: 0, readFailures: 0, aclRetryReturnedTimeouts: 0, aclTimeoutMemoRefusals: 0, + capacityRefusals: 0, headroomEvictions: 0, }; +/** Test-only: zero every spill counter, including ones added later. */ +export function resetSpillCountersForTests(): void { + for (const key of Object.keys(spillCounters) as Array) spillCounters[key] = 0; +} + export type ResponseSpillWriteFailureCode = | "EACLRETRYEXHAUSTED" | "ETIMEDOUT" @@ -102,6 +108,12 @@ export function noteSpillWriteFailure( if (origin === "retry_returned_timeout") spillCounters.aclRetryReturnedTimeouts += 1; else if (origin === "timeout_memo_refusal") spillCounters.aclTimeoutMemoRefusals += 1; } + +/** A publication refused because the durable spill cap could not make room for it (#6747). */ +export function noteSpillCapacityRefusal(): void { + spillCounters.capacityRefusals += 1; + noteSpillWriteFailure(null, "ECAPACITY"); +} /** * Admission-boundary observability (test-visible). directSpills: oversized * candidates routed straight to durable spill without a resident stay or diff --git a/src/responses/state/spill-queue.ts b/src/responses/state/spill-queue.ts index 76deaddf225..f5616fc1824 100644 --- a/src/responses/state/spill-queue.ts +++ b/src/responses/state/spill-queue.ts @@ -11,12 +11,14 @@ import { responseSpillNow, type ResponseSpillPublicationControl, type ResponseSpillRef, + setResponseSpillUnlinkFailureObserver, writeResponseSpillDurably, writeResponseSpillDurablyAsync, } from "../spill-store"; import { enforceAppOwnedMemoryBudget } from "../../lib/app-owned-memory"; import { admissionCounters, + noteSpillCapacityRefusal, noteSpillWriteFailure, noteSpillWriteSuccess, spillAclMemoRefusalOrigin, @@ -42,7 +44,8 @@ export interface SpillQueueStore { pruneResponses(): void; accountedResponseSpillBytes(): number; spillByteCap(): number; - enforceSpilledResponseBudget(): number; + /** No argument: plain cap pruning. A number (0 included): admission reclaim of that headroom. */ + enforceSpilledResponseBudget(requiredHeadroomBytes?: number): number; terminalizationMaxPasses(): number; } @@ -122,6 +125,8 @@ function chargeUnreclaimableSpillPath(path: string | null | undefined, bytes: nu if (!path || bytes <= 0) return; unreclaimableSpillPaths.set(path, bytes); } +// A failed unlink of an installed or deferred spill leaves a real file too; price it the same way. +setResponseSpillUnlinkFailureObserver(chargeUnreclaimableSpillPath); /** Drop charges for paths that have since disappeared; returns the surviving total. */ function reconcileUnreclaimableSpillPaths(): number { @@ -286,9 +291,10 @@ export function queuePendingResponseSpill( // against a total that is short by a whole envelope. const inheritedBytes = inheritedSpill?.payloadBytes ?? 0; if (requireStore().accountedResponseSpillBytes() + footprint + inheritedBytes > requireStore().spillByteCap()) { - requireStore().enforceSpilledResponseBudget(); + // An envelope over the replay ceiling ends in EFBIG anyway; never evict a continuation for it. + if (footprint / 2 <= responseSpillPayloadCap()) requireStore().enforceSpilledResponseBudget(footprint + inheritedBytes); if (requireStore().accountedResponseSpillBytes() + footprint + inheritedBytes > requireStore().spillByteCap()) { - noteSpillWriteFailure(null, "ECAPACITY"); + noteSpillCapacityRefusal(); requireStore().replaceWithSpillFailure(id, candidate); requireStore().deferSupersededSpill(inheritedSpill); return; @@ -421,10 +427,11 @@ function installShutdownFallbackSpill( // over budget. `replaceWithSpillFailure` is the same fail-closed ending the budget // exhaustion path uses, so replay reports `spill_failed` and the client resends. if (requireStore().accountedResponseSpillBytes() + supersededBytes > requireStore().spillByteCap()) { - requireStore().enforceSpilledResponseBudget(); + // The footprint is already reserved above, so only the superseded generation is extra room. + if (footprint / 2 <= responseSpillPayloadCap()) requireStore().enforceSpilledResponseBudget(supersededBytes); if (requireStore().accountedResponseSpillBytes() + supersededBytes > requireStore().spillByteCap()) { if (requireStore().currentEntry(job.id) === candidate) { - noteSpillWriteFailure(null, "ECAPACITY"); + noteSpillCapacityRefusal(); requireStore().replaceWithSpillFailure(job.id, candidate); requireStore().deferSupersededSpill(job.supersededSpill); } diff --git a/structure/INDEX.md b/structure/INDEX.md index 270ebdd45c5..2f4ee43ce09 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -37,7 +37,7 @@ The wire surfaces a client actually talks to. | Doc | Scope | | --- | --- | | [`local-messaging.md`](local-messaging.md) | Command-local metadata discovery, Unix RPC queued submission, peer envelopes, receipts and owned lifecycle bounds. | -| [`transports/byte-accounting.md`](transports/byte-accounting.md) | Request-copy and stream-buffer byte accounting shared by parsing, SSE rewriting, the adapters, and the translator budget. | +| [`transports/byte-accounting.md`](transports/byte-accounting.md) | Request-copy and stream-buffer byte accounting shared by parsing, SSE rewriting, the adapters, and the translator budget, plus durable spill admission. | | [`transports/responses.md`](transports/responses.md) | The Responses HTTP/SSE endpoint, dispatch, credential and upload boundaries, and core module ownership. | | [`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md) | Mixed-wire model defaults, xAI agent-message continuation, declared-tool membership, and passthrough SSE stream shapes. | | [`transports/responses-failover.md`](transports/responses-failover.md) | Upstream reset retry, the ambiguous-resend gate, combo failover and commit boundaries, compaction routing, and output headroom. | @@ -163,7 +163,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/reasoning-effort.ts` | [`runtime.md`](runtime.md) | | `src/remote-control/` | [`remote-workspace.md`](remote-workspace.md) | | `src/remote/` | [`runtime.md`](runtime.md) | -| `src/responses/` | [`runtime.md`](runtime.md)
    [`transports/responses.md`](transports/responses.md)
    [`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
    [`providers-and-adapters.md`](providers-and-adapters.md)
    [`providers/kiro.md`](providers/kiro.md)
    [`providers/xai-grok.md`](providers/xai-grok.md)
    [`providers/chat-compat.md`](providers/chat-compat.md) | +| `src/responses/` | [`runtime.md`](runtime.md)
    [`transports/byte-accounting.md`](transports/byte-accounting.md)
    [`transports/responses.md`](transports/responses.md)
    [`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
    [`providers-and-adapters.md`](providers-and-adapters.md)
    [`providers/kiro.md`](providers/kiro.md)
    [`providers/xai-grok.md`](providers/xai-grok.md)
    [`providers/chat-compat.md`](providers/chat-compat.md) | | `src/router.ts` | [`runtime.md`](runtime.md) | | `src/routing/` | [`catalog.md`](catalog.md) | | `src/server/` | [`runtime.md`](runtime.md)
    [`catalog.md`](catalog.md)
    [`subagents.md`](subagents.md)
    [`transports/byte-accounting.md`](transports/byte-accounting.md)
    [`transports/responses.md`](transports/responses.md)
    [`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
    [`transports/responses-failover.md`](transports/responses-failover.md)
    [`transports/policy-fallback.md`](transports/policy-fallback.md)
    [`transports/streaming-health.md`](transports/streaming-health.md)
    [`transports/inventory.md`](transports/inventory.md)
    [`data-planes/images.md`](data-planes/images.md)
    [`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
    [`providers-and-adapters.md`](providers-and-adapters.md)
    [`providers/jev-decision.md`](providers/jev-decision.md)
    [`providers/xai-grok.md`](providers/xai-grok.md)
    [`adapters/registry.md`](adapters/registry.md)
    [`gui-and-management-api.md`](gui-and-management-api.md)
    [`dashboard-and-usage.md`](dashboard-and-usage.md)
    [`clients/claude-desktop.md`](clients/claude-desktop.md)
    [`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | diff --git a/structure/codex-home.md b/structure/codex-home.md index c6c4b3145e9..19d95ea24da 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -383,9 +383,9 @@ The durable response-spill directory `~/.opencodex/responses-state-spill/` is bo aggregate, not only per file. Continuation state demoted out of the in-memory cap (`MAX_STORED_RESPONSE_BYTES`) is written there, and eviction past `MAX_SPILLED_RESPONSE_BYTES` removes oldest-first through the same deletion point that serves -TTL and count eviction, so an evicted entry unlinks its file. One function owns that ceiling and -three callers drive it: mutation pruning, the lazy load that follows a restart, and the periodic -sweep. The periodic caller is not redundant — the mutation path runs only when traffic arrives, so a +TTL and count eviction, so an evicted entry unlinks its file. One function owns that ceiling; mutation +pruning, the post-restart lazy load, the periodic sweep and [spill admission](transports/byte-accounting.md#durable-spill-admission) +drive it. The periodic caller is not redundant — the mutation path runs only when traffic arrives, so a process that comes up over budget from a snapshot written under a larger ceiling would otherwise stay over it while idle. diff --git a/structure/manifest.json b/structure/manifest.json index 8c9027384ad..f79d2e122e1 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -164,12 +164,13 @@ "path": "transports/byte-accounting.md", "tier": 3, "title": "Byte Accounting", - "scope": "Request-copy and stream-buffer byte accounting shared by parsing, SSE rewriting, the adapters, and the translator budget.", + "scope": "Request-copy and stream-buffer byte accounting shared by parsing, SSE rewriting, the adapters, and the translator budget, plus durable spill admission.", "documents": [ "src/adapters/", "src/chat/", "src/lib/", - "src/server/" + "src/server/", + "src/responses/" ] }, { diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index b50afbabf98..7ad5bde0a67 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -214,6 +214,30 @@ Native steering retains fixed phase deadlines and reconciled replay output; see Native steering generation overrides, explicit public-API eligibility and the consent-gated wire probe follow the [shared control contract](streaming-health.md#steering-settings-public-api-and-diagnostic-probe); this owner does not change routing or execute diagnostic tools. +## Durable spill admission + +`enforceSpilledResponseBudget` in `src/responses/state.ts` owns the durable spill ceiling +described in [Codex home](../codex-home.md). A queued publication, and the shutdown fallback, call it +with the headroom their write needs on top of what is already accounted; called with no argument it +only brings the store back under the ceiling. With headroom it evicts oldest-first, deferred +superseded generations before installed spills, down to the ceiling minus that headroom, and only +while the room is reachable: bytes eviction cannot touch — in-flight reservations, superseded +generations a job owns, and unreclaimable paths — plus the headroom must fit under the ceiling. That +is re-read after every eviction, and once the publication cannot fit nothing more is evicted; the +caller refuses it as a `spill-failed` tombstone. An impossible publication therefore never costs an +unrelated continuation, and neither does one whose envelope exceeds the replay ceiling: admission +skips reclaim for it because it would end in `EFBIG` anyway. + +`deleteResponseSpill` in `src/responses/spill-store.ts` reports a failed unlink (anything but a +missing file) to the spill queue, which charges that path in its unreclaimable ledger until the path +is gone. Every later accounting read sees the file, so admission never counts space that was never +freed; a single no-argument pruning pass still works from its local total and may stop short, leaving +the remainder to the next pass. The orphan sweep settles such files once the unlink can succeed. `spillCapacityRefusals` and +`spillHeadroomEvictions` on the memory endpoint are the cumulative refusal and admission-eviction +counts. `tests/responses/responses-spill-admission-headroom.test.ts` covers headroom eviction, +exact fit, impossible and inherited publications, pinned reservations, the shutdown fallback, +deferred-first order, reload replay and failed unlinks. + ## Unicode pattern normalization `src/adapters/responses-tool-schema.ts` strips unsupported Unicode property patterns with an diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 6cde4bda791..3fccbb72566 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -961,6 +961,7 @@ "responses-snapshot-repair-server.test.ts": "responses", "responses-snapshot-repair.test.ts": "responses", "responses-sparse-terminal-tool-scope.test.ts": "responses", "responses-spend-ledger-wiring.test.ts": "responses", "responses-spend-contract-c.test.ts": "responses", "responses-spend-anchor.test.ts": "responses", "responses-spend-capacity-guard.test.ts": "responses", "responses-spill-acl-recovery.test.ts": "responses", + "responses-spill-admission-headroom.test.ts": "responses", "responses-spill-inspection.test.ts": "responses", "responses-spill-orphan-sweep.test.ts": "responses", "responses-spill-shutdown-clock.test.ts": "responses", "responses-state-snapshot-select.test.ts": "responses", "responses-state-snapshot-stubs.test.ts": "responses", "responses-state-store-false.test.ts": "responses", "responses-state-write-amplification.test.ts": "responses", diff --git a/tests/responses/continuation-dedup.test.ts b/tests/responses/continuation-dedup.test.ts index c5b55b10fd1..983625cd923 100644 --- a/tests/responses/continuation-dedup.test.ts +++ b/tests/responses/continuation-dedup.test.ts @@ -318,7 +318,7 @@ describe("replay overlap: contracts held elsewhere", () => { "spillWrites", "spillWriteFailures", "spillReadFailures", "spillWriteStatus", "spillWriteConsecutiveFailures", "spillLastWriteFailureCode", "spillLastWriteFailureOrigin", - "spillAclRetryReturnedTimeouts", "spillAclTimeoutMemoRefusals", + "spillAclRetryReturnedTimeouts", "spillAclTimeoutMemoRefusals", "spillCapacityRefusals", "spillHeadroomEvictions", "spillLastWriteFailureAt", "spillLastWriteSuccessAt", "replayScopeMismatchDrops", ].sort()); }); diff --git a/tests/responses/responses-spill-admission-headroom.test.ts b/tests/responses/responses-spill-admission-headroom.test.ts new file mode 100644 index 00000000000..854687314e2 --- /dev/null +++ b/tests/responses/responses-spill-admission-headroom.test.ts @@ -0,0 +1,534 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { existsSync, mkdtempSync, readdirSync, statSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + awaitResponseSpillPublicationTailForTests, + clearResponseStateForTests, + clearResponseStateMemoryForTests, + expandPreviousResponseInput, + flushResponseState, + getAccountedResponseSpillBytesForTests, + getSpilledResponseBytesForTests, + pendingResponseSpillMetricsForTests, + rememberResponseState, + responseStateMetrics, + setResponseSpillAsyncAclAttemptBudgetForTests, + setResponseSpillShutdownBudgetForTests, + setResponseStateByteCapForTests, + setSpilledResponseByteCapForTests, +} from "../../src/responses/state"; +import { + RESPONSE_SPILL_DIR_NAME, + recoverOrphanedResponseSpills, + responseSpillDirectory, + setResponseSpillNowForTests, + setResponseSpillPayloadCapForTests, + setSpillIoForTest, +} from "../../src/responses/spill-store"; +import { + resetHardenedStateForTests, + setAsyncIcaclsRunnerForTests, + setIcaclsRunnerForTests, + setNowForTests, + setPlatformForTests, + setStatForTests, +} from "../../src/lib/windows-secret-acl"; +import { + resetWindowsPrincipalForTests, + setAsyncWindowsPrincipalRunnerForTests, + setWindowsPrincipalRunnerForTests, +} from "../../src/lib/windows-user-principal"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const ICACLS_OK = { success: true, exitCode: 0, timedOut: false, stdout: "" }; +const SYNTHETIC_SID = { success: true, exitCode: 0, timedOut: false, stdout: "S-1-5-21-1-2-3-1001\nocx-test\n" }; +const TEXT = "x".repeat(8_000); + +function forceWindowsAclLane(): void { + setPlatformForTests("win32"); + setWindowsPrincipalRunnerForTests(() => SYNTHETIC_SID); + setAsyncWindowsPrincipalRunnerForTests(async () => SYNTHETIC_SID); +} + +function isSpillAclTarget(args: string[]): boolean { + return args.some(arg => arg.includes(RESPONSE_SPILL_DIR_NAME)); +} + +function rememberLarge(id: string, text = TEXT): void { + rememberResponseState( + { model: "test/model", input: text, store: false }, + { id, output: [{ type: "message", role: "assistant", content: text }], status: "completed" }, + undefined, + { force: true }, + ); +} + +function spillFileNames(home: string): string[] { + const dir = responseSpillDirectory(home); + return existsSync(dir) ? readdirSync(dir).filter(name => name.endsWith(".spill.json")) : []; +} + +function spillTempNames(home: string): string[] { + const dir = responseSpillDirectory(home); + return existsSync(dir) ? readdirSync(dir).filter(name => name.endsWith(".tmp")) : []; +} + +/** Price every physical file, including publish temps and failed-unlink debt. */ +function bytesOnDisk(home: string): number { + const dir = responseSpillDirectory(home); + if (!existsSync(dir)) return 0; + let total = 0; + for (const name of readdirSync(dir)) { + try { total += statSync(join(dir, name)).size; } catch { /* raced with an unlink */ } + } + return total; +} + +function expectReplay(id: string, text = TEXT): void { + const expanded = expandPreviousResponseInput({ previous_response_id: id, input: "next" }) as { input: unknown[] }; + expect(expanded.input).toEqual([ + { role: "user", content: text }, + { type: "message", role: "assistant", content: text }, + { role: "user", content: "next" }, + ]); +} + +describe("Response spill admission headroom (#6747)", () => { + let home: string; + let clock: number; + let clockSpy: ReturnType; + const priorHome = process.env["OPENCODEX_HOME"]; + const priorAclTimeout = process.env["OPENCODEX_ACL_TIMEOUT_MS"]; + const releases: Array<() => void> = []; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-spill-headroom-")); + process.env["OPENCODEX_HOME"] = home; + delete process.env["OPENCODEX_ACL_TIMEOUT_MS"]; + clearResponseStateMemoryForTests(); + resetHardenedStateForTests(); + resetWindowsPrincipalForTests(); + forceWindowsAclLane(); + setIcaclsRunnerForTests(() => ICACLS_OK); + setAsyncIcaclsRunnerForTests(async () => ICACLS_OK); + // Fixed, same-width timestamps make equal payloads equal envelopes. Increment only + // between stores to give retention order an unambiguous age independent of wall time. + clock = Date.now(); + clockSpy = spyOn(Date, "now").mockImplementation(() => clock); + setNowForTests(() => clock); + setResponseSpillNowForTests(() => clock); + setResponseStateByteCapForTests(1_024); + setSpilledResponseByteCapForTests(null); + setResponseSpillShutdownBudgetForTests({ totalMs: 120, fallbackReserveMs: 80 }); + }); + + afterEach(async () => { + // Never let an assertion failure strand the serialized tail or its ACL runner. + for (const release of releases.splice(0)) release(); + try { + await awaitResponseSpillPublicationTailForTests(); + } finally { + setSpillIoForTest(null); + setResponseSpillNowForTests(null); + setResponseSpillPayloadCapForTests(null); + setAsyncIcaclsRunnerForTests(null); + setIcaclsRunnerForTests(null); + setNowForTests(null); + setPlatformForTests(null); + setWindowsPrincipalRunnerForTests(null); + setAsyncWindowsPrincipalRunnerForTests(null); + resetWindowsPrincipalForTests(); + setStatForTests(null); + resetHardenedStateForTests(); + setResponseSpillShutdownBudgetForTests(null); + setResponseSpillAsyncAclAttemptBudgetForTests(null); + setResponseStateByteCapForTests(null); + setSpilledResponseByteCapForTests(null); + clearResponseStateForTests(); + clockSpy.mockRestore(); + removeTreeWithRetry(home); + if (priorHome === undefined) delete process.env["OPENCODEX_HOME"]; + else process.env["OPENCODEX_HOME"] = priorHome; + if (priorAclTimeout === undefined) delete process.env["OPENCODEX_ACL_TIMEOUT_MS"]; + else process.env["OPENCODEX_ACL_TIMEOUT_MS"] = priorAclTimeout; + } + }); + + async function seed(id: string, text = TEXT): Promise<{ bytes: number; file: string }> { + const beforeBytes = getSpilledResponseBytesForTests(); + const beforeNames = new Set(spillFileNames(home)); + clock += 1; + rememberLarge(id, text); + await awaitResponseSpillPublicationTailForTests(); + const bytes = getSpilledResponseBytesForTests() - beforeBytes; + const file = spillFileNames(home).find(name => !beforeNames.has(name)); + expect(bytes).toBeGreaterThan(0); + expect(file).toBeDefined(); + expect(statSync(join(responseSpillDirectory(home), file!)).size).toBe(bytes); + return { bytes, file: file! }; + } + + function exists(file: string): boolean { + return existsSync(join(responseSpillDirectory(home), file)); + } + + function gateAcl(): { started: Promise; release: () => void } { + let release!: () => void; + let entered!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { entered = resolve; }); + releases.push(release); + setAsyncIcaclsRunnerForTests(async args => { + // Snapshot hardens share this runner on Windows; they must remain ungated. + if (!isSpillAclTarget(args) || !args.some(arg => arg.endsWith(".tmp"))) return ICACLS_OK; + entered(); + await gate; + return ICACLS_OK; + }); + return { started, release }; + } + + function failUnlink(file: string, code: "EBUSY" | "EPERM" = "EBUSY"): string[] { + const attempts: string[] = []; + setSpillIoForTest({ unlink(path) { + attempts.push(path); + if (path === join(responseSpillDirectory(home), file)) { + throw Object.assign(new Error("fixture: spill still locked"), { code }); + } + unlinkSync(path); + } }); + return attempts; + } + + test("case 1: under-cap admission evicts oldest to fit the next publication", async () => { + const oldest = await seed("resp_a"); + const middle = await seed("resp_b"); + const newestSeed = await seed("resp_c"); + expect(middle.bytes).toBe(oldest.bytes); + expect(newestSeed.bytes).toBe(oldest.bytes); + const cap = Math.floor(oldest.bytes * 4.5); + setSpilledResponseByteCapForTests(cap); + expect(getAccountedResponseSpillBytesForTests()).toBeLessThan(cap); + clock += 1; + rememberLarge("resp_d"); + // Admission happens before any async file creation, so this also bounds the + // peak reservation rather than just the much smaller final installed payload. + expect(getAccountedResponseSpillBytesForTests()).toBeLessThanOrEqual(cap); + await awaitResponseSpillPublicationTailForTests(); + expect(exists(oldest.file)).toBe(false); + expect(exists(middle.file)).toBe(true); + expect(exists(newestSeed.file)).toBe(true); + expect(spillFileNames(home)).toHaveLength(3); + expect(responseStateMetrics()).toMatchObject({ tombstoneCount: 0, spillCapacityRefusals: 0 }); + expect(responseStateMetrics().spillHeadroomEvictions).toBeGreaterThanOrEqual(1); + expect(bytesOnDisk(home)).toBeLessThanOrEqual(cap); + expectReplay("resp_d"); + }); + + test("case 12: an envelope over the replay ceiling never evicts for headroom", async () => { + // Measure the incoming envelope with a same-length id, then start from an empty store. + const probe = await seed("resp_prb", "y".repeat(16_000)); + clearResponseStateForTests(); + const a = await seed("resp_a"); + const b = await seed("resp_b"); + const c = await seed("resp_c"); + expect(probe.bytes).toBeGreaterThan(a.bytes); + setResponseSpillPayloadCapForTests(probe.bytes - 1); + // Without the replay ceiling this publication would fit after evicting the oldest seed. + const cap = 2 * a.bytes + 2 * probe.bytes + Math.floor(a.bytes / 2); + setSpilledResponseByteCapForTests(cap); + expect(getAccountedResponseSpillBytesForTests() + 2 * probe.bytes).toBeGreaterThan(cap); + clock += 1; + rememberLarge("resp_big", "y".repeat(16_000)); + await awaitResponseSpillPublicationTailForTests(); + for (const seeded of [a, b, c]) expect(exists(seeded.file)).toBe(true); + expect(responseStateMetrics()).toMatchObject({ spillHeadroomEvictions: 0, tombstoneCount: 1 }); + expect(bytesOnDisk(home)).toBeLessThanOrEqual(cap); + expectReplay("resp_a"); + }); + + test("case 2: exact fit preserves every seed without a headroom eviction", async () => { + const a = await seed("resp_a"); + const b = await seed("resp_b"); + const cap = getAccountedResponseSpillBytesForTests() + 2 * b.bytes; + setSpilledResponseByteCapForTests(cap); + clock += 1; + rememberLarge("resp_c"); + expect(getAccountedResponseSpillBytesForTests()).toBe(cap); + await awaitResponseSpillPublicationTailForTests(); + expect(exists(a.file)).toBe(true); + expect(exists(b.file)).toBe(true); + expect(responseStateMetrics()).toMatchObject({ spillHeadroomEvictions: 0, spillCapacityRefusals: 0, tombstoneCount: 0 }); + expect(bytesOnDisk(home)).toBeLessThanOrEqual(cap); + expectReplay("resp_c"); + }); + + test("case 3: an impossible two-envelope footprint refuses without evicting a seed", async () => { + const a = await seed("resp_a"); + const cap = Math.floor(a.bytes * 1.5); + setSpilledResponseByteCapForTests(cap); + rememberLarge("resp_b"); + await awaitResponseSpillPublicationTailForTests(); + expect(exists(a.file)).toBe(true); + expect(spillFileNames(home)).toEqual([a.file]); + expect(responseStateMetrics()).toMatchObject({ spillHeadroomEvictions: 0, spillCapacityRefusals: 1, tombstoneCount: 1 }); + expect(bytesOnDisk(home)).toBeLessThanOrEqual(cap); + expectReplay("resp_a"); + }); + + test("case 4: same-id admission prices its inherited generation and evicts enough", async () => { + const unrelated = await seed("resp_a"); + const inherited = await seed("resp_b"); + const cap = 3 * inherited.bytes; + setSpilledResponseByteCapForTests(cap); + clock += 1; + rememberLarge("resp_b", "y".repeat(8_000)); + expect(exists(unrelated.file)).toBe(false); + expect(getAccountedResponseSpillBytesForTests()).toBe(cap); + await awaitResponseSpillPublicationTailForTests(); + // The inherited file is deferred, still priced until a stable snapshot commits. + expect(exists(inherited.file)).toBe(true); + expect(getAccountedResponseSpillBytesForTests()).toBe(2 * inherited.bytes); + expect(responseStateMetrics()).toMatchObject({ spillHeadroomEvictions: 1, spillCapacityRefusals: 0 }); + expect(bytesOnDisk(home)).toBeLessThanOrEqual(cap); + expectReplay("resp_b", "y".repeat(8_000)); + }); + + test("case 4 refusal: inherited plus footprint cannot fit, unrelated seed survives", async () => { + const unrelated = await seed("resp_a"); + const inherited = await seed("resp_b"); + const cap = Math.floor(2.5 * inherited.bytes); + setSpilledResponseByteCapForTests(cap); + rememberLarge("resp_b", "y".repeat(8_000)); + await awaitResponseSpillPublicationTailForTests(); + expect(exists(unrelated.file)).toBe(true); + expect(exists(inherited.file)).toBe(true); + expect(responseStateMetrics()).toMatchObject({ spillHeadroomEvictions: 0, spillCapacityRefusals: 1, tombstoneCount: 1 }); + expect(bytesOnDisk(home)).toBeLessThanOrEqual(cap); + expectReplay("resp_a"); + }); + + test("case 5: an in-flight reservation is pinned during the second admission", async () => { + const seedSpill = await seed("resp_a"); + const cap = Math.floor(3.5 * seedSpill.bytes); + setSpilledResponseByteCapForTests(cap); + const gate = gateAcl(); + clock += 1; + rememberLarge("resp_b"); + await gate.started; + const pinned = getAccountedResponseSpillBytesForTests() - getSpilledResponseBytesForTests(); + expect(pinned).toBe(2 * seedSpill.bytes); + const temp = spillTempNames(home)[0]!; + expect(temp).toBeDefined(); + rememberLarge("resp_c"); + expect(exists(seedSpill.file)).toBe(true); + expect(exists(temp)).toBe(true); + expect(pendingResponseSpillMetricsForTests().count).toBe(1); + expect(getAccountedResponseSpillBytesForTests()).toBe(seedSpill.bytes + pinned); + expect(responseStateMetrics()).toMatchObject({ spillHeadroomEvictions: 0, spillCapacityRefusals: 1 }); + expect(bytesOnDisk(home)).toBeLessThanOrEqual(cap); + gate.release(); + await awaitResponseSpillPublicationTailForTests(); + expectReplay("resp_b"); + expectReplay("resp_a"); + }); + + test("case 6: shutdown fallback reclaims feasible superseded headroom", async () => { + const unrelated = await seed("resp_a"); + const inherited = await seed("resp_b"); + const gate = gateAcl(); + clock += 1; + rememberLarge("resp_b", "y".repeat(8_000)); + await gate.started; + expect(getAccountedResponseSpillBytesForTests()).toBe(4 * inherited.bytes); + const cap = Math.floor(3.5 * inherited.bytes); + // Lower after admission to exercise fallback's own re-admission, not the queue's. + setSpilledResponseByteCapForTests(cap); + let syncSpillCalls = 0; + setIcaclsRunnerForTests(args => { + if (isSpillAclTarget(args)) syncSpillCalls += 1; + return ICACLS_OK; + }); + await flushResponseState(); + expect(syncSpillCalls).toBeGreaterThan(0); + expect(exists(unrelated.file)).toBe(false); + expect(responseStateMetrics()).toMatchObject({ spillHeadroomEvictions: 1, spillCapacityRefusals: 0, tombstoneCount: 0 }); + expect(pendingResponseSpillMetricsForTests()).toEqual({ count: 0, bytes: 0 }); + expect(spillTempNames(home)).toHaveLength(0); + expect(getAccountedResponseSpillBytesForTests()).toBe(inherited.bytes); + expect(bytesOnDisk(home)).toBeLessThanOrEqual(cap); + expectReplay("resp_b", "y".repeat(8_000)); + gate.release(); + await awaitResponseSpillPublicationTailForTests(); + expectReplay("resp_b", "y".repeat(8_000)); + }); + + test("case 7: impossible shutdown superseded footprint preserves unrelated spills", async () => { + const unrelated = await seed("resp_a"); + const inherited = await seed("resp_b"); + const gate = gateAcl(); + clock += 1; + rememberLarge("resp_b", "y".repeat(8_000)); + await gate.started; + const cap = Math.floor(2.5 * inherited.bytes); + setSpilledResponseByteCapForTests(cap); + await expect(flushResponseState()).rejects.toThrow(/shutdown fallback incomplete/); + expect(exists(unrelated.file)).toBe(true); + expect(responseStateMetrics()).toMatchObject({ spillHeadroomEvictions: 0, spillCapacityRefusals: 1, tombstoneCount: 1 }); + expect(pendingResponseSpillMetricsForTests()).toEqual({ count: 0, bytes: 0 }); + expect(getAccountedResponseSpillBytesForTests()).toBe(unrelated.bytes); + expect(spillTempNames(home)).toHaveLength(0); + expect(bytesOnDisk(home)).toBeLessThanOrEqual(cap); + expectReplay("resp_a"); + gate.release(); + }); + + test("case 8: deferred superseded generations are reclaimed before older installed spills", async () => { + const unrelated = await seed("resp_a"); + const deferred = await seed("resp_b"); + await seed("resp_b", "y".repeat(8_000)); + expect(exists(deferred.file)).toBe(true); + const cap = getSpilledResponseBytesForTests() + deferred.bytes; + setSpilledResponseByteCapForTests(cap); + clock += 1; + rememberLarge("resp_c"); + expect(exists(deferred.file)).toBe(false); + expect(exists(unrelated.file)).toBe(true); + expect(getAccountedResponseSpillBytesForTests()).toBe(cap); + await awaitResponseSpillPublicationTailForTests(); + expect(responseStateMetrics()).toMatchObject({ spillHeadroomEvictions: 1, spillCapacityRefusals: 0, tombstoneCount: 0 }); + expect(bytesOnDisk(home)).toBeLessThanOrEqual(cap); + expectReplay("resp_a"); + expectReplay("resp_b", "y".repeat(8_000)); + expectReplay("resp_c"); + }); + + test("case 9: newest admitted continuation survives snapshot flush and disk reload", async () => { + const a = await seed("resp_a"); + await seed("resp_b"); + await seed("resp_c"); + const cap = Math.floor(4.5 * a.bytes); + setSpilledResponseByteCapForTests(cap); + clock += 1; + rememberLarge("resp_d"); + await awaitResponseSpillPublicationTailForTests(); + await flushResponseState(); + clearResponseStateMemoryForTests(); + expectReplay("resp_d"); + expect(exists(a.file)).toBe(false); + expect(responseStateMetrics()).toMatchObject({ spillStubCount: 3, tombstoneCount: 0 }); + expect(getAccountedResponseSpillBytesForTests()).toBeLessThanOrEqual(cap); + expect(bytesOnDisk(home)).toBeLessThanOrEqual(cap); + }); + + test("case 10: no-superseded fallback with cleanup debt refuses without collateral eviction", async () => { + const unrelated = await seed("resp_a"); + const gate = gateAcl(); + clock += 1; + rememberLarge("resp_b"); + await gate.started; + const temp = spillTempNames(home)[0]!; + expect(temp).toBeDefined(); + failUnlink(temp, "EPERM"); + const cap = Math.floor(2.5 * unrelated.bytes); + setSpilledResponseByteCapForTests(cap); + await expect(flushResponseState()).rejects.toThrow(/shutdown fallback incomplete/); + expect(exists(temp)).toBe(true); + expect(exists(unrelated.file)).toBe(true); + expect(responseStateMetrics()).toMatchObject({ spillHeadroomEvictions: 0, spillCapacityRefusals: 1, tombstoneCount: 1 }); + expect(pendingResponseSpillMetricsForTests()).toEqual({ count: 0, bytes: 0 }); + expect(getSpilledResponseBytesForTests()).toBe(unrelated.bytes); + expect(getAccountedResponseSpillBytesForTests()).toBe(2 * unrelated.bytes); + expect(bytesOnDisk(home)).toBeLessThanOrEqual(cap); + expectReplay("resp_a"); + setSpillIoForTest(null); + gate.release(); + await awaitResponseSpillPublicationTailForTests(); + expect(getAccountedResponseSpillBytesForTests()).toBe(unrelated.bytes); + }); + + test("case 11a: failed large oldest unlink makes admission impossible and preserves the small continuation", async () => { + const largeText = "a".repeat(24_000); + const smallText = "b".repeat(3_000); + const a = await seed("resp_a", largeText); + const b = await seed("resp_b", smallText); + const cap = a.bytes + Math.floor(1.5 * b.bytes); + expect(cap).toBeGreaterThanOrEqual(a.bytes + b.bytes); + expect(cap).toBeLessThan(a.bytes + 2 * b.bytes); + setSpilledResponseByteCapForTests(cap); + const attempts = failUnlink(a.file); + rememberLarge("resp_c", smallText); + await awaitResponseSpillPublicationTailForTests(); + expect(attempts).toEqual([join(responseSpillDirectory(home), a.file)]); + expect(exists(a.file)).toBe(true); + expect(exists(b.file)).toBe(true); + expect(responseStateMetrics()).toMatchObject({ spillHeadroomEvictions: 1, spillCapacityRefusals: 1, tombstoneCount: 1 }); + expect(getSpilledResponseBytesForTests()).toBe(b.bytes); + expect(getAccountedResponseSpillBytesForTests()).toBe(a.bytes + b.bytes); + expect(bytesOnDisk(home)).toBeLessThanOrEqual(cap); + expectReplay("resp_b", smallText); + }); + + test("case 11b: failed small victim stays charged, next victim frees room, orphan recovery clears the charge", async () => { + const smallText = "a".repeat(3_000); + const largeText = "b".repeat(24_000); + const a = await seed("resp_a", smallText); + const b = await seed("resp_b", largeText); + const cap = a.bytes + 2 * b.bytes; + setSpilledResponseByteCapForTests(cap); + const attempts = failUnlink(a.file, "EPERM"); + clock += 1; + rememberLarge("resp_c", largeText); + expect(attempts.slice(0, 2)).toEqual([ + join(responseSpillDirectory(home), a.file), join(responseSpillDirectory(home), b.file), + ]); + expect(getAccountedResponseSpillBytesForTests()).toBe(cap); + await awaitResponseSpillPublicationTailForTests(); + expect(exists(a.file)).toBe(true); + expect(exists(b.file)).toBe(false); + expect(responseStateMetrics()).toMatchObject({ spillHeadroomEvictions: 2, spillCapacityRefusals: 0, tombstoneCount: 0 }); + expect(getSpilledResponseBytesForTests()).toBe(b.bytes); + expect(getAccountedResponseSpillBytesForTests()).toBe(a.bytes + b.bytes); + expect(bytesOnDisk(home)).toBeLessThanOrEqual(cap); + expectReplay("resp_c", largeText); + + // Run the production orphan predicate and unlink, retaining all files except the + // logically evicted victim. Advance Date.now past its real mtime and default grace. + setSpillIoForTest(null); + const before = getAccountedResponseSpillBytesForTests(); + clock = Math.max(clock, statSync(join(responseSpillDirectory(home), a.file)).mtimeMs) + 20 * 60_000; + const references = new Set(spillFileNames(home).filter(name => name !== a.file)); + const recovered = recoverOrphanedResponseSpills(references, responseSpillDirectory(home)); + expect(recovered).toMatchObject({ removed: 1, failed: 0, bytesRemoved: a.bytes }); + expect(exists(a.file)).toBe(false); + expect(getAccountedResponseSpillBytesForTests()).toBe(before - a.bytes); + expectReplay("resp_c", largeText); + rememberLarge("resp_d", largeText); + await awaitResponseSpillPublicationTailForTests(); + expectReplay("resp_d", largeText); + expect(responseStateMetrics().spillCapacityRefusals).toBe(0); + expect(bytesOnDisk(home)).toBeLessThanOrEqual(cap); + }); + + test("case 11c: failed deferred unlink makes admission impossible before an installed continuation is evicted", async () => { + const deferred = await seed("resp_a", "a".repeat(24_000)); + const smallText = "b".repeat(3_000); + const installed = await seed("resp_a", smallText); + const cap = deferred.bytes + Math.floor(1.5 * installed.bytes); + expect(cap).toBeGreaterThanOrEqual(deferred.bytes + installed.bytes); + expect(cap).toBeLessThan(deferred.bytes + 2 * installed.bytes); + setSpilledResponseByteCapForTests(cap); + const attempts = failUnlink(deferred.file); + rememberLarge("resp_b", smallText); + await awaitResponseSpillPublicationTailForTests(); + expect(attempts).toEqual([join(responseSpillDirectory(home), deferred.file)]); + expect(exists(deferred.file)).toBe(true); + expect(exists(installed.file)).toBe(true); + expect(responseStateMetrics()).toMatchObject({ spillHeadroomEvictions: 1, spillCapacityRefusals: 1, tombstoneCount: 1 }); + expect(getSpilledResponseBytesForTests()).toBe(installed.bytes); + expect(getAccountedResponseSpillBytesForTests()).toBe(deferred.bytes + installed.bytes); + expect(bytesOnDisk(home)).toBeLessThanOrEqual(cap); + expectReplay("resp_a", smallText); + }); +}); diff --git a/tests/responses/responses-state.test.ts b/tests/responses/responses-state.test.ts index 12ed243b5bb..8bb611a44d7 100644 --- a/tests/responses/responses-state.test.ts +++ b/tests/responses/responses-state.test.ts @@ -3427,7 +3427,7 @@ describe("Responses previous_response_id state", () => { spillLastWriteFailureCode: null, spillLastWriteFailureOrigin: null, spillAclRetryReturnedTimeouts: 0, - spillAclTimeoutMemoRefusals: 0, + spillAclTimeoutMemoRefusals: 0, spillCapacityRefusals: 0, spillHeadroomEvictions: 0, spillLastWriteFailureAt: null, spillLastWriteSuccessAt: null, spillReadFailures: 0, @@ -3589,7 +3589,7 @@ describe("Responses previous_response_id state", () => { spillLastWriteFailureCode: null, spillLastWriteFailureOrigin: null, spillAclRetryReturnedTimeouts: 0, - spillAclTimeoutMemoRefusals: 0, + spillAclTimeoutMemoRefusals: 0, spillCapacityRefusals: 0, spillHeadroomEvictions: 0, spillLastWriteFailureAt: null, spillLastWriteSuccessAt: null, spillReadFailures: 0, diff --git a/tests/server/memory-watchdog.test.ts b/tests/server/memory-watchdog.test.ts index 4da8db5c3e5..31b54826dc4 100644 --- a/tests/server/memory-watchdog.test.ts +++ b/tests/server/memory-watchdog.test.ts @@ -199,6 +199,7 @@ describe("GET /api/system/memory", () => { spillLastWriteFailureOrigin: string | null; spillAclRetryReturnedTimeouts: number; spillAclTimeoutMemoRefusals: number; + spillCapacityRefusals: number; spillHeadroomEvictions: number; spillLastWriteFailureAt: number | null; spillLastWriteSuccessAt: number | null; replayScopeMismatchDrops: number; @@ -228,8 +229,12 @@ describe("GET /api/system/memory", () => { // responseState is a scalar-only continuation-store attribution block: numbers plus fixed // enum/null fields (no paths, messages, tokens, or account identifiers). // The exact count is pinned on purpose: a new field must be reviewed for privacy safety - // before it reaches this surface. 20 after #3522 added failure origins and counters. - expect(Object.keys(body.responseState)).toHaveLength(20); + // before it reaches this surface. 20 after #3522 added failure origins and counters; 22 after + // #6747 added the disk-cap refusal and admission-eviction counts (cumulative integers only). + expect(Object.keys(body.responseState)).toHaveLength(22); + for (const field of ["spillCapacityRefusals", "spillHeadroomEvictions"] as const) { + expect(Number.isSafeInteger(body.responseState[field]) && body.responseState[field] >= 0).toBe(true); + } const { spillWriteStatus, spillLastWriteFailureCode, From 3618baac41abb7e7fe5cdabd03a7a091fa6ac23a Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 9 Oct 2026 23:44:05 +0900 Subject: [PATCH 43/59] feat(cli): hand package launchers off to the Desktop ocx (#6818) * docs(devlog): wp2 execution plan * feat(cli): hand package launchers off to the Desktop ocx and report PATH selection * fix(cli): refuse unsafe Desktop command records and skip handoff on Windows * test(cli): read the macOS pending-cleanup record through host-safe stats on Linux and Windows CI * docs(devlog): wp4 outcome for the Desktop-owned ocx command * docs(structure): move the version-probe handoff opt-out into command selection to keep the service doc within its cap --- bin/ocx.mjs | 146 ++-- .../021_wp2_execution.md | 64 ++ .../049_outcome.md | 32 + .../docs/getting-started/installation.md | 6 + docs-site/src/content/docs/reference/cli.md | 2 + scripts/test-layout/layout.json | 1 + src/cli/cli-path-diagnostics.ts | 132 ++++ src/cli/doctor.ts | 7 + src/cli/status.ts | 6 +- src/lib/desktop-cli-handoff.d.mts | 20 + src/lib/desktop-cli-handoff.mjs | 92 +++ src/lib/desktop-cli-record.d.mts | 29 + src/lib/desktop-cli-record.mjs | 102 +++ src/service/managing-cli.ts | 1 + structure/cli-management.md | 6 + structure/runtime.md | 2 +- tests/cli/cli-path-diagnostics.test.ts | 214 ++++++ .../cli/ocx-launcher-desktop-handoff.test.ts | 626 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 19 files changed, 1432 insertions(+), 57 deletions(-) create mode 100644 devlog/_plan/261009_desktop_owned_path_cli/021_wp2_execution.md create mode 100644 devlog/_plan/261009_desktop_owned_path_cli/049_outcome.md create mode 100644 src/cli/cli-path-diagnostics.ts create mode 100644 src/lib/desktop-cli-handoff.d.mts create mode 100644 src/lib/desktop-cli-handoff.mjs create mode 100644 src/lib/desktop-cli-record.d.mts create mode 100644 src/lib/desktop-cli-record.mjs create mode 100644 tests/cli/cli-path-diagnostics.test.ts create mode 100644 tests/cli/ocx-launcher-desktop-handoff.test.ts diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 7aed87005e3..6ead0be8613 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -10,6 +10,8 @@ */ import { spawn, spawnSync } from "node:child_process"; import { createSupervisionLatch, inspectDesktopSupervision } from "../src/service/desktop-supervision.mjs"; +import { planDesktopCliHandoff, runDesktopCliHandoff } from "../src/lib/desktop-cli-handoff.mjs"; +import { desktopCliRecordPath } from "../src/lib/desktop-cli-record.mjs"; import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../src/update/stop-contract.mjs"; import { probeProxyLiveness } from "../src/update/proxy-liveness-probe.mjs"; import { decidePostStopUpdate } from "../src/update/stop-decision.mjs"; @@ -1018,6 +1020,36 @@ if (codexCliUpdateInspection && typeof process.versions.bun === "string") { process.exit(1); } +const { launchProof, launchContext, inheritedEnv } = createNodeLaunchContext(); +const desktopPlan = planDesktopCliHandoff({ selfPaths: [fileURLToPath(import.meta.url), process.execPath] }); +if (desktopPlan.kind === "error") { + if (desktopPlan.issue.startsWith("record-")) { + console.error(`opencodex: OpenCodex Desktop's terminal-command record at ${desktopCliRecordPath()} could not be used (${desktopPlan.issue}). Open OpenCodex Desktop to repair the terminal command, or run with OCX_NO_DESKTOP_HANDOFF=1.`); + } else { + console.error(`opencodex: Desktop CLI handoff refused (${desktopPlan.issue}). Repair the terminal command in OpenCodex Desktop, or run with OCX_NO_DESKTOP_HANDOFF=1.`); + } + process.exit(1); +} +if (desktopPlan.kind === "handoff") { + const result = await runDesktopCliHandoff(desktopPlan, { + argv: process.argv.slice(2), env: inheritedEnv, proof: launchProof, context: launchContext, + }); + if (result.kind === "error") { + console.error(`opencodex: Desktop CLI handoff failed (${result.issue}). Repair the terminal command in OpenCodex Desktop, or run with OCX_NO_DESKTOP_HANDOFF=1.`); + process.exit(127); + } + if (result.kind === "exit") { + if (result.signal) { + process.kill(process.pid, result.signal); + } else { + process.exit(result.code); + } + // Signal delivery is asynchronous; never resume package repair or Bun lookup. + await new Promise(() => {}); + } + // Only confirmed target ENOENT reaches the ordinary package path below. +} + if (process.argv[2] === "update" && installMethod === "mise") { if (installOwnership.owner) { console.error( @@ -1072,64 +1104,68 @@ const bun = bunRuntime.path; // billing and prevents it from redirecting the subscriber's OAuth bearer. // Disabling Bun's dotenv wholesale with --no-env-file is NOT an option: config // interpolation and provider settings legitimately read the project environment. -const preBunAnthropicSlots = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"] - .filter(name => typeof process.env[name] === "string" && process.env[name] !== ""); -// A configured CODEX_CLI_PATH may legitimately be cwd-relative (`./tools/codex`), which the -// ordinary runtime resolver accepts. Inspection only trusts absolute local paths, so capture -// the absolute form here, in the launcher, while the original cwd is still authoritative; -// resolving it later would silently reinterpret it against a different working directory. -// -// A bare command with no separator (`codex`) is NOT a relative path: the runtime resolver -// deliberately hands those to executable lookup along PATH. Rewriting it to `/codex` -// would make the inspector treat it as an explicit path and stop searching PATH entirely. -const configuredCodexCliPath = typeof process.env.CODEX_CLI_PATH === "string" && process.env.CODEX_CLI_PATH !== "" - ? process.env.CODEX_CLI_PATH - : null; -const preBunCodexCliPath = configuredCodexCliPath !== null - && (configuredCodexCliPath.includes("/") || configuredCodexCliPath.includes("\\") || /^[A-Za-z]:/.test(configuredCodexCliPath)) - ? resolve(configuredCodexCliPath) - : configuredCodexCliPath; -const preBunPath = typeof process.env.PATH === "string" ? process.env.PATH : null; -const preBunPathExt = typeof process.env.PATHEXT === "string" ? process.env.PATHEXT : null; -const preBunCodexCliManagerRoots = Object.fromEntries( - CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS.flatMap(name => { - const value = process.env[name]; - return typeof value === "string" && value !== "" ? [[name, value]] : []; - }), -); -const launchProof = randomBytes(32).toString("base64url"); -const launchContext = JSON.stringify({ - version: 1, - proof: launchProof, - anthropicEnvSlots: preBunAnthropicSlots, - codexCliInspectionEnv: codexCliUpdateInspection ? { - codexCliPath: preBunCodexCliPath, - path: preBunPath, - pathExt: preBunPathExt, - managerRoots: preBunCodexCliManagerRoots, - configDir: configDir(), - } : null, -}); -// The inspection snapshot above already carries PATH, PATHEXT, and the manager-root slots as -// proof-bound values, and `inspectCodexCliInstall` reads them from that snapshot rather than -// from the live environment. Inheriting them again would spend the 32,767-character Windows -// environment block twice, so a large-but-valid shell environment could stop the Bun child -// from spawning and fail the command before it reports anything. Drop the duplicates for the -// one-shot inspection launch only; every other launch inherits the environment unchanged. -// Windows environment names are case-insensitive, but this spread produces an ordinary -// case-sensitive object, and a real Windows environment commonly spells the variable `Path`. -// Deleting only the canonical upper-case spelling would silently leave that copy behind and -// reintroduce the duplication this block exists to prevent, so match on the lowercase form. -const inheritedEnv = { ...process.env }; -if (codexCliUpdateInspection) { - const snapshotted = new Set( - ["PATH", "PATHEXT", ...CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS].map(name => name.toLowerCase()), +function createNodeLaunchContext() { + const preBunAnthropicSlots = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"] + .filter(name => typeof process.env[name] === "string" && process.env[name] !== ""); + // A configured CODEX_CLI_PATH may legitimately be cwd-relative (`./tools/codex`), which the + // ordinary runtime resolver accepts. Inspection only trusts absolute local paths, so capture + // the absolute form here, in the launcher, while the original cwd is still authoritative; + // resolving it later would silently reinterpret it against a different working directory. + // + // A bare command with no separator (`codex`) is NOT a relative path: the runtime resolver + // deliberately hands those to executable lookup along PATH. Rewriting it to `/codex` + // would make the inspector treat it as an explicit path and stop searching PATH entirely. + const configuredCodexCliPath = typeof process.env.CODEX_CLI_PATH === "string" && process.env.CODEX_CLI_PATH !== "" + ? process.env.CODEX_CLI_PATH + : null; + const preBunCodexCliPath = configuredCodexCliPath !== null + && (configuredCodexCliPath.includes("/") || configuredCodexCliPath.includes("\\") || /^[A-Za-z]:/.test(configuredCodexCliPath)) + ? resolve(configuredCodexCliPath) + : configuredCodexCliPath; + const preBunPath = typeof process.env.PATH === "string" ? process.env.PATH : null; + const preBunPathExt = typeof process.env.PATHEXT === "string" ? process.env.PATHEXT : null; + const preBunCodexCliManagerRoots = Object.fromEntries( + CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS.flatMap(name => { + const value = process.env[name]; + return typeof value === "string" && value !== "" ? [[name, value]] : []; + }), ); - for (const name of Object.keys(inheritedEnv)) { - if (snapshotted.has(name.toLowerCase())) delete inheritedEnv[name]; + const launchProof = randomBytes(32).toString("base64url"); + const launchContext = JSON.stringify({ + version: 1, + proof: launchProof, + anthropicEnvSlots: preBunAnthropicSlots, + codexCliInspectionEnv: codexCliUpdateInspection ? { + codexCliPath: preBunCodexCliPath, + path: preBunPath, + pathExt: preBunPathExt, + managerRoots: preBunCodexCliManagerRoots, + configDir: configDir(), + } : null, + }); + // The inspection snapshot above already carries PATH, PATHEXT, and the manager-root slots as + // proof-bound values, and `inspectCodexCliInstall` reads them from that snapshot rather than + // from the live environment. Inheriting them again would spend the 32,767-character Windows + // environment block twice, so a large-but-valid shell environment could stop the Bun child + // from spawning and fail the command before it reports anything. Drop the duplicates for the + // one-shot inspection launch only; every other launch inherits the environment unchanged. + // Windows environment names are case-insensitive, but this spread produces an ordinary + // case-sensitive object, and a real Windows environment commonly spells the variable `Path`. + // Deleting only the canonical upper-case spelling would silently leave that copy behind and + // reintroduce the duplication this block exists to prevent, so match on the lowercase form. + const inheritedEnv = { ...process.env }; + if (codexCliUpdateInspection) { + const snapshotted = new Set( + ["PATH", "PATHEXT", ...CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS].map(name => name.toLowerCase()), + ); + for (const name of Object.keys(inheritedEnv)) { + if (snapshotted.has(name.toLowerCase())) delete inheritedEnv[name]; + } } + return { launchProof, launchContext, inheritedEnv }; } -const child = spawn(bun, [cliPath, `${NODE_LAUNCH_PROOF_PREFIX}${launchProof}`, ...process.argv.slice(2)], { + +const child = spawn(bun, [cliPath, `${NODE_LAUNCH_PROOF_PREFIX}${launchProof}`, ...process.argv.slice(2).filter(arg => !arg.startsWith(NODE_LAUNCH_PROOF_PREFIX))], { stdio: "inherit", // A headless Windows parent (Task Scheduler, dashboard restart, shortcut) has no // console to inherit. Without this flag Windows allocates a visible console for diff --git a/devlog/_plan/261009_desktop_owned_path_cli/021_wp2_execution.md b/devlog/_plan/261009_desktop_owned_path_cli/021_wp2_execution.md new file mode 100644 index 00000000000..125b806da13 --- /dev/null +++ b/devlog/_plan/261009_desktop_owned_path_cli/021_wp2_execution.md @@ -0,0 +1,64 @@ +# 021 — wp2 execution plan (re-verified) + +wp2 implements 020 as amended by 000 (AM-1, AM-2) and 003 (AM-2 corrected, AM-5, AM-8). It runs on branch `codex/desktop-cli-handoff`, stacked on PR 1 (#6816, `codex/desktop-owned-path-cli` at bcbd0363be). This document and 003 win over 020 where they differ. + +## Stale check + +| 020 anchor | Current state on `origin/dev` 71c4ae3c8e + PR 1 | Action | +|---|---|---| +| `bin/ocx.mjs` update-help, `codexCliUpdateInspection`, mise/npm update branches, `resolveBun`, launch proof creation | Unchanged (1120 lines). #6807 (head 178f19a6f8) and #6809 (head f22a8b1ba5) are open and rebased on `dev`; both edit this file | Insert the handoff at the semantic point (after the inspection block, before the mise/npm update branch); rebase over #6807/#6809 when they land, anchoring on `const codexCliUpdateInspection` and the following mise `if` | +| `src/cli/status.ts` | #6802 merged (+70 lines: `selectStatusStartupHealth` returns `{ startup, startupSource }`, live supervision) | Add `cliCommand` as a separate additive field computed at `collectStatus` entry; do not touch `startupSource`/`serviceSummary` | +| `src/cli/doctor.ts` | #6802 changed restart-safety source and advice | New "ocx command selection" section after Paths, before response-state sections | +| `structure/runtime.md` | 600 lines (cap); #6802 changed 1 line | Edit the existing entrypoint row with zero line growth | +| `structure/ops/service-and-sidecars.md` | 541 → 541 (#6802 changed 4 lines) | +2 lines as planned | +| `src/service/managing-cli.ts`, `src/cli/launcher-context.ts` | Unchanged | Apply as planned | +| Record schema (wp1 actual) | Rust `Record`/`Bundle` use `camelCase` with `deny_unknown_fields`; fixtures in `tests/fixtures/desktop-cli-record/` (10 files, including `first-pending.json`) | TS reader consumes only `version`, `enabled`, `bundle.platform`, `bundle.kind`, `bundle.cliExecutable`, `pending`; tolerates other fields (the reader is not the writer) | + +## Amendments applied in wp2 + +- **AM-5:** the reader validates `bundle.kind` against the platform pair (`darwin`/`macos-app`, `win32`/`windows-install`, `linux`/`linux-deb`), refuses `.`/`..` segments, NUL, newline and paths over 4096 characters, and is tested against every file in `tests/fixtures/desktop-cli-record/` with the same expectations as the Rust test. `enabled:false` wins over `pending` (no target); enabled + pending is `record-pending`. Diagnostics report `cleanup-pending` for a disabled record with a pending journal. +- **AM-1:** reader bound stays 64 KiB, matching the writer cap. +- **AM-2 (corrected):** the launcher error for `record-invalid`, `record-too-large`, `record-unreadable` and `record-pending` reads: "opencodex: OpenCodex Desktop's terminal-command record at could not be used (). Open OpenCodex Desktop to repair the terminal command, or run with OCX_NO_DESKTOP_HANDOFF=1." No advice to delete the record. +- **AM-8:** after `spawn` fails with ENOENT, `lstat` the target; only a second ENOENT resumes the package path, otherwise print a fixed error and exit 127. Test: a target script whose interpreter is missing. + +## B worker split + +| Worker | Files | +|---|---| +| W4 handoff | NEW `src/lib/desktop-cli-record.mjs` + `.d.mts`, NEW `src/lib/desktop-cli-handoff.mjs` + `.d.mts`, MODIFY `bin/ocx.mjs` (hook + proof factory reuse), MODIFY `src/service/managing-cli.ts` (`OCX_NO_DESKTOP_HANDOFF=1` on internal probes), NEW `tests/cli/ocx-launcher-desktop-handoff.test.ts` | +| W5 diagnostics and docs | NEW `src/cli/cli-path-diagnostics.ts`, MODIFY `src/cli/status.ts`, `src/cli/doctor.ts`, NEW `tests/cli/cli-path-diagnostics.test.ts`, MODIFY `structure/runtime.md` (zero growth), `structure/cli-management.md`, `structure/ops/service-and-sidecars.md`, `docs-site/src/content/docs/getting-started/installation.md`, `docs-site/src/content/docs/reference/cli.md` | +| main | registrations of both new tests in `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`; integration | + +W5 imports the record reader from W4's `src/lib/desktop-cli-record.mjs` using the export names in 020 (`readDesktopCliRecord`, `desktopCliRecordPath`, `DESKTOP_CLI_RECORD_MAX_BYTES`, type `DesktopCliRecordRead`). + +## C checks + +| Command | Reads the change? | +|---|---| +| `bun test tests/cli/ocx-launcher-desktop-handoff.test.ts tests/cli/cli-path-diagnostics.test.ts` | Yes, both new files and their imports | +| `bun test tests/cli/ocx-launcher-runtime.test.ts tests/cli/ocx-launcher-source.test.ts tests/service/managing-cli.test.ts tests/cli/cli-status-json.test.ts tests/codex-integration/doctor.test.ts` | Yes, existing launcher/probe/status/doctor contracts around the hooks | +| `bun test tests/test-layout.test.ts tests/test-layout-tooling.test.ts` | Yes, registrations | +| `node --check bin/ocx.mjs`, `bun run typecheck`, `structure:check`, `privacy:scan`, file-size ratchet | Yes after staging | +| Manual: the dev-checkout `bin/ocx.mjs` run with an isolated `HOME` holding a valid record pointing at a fake executable prints the fake's output; with `OCX_NO_DESKTOP_HANDOFF=1` it does not | Yes, real launcher process | + +The A11 compiled check (AM-13) runs in wp3 against PR 1's shim, together with the security review of both PRs. + + +## Reflection (same architect, 01a11f35): ALIGNED + +Folded: + +- **Reader result type, fixed before B.** `DesktopCliRecordRead` is `{ state: "missing", path } | { state: "disabled", path, cleanupPending: boolean } | { state: "ready", path, record: { platform, kind, cliExecutable } } | { state: "invalid" | "unreadable", path, issue }`. W4 writes it in `.d.mts`; W5 consumes `cleanupPending` for the `cleanup-pending` diagnostic. +- **Fixture expectations map validity to consumption state**, not pass/fail: `valid-*` on its own host → `ready` (other hosts → `invalid`/`record-invalid`); `disabled-tombstone` → `disabled`, `cleanupPending:false`; `disabled-with-pending` → `disabled`, `cleanupPending:true`; `pending-enabled` and `first-pending` → `invalid`/`record-pending` (valid for Rust recovery, refused for handoff); `appimage-kind`, `relative-target`, `dotdot-target` → `invalid`/`record-invalid`. +- **Path rules match Rust exactly:** length counted in Unicode code points (`[...s].length`), Windows absolute means drive-rooted (`C:\\` or `C:/`) or UNC (`\\\\server\\share`) only; root-relative `\\ocx.exe` and drive-relative `C:ocx.exe` are refused. Extra boundary cases in the TS test: 4096 vs 4097 code points with an astral character, root-relative, drive-relative, UNC. +- **Manual smoke checks both sides:** with the record, `ocx --version` prints the fake target's marker; with `OCX_NO_DESKTOP_HANDOFF=1`, `ocx --version` prints the package version from `package.json` and exits 0. + + +## Audit (independent, 01a11f6b): NEAR-PASS + +Folded: + +- Every consumer uses the flattened reader result: `record.cliExecutable`, `record.platform`, `record.kind` (020's `record.bundle.cliExecutable` examples are superseded) in the handoff, diagnostics and test helpers; the real-launcher smoke is required in C because typecheck does not read `.mjs` bodies. +- Windows path rule restated to match Rust exactly: absolute means a drive root (`C:\\` / `C:/`) or a `\\\\` or `//` prefix; the server/share parts are not checked. TS boundary tests include `//server/share/ocx.exe` (accepted), `\\\\server` (accepted, same as Rust), `\\ocx.exe` (refused), `C:ocx.exe` (refused). +- After #6807/#6809 land, PR 2's rebase rereads `structure/runtime.md` (600-line cap), `structure/ops/service-and-sidecars.md`, `structure/cli-management.md` and the installation page, and reruns the launcher/managing-cli tests. + diff --git a/devlog/_plan/261009_desktop_owned_path_cli/049_outcome.md b/devlog/_plan/261009_desktop_owned_path_cli/049_outcome.md new file mode 100644 index 00000000000..8924ac77330 --- /dev/null +++ b/devlog/_plan/261009_desktop_owned_path_cli/049_outcome.md @@ -0,0 +1,32 @@ +# 049 — wp4 outcome: both PRs on `dev` + +## Landed + +| PR | Squash on `dev` | Exact-head evidence | +|---|---|---| +| #6816 Desktop owns the `ocx` terminal command on PATH | b89bbfb083 | head 2103c18b4f (rebased on #6820 bb36029ef9). Cross-platform CI 37922442745 success on attempt 2: `macos 1/2` failed on the inherited codex-prompt-lock contender deadline (fixed on `dev` by #6808) and `windows 8/9` on an AUTH-11 15 s runner timeout; only those two jobs were rerun. Service lifecycle 37922442829 success on all three hosts. Combined tree `dev`+#6808+#6816: typecheck, structure:check, ratchet and layout guards pass. | +| #6818 Package launchers hand off to the Desktop `ocx`; status/doctor report PATH selection | this PR | Retargeted to `dev` after #6816 and rebased with `--onto`; final evidence is recorded on the PR. | + +Admin squash merges under the maintainer instruction to integrate through admin review on `dev`. Code and security reviews passed for both PRs (031, 032 and the PR 2 rounds recorded in 040). + +## Late fix in wp4 + +The CI-only failure of `disabled pending cleanup warns without selecting a target` (Linux `test 4/4`, `windows 9/9`) came from the test, not the reader. It reads a macOS record on every host, so Linux ran the macOS-only `ls -lde` ACL probe and Windows reported NTFS modes the POSIX owner/mode check rejects; both returned `record-unsafe`. The test now passes the same host-safe stats seam the handoff suite already uses and hands the read to the diagnostics. Verified on macOS and on Windows `mini` (13 pass, 2 skip). + +A `dev` regression from #6811 (`ocx stop` failing with `realpath ENOENT` when `~/.codex` is absent) held PR 1's Service lifecycle jobs red until lane L2 landed #6820. + +## Criteria + +| Criterion | Evidence | +|---|---| +| c1 app-only install gets `ocx` on PATH (macOS, Windows; deb unchanged) | #6816 Rust installer, shim and PATH writers; cargo tests on macOS (246) and Windows (191) | +| c2 Desktop `ocx` first in a new login shell; Windows user PATH order | rc-block ordering tests, `real_shell` ignored test (zsh, bash), HKCU Path order tests | +| c3 launcher handoff with recursion guard and escape hatch | #6818 `ocx-launcher-desktop-handoff.test.ts`, real-launcher smoke | +| c4 foreign-file refusal, idempotence, opt-out cleanup | Rust record/journal tests and TS record-reader safety tests | +| c5 local gates and exact-head CI | typecheck, structure:check, focused bun tests, cargo test/clippy, privacy:scan; CI above | +| c6 independent sol code and security review PASS | 032 and 040 | +| c7 admin squash merge with docs | b89bbfb083 and this PR, docs-site installation and CLI reference | + +## Not verified here + +Packaged-app click-through of the CLI page, fish on a real login, the Linux deb path, and Windows terminal pickup after a real registry write in a fresh session are left to release QA. No release, version bump, or promotion was done. diff --git a/docs-site/src/content/docs/getting-started/installation.md b/docs-site/src/content/docs/getting-started/installation.md index 17cba0a6b3b..b79a8b6badc 100644 --- a/docs-site/src/content/docs/getting-started/installation.md +++ b/docs-site/src/content/docs/getting-started/installation.md @@ -7,6 +7,12 @@ opencodex installs two equivalent command names, `ocx` and `opencodex`. Both lau local HTTP server (built on Bun). Model requests go to the provider selected by routing; optional vision and web-search sidecars can also use your ChatGPT login when a routed model needs them. +## Desktop terminal command + +A stable OpenCodex Desktop install configures `ocx` for new terminals when the app starts. Open a new terminal and run `ocx status` or `ocx doctor` to inspect the first executable on PATH. On macOS and Linux deb installs, a managed block at the end of zsh, bash and fish startup files puts `~/.opencodex-desktop/bin` first on PATH and uses the Desktop-owned shim. Windows puts the bundled `ocx.exe` install directory first in the user `Path`. Existing terminals, aliases, shell caches and later PATH changes can still select another command; a conflicting Windows system PATH entry can also win. Use the app’s Terminal command settings to repair, disable or remove this configuration. AppImage is excluded. + +On macOS and Linux, a current npm/pnpm package launcher that still runs delegates eligible commands to the Desktop CLI when its record is enabled and passes ownership, permission and file-safety checks. Windows has no package-launcher handoff: new terminals select the Desktop `ocx.exe` through user `Path` order. A `record-unsafe` error means the record or its directory failed a safety check. Open OpenCodex Desktop to repair the terminal command, or use `OCX_NO_DESKTOP_HANDOFF=1` for a package invocation on macOS/Linux. Package update/removal and internal inspection keep their existing path. `OCX_NO_DESKTOP_HANDOFF=1` suppresses that package handoff for one invocation; it does not change a direct Desktop shim or PATH configuration. + ## Prerequisites | Requirement | Why | diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index 6fde5049a97..2f13ff35df5 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -254,6 +254,8 @@ latter need a live target. Before live operations, run `ocx ready --json` and version mismatch. `unknown` does not confirm matching builds. Offline help, local configuration and local Lab inspection do not require startup. +`ocx status --json` includes `cliCommand`: configured Desktop intent, the expected executable, observed PATH candidates, `pathFirst`, `desktopFirstOnPath`, issue codes, and `shellResolution: "unobserved"`. The human status report prints one command-selection line. `ocx doctor` adds an “ocx command selection” section: invalid, unsafe (`record-unsafe`) or enabled-pending records and missing/unusable Desktop targets fail the check, while disabled cleanup-pending records, PATH ordering conflicts and incomplete scans warn. `OCX_NO_DESKTOP_HANDOFF=1` suppresses package-launcher handoff for one invocation; it leaves the Desktop shim and PATH configuration in place. These read-only observations do not execute candidates, resolve parent-shell aliases/functions, or identify the proxy’s runtime owner. On Windows, package-launcher handoff is disabled; user `Path` order selects the Desktop `ocx.exe`. Status and doctor still read the record and report the first PATH candidate. A possible cmd current-directory candidate is reported separately from PATH order. + Output flags are per command. `doctor` rejects `--json` with exit 2; [`v2` (family reference)](/reference/cli/agents/) supports `--json` for local and `--live` targets. Even for JSON-capable management commands, API failures diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ca73cb89802..6c804c91a0a 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1311,6 +1311,7 @@ "oauth-tos-warning.test.ts": "gui", "oauth-upsert-preserves-api-key.test.ts": "oauth", "ocx-launcher-runtime.test.ts": "cli", + "ocx-launcher-desktop-handoff.test.ts": "cli", "cli-path-diagnostics.test.ts": "cli", "ocx-launcher-source.test.ts": "cli", "ocx-run.test.ts": "cli", "ollama-native-parser.test.ts": "providers/ollama", "ollama-native-reasoning-wire.test.ts": "providers/ollama", "ollama-native-structured-output.test.ts": "providers/ollama", "ollama-native-tool-continuations.test.ts": "providers/ollama", diff --git a/src/cli/cli-path-diagnostics.ts b/src/cli/cli-path-diagnostics.ts new file mode 100644 index 00000000000..e5d5968b6c9 --- /dev/null +++ b/src/cli/cli-path-diagnostics.ts @@ -0,0 +1,132 @@ +import { accessSync, constants, realpathSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { posix, win32 } from "node:path"; +import { readDesktopCliRecord, type DesktopCliRecordRead, desktopCliRecordPath, DESKTOP_CLI_RECORD_MAX_BYTES } from "../lib/desktop-cli-record.mjs"; +import { redactUserPath } from "../lib/redact"; + +export type CliCommandIssue = Extract["issue"] + | "cleanup-pending" + | "desktop-target-missing" | "desktop-target-unusable" + | "expected-command-missing" | "expected-command-unusable" + | "path-missing" | "path-first-not-desktop" | "path-unreadable" + | "path-scan-truncated" | "windows-current-directory-shadow"; +export type CliPathCandidate = { + path: string; realpath: string; pathIndex: number; extension: string; +}; +export type CliPathDiagnostics = { + configured: boolean; + recordPath: string; + packageHandoff: "enabled" | "disabled-on-windows"; + recordState: DesktopCliRecordRead["state"]; + expectedExecutable: string | null; + handoffTarget: string | null; + candidates: CliPathCandidate[]; + pathFirst: CliPathCandidate | null; + desktopFirstOnPath: boolean | null; + currentDirectoryCandidate: CliPathCandidate | null; + shellResolution: "unobserved"; + issues: CliCommandIssue[]; +}; +export type CliPathObservation = + | { kind: "usable"; realpath: string } + | { kind: "missing" | "unusable" | "unreadable" }; +export type CliPathDiagnosticOptions = { + platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; cwd?: string; home?: string; + recordRead?: DesktopCliRecordRead; + observe?: (path: string) => CliPathObservation; +}; + +export function collectCliPathDiagnostics(options: CliPathDiagnosticOptions = {}): CliPathDiagnostics { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const api = platform === "win32" ? win32 : posix; + const cwd = options.cwd ?? process.cwd(); + const home = options.home ?? (platform === "win32" ? env.USERPROFILE || homedir() : homedir()); + const read = options.recordRead ?? readDesktopCliRecord({ platform, env, home, recordPath: desktopCliRecordPath({ platform, env, home }) }); + const configured = read.state === "ready"; + const target = configured && read.state === "ready" ? read.record.cliExecutable : null; + const expected = configured ? (platform === "win32" ? target : api.join(home, ".opencodex-desktop", "bin", "ocx")) : null; + const issues: CliCommandIssue[] = []; + const add = (issue: CliCommandIssue): void => { if (!issues.includes(issue)) issues.push(issue); }; + if (read.state === "invalid" || read.state === "unreadable") add(read.issue); + if (read.state === "disabled" && read.cleanupPending) add("cleanup-pending"); + const observe = options.observe ?? ((path: string): CliPathObservation => { + try { + if (!statSync(path).isFile()) return { kind: "unusable" }; + accessSync(path, platform === "win32" ? constants.F_OK : constants.X_OK); + return { kind: "usable", realpath: realpathSync(path) }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + return { kind: code === "ENOENT" || code === "ENOTDIR" ? "missing" : code === "EACCES" ? "unusable" : "unreadable" }; + } + }); + const key = (path: string): string => platform === "win32" ? path.toLowerCase() : path; + const same = (a: string, b: string): boolean => key(a) === key(b); + const envValue = (name: string): string | undefined => platform === "win32" + ? env[Object.keys(env).find(key => key.toLowerCase() === name.toLowerCase()) ?? name] : env[name]; + const path = envValue("PATH"); + const rawExtensions = platform === "win32" + ? (envValue("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(ext => /^\.[A-Za-z0-9]+$/.test(ext)) : []; + const uniqueExtensions = [...new Set(rawExtensions.map(ext => ext.toLowerCase()))]; + const extensions = platform === "win32" ? [...uniqueExtensions.slice(0, 32), ""] : [""]; + if (uniqueExtensions.length > 32) add("path-scan-truncated"); + const directories = path === undefined ? [] : path.split(platform === "win32" ? ";" : ":"); + if (directories.length > 256) add("path-scan-truncated"); + const candidates: CliPathCandidate[] = []; + const scan = (directory: string, pathIndex: number): CliPathCandidate[] => { + const result: CliPathCandidate[] = []; + const unquoted = platform === "win32" && directory.startsWith('"') && directory.endsWith('"') ? directory.slice(1, -1) : directory; + for (const extension of extensions) { + const candidate = api.resolve(cwd, unquoted || ".", `ocx${extension}`); + const found = observe(candidate); + if (found.kind === "unreadable") add("path-unreadable"); + if (found.kind === "usable") result.push({ path: candidate, realpath: found.realpath, pathIndex, extension }); + } + return result; + }; + for (const [index, directory] of directories.slice(0, 256).entries()) candidates.push(...scan(directory, index)); + const pathFirst = candidates[0] ?? null; + const currentDirectoryCandidate = platform === "win32" ? scan(cwd, -1)[0] ?? null : null; + let expectedRealpath: string | null = null; + if (target) { + const found = observe(target); + if (found.kind === "missing") add("desktop-target-missing"); + else if (found.kind !== "usable") add("desktop-target-unusable"); + } + if (expected) { + const found = observe(expected); + if (found.kind === "missing") add("expected-command-missing"); + else if (found.kind !== "usable") add("expected-command-unusable"); + else expectedRealpath = found.realpath; + if (!pathFirst) add("path-missing"); + else if (!same(pathFirst.path, expected) && (!expectedRealpath || !same(pathFirst.realpath, expectedRealpath))) add("path-first-not-desktop"); + if (currentDirectoryCandidate && !same(currentDirectoryCandidate.path, expected) + && (!expectedRealpath || !same(currentDirectoryCandidate.realpath, expectedRealpath))) add("windows-current-directory-shadow"); + } + return { + configured, recordPath: read.path, packageHandoff: platform === "win32" ? "disabled-on-windows" : "enabled", + recordState: read.state, expectedExecutable: expected, handoffTarget: platform === "win32" ? null : target, + candidates, pathFirst, + desktopFirstOnPath: configured && !issues.includes("path-unreadable") && !issues.includes("path-scan-truncated") + ? Boolean(pathFirst && expected && expectedRealpath + && (same(pathFirst.path, expected) || same(pathFirst.realpath, expectedRealpath))) : null, + currentDirectoryCandidate, shellResolution: "unobserved", issues, + }; +} + +export function formatCliCommandLine(value: CliPathDiagnostics): string { + const first = value.pathFirst ? redactUserPath(value.pathFirst.path).replace(/[\x00-\x1f\x7f]/g, "?") : "none observed"; + return `ocx command: PATH first=${first}; Desktop=${!value.configured ? "not configured" : value.desktopFirstOnPath === null ? "unobserved" : value.desktopFirstOnPath ? "first" : "not first"}; ${value.packageHandoff === "disabled-on-windows" ? "package handoff=disabled on Windows (user Path selects Desktop); " : ""}shell=unobserved${value.issues.length ? `; issues=${value.issues.join(",")}` : ""}`; +} + +export function formatCliStatusHealthLabel(health: string, value: CliPathDiagnostics, local: boolean): string { + return `${health}${local ? " (local)" : ""}\n ${formatCliCommandLine(value)}`; +} + +export function cliCommandDoctorChecks(value: CliPathDiagnostics): { level: "OK" | "WARN" | "FAIL"; message: string }[] { + const hard = value.issues.some(issue => issue.startsWith("record-") || issue.startsWith("desktop-target-") || issue.startsWith("expected-command-")); + const recovery = value.issues.some(issue => issue.startsWith("record-")) + ? ` The terminal-command record at ${redactUserPath(value.recordPath).replace(/[\x00-\x1f\x7f]/g, "?")} could not be used. Open OpenCodex Desktop to repair the terminal command.${value.packageHandoff === "disabled-on-windows" ? "" : " Or run with OCX_NO_DESKTOP_HANDOFF=1."}${value.issues.includes("record-too-large") ? ` The record limit is ${DESKTOP_CLI_RECORD_MAX_BYTES} bytes.` : ""}` + : value.issues.includes("cleanup-pending") ? " Open OpenCodex Desktop to finish terminal-command cleanup." : ""; + return [{ level: hard ? "FAIL" : value.issues.length ? "WARN" : "OK", message: formatCliCommandLine(value) + recovery }]; +} diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index df152627648..53843c6c5ac 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -7,6 +7,7 @@ * it never sets proxy env, relocates state dirs, mutates quota, or changes * networking. See devlog/_plan/260630_wsl-account-autoswitch/30_*. */ +import { cliCommandDoctorChecks, collectCliPathDiagnostics } from "./cli-path-diagnostics"; import { accessSync, constants, existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; @@ -1325,6 +1326,12 @@ export async function runDoctor(args: string[] = []): Promise { console.log(` ${row.exists ? "ok " : "-- "} ${row.label}: ${row.path}${flags ? ` (${flags})` : ""}`); } + console.log("\nocx command selection"); + for (const check of cliCommandDoctorChecks(collectCliPathDiagnostics())) { + console.log(` ${check.level === "OK" ? "ok " : "!! "} ${check.message}`); + if (check.level === "FAIL") recordDoctorFailure(); + } + // Runs without the proxy on purpose: the worst accumulation happens when the proxy will // not start, which is exactly when the in-process periodic reclaim never ticks. const reclaimTemps = args.includes(RECLAIM_RESPONSE_TEMPS_FLAG); diff --git a/src/cli/status.ts b/src/cli/status.ts index efac2e65688..a840277c262 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -1,3 +1,4 @@ +import { collectCliPathDiagnostics, formatCliStatusHealthLabel, type CliPathDiagnostics } from "./cli-path-diagnostics"; import type { CodexMainAccountPolicyHealth } from "../oauth/health"; import { durableBunRuntime } from "../lib/bun-runtime"; import { existsSync, readFileSync } from "node:fs"; @@ -216,6 +217,7 @@ export type CliStatusJson = { * ignores the key is unaffected, and `proxyVersion` is null when nothing is live. */ versionSkew: VersionSkew; + cliCommand: CliPathDiagnostics; startupSource: "live" | "local" | "local-supervision-override"; }; @@ -727,6 +729,7 @@ export function missingCodexCatalogLines(missingCatalogPath: string | null): str /** `mainAccountPolicy`: only `--json` asks; the human report reads the same accounts once via OAuth health. */ export async function collectStatus(options: { mainAccountPolicy?: boolean } = {}): Promise { + const cliCommand = collectCliPathDiagnostics(); const configDiagnostics = readConfigDiagnostics(); const config = configDiagnostics.config; const claudeDesktop = { @@ -916,7 +919,7 @@ export async function collectStatus(options: { mainAccountPolicy?: boolean } = { return { proxyLabel, - healthLabel: health.label, + healthLabel: formatCliStatusHealthLabel(health.label, cliCommand, remoteHub.connected), json: { ...(mainAccountHardLock ? { mainAccountHardLock } : {}), schemaVersion: 1, @@ -979,6 +982,7 @@ export async function collectStatus(options: { mainAccountPolicy?: boolean } = { // fact about this install, not about the Codex runtime, and filing it there would // print it under the wrong heading (#2701). versionSkew, + cliCommand, startupSource, }, }; diff --git a/src/lib/desktop-cli-handoff.d.mts b/src/lib/desktop-cli-handoff.d.mts new file mode 100644 index 00000000000..764095f5ae1 --- /dev/null +++ b/src/lib/desktop-cli-handoff.d.mts @@ -0,0 +1,20 @@ +import type { spawn } from "node:child_process"; +import type { accessSync, lstatSync, statSync } from "node:fs"; +import type { DesktopCliRecordRead, DesktopCliRecordIssue } from "./desktop-cli-record.mjs"; +export type DesktopHandoffIssue = DesktopCliRecordIssue | "target-invalid" | "target-self" | "target-unusable" | "spawn-failed"; +export type DesktopCliHandoffPlan = + | { kind: "continue"; reason: "excluded" | "missing" | "disabled" | "target-missing" | "windows-path-only" } + | { kind: "error"; issue: DesktopHandoffIssue } + | { kind: "handoff"; target: string }; +export type DesktopCliHandoffExit = + | { kind: "continue" } + | { kind: "error"; issue: "spawn-failed" } + | { kind: "exit"; code: number; signal: NodeJS.Signals | null }; +export function desktopHandoffExcluded(argv: string[], env?: NodeJS.ProcessEnv): boolean; +export function planDesktopCliHandoff(input?: { + argv?: string[]; env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; + recordRead?: DesktopCliRecordRead; selfPaths?: (string | undefined)[]; +}, deps?: { stat?: typeof statSync; access?: typeof accessSync; realpath?: (path: string) => string }): DesktopCliHandoffPlan; +export function runDesktopCliHandoff(plan: Extract, launch: { + argv: string[]; env: NodeJS.ProcessEnv; proof: string; context: string; +}, deps?: { spawn?: typeof spawn; lstat?: typeof lstatSync; parent?: Pick; platform?: NodeJS.Platform }): Promise; diff --git a/src/lib/desktop-cli-handoff.mjs b/src/lib/desktop-cli-handoff.mjs new file mode 100644 index 00000000000..ece1a407ce6 --- /dev/null +++ b/src/lib/desktop-cli-handoff.mjs @@ -0,0 +1,92 @@ +import { spawn } from "node:child_process"; +import { accessSync, constants, lstatSync, realpathSync, statSync } from "node:fs"; +import { posix, win32 } from "node:path"; +import { readDesktopCliRecord } from "./desktop-cli-record.mjs"; +import { isCodexCliUpdateInspectionArgv } from "../update/codex-cli-update-launch-policy.mjs"; + +const PROOF_PREFIX = "--ocx-internal-launch-proof="; + +export function desktopHandoffExcluded(argv, env = process.env) { + const args = argv.filter(arg => !arg.startsWith(PROOF_PREFIX)); + return env.OCX_NO_DESKTOP_HANDOFF === "1" + || ["update", "uninstall", "remove"].includes(args[0]) + || (args[0] ?? "").startsWith("__") + || isCodexCliUpdateInspectionArgv(["node", "ocx.mjs", ...args]); +} + +export function planDesktopCliHandoff(input = {}, deps = {}) { + const platform = input.platform ?? process.platform; + if (platform === "win32") return { kind: "continue", reason: "windows-path-only" }; + const argv = input.argv ?? process.argv.slice(2); + const env = input.env ?? process.env; + if (desktopHandoffExcluded(argv, env)) return { kind: "continue", reason: "excluded" }; + const read = input.recordRead ?? readDesktopCliRecord({ env, platform: input.platform }); + if (read.state === "missing" || read.state === "disabled") { + return { kind: "continue", reason: read.state }; + } + if (read.state !== "ready") return { kind: "error", issue: read.issue }; + const api = platform === "win32" ? win32 : posix; + const target = read.record.cliExecutable; + if (!api.isAbsolute(target)) return { kind: "error", issue: "target-invalid" }; + const stat = deps.stat ?? statSync; + const access = deps.access ?? accessSync; + const realpath = deps.realpath ?? realpathSync; + try { + if (!stat(target).isFile()) return { kind: "error", issue: "target-invalid" }; + access(target, platform === "win32" ? constants.F_OK : constants.X_OK); + const physical = realpath(target); + const key = path => platform === "win32" ? path.toLowerCase() : path; + for (const self of input.selfPaths ?? [process.argv[1], process.execPath]) { + if (!self) continue; + let own; + try { own = realpath(self); } + catch (error) { if (error?.code === "ENOENT") continue; throw error; } + if (key(own) === key(physical)) return { kind: "error", issue: "target-self" }; + } + return { kind: "handoff", target: physical }; + } catch (error) { + return error?.code === "ENOENT" + ? { kind: "continue", reason: "target-missing" } + : { kind: "error", issue: "target-unusable" }; + } +} + +export function runDesktopCliHandoff(plan, launch, deps = {}) { + const spawnChild = deps.spawn ?? spawn; + const parent = deps.parent ?? process; + const platform = deps.platform ?? process.platform; + const lstat = deps.lstat ?? lstatSync; + const spawnFailure = error => { + if (error?.code === "ENOENT") { + try { lstat(plan.target); } + catch (checkError) { if (checkError?.code === "ENOENT") return { kind: "continue" }; } + } + return { kind: "error", issue: "spawn-failed" }; + }; + const argv = launch.argv.filter(arg => !arg.startsWith(PROOF_PREFIX)); + const env = { ...launch.env, OCX_NO_DESKTOP_HANDOFF: "1", OCX_NODE_LAUNCH_CONTEXT: launch.context }; + delete env.OCX_BUN_RUNTIME_SOURCE; + delete env.OCX_BUN_RUNTIME_PATH; + return new Promise(resolveExit => { + let child; + try { + child = spawnChild(plan.target, [`${PROOF_PREFIX}${launch.proof}`, ...argv], { + stdio: "inherit", shell: false, windowsHide: true, env, + }); + } catch (error) { + resolveExit(spawnFailure(error)); + return; + } + const forwarded = platform === "win32" ? ["SIGINT", "SIGTERM"] : ["SIGINT", "SIGTERM", "SIGHUP"]; + const handlers = forwarded.map(signal => { + const handler = () => { try { child.kill(signal); } catch { /* child already exited */ } }; + parent.on(signal, handler); + return [signal, handler]; + }); + const clear = () => { for (const [signal, handler] of handlers) parent.removeListener(signal, handler); }; + let done = false; + const finish = result => { if (done) return; done = true; clear(); resolveExit(result); }; + child.once("error", error => finish(spawnFailure(error))); + child.once("exit", (code, signal) => finish({ kind: "exit", code: code ?? 1, signal })); + }); +} diff --git a/src/lib/desktop-cli-record.d.mts b/src/lib/desktop-cli-record.d.mts new file mode 100644 index 00000000000..c80910a14f9 --- /dev/null +++ b/src/lib/desktop-cli-record.d.mts @@ -0,0 +1,29 @@ +import type { Stats } from "node:fs"; + +export const DESKTOP_CLI_RECORD_MAX_BYTES: number; +export type DesktopCliRecord = Readonly<{ + platform: "darwin" | "win32" | "linux"; + kind: "macos-app" | "windows-install" | "linux-deb"; + cliExecutable: string; +}>; +export type DesktopCliRecordIssue = "record-invalid" | "record-too-large" | "record-pending" | "record-unreadable" | "record-unsafe"; +export type DesktopCliRecordRead = + | { state: "missing"; path: string } + | { state: "disabled"; path: string; cleanupPending: boolean } + | { state: "ready"; path: string; record: DesktopCliRecord } + | { state: "invalid" | "unreadable"; path: string; issue: DesktopCliRecordIssue }; +export type DesktopCliRecordOptions = { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + home?: string; + recordPath?: string; +}; +export function desktopCliRecordPath(options?: DesktopCliRecordOptions): string; +export type DesktopCliRecordDeps = { + lstat?: (path: string) => Stats; + fstat?: (fd: number) => Stats; + open?: (path: string, flags: number) => number; + euid?: number; + checkAcl?: (directory: string, path: string) => boolean; +}; +export function readDesktopCliRecord(options?: DesktopCliRecordOptions, deps?: DesktopCliRecordDeps): DesktopCliRecordRead; diff --git a/src/lib/desktop-cli-record.mjs b/src/lib/desktop-cli-record.mjs new file mode 100644 index 00000000000..020d44b86a8 --- /dev/null +++ b/src/lib/desktop-cli-record.mjs @@ -0,0 +1,102 @@ +import { spawnSync } from "node:child_process"; +import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, posix, resolve, win32 } from "node:path"; + +export const DESKTOP_CLI_RECORD_MAX_BYTES = 64 * 1024; + +export function desktopCliRecordPath(options = {}) { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const home = options.home ?? (platform === "win32" ? env.USERPROFILE || homedir() : homedir()); + return (platform === "win32" ? win32 : posix).join(home, ".opencodex-desktop", "cli.json"); +} + +// Keep these lexical rules aligned with Rust's absolute_for in cli_command_record.rs. +function validTargetPath(path, platform) { + if ([...path].length > 4096 || /[\x00\r\n]/.test(path)) return false; + const absolute = platform === "win32" + ? /^[A-Za-z]:[/\\]/.test(path) || path.startsWith("\\\\") || path.startsWith("//") + : path.startsWith("/"); + return absolute && !path.split(platform === "win32" ? /[/\\]/ : /\//) + .some(part => part === "." || part === ".."); +} + +function noMacAcl(directory, path) { + const result = spawnSync("/bin/ls", ["-lde", directory, path], { + encoding: "utf8", timeout: 1000, shell: false, windowsHide: true, + env: { ...process.env, LC_ALL: "C" }, + }); + if (result.error || result.status !== 0 || result.stderr) return false; + // Each path has one metadata line; ACL entries add numbered lines and a "+" marker. + const lines = result.stdout.trimEnd().split("\n"); + return lines.length === 2 && lines.every(line => /^[d-][rwxStTs-]{9}[.@]?\s/.test(line)); +} + +export function readDesktopCliRecord(options = {}, deps = {}) { + const platform = options.platform ?? process.platform; + const path = options.recordPath ?? desktopCliRecordPath(options); + const directory = dirname(resolve(path)); + const lstat = deps.lstat ?? lstatSync; + const fstat = deps.fstat ?? fstatSync; + const open = deps.open ?? openSync; + const unsafe = () => ({ state: "invalid", path, issue: "record-unsafe" }); + const posixHost = platform !== "win32"; + let fd; + let checkingSafety = true; + let absenceAllowed = true; + try { + const dirInfo = lstat(directory); + if (dirInfo.isSymbolicLink() || !dirInfo.isDirectory()) return unsafe(); + const uid = posixHost ? deps.euid ?? process.geteuid?.() : undefined; + if (posixHost && (uid === undefined || dirInfo.uid !== uid || (dirInfo.mode & 0o077) !== 0)) return unsafe(); + const fileInfo = lstat(path); + absenceAllowed = false; + if (fileInfo.isSymbolicLink() || !fileInfo.isFile() + || (posixHost && (fileInfo.uid !== uid || (fileInfo.mode & 0o077) !== 0))) return unsafe(); + const flags = constants.O_RDONLY | (posixHost ? constants.O_NOFOLLOW | constants.O_NONBLOCK : 0); + fd = open(path, flags); + const info = fstat(fd); + if (!info.isFile() || ["dev", "ino", "uid", "mode"].some(key => info[key] !== fileInfo[key])) return unsafe(); + if (platform === "darwin" && !(deps.checkAcl ?? noMacAcl)(directory, resolve(path))) return unsafe(); + checkingSafety = false; + if (info.size > DESKTOP_CLI_RECORD_MAX_BYTES) { + return { state: "invalid", path, issue: "record-too-large" }; + } + const bytes = Buffer.alloc(DESKTOP_CLI_RECORD_MAX_BYTES + 1); + let used = 0; + while (used < bytes.length) { + const n = readSync(fd, bytes, used, bytes.length - used, null); + if (n === 0) break; + used += n; + } + if (used > DESKTOP_CLI_RECORD_MAX_BYTES) { + return { state: "invalid", path, issue: "record-too-large" }; + } + let value; + try { value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, used))); } + catch { return { state: "invalid", path, issue: "record-invalid" }; } + if (!value || typeof value !== "object" || Array.isArray(value) + || value.version !== 1 || typeof value.enabled !== "boolean") { + return { state: "invalid", path, issue: "record-invalid" }; + } + if (!value.enabled) return { state: "disabled", path, cleanupPending: value.pending != null }; + if (value.pending != null) return { state: "invalid", path, issue: "record-pending" }; + const bundle = value.bundle; + const kind = { darwin: "macos-app", win32: "windows-install", linux: "linux-deb" }[platform]; + if (!bundle || typeof bundle !== "object" || Array.isArray(bundle) + || !kind || bundle.platform !== platform || bundle.kind !== kind + || typeof bundle.cliExecutable !== "string" || !validTargetPath(bundle.cliExecutable, platform)) { + return { state: "invalid", path, issue: "record-invalid" }; + } + return { state: "ready", path, record: { + platform, kind, cliExecutable: bundle.cliExecutable, + } }; + } catch (error) { + return error?.code === "ENOENT" && absenceAllowed + ? { state: "missing", path } + : checkingSafety ? unsafe() : { state: "unreadable", path, issue: "record-unreadable" }; + } finally { + if (fd !== undefined) closeSync(fd); + } +} diff --git a/src/service/managing-cli.ts b/src/service/managing-cli.ts index 26f622e066c..d5efce657c7 100644 --- a/src/service/managing-cli.ts +++ b/src/service/managing-cli.ts @@ -81,6 +81,7 @@ function probeVersionOnce( windowsShim ? ["/c", shimCommand] : [...args, "--version"], { timeout: VERSION_PROBE_TIMEOUT_MS, encoding: "utf8", stdio: "pipe", + env: { ...deps.env, OCX_NO_DESKTOP_HANDOFF: "1" }, windowsHide: true, ...(windowsShim ? { windowsVerbatimArguments: true } : {}), }, ) as SpawnSyncReturns; diff --git a/structure/cli-management.md b/structure/cli-management.md index bac73785776..0aef70d2f90 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -113,3 +113,9 @@ Storage policy flags in `src/cli/storage.ts` preserve omitted enable/settings an `src/cli/account-api.ts` forwards explicit quota/refresh intent to the existing per-key owner; ordinary key listing omits both. Opt-in requests reject redirects. `src/cli/account-key-quota.ts` rebuilds the public ProviderQuota projection, including custom windows and credits, with finite-field validation and no credential or private publication fields. Missing mode evidence is unverified/nonzero. Human rendering separates probe/passive/unsupported, unavailable and unmeasured from actual zero; Codex/OAuth rendering remains separate. This read can invoke upstream quota work but does not change key selection or provider aggregate caches. Account list/current rows additionally project, through `src/cli/account-next-actions.ts`, only an allow-listed server health label, a locally generated recovery command built from shell-safe selectors (never server summary/action text), and the Codex boolean `creditsAfterLimit` as read-only consent readback; empty listings add a fixed next-action note to human output and JSON `notes`. The operating guide's eight-domain workflow table is navigation rather than parity measurement. Handwritten JSON, recipe, recovery and target docs qualify bounded windows, model-only search, partial/default results and human-only/session actions. Generated chapter counts still derive exclusively from capability declarations. + +## ocx command selection + +`src/cli/cli-path-diagnostics.ts` reads the settled Desktop record through `src/lib/desktop-cli-record.mjs` and observes regular executable candidates on this process's PATH without executing them. `pathFirst` follows PATH/PATHEXT order, including cwd-relative and empty entries; Windows's possible cmd current-directory candidate is separate. The expected command is the owned POSIX shim or the recorded Windows bundled executable. Windows package-launcher handoff is disabled before any record read or target check; user `Path` order selects the Desktop executable, while diagnostics still read the record and report PATH order. A stale package candidate first on PATH is reported even if its current launcher would hand off. `src/cli/status.ts` exposes additive `cliCommand` JSON and a human-view line; `src/cli/doctor.ts` prints the separate “ocx command selection” checks. Neither observation supplies runtime ownership or service control authority. Parent-shell aliases, functions and caches remain unobserved. Before JSON interpretation, the POSIX reader requires a real private directory and regular non-symlink record owned by the effective uid, no group/other bits, and matching descriptor dev/ino/uid/mode after nonblocking no-follow open; macOS also refuses extended ACL entries or a failed one-second ACL check. Safety failures report `record-unsafe`. Invalid, unsafe or enabled-pending records and unusable Desktop targets are FAIL; disabled records with `cleanupPending` report `cleanup-pending` as WARN, as do PATH ordering and incomplete scans. + +Internal version observations in `src/service/managing-cli.ts` (`probeVersionOnce`) set `OCX_NO_DESKTOP_HANDOFF=1` in the spawned child's environment so the selected package executable reports its own version. This flag suppresses the package launcher's Desktop handoff only; it does not bypass service ownership or update guards. Normal user `ocx --version` remains eligible for handoff. diff --git a/structure/runtime.md b/structure/runtime.md index 432f7aef7c7..f87e4a5fc0d 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -120,7 +120,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | Path | Responsibility | | --- | --- | -| `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Before project dotenv: explicit Bun override, bundle, permitted installer recovery, then `src/lib/bun-path-runtime.mjs` validates absolute PATH Bun candidates with canonical regular/executable and size checks, POSIX rejection of group/world-writable resolved files or parent directories, and bounded version-policy and identity checks (`--version` must match `-e`'s `Bun.version`; stable version has the dependency pin's major and minor ≥ pinned minor); PATH selection stamps `process` with its canonical path and prints a version-only stderr notice. Failure names an executable macOS Desktop CLI when present without delegating. The proof-bound Anthropic snapshot, signal/exit propagation and zero-install updater inspection stay intact. `src/cli/version-skew-notice.ts`, called from `src/cli/root.ts`, makes one identity-checked 200 ms advisory probe for start/stop/restart/update/service, excluding JSON/help/internal; Node-managed update bypasses this notice. | +| `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Before project dotenv: explicit Bun override, bundle, permitted installer recovery, then `src/lib/bun-path-runtime.mjs` validates absolute PATH Bun candidates with canonical regular/executable and size checks, POSIX rejection of group/world-writable resolved files or parent directories, and bounded version-policy and identity checks (`--version` must match `-e`'s `Bun.version`; stable version has the dependency pin's major and minor ≥ pinned minor); PATH selection stamps `process` with its canonical path and prints a version-only stderr notice. Failure names an executable macOS Desktop CLI when present without delegating. The proof-bound Anthropic snapshot, signal/exit propagation and zero-install updater inspection stay intact. `src/cli/version-skew-notice.ts`, called from `src/cli/root.ts`, makes one identity-checked 200 ms advisory probe for start/stop/restart/update/service, excluding JSON/help/internal; Node-managed update bypasses this notice. Before package repair or Bun resolution, `src/lib/desktop-cli-record.mjs` validates the bounded settled Desktop command record before JSON (POSIX private ownership/modes, no symlinks, matching no-follow nonblocking descriptor and macOS ACL checks; failures are `record-unsafe`) and `src/lib/desktop-cli-handoff.mjs` delegates eligible POSIX commands to its validated target with a regenerated argv proof, inherited IO and mirrored exit/signal. Windows always retains the package path and selects Desktop through user `Path` order. Update, removal, internal inspection and `OCX_NO_DESKTOP_HANDOFF=1` retain the package path; only target ENOENT confirmed by a post-spawn `lstat` resumes it after selection. Command selection is separate from runtime/service authority; see [command selection](cli-management.md#ocx-command-selection). | | `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, and `durableBunPath()` (path baked into service/shim artifacts). Durable selection accepts only the source/path pair already stamped for the running executable; it never re-reads a project-dotenv `OPENCODEX_BUN_PATH`. | | `src/lib/plain-data.ts` | Detached copies for a consumer that must not observe later edits. Descriptor-based reads, including array elements, so an accessor is refused rather than invoked; refuses cycles, functions, class instances and anything else JSON could not have produced, and returns a copy-or-refusal union rather than degrading silently. Symbol-keyed process bookkeeping is skipped. | | `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. `restart` refuses an in-place restart requested by a CLI whose version differs from the attested `/healthz` version; a newer CLI may use the guarded standalone update path described below, because an in-place replacement respawns from the live installation; placeholder versions (unknown/0.0.0) stay incomparable and keep the restart path. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. Hidden `__update-badge` prints the read-only package badge JSON without refresh or shim repair for the npm tray. The full top-level reference is rendered by `src/cli/help.ts`; this table names the groups, not every verb. After help/version and unknown-root early exits, recognized commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `status`, `doctor`, and `codex-shim status` skip shim auto-repair; `system codex-cli-update` and [command-local messaging](local-messaging.md#command-local-cli) suppress it for their whole namespaces, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). `src/cli/init.ts` validates an explicit setup port as a decimal integer from 1 through 65535 before publishing the initial configuration. Only an empty answer selects 10100; malformed input is reported and the port question is asked again, while EOF or SIGINT still cancels without saving. | diff --git a/tests/cli/cli-path-diagnostics.test.ts b/tests/cli/cli-path-diagnostics.test.ts new file mode 100644 index 00000000000..235b6fc167b --- /dev/null +++ b/tests/cli/cli-path-diagnostics.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, test } from "bun:test"; +import { chmodSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { collectCliPathDiagnostics, formatCliCommandLine, formatCliStatusHealthLabel, cliCommandDoctorChecks, type CliPathDiagnosticOptions, type CliPathObservation } from "../../src/cli/cli-path-diagnostics"; +import { readDesktopCliRecord, desktopCliRecordPath, DESKTOP_CLI_RECORD_MAX_BYTES, type DesktopCliRecordDeps, type DesktopCliRecordRead } from "../../src/lib/desktop-cli-record.mjs"; +import { repoPath } from "../helpers/repo-root"; + +// The pending-cleanup case reads a macOS record on every CI host. NTFS reports POSIX +// owner/mode bits the reader rejects, and only macOS ships the `ls -lde` ACL probe, +// so non-mac hosts substitute the safe stats a real macOS record directory would have. +const macRecordOnHost: DesktopCliRecordDeps = { + ...(process.platform === "win32" ? { + euid: 0, + lstat: (path: string) => { const stat = lstatSync(path); stat.uid = 0; stat.mode = stat.isDirectory() ? 0o40700 : 0o100600; return stat; }, + fstat: (fd: number) => { const stat = fstatSync(fd); stat.uid = 0; stat.mode = 0o100600; return stat; }, + } : {}), + ...(process.platform === "darwin" ? {} : { checkAcl: () => true }), +}; + +function record(platform: NodeJS.Platform, target: string): DesktopCliRecordRead { + if (platform !== "darwin" && platform !== "linux" && platform !== "win32") throw new Error("Unsupported fixture platform"); + return { state: "ready", path: "fixture", record: { platform, kind: platform === "win32" ? "windows-install" : platform === "darwin" ? "macos-app" : "linux-deb", cliExecutable: target } }; +} +function fixture(platform: NodeJS.Platform, env: NodeJS.ProcessEnv, files: string[], extra: Partial = {}) { + const windows = platform === "win32"; + const normalize = (path: string) => windows ? path.toLowerCase() : path; + const present = new Set(files.map(normalize)); + let observations = 0; + const observe = (path: string): CliPathObservation => { observations++; return present.has(normalize(path)) ? { kind: "usable", realpath: path } : { kind: "missing" }; }; + const result = collectCliPathDiagnostics({ platform, env, cwd: windows ? "C:\\work" : "/work", home: windows ? "C:\\Users\\example" : "/Users/example", recordRead: record(platform, windows ? "C:\\App\\ocx.exe" : "/Applications/OpenCodex.app/Contents/MacOS/ocx"), observe, ...extra }); + return { result, observations }; +} +const desktopBin = "/Users/example" + "/.opencodex-desktop/bin"; +const shim = desktopBin + "/ocx"; +const target = "/Applications/OpenCodex.app/Contents/MacOS/ocx"; +describe("observed ocx PATH selection", () => { + test("POSIX Desktop shim first, npm later; never executes candidates", () => { + const { result } = fixture("darwin", { PATH: `${desktopBin}:/npm/bin` }, [shim, target, "/npm/bin/ocx"]); + expect(result.pathFirst?.path).toBe(shim); expect(result.expectedExecutable).toBe(shim); + expect(result.desktopFirstOnPath).toBe(true); expect(result.shellResolution).toBe("unobserved"); expect(result.issues).toEqual([]); + const source = readFileSync(repoPath("src", "cli", "cli-path-diagnostics.ts"), "utf8"); + expect(source).not.toMatch(/child_process|Bun\.spawn|execSync|spawnSync/); + }); + test("npm first is an issue even if that launcher can hand off", () => { + const { result } = fixture("linux", { PATH: `/npm/bin:${desktopBin}` }, ["/npm/bin/ocx", shim, target]); + expect(result.pathFirst?.path).toBe("/npm/bin/ocx"); expect(result.desktopFirstOnPath).toBe(false); expect(result.issues).toContain("path-first-not-desktop"); + expect(cliCommandDoctorChecks(result)[0]!.level).toBe("WARN"); + }); + test("POSIX empty and relative PATH elements resolve against cwd; absent PATH differs", () => { + for (const path of [":/bin", ".:/bin", "tools:/bin"]) { + const { result } = fixture("linux", { PATH: path }, ["/work/ocx", "/work/tools/ocx", "/bin/ocx", shim, target]); + expect(result.pathFirst?.path).toBe(path.startsWith("tools") ? "/work/tools/ocx" : "/work/ocx"); + } + expect(fixture("linux", {}, ["/work/ocx", shim, target]).result.pathFirst).toBeNull(); + expect(fixture("linux", { PATH: "" }, ["/work/ocx", shim, target]).result.pathFirst?.path).toBe("/work/ocx"); + }); + test("Windows PATHEXT order, case-insensitive Path, quotes and bundled expected exe", () => { + const { result } = fixture("win32", { Path: '"C:\\App";C:\\npm', PathExt: ".CMD;.EXE" }, ["C:\\App\\ocx.exe", "C:\\App\\ocx.cmd", "C:\\npm\\ocx.cmd"]); + expect(result.pathFirst?.extension).toBe(".cmd"); expect(result.expectedExecutable).toBe("C:\\App\\ocx.exe"); expect(result.issues).toContain("path-first-not-desktop"); + const selected = fixture("win32", { PATH: "c:\\app", PATHEXT: ".EXE;.CMD" }, ["C:\\App\\ocx.exe"]).result; + expect(selected.desktopFirstOnPath).toBe(true); + expect(selected.packageHandoff).toBe("disabled-on-windows"); + expect(selected.handoffTarget).toBeNull(); + expect(formatCliCommandLine(selected)).toContain("package handoff=disabled on Windows"); + expect(formatCliCommandLine(selected)).toContain("PATH first=c:\\app\\ocx.exe"); + }); + test("Windows empty entry and cmd cwd candidate remain distinct from PATH first", () => { + const row = fixture("win32", { PATH: "C:\\App", PATHEXT: ".EXE" }, ["C:\\App\\ocx.exe", "C:\\work\\ocx.exe"]).result; + expect(row.pathFirst?.path).toBe("C:\\App\\ocx.exe"); expect(row.currentDirectoryCandidate?.path).toBe("C:\\work\\ocx.exe"); expect(row.issues).toContain("windows-current-directory-shadow"); + expect(fixture("win32", { PATH: ";C:\\App", PATHEXT: ".EXE" }, ["C:\\work\\ocx.exe", "C:\\App\\ocx.exe"]).result.pathFirst?.path).toBe("C:\\work\\ocx.exe"); + }); + test("invalid PATHEXT entries ignored; extensionless last; missing PATH and bounded scan", () => { + const row = fixture("win32", { PATH: "C:\\npm", PATHEXT: ".EXE;bad;../evil;.CMD" }, ["C:\\npm\\ocx", "C:\\App\\ocx.exe"]).result; + expect(row.pathFirst?.extension).toBe(""); + expect(fixture("linux", {}, [shim, target]).result.issues).toContain("path-missing"); + const long = fixture("linux", { PATH: Array(257).fill("/missing").join(":") }, [shim, target]); + expect(long.result.issues).toContain("path-scan-truncated"); expect(long.observations).toBe(258); expect(long.result.desktopFirstOnPath).toBeNull(); + const extensions = Array.from({ length: 33 }, (_, i) => `.x${i}`).join(";"); + expect(fixture("win32", { PATH: "C:\\App", PATHEXT: extensions }, ["C:\\App\\ocx.exe"]).result.issues).toContain("path-scan-truncated"); + }); + test("missing or disabled Desktop is informational; bad records and missing target fail", () => { + for (const state of ["missing", "disabled"] as const) { + const row = fixture("linux", { PATH: "/npm" }, ["/npm/ocx"], { recordRead: state === "disabled" ? { state, path: "fixture", cleanupPending: false } : { state, path: "fixture" } }).result; + expect(row.desktopFirstOnPath).toBeNull(); expect(cliCommandDoctorChecks(row)[0]!.level).toBe("OK"); + } + for (const issue of ["record-invalid", "record-too-large", "record-pending", "record-unreadable", "record-unsafe"] as const) { + const row = fixture("linux", {}, [], { recordRead: { state: issue === "record-unreadable" ? "unreadable" : "invalid", path: "fixture", issue } }).result; + expect(row.issues).toContain(issue); expect(cliCommandDoctorChecks(row)[0]!.level).toBe("FAIL"); + } + const missing = fixture("linux", { PATH: "/npm" }, ["/npm/ocx"]).result; + expect(missing.issues).toContain("desktop-target-missing"); expect(missing.issues).toContain("expected-command-missing"); + }); + test("disabled pending cleanup warns without selecting a target; enabled pending fails", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-path-pending-")); + try { + const path = desktopCliRecordPath({ platform: process.platform, home: root }); + mkdirSync(join(root, ".opencodex-desktop"), { mode: 0o700 }); + for (const [name, issue, level] of [ + ["disabled-with-pending", "cleanup-pending", "WARN"], + ["pending-enabled", "record-pending", "FAIL"], + ["first-pending", "record-pending", "FAIL"], + ] as const) { + writeFileSync(path, readFileSync(repoPath("tests", "fixtures", "desktop-cli-record", `${name}.json`)), { mode: 0o600 }); + const read = readDesktopCliRecord({ platform: "darwin", home: root, recordPath: path }, macRecordOnHost); + if (issue === "cleanup-pending") expect(read).toEqual({ state: "disabled", path, cleanupPending: true }); + else expect(read).toEqual({ state: "invalid", path, issue }); + const row = collectCliPathDiagnostics({ platform: "darwin", home: root, cwd: root, env: { PATH: "" }, observe: () => ({ kind: "missing" }), recordRead: process.platform === "darwin" ? undefined : read }); + expect(row.configured).toBe(false); + expect(row.handoffTarget).toBeNull(); + expect(row.expectedExecutable).toBeNull(); + expect(row.desktopFirstOnPath).toBeNull(); + expect(row.issues).toEqual([issue]); + const check = cliCommandDoctorChecks(row)[0]!; + expect(check.level).toBe(level); + if (level === "FAIL") { + expect(check.message).toContain("Open OpenCodex Desktop to repair the terminal command"); + expect(check.message).toContain("OCX_NO_DESKTOP_HANDOFF=1"); + expect(check.message).not.toMatch(/delet|remov/i); + } else expect(check.message).toContain("finish terminal-command cleanup"); + } + writeFileSync(path, "x".repeat(DESKTOP_CLI_RECORD_MAX_BYTES + 1), { mode: 0o600 }); + const tooLargeRead = process.platform === "darwin" ? undefined : readDesktopCliRecord({ platform: "darwin", home: root, recordPath: path }, macRecordOnHost); + const tooLarge = collectCliPathDiagnostics({ platform: "darwin", home: root, cwd: root, env: {}, observe: () => ({ kind: "missing" }), recordRead: tooLargeRead }); + expect(tooLarge.issues).toEqual(["record-too-large"]); + expect(cliCommandDoctorChecks(tooLarge)[0]!.message).toContain(`${DESKTOP_CLI_RECORD_MAX_BYTES} bytes`); + } finally { rmSync(root, { recursive: true, force: true }); } + }); + test("unusable/unreadable observations have named codes and no false OK", () => { + const row = fixture("linux", { PATH: "/bad" }, [], { observe: path => path === target ? { kind: "unusable" } : path === shim ? { kind: "unusable" } : { kind: "unreadable" } }).result; + expect(row.issues).toEqual(expect.arrayContaining(["desktop-target-unusable", "expected-command-unusable", "path-unreadable"])); + expect(row.desktopFirstOnPath).toBeNull(); + expect(cliCommandDoctorChecks(row)[0]!.level).toBe("FAIL"); + }); + test("realpath alias of owned command compares physically and output stays one safe line", () => { + const row = fixture("linux", { PATH: "/alias" }, [], { observe: path => ({ kind: "usable", realpath: path === target ? target : shim }) }).result; + expect(row.desktopFirstOnPath).toBe(true); + const line = formatCliCommandLine(row); expect(line).toContain("shell=unobserved"); expect(line).not.toContain("\n"); + const unsafe = { ...row, pathFirst: { ...row.pathFirst!, path: "/fixture/line\nbreak/ocx" } }; + expect(formatCliCommandLine(unsafe)).not.toContain("\n"); + }); + test("connected status adapter renders one command line and preserves local labels", () => { + const row = fixture("linux", { PATH: "/npm" }, ["/npm/ocx", shim, target]).result; + for (const connected of [false, true]) { + const rendered = `Health: ${formatCliStatusHealthLabel("healthy", row, connected)}${connected ? " (local)" : ""}`; + const lines = rendered.split("\n"); expect(lines).toHaveLength(2); + for (const line of lines) expect(line.includes(" (local)")).toBe(connected); + expect(lines[1]).toContain("ocx command:"); + } + const json = JSON.parse(JSON.stringify({ proxy: { health: { message: "healthy" } }, cliCommand: row })); + expect(json.proxy.health.message).toBe("healthy"); + }); + (process.platform === "win32" ? test.skip : test)("default observer rejects directory/non-executable and observes physical symlink without execution", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-path-observe-")); + try { + const bin = join(root, ".opencodex-desktop", "bin"); mkdirSync(bin, { recursive: true }); + const target = join(root, "ocx-target"); writeFileSync(target, "fixture; never executed"); chmodSync(target, 0o755); + const shimPath = join(bin, "ocx"); symlinkSync(target, shimPath); + const platform = process.platform; + const options = { platform, home: root, cwd: root, recordRead: record(platform, target), env: { PATH: bin } }; + expect(collectCliPathDiagnostics(options).desktopFirstOnPath).toBe(true); + rmSync(shimPath); mkdirSync(shimPath); + expect(collectCliPathDiagnostics(options).issues).toContain("expected-command-unusable"); + rmSync(shimPath, { recursive: true }); writeFileSync(shimPath, "fixture"); chmodSync(shimPath, 0o600); + expect(collectCliPathDiagnostics(options).issues).toContain("expected-command-unusable"); + expect(collectCliPathDiagnostics({ ...options, env: { PATH: target } }).pathFirst).toBeNull(); + rmSync(shimPath); symlinkSync(shimPath, shimPath); + expect(collectCliPathDiagnostics(options).issues).toContain("path-unreadable"); + } finally { rmSync(root, { recursive: true, force: true }); } + }); + test("status JSON field and human line, doctor independent registration, root dispatcher untouched", () => { + const status = readFileSync(repoPath("src", "cli", "status.ts"), "utf8"); + expect(status).toContain("cliCommand: CliPathDiagnostics;"); expect(status).toContain(" cliCommand,"); + expect(status).toContain("formatCliStatusHealthLabel(health.label, cliCommand, remoteHub.connected)"); + const doctor = readFileSync(repoPath("src", "cli", "doctor.ts"), "utf8"); + expect(doctor).toContain('console.log("\\nocx command selection")'); expect(doctor).toContain('if (check.level === "FAIL") recordDoctorFailure()'); + expect(doctor.indexOf('console.log("Paths")')).toBeLessThan(doctor.indexOf('console.log("\\nocx command selection")')); + expect(doctor.indexOf('console.log("\\nocx command selection")')).toBeLessThan(doctor.indexOf('console.log("\\nResponse-state temp files")')); + expect(doctor.indexOf('console.log("\\nocx command selection")')).toBeLessThan(doctor.indexOf('console.log("\\nCodex runtime selection")')); + const json = JSON.parse(JSON.stringify(fixture("linux", { PATH: "/npm" }, ["/npm/ocx", shim, target]).result)); + expect(json.pathFirst.path).toBe("/npm/ocx"); expect(json.shellResolution).toBe("unobserved"); + }); + + (process.platform === "win32" ? test.skip : test)("unsafe record is a doctor FAIL with its path and no configured target", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-path-unsafe-")); + try { + mkdirSync(join(root, ".opencodex-desktop"), { mode: 0o700 }); + const path = desktopCliRecordPath({ home: root }); + writeFileSync(path, '{"version":1,"enabled":false}', { mode: 0o600 }); + chmodSync(path, 0o644); + const row = collectCliPathDiagnostics({ home: root, cwd: root, env: { PATH: "" }, observe: () => ({ kind: "missing" }) }); + expect(row.issues).toEqual(["record-unsafe"]); + expect(row.configured).toBe(false); + expect(row.handoffTarget).toBeNull(); + const check = cliCommandDoctorChecks(row)[0]!; + expect(check.level).toBe("FAIL"); + expect(check.message).toContain(path); + expect(check.message).toContain("OCX_NO_DESKTOP_HANDOFF=1"); + } finally { rmSync(root, { recursive: true, force: true }); } + }); + test("Windows unsafe records still report a PATH first candidate with handoff disabled", () => { + const row = fixture("win32", { PATH: "C:\\npm", PATHEXT: ".CMD" }, ["C:\\npm\\ocx.cmd"], { + recordRead: { state: "invalid", path: "C:\\Desktop\\cli.json", issue: "record-unsafe" }, + }).result; + expect(row.pathFirst?.path).toBe("C:\\npm\\ocx.cmd"); + expect(row.packageHandoff).toBe("disabled-on-windows"); + expect(row.handoffTarget).toBeNull(); + expect(row.issues).toContain("record-unsafe"); + const check = cliCommandDoctorChecks(row)[0]!; + expect(check.level).toBe("FAIL"); + expect(check.message).toContain("package handoff=disabled on Windows"); + expect(check.message).not.toContain("OCX_NO_DESKTOP_HANDOFF=1"); + }); +}); diff --git a/tests/cli/ocx-launcher-desktop-handoff.test.ts b/tests/cli/ocx-launcher-desktop-handoff.test.ts new file mode 100644 index 00000000000..f15b757dec2 --- /dev/null +++ b/tests/cli/ocx-launcher-desktop-handoff.test.ts @@ -0,0 +1,626 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { chmodSync, chownSync, constants, fstatSync, lstatSync, mkdirSync, openSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, symlinkSync, writeFileSync as writeFile, type statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { readDesktopCliRecord as readRecord, desktopCliRecordPath, DESKTOP_CLI_RECORD_MAX_BYTES, type DesktopCliRecordRead, type DesktopCliRecordOptions, type DesktopCliRecordDeps } from "../../src/lib/desktop-cli-record.mjs"; +import { desktopHandoffExcluded, planDesktopCliHandoff, runDesktopCliHandoff } from "../../src/lib/desktop-cli-handoff.mjs"; +import { initializeNodeLauncherContext } from "../../src/cli/launcher-context"; +import { buildNativeClaudeEnv } from "../../src/cli/claude"; +import { observeManagingClis } from "../../src/service/managing-cli"; +import { repoPath } from "../helpers/repo-root"; + +// Explicit private fixtures; only temp paths are written by this helper. +function writeFileSync(path: string, data: Parameters[1]): void { + writeFile(path, data, { mode: 0o600 }); +} +function readDesktopCliRecord(options: DesktopCliRecordOptions = {}, deps: DesktopCliRecordDeps = {}): DesktopCliRecordRead { + const hostStats = process.platform === "win32" ? { + euid: 0, + lstat: (path: string) => { const stat = lstatSync(path); stat.uid = 0; stat.mode = stat.isDirectory() ? 0o40700 : 0o100600; return stat; }, + fstat: (fd: number) => { const stat = fstatSync(fd); stat.uid = 0; stat.mode = 0o100600; return stat; }, + } : {}; + return readRecord(options, { ...hostStats, ...deps }); +} +const roots: string[] = []; +const hostKind = { darwin: "macos-app", win32: "windows-install", linux: "linux-deb" }[process.platform as "darwin" | "win32" | "linux"]; +const PREFIX = "--ocx-internal-launch-proof="; +const proof = "A".repeat(43); +const context = JSON.stringify({ version: 1, proof, anthropicEnvSlots: ["ANTHROPIC_API_KEY"], codexCliInspectionEnv: null }); +afterEach(() => { + initializeNodeLauncherContext(["bun", "index.ts"], {}); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); +function box(): string { + const root = mkdtempSync(join(tmpdir(), "ocx-handoff-")); chmodSync(root, 0o700); roots.push(root); return root; +} +function ready(target = "/fixture/desktop/ocx"): DesktopCliRecordRead { + return { state: "ready", path: "/fixture/cli.json", record: { + platform: "linux", kind: "linux-deb", cliExecutable: target, + } }; +} +const stat = (() => ({ isFile: () => true })) as unknown as typeof statSync; +const deps = { stat, access: () => {}, realpath: (path: string) => path }; +const coded = (code: string): NodeJS.ErrnoException => Object.assign(new Error("fixture"), { code }); + +function fakeSpawn(error?: string, exitSignal: NodeJS.Signals | null = null) { + const parent = new EventEmitter(); + const child = new EventEmitter() as EventEmitter & { kill: (signal: string) => boolean }; + const killed: string[] = []; + child.kill = signal => { killed.push(signal); return true; }; + let observed: { target: string; argv: string[]; options: Record } | undefined; + const run = ((target: string, argv: string[], options: Record) => { + observed = { target, argv, options }; + queueMicrotask(() => { + parent.emit("SIGTERM"); + if (error) { child.emit("error", coded(error)); child.emit("exit", 99, null); } + else child.emit("exit", 17, exitSignal); + }); + return child; + }) as unknown as typeof spawn; + return { run, parent, killed, observed: () => observed }; +} + +describe("Desktop record and handoff policy", () => { + test("fixed record location and injected reader path", () => { + expect(desktopCliRecordPath({ platform: "darwin", home: "/Users/example" })).toBe("/Users/example" + "/.opencodex-desktop/cli.json"); + expect(desktopCliRecordPath({ platform: "win32", env: { USERPROFILE: "C:\\Users\\example" } })).toBe("C:\\Users\\example\\.opencodex-desktop\\cli.json"); + const root = box(); const recordPath = join(root, "cli.json"); + expect(readDesktopCliRecord({ recordPath }).state).toBe("missing"); + writeFileSync(recordPath, '{"version":1,"enabled":false}'); + expect(readDesktopCliRecord({ recordPath }).state).toBe("disabled"); + writeFileSync(recordPath, JSON.stringify({ version: 1, enabled: true, bundle: { platform: process.platform, kind: hostKind, cliExecutable: join(root, "ocx") }, ownerId: "ignored" })); + expect(readDesktopCliRecord({ recordPath }).state).toBe("ready"); + }); + test("reader refuses oversized, malformed, pending and wrong-platform records", () => { + const root = box(); const recordPath = join(root, "cli.json"); + const valid = { version: 1, enabled: true, bundle: { platform: process.platform, kind: hostKind, cliExecutable: join(root, "ocx") } }; + for (const value of ["{", "[]", "null", "42", JSON.stringify({ ...valid, enabled: "yes" }), JSON.stringify({ ...valid, bundle: null }), JSON.stringify({ ...valid, version: 2 }), JSON.stringify({ ...valid, pending: {} }), JSON.stringify({ ...valid, bundle: { platform: "other", cliExecutable: "relative" } }), JSON.stringify({ ...valid, bundle: { platform: process.platform, kind: hostKind, cliExecutable: "relative" } }), JSON.stringify({ ...valid, bundle: { platform: process.platform, kind: hostKind, cliExecutable: "/" + "x".repeat(4096) } }), JSON.stringify({ ...valid, bundle: { platform: process.platform, kind: hostKind, cliExecutable: "/fixture/line\nbreak" } })]) { + writeFileSync(recordPath, value); expect(readDesktopCliRecord({ recordPath }).state).toBe("invalid"); + } + writeFileSync(recordPath, " ".repeat(DESKTOP_CLI_RECORD_MAX_BYTES)); + expect(readDesktopCliRecord({ recordPath })).toMatchObject({ issue: "record-invalid" }); + writeFileSync(recordPath, " ".repeat(DESKTOP_CLI_RECORD_MAX_BYTES + 1)); + expect(readDesktopCliRecord({ recordPath })).toMatchObject({ issue: "record-too-large" }); + rmSync(recordPath); mkdirSync(recordPath); + expect(readDesktopCliRecord({ recordPath }).state).toBe("invalid"); + }); + test("descriptor sentinel bounds growth after metadata and strict UTF-8 rejects corrupt bytes", () => { + const root = box(); const recordPath = join(root, "cli.json"); + writeFileSync(recordPath, Buffer.from([0xff])); + expect(readDesktopCliRecord({ recordPath })).toMatchObject({ issue: "record-invalid" }); + writeFileSync(recordPath, " ".repeat(DESKTOP_CLI_RECORD_MAX_BYTES + 1)); + const script = `import fs from 'node:fs'; import { syncBuiltinESMExports } from 'node:module'; +const original = fs.fstatSync; fs.fstatSync = (...args) => { const info = original(...args); info.size = 1; return info; }; syncBuiltinESMExports(); +const mod = await import(${JSON.stringify(pathToFileURL(repoPath("src", "lib", "desktop-cli-record.mjs")).href)}); +console.log(JSON.stringify(mod.readDesktopCliRecord({recordPath:${JSON.stringify(recordPath)}})));`; + const result = spawnSync("node", ["--input-type=module", "-e", script], { encoding: "utf8", timeout: 5000 }); + expect(result.status).toBe(0); expect(JSON.parse(result.stdout)).toMatchObject({ issue: "record-too-large" }); + }); + (process.platform === "win32" ? test.skip : test)("reader symlinks are unsafe and disabled intent wins over pending journal", () => { + const root = box(); const recordPath = join(root, "cli.json"); + symlinkSync(recordPath, recordPath); + expect(readDesktopCliRecord({ recordPath })).toMatchObject({ state: "invalid", issue: "record-unsafe" }); + rmSync(recordPath); writeFileSync(recordPath, '{"version":1,"enabled":false,"pending":{}}'); + expect(readDesktopCliRecord({ recordPath })).toMatchObject({ state: "disabled", cleanupPending: true }); + }); + test("record absence and disabled intent keep the package path; invalid records error", () => { + for (const state of ["missing", "disabled"] as const) expect(planDesktopCliHandoff({ platform: "linux", argv: ["status"], env: {}, recordRead: state === "disabled" ? { state, path: "fixture", cleanupPending: false } : { state, path: "fixture" } })).toEqual({ kind: "continue", reason: state }); + for (const issue of ["record-invalid", "record-too-large", "record-pending", "record-unreadable", "record-unsafe"] as const) expect(planDesktopCliHandoff({ platform: "linux", argv: ["status"], env: {}, recordRead: { state: issue === "record-unreadable" ? "unreadable" : "invalid", issue, path: "fixture" } })).toEqual({ kind: "error", issue }); + }); + test("update, removal, internal inspection and codex-cli-update are exceptions", () => { + for (const argv of [["update"], ["update", "--help"], ["uninstall"], ["remove"], ["__update-badge"], ["system", "codex-cli-update", "malformed"], [PREFIX + "invalid", "update"]]) { + expect(desktopHandoffExcluded(argv, {})).toBe(true); + expect(planDesktopCliHandoff({ platform: "linux", argv, env: {}, recordRead: ready() }, { stat: (() => { throw new Error("must not read target"); }) as typeof stat })).toMatchObject({ reason: "excluded" }); + } + expect(desktopHandoffExcluded(["--version"], {})).toBe(false); + expect(desktopHandoffExcluded(["inspect", "config"], {})).toBe(false); + }); + test("OCX_NO_DESKTOP_HANDOFF is exact and keeps user version handoff eligible", () => { + expect(desktopHandoffExcluded(["--version"], { OCX_NO_DESKTOP_HANDOFF: "1" })).toBe(true); + expect(desktopHandoffExcluded(["--version"], { OCX_NO_DESKTOP_HANDOFF: "0" })).toBe(false); + }); + test("absolute regular executable target selected; self realpath never delegated", () => { + expect(planDesktopCliHandoff({ argv: [], env: {}, platform: "linux", recordRead: ready(), selfPaths: ["/fixture/package/ocx.mjs"] }, deps)).toEqual({ kind: "handoff", target: "/fixture/desktop/ocx" }); + expect(planDesktopCliHandoff({ argv: [], env: {}, platform: "linux", recordRead: ready(), selfPaths: ["/fixture/alias"] }, { ...deps, realpath: () => "/fixture/same" })).toMatchObject({ kind: "error", issue: "target-self" }); + expect(planDesktopCliHandoff({ argv: [], env: {}, platform: "linux", recordRead: ready("relative") }, deps)).toMatchObject({ issue: "target-invalid" }); + const selfPaths = ["/fixture/missing-self"]; + const missingSelf = { ...deps, realpath: (path: string) => { if (path === selfPaths[0]) throw coded("ENOENT"); return path; } }; + expect(planDesktopCliHandoff({ argv: [], env: {}, platform: "linux", recordRead: ready(), selfPaths }, missingSelf).kind).toBe("handoff"); + const badSelf = { ...deps, realpath: (path: string) => { if (path === selfPaths[0]) throw coded("ELOOP"); return path; } }; + expect(planDesktopCliHandoff({ argv: [], env: {}, platform: "linux", recordRead: ready(), selfPaths }, badSelf)).toMatchObject({ issue: "target-unusable" }); + expect(planDesktopCliHandoff({ argv: [], env: {}, platform: "linux", recordRead: ready() }, { ...deps, stat: (() => ({ isFile: () => false })) as unknown as typeof statSync })).toMatchObject({ issue: "target-invalid" }); + }); + test("target ENOENT resumes and EACCES or other errors fail", () => { + for (const code of ["ENOENT", "EACCES", "ENOTDIR", "ENOEXEC", "ELOOP"]) { + const plan = planDesktopCliHandoff({ argv: ["status"], env: {}, platform: "linux", recordRead: ready() }, { ...deps, access: () => { throw coded(code); } }); + expect(plan.kind).toBe(code === "ENOENT" ? "continue" : "error"); + } + }); + test("Windows returns before reading valid, unreadable or unsafe records and never selects a spawn", () => { + for (const read of [ready("C:\\App\\ocx.exe"), { state: "unreadable", path: "fixture", issue: "record-unreadable" }, { state: "invalid", path: "fixture", issue: "record-unsafe" }] as DesktopCliRecordRead[]) { + let readAttempted = false; + let targetChecked = false; + let spawned = false; + const input = { platform: "win32" as const, argv: ["--version"], env: {} }; + Object.defineProperty(input, "recordRead", { get() { readAttempted = true; return read; } }); + const plan = planDesktopCliHandoff(input, { stat: (() => { targetChecked = true; throw new Error("must not check target"); }) as typeof stat }); + if (plan.kind === "handoff") spawned = true; + expect(plan).toEqual({ kind: "continue", reason: "windows-path-only" }); + expect(readAttempted).toBe(false); + expect(targetChecked).toBe(false); + expect(spawned).toBe(false); + } + }); +}); + +describe("async handoff transport", () => { + test("argv, inherited stdin, fresh proof and exit code; signal handlers cleaned", async () => { + const fake = fakeSpawn(); + const result = await runDesktopCliHandoff({ kind: "handoff", target: "/fixture/ocx" }, { + argv: [PREFIX + "obsolete", "claude", "a b", "--literal=$x"], proof, context, + env: { PATH: "fixture", OCX_BUN_RUNTIME_PATH: "stale", OCX_BUN_RUNTIME_SOURCE: "bundled" }, + }, { spawn: fake.run, parent: fake.parent as unknown as NodeJS.Process, platform: "linux" }); + expect(result).toEqual({ kind: "exit", code: 17, signal: null }); + const called = fake.observed()!; + expect(called.argv).toEqual([PREFIX + proof, "claude", "a b", "--literal=$x"]); + expect(called.options).toMatchObject({ stdio: "inherit", shell: false, windowsHide: true }); + expect(called.options.env).toEqual({ PATH: "fixture", OCX_NO_DESKTOP_HANDOFF: "1", OCX_NODE_LAUNCH_CONTEXT: context }); + expect(fake.killed).toEqual(["SIGTERM"]); expect(fake.parent.listenerCount("SIGTERM")).toBe(0); + }); + test("child signal is retained and confirmed asynchronous target ENOENT resumes", async () => { + for (const error of [undefined, "ENOENT", "EACCES", "ENOEXEC"]) { + const fake = fakeSpawn(error, "SIGTERM"); + const result = await runDesktopCliHandoff({ kind: "handoff", target: "/fixture/ocx" }, { argv: [], env: {}, proof, context }, { spawn: fake.run, parent: fake.parent as unknown as NodeJS.Process, lstat: (() => { throw coded("ENOENT"); }) as typeof lstatSync }); + expect(result.kind).toBe(!error ? "exit" : error === "ENOENT" ? "continue" : "error"); + if (result.kind === "exit") expect(result.signal).toBe("SIGTERM"); + expect(fake.parent.eventNames()).toHaveLength(0); + } + }); + test("Windows forwards INT/TERM only and a kill race still settles from child exit", async () => { + const fake = fakeSpawn(); + const pending = runDesktopCliHandoff({ kind: "handoff", target: "C:\\App\\ocx.exe" }, { argv: [], env: {}, proof, context }, { spawn: fake.run, parent: fake.parent as unknown as NodeJS.Process, platform: "win32" }); + expect(fake.parent.listenerCount("SIGHUP")).toBe(0); expect(fake.parent.listenerCount("SIGINT")).toBe(1); + expect((await pending).kind).toBe("exit"); + const parent = new EventEmitter(); + const child = new EventEmitter() as EventEmitter & { kill: () => never }; + child.kill = () => { throw new Error("already exited"); }; + const run = (() => { queueMicrotask(() => { parent.emit("SIGTERM"); child.emit("exit", null, null); }); return child; }) as unknown as typeof spawn; + expect(await runDesktopCliHandoff({ kind: "handoff", target: "/fixture/ocx" }, { argv: [], env: {}, proof, context }, { spawn: run, parent: parent as unknown as NodeJS.Process })).toEqual({ kind: "exit", code: 1, signal: null }); + }); + test("synchronous spawn errors have the same confirmed-target ENOENT policy", async () => { + for (const code of ["ENOENT", "EACCES"]) { + const fail = (() => { throw coded(code); }) as unknown as typeof spawn; + expect((await runDesktopCliHandoff({ kind: "handoff", target: "/fixture/ocx" }, { argv: [], env: {}, proof, context }, { spawn: fail, lstat: (() => { throw coded("ENOENT"); }) as typeof lstatSync })).kind).toBe(code === "ENOENT" ? "continue" : "error"); + } + }); + test("proof reaches the real Claude native-env consumer and keeps exported slots", () => { + const env = { OCX_NODE_LAUNCH_CONTEXT: context, ANTHROPIC_API_KEY: "fixture-user-key", ANTHROPIC_BASE_URL: "https://fixture.invalid", OCX_PRE_BUN_ANTHROPIC_ENV: "obsolete" }; + const argv = ["bun", "index.ts", PREFIX + proof, "claude"]; + expect(initializeNodeLauncherContext(argv, env)?.anthropicEnvSlots).toEqual(["ANTHROPIC_API_KEY"]); + expect(argv).toEqual(["bun", "index.ts", "claude"]); + const config = { port: 10100, providers: {} } as Parameters[0]; + const child = buildNativeClaudeEnv(config, env); + expect(child.ANTHROPIC_API_KEY).toBe("fixture-user-key"); + expect(child.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(child.OCX_NODE_LAUNCH_CONTEXT).toBeUndefined(); + }); + test("internal managing CLI probes set the bypass in the actual spawn environment", () => { + let actual: NodeJS.ProcessEnv | undefined; + const probe = ((_command: string, _args: string[], options: { env: NodeJS.ProcessEnv }) => { actual = options.env; return { status: 0, stdout: "2.82.0", stderr: "" }; }) as unknown as typeof spawnSync; + const result = observeManagingClis(null, { platform: "linux", env: { PATH: "/fixture", KEEP: "yes" }, execPath: "/fixture/self", exists: path => path === "/fixture/ocx", isFile: () => true, spawn: probe }); + expect(result.path.status).toBe("observed"); expect(actual).toEqual({ PATH: "/fixture", KEEP: "yes", OCX_NO_DESKTOP_HANDOFF: "1" }); + }); +}); + +function packageFixture() { + const home = box(); const directory = join(home, ".opencodex-desktop"); mkdirSync(directory, { mode: 0o700 }); + for (const name of ["opencodex", "codex", "grok"]) mkdirSync(join(home, name)); + const target = join(home, "desktop-cli"); + const recordPath = join(directory, "cli.json"); + writeFileSync(recordPath, JSON.stringify({ version: 1, enabled: true, bundle: { platform: process.platform, kind: hostKind, cliExecutable: target } })); + const env: NodeJS.ProcessEnv = { ...process.env, HOME: home, USERPROFILE: home, OPENCODEX_HOME: join(home, "opencodex"), CODEX_HOME: join(home, "codex"), GROK_HOME: join(home, "grok"), ANTHROPIC_AUTH_TOKEN: "", ANTHROPIC_BASE_URL: "", OCX_NO_DESKTOP_HANDOFF: "0", OCX_NODE_LAUNCH_CONTEXT: "forged", ANTHROPIC_API_KEY: "fixture-user-key", OPENCODEX_BUN_PATH: join(home, "missing-bun") }; + return { home, target, env }; +} +const posixTest = process.platform === "win32" ? test.skip : test; +const launcher = repoPath("bin", "ocx.mjs"); +posixTest("real Node launcher regenerates proof, forwards argv/stdin, mirrors exit and bypasses Bun lookup", () => { + const fixture = packageFixture(); + writeFileSync(fixture.target, `#!/usr/bin/env node\nconst fs = require('node:fs');\nconsole.log(JSON.stringify({ argv: process.argv.slice(2), context: JSON.parse(process.env.OCX_NODE_LAUNCH_CONTEXT), stdin: fs.readFileSync(0, 'utf8'), bypass: process.env.OCX_NO_DESKTOP_HANDOFF }));\nprocess.exit(23);\n`); + chmodSync(fixture.target, 0o755); + const result = spawnSync("node", [launcher, "claude", "a b", PREFIX + "old"], { env: fixture.env, input: "fixture stdin\n", encoding: "utf8", timeout: 5000 }); + expect(result.error).toBeUndefined(); expect(result.status).toBe(23); + const row = JSON.parse(result.stdout.trim()); + expect(row.stdin).toBe("fixture stdin\n"); expect(row.bypass).toBe("1"); + expect(row.argv.slice(1)).toEqual(["claude", "a b"]); + expect(row.argv[0]).toBe(PREFIX + row.context.proof); + expect(row.context.proof).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(row.context.anthropicEnvSlots).toContain("ANTHROPIC_API_KEY"); + const argv = ["bun", "index.ts", ...row.argv]; + const capturedEnv = { OCX_NODE_LAUNCH_CONTEXT: JSON.stringify(row.context), ANTHROPIC_API_KEY: "fixture-user-key", ANTHROPIC_BASE_URL: "https://dotenv.invalid" }; + const captured = initializeNodeLauncherContext(argv, capturedEnv); + expect(captured?.anthropicEnvSlots).toContain("ANTHROPIC_API_KEY"); + const config = { port: 10100, providers: {} } as Parameters[0]; + const native = buildNativeClaudeEnv(config, capturedEnv); + expect(native.ANTHROPIC_API_KEY).toBe("fixture-user-key"); + expect(native.ANTHROPIC_BASE_URL).toBeUndefined(); +}); +posixTest("real target disappearance resumes Bun; EACCES never resumes Bun", () => { + const fixture = packageFixture(); + const absent = spawnSync("node", [launcher, "--version"], { env: fixture.env, encoding: "utf8", timeout: 5000 }); + expect(absent.stderr).toContain("OPENCODEX_BUN_PATH"); + writeFileSync(fixture.target, "not executable"); chmodSync(fixture.target, 0o600); + const denied = spawnSync("node", [launcher, "--version"], { env: fixture.env, encoding: "utf8", timeout: 5000 }); + expect(denied.status).toBe(1); expect(denied.stderr).toContain("target-unusable"); expect(denied.stderr).not.toContain("OPENCODEX_BUN_PATH"); +}); +posixTest("real update-help is untouched; opt-out and bad record activate distinct paths", () => { + const fixture = packageFixture(); + writeFileSync(fixture.target, "#!/usr/bin/env node\nconsole.log('desktop-picked'); process.exit(0);\n"); chmodSync(fixture.target, 0o755); + const help = spawnSync("node", [launcher, "update", "--help"], { env: fixture.env, encoding: "utf8", timeout: 5000 }); + expect(help.status).toBe(0); expect(help.stdout).toContain("Usage: ocx update"); expect(help.stdout).not.toContain("desktop-picked"); + const skipped = spawnSync("node", [launcher, "--version"], { env: { ...fixture.env, OCX_NO_DESKTOP_HANDOFF: "1" }, encoding: "utf8", timeout: 5000 }); + expect(skipped.stdout).not.toContain("desktop-picked"); expect(skipped.stderr).toContain("OPENCODEX_BUN_PATH"); + writeFileSync(join(fixture.home, ".opencodex-desktop", "cli.json"), "{"); + const invalid = spawnSync("node", [launcher, "--version"], { env: fixture.env, encoding: "utf8", timeout: 5000 }); + expect(invalid.status).toBe(1); expect(invalid.stderr).toContain("record-invalid"); expect(invalid.stderr).not.toContain("OPENCODEX_BUN_PATH"); +}); +posixTest("real child terminating signal is mirrored by Node parent", () => { + const fixture = packageFixture(); + writeFileSync(fixture.target, "#!/usr/bin/env node\nprocess.kill(process.pid, 'SIGTERM');\n"); chmodSync(fixture.target, 0o755); + const result = spawnSync("node", [launcher, "status"], { env: fixture.env, encoding: "utf8", timeout: 5000 }); + expect(result.signal).toBe("SIGTERM"); +}); +posixTest("signal sent only to real launcher reaches its child and waits for child exit", async () => { + const fixture = packageFixture(); + writeFileSync(fixture.target, "#!/usr/bin/env node\nprocess.on('SIGTERM', () => process.exit(29));\nconsole.log(process.pid); setInterval(() => {}, 1000);\n"); chmodSync(fixture.target, 0o755); + const child: ChildProcess = spawn("node", [launcher, "status"], { env: fixture.env, stdio: ["ignore", "pipe", "pipe"] }); + let timer: ReturnType | undefined; + let targetPid: number | undefined; + try { + const code = await new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error("fixture timeout")), 5000); + child.once("error", reject); child.once("exit", resolve); + child.stdout!.once("data", bytes => { targetPid = Number(String(bytes).trim()); child.kill("SIGTERM"); }); + }); + expect(code).toBe(29); + } finally { + clearTimeout(timer); + if (child.exitCode === null && child.signalCode === null) { + if (targetPid && Number.isSafeInteger(targetPid)) { try { process.kill(targetPid, "SIGKILL"); } catch { /* exited */ } } + child.kill("SIGKILL"); + } + } +}); +test("launcher source anchors preserve exceptions before handoff and handoff before repair/resolver", () => { + const source = readFileSync(launcher, "utf8"); + const hook = source.indexOf("const desktopPlan ="); + expect(hook).toBeGreaterThan(source.indexOf("const codexCliUpdateInspection =")); + expect(hook).toBeLessThan(source.indexOf('if (process.argv[2] === "update" && installMethod === "mise")')); + expect(hook).toBeLessThan(source.indexOf("const probe = bootRestoreProbe(")); + expect(hook).toBeLessThan(source.indexOf("const bunRuntime = resolveBun(")); + expect(source).toContain("const { launchProof, launchContext, inheritedEnv } = createNodeLaunchContext();"); + expect(source.match(/randomBytes\(32\)/g)).toHaveLength(1); +}); + +describe("shared Rust record fixtures and lexical path boundaries", () => { + test("the whole Rust fixture directory maps to consumption state on every supported host", () => { + const fixtureDir = repoPath("tests", "fixtures", "desktop-cli-record"); + const expected = { + "valid-darwin.json": { state: "ready", host: "darwin" }, + "valid-win32.json": { state: "ready", host: "win32" }, + "notify-pending.json": { state: "ready", host: "win32" }, + "valid-linux.json": { state: "ready", host: "linux" }, + "disabled-tombstone.json": { state: "disabled", cleanupPending: false }, + "disabled-with-pending.json": { state: "disabled", cleanupPending: true }, + "pending-enabled.json": { state: "invalid", issue: "record-pending" }, + "first-pending.json": { state: "invalid", issue: "record-pending" }, + "appimage-kind.json": { state: "invalid", issue: "record-invalid" }, + "relative-target.json": { state: "invalid", issue: "record-invalid" }, + "dotdot-target.json": { state: "invalid", issue: "record-invalid" }, + } as const; + const names = readdirSync(fixtureDir).sort(); + expect(names).toEqual(Object.keys(expected).sort()); + const recordPath = join(box(), "cli.json"); + for (const platform of ["darwin", "win32", "linux"] as const) { + for (const name of names) { + const want = expected[name as keyof typeof expected]; + writeFileSync(recordPath, readFileSync(join(fixtureDir, name))); + const actual = readDesktopCliRecord({ recordPath, platform }, { checkAcl: () => true }); + if (want.state === "ready") { + if (want.host === platform) { + const fixture = JSON.parse(readFileSync(join(fixtureDir, name), "utf8")); + expect(actual).toMatchObject({ state: "ready", record: { + platform, kind: fixture.bundle.kind, cliExecutable: fixture.bundle.cliExecutable, + } }); + if (actual.state === "ready") expect(Object.keys(actual.record).sort()).toEqual(["cliExecutable", "kind", "platform"]); + } else { + expect(actual).toMatchObject({ state: "invalid", issue: "record-invalid" }); + } + } else { + expect(actual).toMatchObject(want); + } + } + } + }); + + test("path length counts Unicode code points and paths are not normalized before validation", () => { + const recordPath = join(box(), "cli.json"); + const cases: [string, boolean][] = [ + ["/" + "x".repeat(4095), true], + ["/" + "x".repeat(4096), false], + ["/" + "😀".repeat(4095), true], + ["/" + "😀".repeat(4096), false], + ["/fixture/./ocx", false], + ["/fixture/../ocx", false], + ["/fixture/ocx/..", false], + ["/fixture/ocx/.", false], + ["/fixture/ocx\n", false], + ["/fixture/ocx\r", false], + ["/fixture/ocx\0", false], + ["relative/ocx", false], + ["/fixture/.../ocx", true], + ["/fixture\\..\\ocx", true], + ["//fixture//ocx", true], + ]; + for (const [cliExecutable, accepted] of cases) { + writeFileSync(recordPath, JSON.stringify({ + version: 1, enabled: true, bundle: { platform: "darwin", kind: "macos-app", cliExecutable }, + })); + expect(readDesktopCliRecord({ recordPath, platform: "darwin" }, { checkAcl: () => true }).state).toBe(accepted ? "ready" : "invalid"); + } + }); + + test("Windows absolute paths match Rust drive and UNC rules exactly", () => { + const recordPath = join(box(), "cli.json"); + const cases: [string, boolean][] = [ + ["C:\\App\\ocx.exe", true], ["C:/App/ocx.exe", true], + ["\\\\server\\share\\ocx.exe", true], ["//server/share/ocx.exe", true], + ["\\\\server", true], ["\\\\", true], ["//", true], + ["\\ocx.exe", false], ["/ocx.exe", false], ["C:ocx.exe", false], + ["1:/ocx.exe", false], ["C:\\App\\..\\ocx.exe", false], ["C:/App/./ocx.exe", false], + ["C:\\App/../ocx.exe", false], + ]; + for (const [cliExecutable, accepted] of cases) { + writeFileSync(recordPath, JSON.stringify({ + version: 1, enabled: true, bundle: { platform: "win32", kind: "windows-install", cliExecutable }, + })); + expect(readDesktopCliRecord({ recordPath, platform: "win32" }).state).toBe(accepted ? "ready" : "invalid"); + } + }); + + test("platform-kind pairs are closed and the 64 KiB bound accepts a padded valid record", () => { + const recordPath = join(box(), "cli.json"); + const valid = { version: 1, enabled: true, bundle: { platform: "linux", kind: "linux-deb", cliExecutable: "/fixture/ocx" } }; + for (const kind of ["macos-app", "windows-install", "linux-appimage", "", null]) { + writeFileSync(recordPath, JSON.stringify({ ...valid, bundle: { ...valid.bundle, kind } })); + expect(readDesktopCliRecord({ recordPath, platform: "linux" })).toMatchObject({ issue: "record-invalid" }); + } + writeFileSync(recordPath, JSON.stringify({ ...valid, bundle: { ...valid.bundle, platform: "freebsd" } })); + expect(readDesktopCliRecord({ recordPath, platform: "freebsd" })).toMatchObject({ issue: "record-invalid" }); + const json = JSON.stringify(valid); + writeFileSync(recordPath, json + " ".repeat(DESKTOP_CLI_RECORD_MAX_BYTES - Buffer.byteLength(json))); + expect(readDesktopCliRecord({ recordPath, platform: "linux" }).state).toBe("ready"); + writeFileSync(recordPath, json + " ".repeat(DESKTOP_CLI_RECORD_MAX_BYTES - Buffer.byteLength(json) + 1)); + expect(readDesktopCliRecord({ recordPath, platform: "linux" })).toMatchObject({ issue: "record-too-large" }); + }); +}); + +test("AM-8 spawn ENOENT resumes only after lstat also reports ENOENT", async () => { + for (const synchronous of [false, true]) { + for (const check of ["exists", "ENOENT", "EACCES", "ELOOP", "ENOTDIR"]) { + let checked: string | undefined; + const fake = fakeSpawn("ENOENT"); + const spawnChild = synchronous ? (() => { throw coded("ENOENT"); }) as typeof spawn : fake.run; + const lstat = ((target: string) => { + checked = target; + if (check !== "exists") throw coded(check); + return { isFile: () => true }; + }) as unknown as typeof lstatSync; + const result = await runDesktopCliHandoff( + { kind: "handoff", target: "/fixture/desktop/ocx" }, + { argv: [], env: {}, proof, context }, + { spawn: spawnChild, parent: fake.parent as unknown as NodeJS.Process, lstat }, + ); + expect(checked).toBe("/fixture/desktop/ocx"); + expect(result).toEqual(check === "ENOENT" ? { kind: "continue" } : { kind: "error", issue: "spawn-failed" }); + expect(fake.parent.eventNames()).toHaveLength(0); + } + } +}); + +posixTest("AM-8 an executable with a missing interpreter exits 127 without Bun fallback", () => { + const fixture = packageFixture(); + writeFileSync(fixture.target, "#!" + join(fixture.home, "missing-interpreter") + "\nexit 0\n"); + chmodSync(fixture.target, 0o755); + const result = spawnSync("node", [launcher, "--version"], { env: fixture.env, encoding: "utf8", timeout: 5000 }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(127); + expect(result.stderr).toContain("Desktop CLI handoff failed (spawn-failed)"); + expect(result.stderr).not.toContain("OPENCODEX_BUN_PATH"); +}); + +posixTest("AM-2 record refusal names the record, repair and exact bypass for every issue", () => { + const fixture = packageFixture(); + const recordPath = join(fixture.home, ".opencodex-desktop", "cli.json"); + const cases = [ + ["record-invalid", "{"], + ["record-too-large", " ".repeat(DESKTOP_CLI_RECORD_MAX_BYTES + 1)], + ["record-pending", JSON.stringify({ version: 1, enabled: true, pending: {} })], + ["record-unsafe", null], + ] as const; + for (const [issue, content] of cases) { + rmSync(recordPath, { force: true }); + if (content === null) symlinkSync(recordPath, recordPath); + else writeFileSync(recordPath, content); + const result = spawnSync("node", [launcher, "--version"], { env: fixture.env, encoding: "utf8", timeout: 5000 }); + expect(result.status).toBe(1); + expect(result.stderr.trim()).toBe( + "opencodex: OpenCodex Desktop's terminal-command record at " + recordPath + + " could not be used (" + issue + "). Open OpenCodex Desktop to repair the terminal command, or run with OCX_NO_DESKTOP_HANDOFF=1.", + ); + expect(result.stderr).not.toContain("OPENCODEX_BUN_PATH"); + } +}); + +posixTest("real --version opts out to the package version even with a broken record", () => { + const fixture = packageFixture(); + writeFileSync(join(fixture.home, ".opencodex-desktop", "cli.json"), "{"); + const result = spawnSync("node", [launcher, "--version"], { + env: { ...fixture.env, OPENCODEX_BUN_PATH: process.execPath, OCX_NO_DESKTOP_HANDOFF: "1" }, + encoding: "utf8", timeout: 10000, + }); + const version = JSON.parse(readFileSync(repoPath("package.json"), "utf8")).version; + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe(`opencodex ${version}`); +}); + +function safetyFixture() { + const home = box(); + const directory = join(home, ".opencodex-desktop"); + mkdirSync(directory, { mode: 0o700 }); + chmodSync(directory, 0o700); + const recordPath = join(directory, "cli.json"); + writeFileSync(recordPath, JSON.stringify({ + version: 1, enabled: true, bundle: { platform: process.platform, kind: hostKind, cliExecutable: join(home, "ocx") }, + })); + chmodSync(recordPath, 0o600); + return { home, directory, recordPath }; +} +function expectUnsafe(read: DesktopCliRecordRead): void { + expect(read).toMatchObject({ state: "invalid", issue: "record-unsafe" }); + expect(planDesktopCliHandoff({ platform: "linux", argv: ["--version"], env: {}, recordRead: read }, { + stat: (() => { throw new Error("unsafe record must not inspect or select a target"); }) as typeof stat, + })).toEqual({ kind: "error", issue: "record-unsafe" }); +} + +posixTest("POSIX safety precedes JSON/disabled interpretation and accepts explicit 0700/0600", () => { + const fixture = safetyFixture(); + expect(readDesktopCliRecord({ home: fixture.home }).state).toBe("ready"); + for (const value of ['{"version":1,"enabled":false}', "{"]) { + writeFileSync(fixture.recordPath, value); + chmodSync(fixture.recordPath, 0o666); + expectUnsafe(readDesktopCliRecord({ home: fixture.home })); + chmodSync(fixture.recordPath, 0o600); + } + chmodSync(fixture.directory, 0o777); + expectUnsafe(readDesktopCliRecord({ home: fixture.home })); +}); + +posixTest("directory and record symlinks are refused without handoff", () => { + for (const linked of ["directory", "record"]) { + const fixture = safetyFixture(); + if (linked === "directory") { + const moved = join(fixture.home, "moved-directory"); + renameSync(fixture.directory, moved); + symlinkSync(moved, fixture.directory, "dir"); + } else { + const moved = join(fixture.directory, "moved-record"); + renameSync(fixture.recordPath, moved); + symlinkSync(moved, fixture.recordPath); + } + expectUnsafe(readDesktopCliRecord({ home: fixture.home })); + } +}); + +posixTest("stat seam refuses foreign ownership for either path without requiring root", () => { + for (const foreign of ["directory", "record"]) { + const fixture = safetyFixture(); + expectUnsafe(readDesktopCliRecord({ home: fixture.home }, { + lstat: path => { + const info = lstatSync(path); + if (path === (foreign === "directory" ? fixture.directory : fixture.recordPath)) info.uid += 1; + return info; + }, + })); + } +}); +const rootTest = process.platform !== "win32" && process.geteuid?.() === 0 ? test : test.skip; +if (process.platform === "win32" || process.geteuid?.() !== 0) console.log("SKIP real foreign-owner fixture: effective uid is not root; injected-owner cases still run."); +rootTest("root-only real foreign-owner directory and record are refused", () => { + for (const foreign of ["directory", "record"]) { + const fixture = safetyFixture(); + chownSync(foreign === "directory" ? fixture.directory : fixture.recordPath, 10001, -1); + expectUnsafe(readDesktopCliRecord({ home: fixture.home })); + } +}); + +posixTest("lstat to open replacement and every descriptor identity field fail closed", () => { + const fixture = safetyFixture(); + let actualFlags = 0; + expectUnsafe(readDesktopCliRecord({ home: fixture.home }, { + open: (path, flags) => { + actualFlags = flags; + renameSync(path, join(fixture.directory, "old-record")); + writeFileSync(path, '{"version":1,"enabled":false}'); + return openSync(path, flags); + }, + })); + expect(actualFlags & constants.O_NOFOLLOW).toBe(constants.O_NOFOLLOW); + expect(actualFlags & constants.O_NONBLOCK).toBe(constants.O_NONBLOCK); + for (const field of ["dev", "ino", "uid", "mode"] as const) { + expectUnsafe(readDesktopCliRecord({ home: fixture.home }, { + fstat: fd => { const info = fstatSync(fd); info[field] += 1; return info; }, + })); + } + expectUnsafe(readDesktopCliRecord({ home: fixture.home }, { + open: () => { throw coded("ENOENT"); }, + })); +}); + +posixTest("a FIFO record and a FIFO replacement return refusals before the 2-second deadline", () => { + for (const replaceAtOpen of [false, true]) { + const fixture = safetyFixture(); + if (!replaceAtOpen) { + rmSync(fixture.recordPath); + const created = spawnSync("mkfifo", ["-m", "600", fixture.recordPath], { encoding: "utf8", timeout: 1000 }); + expect(created.error).toBeUndefined(); + expect(created.status).toBe(0); + } + const script = [ + 'import { readDesktopCliRecord } from ' + JSON.stringify(pathToFileURL(repoPath("src", "lib", "desktop-cli-record.mjs")).href) + ';', + 'import { openSync, renameSync } from "node:fs";', + 'import { spawnSync } from "node:child_process";', + 'const path = ' + JSON.stringify(fixture.recordPath) + ';', + 'const deps = ' + replaceAtOpen + ' ? { open: (path, flags) => {', + 'renameSync(path, path + ".old");', + 'const made = spawnSync("mkfifo", ["-m", "600", path], { timeout: 1000 });', + 'if (made.error || made.status !== 0) throw new Error("mkfifo failed");', + 'return openSync(path, flags); } } : {};', + 'console.log(JSON.stringify(readDesktopCliRecord({ recordPath: path }, deps)));', + ].join("\n"); + const result = spawnSync("node", ["--input-type=module", "-e", script], { encoding: "utf8", timeout: 2000 }); + expect(result.error).toBeUndefined(); // Timeout termination is a failure, never refusal evidence. + expect(result.signal).toBeNull(); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ state: "invalid", issue: "record-unsafe" }); + } +}); + +posixTest("macOS ACL seam rejects entries and failed checks before JSON interpretation", () => { + const fixture = safetyFixture(); + writeFileSync(fixture.recordPath, '{"version":1,"enabled":false}'); + for (const checkAcl of [() => false, () => { throw coded("ETIMEDOUT"); }, () => { throw coded("ENOENT"); }]) { + expectUnsafe(readDesktopCliRecord({ recordPath: fixture.recordPath, platform: "darwin" }, { checkAcl })); + } + let checked: string[] = []; + expect(readDesktopCliRecord({ recordPath: fixture.recordPath, platform: "darwin" }, { + checkAcl: (directory, path) => { checked = [directory, path]; return true; }, + }).state).toBe("disabled"); + expect(checked).toEqual([fixture.directory, fixture.recordPath]); +}); +const macTest = process.platform === "darwin" ? test : test.skip; +if (process.platform !== "darwin") console.log("SKIP real macOS extended ACL fixture: chmod +a is only available on macOS."); +macTest("macOS real extended ACLs on a 0700 directory or 0600 record are refused", () => { + for (const aclTarget of ["directory", "record"]) { + const fixture = safetyFixture(); + const path = aclTarget === "directory" ? fixture.directory : fixture.recordPath; + const added = spawnSync("/bin/chmod", ["+a", "everyone allow read", path], { encoding: "utf8", timeout: 1000 }); + expect(added.error).toBeUndefined(); + expect(added.status).toBe(0); + expect(lstatSync(path).mode & 0o077).toBe(0); + expectUnsafe(readDesktopCliRecord({ home: fixture.home })); + } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 3fccbb72566..c6bea9df023 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -794,6 +794,7 @@ "oauth-status-privacy.test.ts": "oauth", "oauth-store-multi.test.ts": "oauth", "oauth-tos-warning.test.ts": "gui", "oauth-upsert-preserves-api-key.test.ts": "oauth", "ocx-launcher-runtime.test.ts": "cli", "ocx-launcher-source.test.ts": "cli", + "ocx-launcher-desktop-handoff.test.ts": "cli", "cli-path-diagnostics.test.ts": "cli", "ocx-run.test.ts": "cli", "ollama-native-parser.test.ts": "providers/ollama", "ollama-native-reasoning-wire.test.ts": "providers/ollama", "ollama-native-structured-output.test.ts": "providers/ollama", "ollama-native-v4.test.ts": "providers/ollama", From d17a9f2239031df31d978f1419f326142c00d88b Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 10 Oct 2026 00:39:03 +0900 Subject: [PATCH 44/59] feat(catalog): refresh already-connected clients after a background catalog change (#6836) When a background catalog auto-refresh changes the served set, a lifecycle-injected hook runs syncEnabledClientIntegrations in an unattended mode that refreshes only client integrations OpenCodex already wrote and still owns: an existing Grok fence, a Claude Desktop gateway profile that is still selected and byte-identical to the last applied write (mode kept, metadata and backups untouched), owned file integrations, and owned Aside profiles; never Cline and never a first enrollment. Every write rechecks the hub/sibling gate, an unchanged config snapshot and the scheduler generation through a new synchronous coordinated-writer guard, and server.stop revokes delivery before its first await. Attended callers are unchanged. Closes #6784 --- .../261009_n1_catalog_client/000_plan.md | 38 ++ .../010_carry_6801_role_effort.md | 73 +++ .../020_autorefresh_client_fanout.md | 283 +++++++++++ .../docs/reference/configuration/server.md | 8 + scripts/test-layout/layout.json | 8 +- src/claude/desktop-3p.ts | 65 ++- src/codex/catalog-auto-refresh.ts | 19 + src/grok/inject.ts | 54 ++- src/grok/sync.ts | 3 +- src/integrations/aside-profiles.ts | 13 +- src/integrations/catalog-refresh.ts | 20 +- src/integrations/writer.ts | 12 +- src/server/background-lifecycle.ts | 27 ++ src/server/index.ts | 1 + src/server/management/config-routes.ts | 67 ++- structure/catalog.md | 2 +- structure/clients/claude-desktop.md | 1 + structure/clients/integrations.md | 2 +- .../aside-profiles-catalog-refresh.test.ts | 146 ++++++ .../clients/integrations-writer-guard.test.ts | 138 ++++++ ...ync-client-integrations-unattended.test.ts | 448 ++++++++++++++++++ ...catalog-auto-refresh-client-fanout.test.ts | 226 +++++++++ tests/fixtures/test-layout-expected.json | 6 +- 23 files changed, 1628 insertions(+), 32 deletions(-) create mode 100644 devlog/_plan/261009_n1_catalog_client/000_plan.md create mode 100644 devlog/_plan/261009_n1_catalog_client/010_carry_6801_role_effort.md create mode 100644 devlog/_plan/261009_n1_catalog_client/020_autorefresh_client_fanout.md create mode 100644 tests/clients/aside-profiles-catalog-refresh.test.ts create mode 100644 tests/clients/integrations-writer-guard.test.ts create mode 100644 tests/clients/sync-client-integrations-unattended.test.ts create mode 100644 tests/codex-integration/catalog-auto-refresh-client-fanout.test.ts diff --git a/devlog/_plan/261009_n1_catalog_client/000_plan.md b/devlog/_plan/261009_n1_catalog_client/000_plan.md new file mode 100644 index 00000000000..5f1e4d305e3 --- /dev/null +++ b/devlog/_plan/261009_n1_catalog_client/000_plan.md @@ -0,0 +1,38 @@ +# 261009 N1 — catalog and client sweep lane + +Lane N1 of the 2026-10-09 sweep (coordinator chat 01a11fe0). Source worktree +`opencodex-lanes/261009-N1-catalog`, base `origin/dev` `730d898457`. + +## Items and disposition + +| Item | Status at P | Work-phase | +|---|---|---| +| #6819 / #6817 operator Ultra Fast retention | Squash-merged by a maintainer as `730d898457`; #6817 closed COMPLETED | none (done upstream) | +| #6801 LazyCodex role reasoning effort (LilMGenius) | Open, policy checks green, repository CI never ran (fork approval) | wp1 → [010](010_carry_6801_role_effort.md) | +| #6784 background auto-refresh client fan-out | Open feature issue | wp2 → [020](020_autorefresh_client_fanout.md) | + +## Constraints + +- One PR per item, both targeting `dev`; carried commits keep their author and the PR names + `Co-authored-by: LilMGenius `. +- Local proof is focused tests, `bun run typecheck`, and the gates each change touches + (structure, skill surface, test layout, file-size ratchet, privacy). Full suite is left to + exact-head hosted CI and recorded as such. +- `src/server/index.ts` sits at 887 of its 893-line cap; neither work-phase touches it. + `structure/*.md` documents are capped at 600 lines (config 600, runtime 600, catalog 599, + clients/integrations 598), so contract edits extend existing paragraphs in place. +- Background writes never touch a client file that OpenCodex has not already written, and never + touch Cline (its unattended-refresh exclusion is an existing contract). +- No merge, no comments on or closing of other PRs/issues, no release. + +## Dependency order + +wp0 (this roadmap) → wp1 and wp2 are independent of each other. wp1 runs first because it is a +carry with a known diff; wp2 is new design. + +## Verification map + +| Phase | Local | Hosted | +|---|---|---| +| wp1 | omo-role-models, codex-agent-role-routes, codex-role-auto-assign-routes, codex-agent-role-models, cli agent roles tests, skill-ocx, file-size-ratchet, test-layout; typecheck; skill:surface:check; structure:check | Cross-platform CI on the PR head | +| wp2 | new client-fanout scheduler test, new unattended sync test, sync-client-integrations, catalog-auto-refresh-scheduler, core-lab-boundary, test-layout, file-size-ratchet; typecheck; structure:check; privacy:scan | Cross-platform CI on the PR head | diff --git a/devlog/_plan/261009_n1_catalog_client/010_carry_6801_role_effort.md b/devlog/_plan/261009_n1_catalog_client/010_carry_6801_role_effort.md new file mode 100644 index 00000000000..15b08e4d17a --- /dev/null +++ b/devlog/_plan/261009_n1_catalog_client/010_carry_6801_role_effort.md @@ -0,0 +1,73 @@ +# 010 — wp1: carry #6801 (LazyCodex role reasoning effort) + +Branch `codex/n1-lazycodex-role-effort` from `origin/dev` `730d898457`. + +## Carry + +```sh +git -C fetch origin pull/6801/head +git -C switch -c codex/n1-lazycodex-role-effort origin/dev +git -C cherry-pick 3771131fa7 c684e3981a f904047cf4 329ace492a +``` + +Independent review (sol, read-only) confirmed `git apply --check` and `merge-tree` are clean on +`730d898457`; the four commits produce ten changed files. Carried behaviour: + +- `GET /api/codex-agent-roles` adds `effort` (root `model_reasoning_effort` or `null`). +- `PUT /api/codex-agent-roles/{role}` with `effort` also writes + `"[codex]".agents..reasoning` in omo.jsonc through `omoReasoningFor` + (`none → off`; `ultra` has no LazyCodex level, so `null` deletes a stale `reasoning`). + A model-only PUT passes `undefined` and keeps any existing `reasoning`. +- `ocx agent roles set --effort `; `ocx agent roles` prints the effort. + +## Fix 1 — empty `--effort` must not become a model-only write (MODIFY `src/cli/agent.ts`) + +`takeOption` returns `""` for `--effort ""`, and the carried body expression drops it, so the +command changes the model and silently ignores the supplied flag. + +```diff + const effort = takeOption(args, "--effort"); ++ if (effort !== undefined && effort.trim() === "") { ++ throw new CliUsageError("--effort needs a reasoning level", USAGE); ++ } + const role = args.shift(); +@@ +- { method: "PUT", body: JSON.stringify(effort ? { model, effort } : { model }) }, ++ { method: "PUT", body: JSON.stringify(effort !== undefined ? { model, effort } : { model }) }, +``` + +Regression: `tests/cli/cli-headless-parity.test.ts` (near :991, the documented CLI resource cases) asserts that +`--effort ""` throws `CliUsageError` with no request sent, and that `--effort high` sends +`{ model, effort: "high" }`. + +## Fix 2 — owning contract text (MODIFY `structure/clients/integrations.md`) + +The omo role-model paragraph still says the write "changes one value the user just chose". Rewrite +that sentence in place (file is at 598/600 lines; no net new lines): + +```diff +-snapshot, or journal. It changes one value the user just chose and leaves every other key as it +-was, re-serialized with the file's indentation, line endings, and BOM. ++snapshot, or journal. It changes the role's `model` and, when the request carries an effort, its `reasoning` (`none` becomes `off`; a level LazyCodex lacks, such as `ultra`, removes a stale `reasoning`; no effort keeps it), and leaves every other key as it ++was, re-serialized with the file's indentation, line endings, and BOM. +``` + +## Route regression (MODIFY `tests/server/codex-agent-role-routes.test.ts`) + +Add a case: an omo.jsonc entry with `reasoning: "high"` survives a model-only PUT (TOML effort +untouched, omo `reasoning` unchanged). + +## Commit and PR + +One fix commit on top of the four carried commits, with trailer +`Co-authored-by: LilMGenius `. PR title +`feat(codex): carry each LazyCodex role's reasoning effort into omo.jsonc (carry #6801)`; template +sections filled; Verification lists exact commands and the CI-left coverage. + +## P re-verification (wp1 entry) + +`origin/dev` moved to `bb36029ef9` (#6820, Codex restore on a missing home). It touches none of the +carried files, the CLI test, or `structure/clients/integrations.md`; #6801's head is still +`329ace492a`. The branch starts from `bb36029ef9`; the plan above is unchanged. Merge authority was +granted afterwards by the coordinator: squash-merge with `--admin` after the gate, then close #6801 +with a credit comment. diff --git a/devlog/_plan/261009_n1_catalog_client/020_autorefresh_client_fanout.md b/devlog/_plan/261009_n1_catalog_client/020_autorefresh_client_fanout.md new file mode 100644 index 00000000000..0b99723d36a --- /dev/null +++ b/devlog/_plan/261009_n1_catalog_client/020_autorefresh_client_fanout.md @@ -0,0 +1,283 @@ +# 020 — wp2: background catalog refresh reaches already-connected clients (#6784) + +Branch `codex/n1-catalog-autorefresh-fanout` from `origin/dev`. +Amended after the wp0 audit (FAIL, five blockers: writer-level cancellation, Desktop ownership, +Desktop mode preservation, Aside owned refresh, hub gate). Every blocker is folded below. + +## Current behaviour (730d898457) + +- `src/codex/catalog-auto-refresh.ts` tick converges the Codex catalog + (`createManagementConvergeCodex(config)`), records the outcome and logs; nothing else. +- `/api/sync` and fast-row settings saves call + `syncEnabledClientIntegrations(port, config, deps)` in + `src/server/management/config-routes.ts:203`: Grok (toggle on), Claude Desktop (on and not + first-party; always written in `"static"` mode at :253), then + `refreshOwnedCatalogIntegrations` for ten file clients. +- That helper is not owned-only: native toggles default on, so it can create a Grok fence and + create/select a Desktop profile (`desktop-3p.ts:745-762`); Aside can first-apply a newly enabled + profile (`aside-profiles.ts:278`); Cline is in its list although + `structure/clients/integrations.md` excludes Cline from unattended refresh. Its native + predicates do not check `localClientSyncAllowed` (`desired-state.ts:80`), which a hub needs. + +## Rule for the unattended path + +Refresh only what OpenCodex already wrote and still owns, preserve how it was written, never +first-enroll, and stop before any write once the scheduler generation is no longer current. + +## Design + +1. MODIFY `src/codex/catalog-auto-refresh.ts` — injectable hook, no new static imports. + +```ts +/** Refreshes already-connected client integrations after a changed catalog; set by the server lifecycle. */ +export type CatalogChangedClientFanout = + (isCurrent: () => boolean) => Promise>; +let clientFanout: CatalogChangedClientFanout | null = null; +export function setCatalogAutoRefreshClientFanout(fanout: CatalogChangedClientFanout | null): void { + clientFanout = fanout; +} +``` + + In `tick()` after `recordCatalogAutoRefreshOutcome(...)` and the existing change log: + +```ts + const fanout = clientFanout; + if (outcome.changed && fanout) { + const current = () => entryGeneration === generation; + try { + const failed = (await fanout(current)).filter(result => !result.ok).length; + // Privacy scan: counts only, never client reasons, paths, or identifiers. + if (current() && failed > 0) console.warn(`[catalog-auto-refresh] ${failed} client integration(s) were not refreshed; ocx sync retries them`); + } catch { + if (current()) console.warn("[catalog-auto-refresh] client integrations were not refreshed; ocx sync retries them"); + } + } +``` + +2. MODIFY `src/server/background-lifecycle.ts` — set the hook in `startProcessLoops` before + `startCatalogAutoRefresh()`; clear it in `stopProcessLoops` and in the start failure path. + The hook is process-wide, like the loops; it resolves the port from the process runtime record + at call time, so lease hand-over between in-process servers needs no re-registration. + +```ts +/** Unattended fan-out for a changed background catalog; heavy modules load on demand. */ +async function refreshConnectedClientsAfterCatalogChange(isCurrent: () => boolean) { + const [{ readRuntimePort }, { loadConfig }, { localClientSyncAllowed }, { syncEnabledClientIntegrations }] = + await Promise.all([ + import("../config/process-state"), + import("../config"), + import("../codex/desired-state"), + import("./management/config-routes"), + ]); + const runtime = readRuntimePort(process.pid); + const config = loadConfig(); + // Hub and sibling gate for every local client, independent of why the catalog tick ran. + if (!runtime || !isCurrent() || !localClientSyncAllowed(config)) return []; + return syncEnabledClientIntegrations(runtime.port, config, {}, { unattended: { isCurrent } }); +} +``` + +3. MODIFY `src/server/management/config-routes.ts` `syncEnabledClientIntegrations` — fourth + parameter `options: { unattended?: { isCurrent: () => boolean } } = {}`. Attended callers pass + nothing and keep today's behaviour byte for byte. With `unattended`: + - `stale = () => unattended !== undefined && !unattended.isCurrent()`; return the accumulated + outcomes when `stale()` before each client. + - **Grok**: skip unless `grokManagedBlockPresent()`. Call `syncGrokConfig` with deps whose + `injectGrokConfig` wrapper rechecks, immediately before delegating, `!stale()`, the fresh + `grokIntegrationEnabled(loadConfig())`, and `grokManagedBlockPresent()`; otherwise it returns + `{ ok: true, changed: false, message: "Grok refresh skipped" }` without writing. + - **Claude Desktop**: `const mode = desktop3pUnattendedRefreshMode(latest.claudeCode?.desktopProfile?.appliedFingerprint)` + is evaluated inside `runPickerTransition` on the fresh `latest` read; skip when `null` or + `stale()`. The writer receives `mode` instead of the literal `"static"` only on this path. + - **File clients**: list minus `cline` (contract exclusion); pass + `{ isCurrent, refreshOnly: true }` to `refreshOwnedCatalogIntegrations`. + +4. MODIFY `src/claude/desktop-3p.ts` — NEW export: + +```ts +/** + * Unattended refresh gate. Returns the applied profile's own mode only when OpenCodex owns the + * selected profile and its bytes still match our last write; anything else is null (skip). + */ +export function desktop3pUnattendedRefreshMode(appliedFingerprint: string | null | undefined): Desktop3pConfigMode | null { + if (!appliedFingerprint) return null; + const inspected = inspectDesktop3pConfigLibrary({ appliedFingerprint }); + if (inspected.kind !== "gateway_ours" || inspected.ownedProfileActive !== true || !inspected.selectedProfilePath) return null; + try { + const profile = JSON.parse(readFileSync(inspected.selectedProfilePath, "utf8")) as Record; + if (profile.modelDiscoveryEnabled !== true) return "static"; + return Array.isArray(profile.inferenceModels) ? "hybrid" : "discovery"; + } catch { + return null; + } +} +``` + + `gateway_ours` requires the selected entry to be owned and the on-disk sha to equal + `appliedFingerprint`, so a deleted, foreign, reselected, broken or user-edited profile is refused + and no new profile is allocated or selected (`existing` is always the selected owned entry). + +5. MODIFY `src/integrations/catalog-refresh.ts` — third parameter + `options: { isCurrent?: () => boolean; refreshOnly?: boolean } = {}`: + - break out of the client loop when `options.isCurrent?.() === false`; + - pass `revalidate` to `refreshOwnedIntegration(..., { revalidate })` and to Aside, where + `revalidate` returns `{ ok: false, reason: "superseded_store", state: "current", clientId, message: "Background refresh superseded" }` + when `isCurrent()` is false (checked under the writer lock, before any side effect, + `writer.ts:917`); otherwise null. + - pass `refreshOnly` to `refreshAsideProfiles`. + +6. MODIFY `src/integrations/owned-refresh.ts` — none needed: `options` already reaches + `refreshIntegrationCoordinated`. The models load happens before the lock; the lock-held + revalidate is the write gate. + +7. MODIFY `src/integrations/aside-profiles.ts` `refreshAsideProfiles(input, options?)` with + `options: { refreshOnly?: boolean; revalidateBeforeWrite?: ... }`: when `refreshOnly` and the + profile is not owned, `continue` (no first apply); pass `revalidate` into the coordinated + operation, matching the existing pattern at `aside-profiles.ts:244`. + +8. NEW export in `src/grok/inject.ts`: + +```ts +/** True when config.toml holds a complete OpenCodex-managed block (refresh-only callers). */ +export function grokManagedBlockPresent(grokHome?: string): boolean { + try { + const path = join(resolveGrokHome(grokHome), "config.toml"); + if (!existsSync(path)) return false; + const region = findManagedRegion(applyEol(readFileSync(path, "utf8"), "\n")); + return region != null && !region.orphaned; + } catch { + return false; + } +} +``` + +## Tests (NEW sibling files; register each in `scripts/test-layout/layout.json` explicit and +`tests/fixtures/test-layout-expected.json`) + +- `tests/codex-integration/catalog-auto-refresh-client-fanout.test.ts`: hook called once when the + converge reports `changed`; not on unchanged; rejection/failed result swallowed with a + count-only log; stop during converge prevents the call; `isCurrent` false after stop during the + hook. +- `tests/clients/sync-client-integrations-unattended.test.ts` (isolated homes): + Grok skipped without a managed block, refreshed with one, and not written when the block is + removed or generation stops during discovery; Desktop skipped with no marker, a stale marker, + a foreign selection or a deleted profile, and an applied hybrid/discovery/static profile keeps + its mode; `cline` never reaches the owned refresher; a stale `isCurrent` refuses under the lock; + attended call unchanged. +- `tests/integrations/aside-profiles-refresh-only.test.ts` (or the nearest existing Aside domain): + refreshOnly skips an unowned enabled profile and refreshes an owned one. +- Background lifecycle: hook registered on start, cleared on last release; hub without loopback + listener and sibling return `[]`; missing runtime record returns `[]`. + +Verification adds `tests/lab/core-lab-boundary.test.ts` and +`tests/usage/quota-reset-core-boundary.test.ts` to the focused set. + +## Docs + +- `structure/catalog.md:102` — append to the scheduler paragraph (same line). +- `structure/clients/integrations.md` — extend the Cline unattended sentence to name the + background refresh rule (no net new lines; file at 598/600). +- `docs-site/src/content/docs/reference/configuration/server.md` catalogAutoRefresh section — + which clients follow a background refresh and why others need `ocx sync`. + +## Out of scope + +Convergence `beforeCommit` generation guard for a stale tick's Codex commit (existing behaviour); +triggering on roster changes that leave Codex bytes unchanged; a Grok writer lock. + +## Amendment after audit round 2 (supersedes items 3, 4, 5 and 7 where they differ) + +Round 2 left two blockers: an await gap between `revalidate` and the file mutation, and a +Desktop precondition evaluated outside the Desktop writer's locks. Both are closed with a +synchronous final guard that runs under the relevant lock with no await before the write. + +A. MODIFY `src/integrations/writer.ts` — `CoordinatedIntegrationOptions` gains + +```ts + /** + * Synchronous last word, evaluated with no await between it and the transaction (after + * `revalidate` and any lock acquisition). A non-null result refuses without writing. + */ + guard?: (frozen: IntegrationWriteInput) => WriteOutcome | null; +``` + + `coordinatedWrite` wraps its operation once: + `const guarded = (frozen) => options?.guard?.(frozen) ?? operation(frozen);` and uses + `guarded` on all three paths (no writer lock, absent client home, `withClientLocks`). Restore is + unchanged. Attended callers pass no guard. + +B. `src/integrations/catalog-refresh.ts` third parameter becomes + `options: { refreshOnly?: boolean; admit?: () => boolean } = {}`. It stops the client loop when + `admit?.() === false` and passes + `guard: frozen => admit() ? null : supersededRefusal(frozen.clientId)` (typed `WriteOutcome`, + `reason: "superseded_store"`, `state: "current"`) into `refreshOwnedIntegration(..., { guard })` + and `refreshAsideProfiles(..., { refreshOnly, guard })`. + +C. `src/integrations/aside-profiles.ts` `refreshAsideProfiles(input, options?: { refreshOnly?: boolean; guard?: CoordinatedIntegrationOptions["guard"] })`: + skip an unowned profile when `refreshOnly`; pass `guard` into the coordinated operation; the + outcome keeps `profileId` from the loop (the guard's refusal has none). + +D. `config-routes.ts` builds `admit = () => unattended.isCurrent() && localClientSyncAllowed(loadConfig())` + (fresh read; hub/sibling permission rechecked at every write) and passes it to B, the Grok + wrapper and the Desktop writer. + +E. MODIFY `src/claude/desktop-3p.ts` `writeDesktop3pConfig` — new trailing parameter + `refreshOnly?: { appliedFingerprint: string; admit: () => boolean }`. Inside the existing + `withClientLifecycleSync(withConfigMutationLockSync(...))` callback (synchronous), after the + desired-state and remote-store checks, a refresh-only call: + 1. returns `{ written: false, reason: "desktop_refresh_only_skipped" }` unless `admit()`, + `latest.config.claudeCode?.desktopProfile?.appliedFingerprint === refreshOnly.appliedFingerprint`, + `inspectDesktop3pConfigLibrary({ appliedFingerprint }).kind === "gateway_ours"`, and the metadata's + applied entry satisfies `isOwnedDesktopGatewayEntry`; + 2. derives the mode from the selected profile bytes read under the same lock + (`modelDiscoveryEnabled !== true` → static; with an `inferenceModels` array → hybrid; else + discovery) and ignores the caller's `mode`; + 3. calls `writeDesktop3pConfigWithGenerator(generate, { requiredId: appliedId })`, which refuses + (`desktop_refresh_only_skipped`) instead of allocating or selecting when the applied gateway + entry is not `requiredId`. + Item 4's standalone `desktop3pUnattendedRefreshMode` helper is dropped; the gate lives inside the + writer. `config-routes.ts` keeps a cheap pre-check (no marker → skip before model discovery) and + omits a `desktop_refresh_only_skipped` result from the outcomes (not touched ≠ failed). + +F. Grok wrapper (item 3) is unchanged: `injectGrokConfig` is synchronous, so the wrapper's + `admit()`, fresh toggle and `grokManagedBlockPresent()` checks run with no await before the write. + +Added tests: writer guard refuses with no file change on each of the three coordinated paths; +admit turning false between `revalidate` and the mutation refuses; Desktop refresh-only refuses a +standard/foreign selection, a stale marker, a deleted profile and an edited profile, preserves +static/hybrid/discovery, and never allocates a new profile; Aside refresh-only skips unowned. + +## Audit round 3 notes folded (PASS) + +- B attaches `guard` only when `options.admit` is supplied; attended callers never invoke it. +- Every Desktop refresh-only refusal returns the required `path` field + (`resolveDesktop3pConfigLibraryPath()`), like the existing refusals. +- `refreshOnly` is the ninth parameter of `writeDesktop3pConfig`, after `lifecycleLockDeps`, so the + existing test seam keeps its position. + +## P re-verification and implementation-review amendments (wp2 entry) + +`origin/dev` is now `c1a360993f` (#6823 landed wp1; #6734). Neither touches this unit's files +except `structure/clients/integrations.md`, in a different paragraph. The implementation was +prepared against `37e9294125` and reviewed in three rounds by an independent sol reviewer and a +sol security reviewer (both PASS on round 3). Their findings changed the design as follows: + +- **Config freshness.** Unattended `admit()` also requires the fresh config to equal the snapshot + used for projection, ignoring only `claudeCode.desktopProfile.appliedFingerprint/appliedAt`. + An exclusion saved during discovery can no longer be undone by the background write. +- **Grok.** `injectGrokConfig` takes `refreshOnly: { admit }` (threaded through `syncGrokConfig`). + Inside its synchronous transaction it refuses without a complete fence, on a symlinked + `config.toml` or backup, or when not admitted. It writes no backup and publishes with + `atomicWriteFileNoFollow`. Its `validateBeforeRename` re-reads the file and refuses on drift. +- **Desktop.** Refresh-only publication writes no backup and leaves `_meta.json` untouched, so a + concurrent native selection is never undone. It publishes with `atomicWriteFileNoFollow`. + `validateBeforeRename` rechecks `admit()`, the selected owned gateway entry, a regular-file + profile, and the applied fingerprint. Symlinked library, metadata, profile or backup refuses. +- **Shutdown.** `ServerBackgroundLifecycleLease.revokeClientFanout()` is the first statement of the + `server.stop` override in `src/server/index.ts` (one line; 888/893). A delivery binds to the owner + captured at its start and stops when that owner is revoked or released. +- **Docs.** `structure/clients/claude-desktop.md` gains the refresh-only contract. + +Residual risk accepted by both reviewers: a non-cooperating native process (Claude Desktop, Grok) +can still change bytes in the synchronous interval between `validateBeforeRename` and the rename. +This is the same trust assumption the attended writers already make. diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 70e0fd0a32c..ad97ba0422c 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -477,6 +477,14 @@ Codex app-servers are running, the proxy logs a restart hint and records `reload in its auto-refresh status. Run `ocx sync --restart-codex` when ready to restart those sessions. Automatic refresh never restarts them. +When a background refresh changes the served set, OpenCodex also updates client integrations +it has already written: an existing Grok Build block, the Claude Desktop gateway profile it +applied (only while that profile is still selected and unedited, keeping its static, hybrid or +discovery mode), and the owned blocks of file integrations such as OpenCode, Kilo, Pi, omo and +Aside. It never connects a client for the first time, never refreshes Cline (stop Cline and +run `ocx sync` instead), and skips local clients on a hub without the loopback listener. A +client it could not refresh is counted in the proxy log; `ocx sync` retries it. + ## Quota-reset notifications (`quotaResetNotify`) Off by default. When the section is absent, no detection runs, no timer starts, and no state diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 6c804c91a0a..adb7db7b2b6 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -246,7 +246,7 @@ "aside-profile-paths.test.ts": "clients", "aside-profile-sync-owner.test.ts": "clients", "aside-profiles-routes.test.ts": "server", - "aside-profiles.test.ts": "clients", + "aside-profiles.test.ts": "clients", "aside-profiles-catalog-refresh.test.ts": "clients", "assert-mergeable-review.test.ts": "ci-workflows", "audio-client.test.ts": "server", "audio-dictation.test.ts": "server", @@ -277,7 +277,7 @@ "bun-stream-caps.test.ts": "lib", "cache-diagnostic.test.ts": "usage", "cancel-body-on-abort.test.ts": "server", - "catalog-auto-refresh-scheduler.test.ts": "codex-integration", + "catalog-auto-refresh-client-fanout.test.ts": "codex-integration", "catalog-auto-refresh-scheduler.test.ts": "codex-integration", "catalog-cursor-search.test.ts": "codex-integration", "catalog-custom-ordering.test.ts": "codex-integration", "catalog-duplicate-slug-dedup.test.ts": "codex-integration", @@ -1046,7 +1046,7 @@ "integrations-state.test.ts": "clients", "integrations-superseded-store.test.ts": "clients", "integrations-writer-frozen-config.test.ts": "clients", - "integrations-writer.test.ts": "clients", + "integrations-writer.test.ts": "clients", "integrations-writer-guard.test.ts": "clients", "interactive-confirm.test.ts": "cli", "issue-452-empty-503.test.ts": "codex-integration", "issue-702-expired-replay-state.test.ts": "codex-integration", @@ -1801,7 +1801,7 @@ "local-chat-completion.test.ts": "lib", "injection-model-suggest-routes.test.ts": "codex-integration", "subagent-roster-retention.test.ts": "routing", - "sync-client-integrations.test.ts": "clients", + "sync-client-integrations-unattended.test.ts": "clients", "sync-client-integrations.test.ts": "clients", "synced-state-location.test.ts": "lib", "synthetic-tool.test.ts": "images", "system-env.test.ts": "server", diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index 5ce4da5f21f..e2f0eec30e6 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -10,9 +10,10 @@ import { } from "./desktop-3p-library"; export { resolveDesktop3pConfigLibraryPath, type Desktop3pConfigLibraryOptions } from "./desktop-3p-library"; import { createHash, randomUUID } from "node:crypto"; -import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, lstatSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { atomicWriteFile, readConfigDiagnostics, withConfigMutationLockSync } from "../config"; +import { atomicWriteFileNoFollow } from "../config/atomic-write"; import { claudeDesktopIntegrationEnabled } from "../codex/desired-state"; import type { OcxClaudeDesktopProfile } from "../types"; import { @@ -662,6 +663,7 @@ export function writeDesktop3pConfig( profile?: OcxClaudeDesktopProfile, nativeContextCap?: NativeContextLimitsInput, lifecycleLockDeps?: ClientLifecycleLockDeps, + refreshOnly?: { appliedFingerprint: string; admit: () => boolean }, ): { written: boolean; path: string; reason?: string; fingerprint?: string } { try { return withClientLifecycleSync(() => withConfigMutationLockSync(() => { @@ -674,9 +676,29 @@ export function writeDesktop3pConfig( if (!claudeDesktopIntegrationEnabled(latest.config)) { return { written: false, path: resolveDesktop3pConfigLibraryPath(), reason: "desired_state_changed" }; } + if (refreshOnly && !desktopRefreshPathsSafe(resolveDesktop3pConfigLibraryPath())) { + return { written: false, path: resolveDesktop3pConfigLibraryPath(), reason: "desktop_refresh_only_skipped" }; + } if (connection.kind === "connected" || inspectRemoteDesktopCleanup().kind !== "absent") { return { written: false, path: resolveDesktop3pConfigLibraryPath(), reason: "desktop_remote_store_active" }; } + let requiredId: string | undefined; + if (refreshOnly) { + const refused = { written: false, path: resolveDesktop3pConfigLibraryPath(), reason: "desktop_refresh_only_skipped" }; + // Ownership and bytes must still match under L→C; discovery outside these locks is not authority. + if (!refreshOnly.admit() || latest.config.claudeCode?.desktopProfile?.appliedFingerprint !== refreshOnly.appliedFingerprint) return refused; + const inspected = inspectDesktop3pConfigLibrary({ appliedFingerprint: refreshOnly.appliedFingerprint }); + if (inspected.kind !== "gateway_ours" || !inspected.appliedId || !inspected.selectedProfilePath) return refused; + if (!desktopRefreshPathsSafe(inspected.libraryPath, inspected.selectedProfilePath)) return refused; + const metadata = parseMetadata(join(inspected.libraryPath, "_meta.json")); + if (!isOwnedDesktopGatewayEntry(metadata.entries.find(entry => entry.id === inspected.appliedId))) return refused; + try { + const selected = JSON.parse(readFileSync(inspected.selectedProfilePath, "utf8")) as Record; + mode = selected.modelDiscoveryEnabled !== true ? "static" + : Array.isArray(selected.inferenceModels) ? "hybrid" : "discovery"; + } catch { return refused; } + requiredId = inspected.appliedId; + } // Claude Desktop runs on this machine, so it dials the unauthenticated loopback listener // when one is enabled — on such a hub that is the only credential-free local socket // (#4236) — and otherwise the bind address, which answers but demands data-plane @@ -701,7 +723,7 @@ export function writeDesktop3pConfig( } return writeDesktop3pConfigWithGenerator(() => ( generateDesktop3pConfig(destination.origin, nativeSlugs, routedModels, gatewayKey, mode, profile, nativeContextCap) - )); + ), requiredId && refreshOnly ? { requiredId, expectedFingerprint: refreshOnly.appliedFingerprint, admit: refreshOnly.admit } : undefined); }), lifecycleLockDeps); } catch { return { written: false, path: resolveDesktop3pConfigLibraryPath(), reason: "desktop_lifecycle_busy_or_unsafe" }; } } @@ -734,17 +756,38 @@ export function writeRemoteDesktop3pConfig(options: { } catch { return { written: false, path: "", reason: "desktop_lifecycle_busy_or_unsafe" }; } } +/** Refresh-only writes never follow links, including a dangling backup link. */ +function desktopRefreshPathsSafe(libraryPath: string, configPath?: string): boolean { + try { + if (!lstatSync(libraryPath).isDirectory() || !lstatSync(join(libraryPath, "_meta.json")).isFile()) return false; + if (configPath) { + if (!lstatSync(configPath).isFile()) return false; + try { if (lstatSync(`${configPath}.bak`).isSymbolicLink()) return false; } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false; } + } + return true; + } catch { return false; } +} + function writeDesktop3pConfigWithGenerator( generate: () => object, + refreshOnly?: { requiredId: string; expectedFingerprint: string; admit: () => boolean }, ): { written: boolean; path: string; reason?: string; fingerprint?: string } { const libraryPath = resolveDesktop3pConfigLibraryPath(); const metadataPath = join(libraryPath, "_meta.json"); let configPath = libraryPath; try { - mkdirSync(libraryPath, { recursive: true, mode: 0o700 }); + if (refreshOnly && !desktopRefreshPathsSafe(libraryPath)) { + return { written: false, path: libraryPath, reason: "desktop_refresh_only_skipped" }; + } + if (!refreshOnly) mkdirSync(libraryPath, { recursive: true, mode: 0o700 }); const metadata = parseMetadata(metadataPath); const selected = metadata.entries.find(entry => entry?.id === metadata.appliedId && isOwnedDesktopGatewayEntry(entry)); + // A refresh may reuse only the applied entry; fallback allocation would silently enroll again. + if (refreshOnly && selected?.id !== refreshOnly.requiredId) { + return { written: false, path: libraryPath, reason: "desktop_refresh_only_skipped" }; + } const existing = selected ?? metadata.entries.find(entry => isOwnedDesktopGatewayEntry(entry) && typeof entry.id === "string"); const id = existing?.id ?? randomUUID(); configPath = profilePath(libraryPath, id); @@ -757,6 +800,22 @@ function writeDesktop3pConfigWithGenerator( const preserved = readDesktopProfileForeignKeys(configPath); const configJson = JSON.stringify({ ...preserved, ...generated }, null, 2) + "\n"; const fingerprint = createHash("sha256").update(configJson).digest("hex").slice(0, 16); + if (refreshOnly) { + atomicWriteFileNoFollow(configPath, configJson, undefined, { + validateBeforeRename: () => { + try { + if (!refreshOnly.admit() || !desktopRefreshPathsSafe(libraryPath, configPath)) throw new Error(); + const freshMetadata = parseMetadata(metadataPath); + if (freshMetadata.appliedId !== refreshOnly.requiredId + || !isOwnedDesktopGatewayEntry(freshMetadata.entries.find(entry => entry.id === refreshOnly.requiredId)) + || createHash("sha256").update(readFileSync(configPath)).digest("hex").slice(0, 16) !== refreshOnly.expectedFingerprint) { + throw new Error(); + } + } catch { throw new Error("desktop_refresh_only_skipped"); } + }, + }); + return { written: true, path: configPath, fingerprint }; + } const { backupPath } = atomicReplaceDesktopConfig(configPath, configJson); try { atomicWriteFile(metadataPath, JSON.stringify({ ...metadata, appliedId: id, entries }, null, 2) + "\n"); diff --git a/src/codex/catalog-auto-refresh.ts b/src/codex/catalog-auto-refresh.ts index b3362054df3..c9e75dbd3a1 100644 --- a/src/codex/catalog-auto-refresh.ts +++ b/src/codex/catalog-auto-refresh.ts @@ -50,6 +50,14 @@ let generation = 0; /** setInterval does not skip a firing while the previous callback is still awaiting. */ let inFlight = false; +/** Refreshes already-connected clients after a changed catalog; owned by the server lifecycle. */ +export type CatalogChangedClientFanout = + (isCurrent: () => boolean) => Promise>; +let clientFanout: CatalogChangedClientFanout | null = null; +export function setCatalogAutoRefreshClientFanout(fanout: CatalogChangedClientFanout | null): void { + clientFanout = fanout; +} + /** Read only a regular file, with a byte limit even if it grows after the stat. */ function readBoundedRegularFile(path: string, maxBytes: number): string | null { let fd: number | undefined; @@ -259,6 +267,17 @@ async function tick(): Promise { ? "[catalog-auto-refresh] served model set changed; running Codex sessions keep the old list until restarted (ocx sync --restart-codex)" : "[catalog-auto-refresh] served model set changed"); } + const fanout = clientFanout; + if (outcome.changed && fanout) { + const current = () => entryGeneration === generation; + try { + const failed = (await fanout(current)).filter(result => !result.ok).length; + // Outcomes can contain private paths and reasons; only the failure count leaves this hook. + if (current() && failed > 0) console.warn(`[catalog-auto-refresh] ${failed} client integration(s) were not refreshed; ocx sync retries them`); + } catch { + if (current()) console.warn("[catalog-auto-refresh] client integrations were not refreshed; ocx sync retries them"); + } + } } catch { // A failed refresh is not an error worth surfacing: the next tick tries again. } finally { diff --git a/src/grok/inject.ts b/src/grok/inject.ts index 2c9e2b3a75f..179571cfcb0 100644 --- a/src/grok/inject.ts +++ b/src/grok/inject.ts @@ -1,7 +1,8 @@ -import { constants, copyFileSync, existsSync, readFileSync, statSync } from "node:fs"; +import { constants, copyFileSync, existsSync, readFileSync, lstatSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { atomicWriteFile } from "../config"; +import { atomicWriteFileNoFollow } from "../config/atomic-write"; import { applyEol, dominantEol, isLoopbackHostname, providerBaseHost } from "../codex/inject"; import { grokDefaultReasoningEffort, @@ -22,7 +23,7 @@ export interface GrokInjectResult { ok: boolean; changed: boolean; message: string; - skippedReason?: "no-grok-home" | "orphaned-marker" | "non-loopback"; + skippedReason?: "no-grok-home" | "orphaned-marker" | "non-loopback" | "refresh-only"; } const BEGIN_MARKER = "# >>> opencodex managed block — do not edit (removed by `ocx stop`) >>>"; @@ -78,6 +79,18 @@ export function findManagedRegion(content: string): ManagedRegion | null { return { start: begin.index, end: endMatch.index + endMatch[0].length, orphaned: false }; } +/** A complete managed fence is the existing opt-in required by refresh-only callers. */ +export function grokManagedBlockPresent(grokHome?: string): boolean { + try { + const path = join(resolveGrokHome(grokHome), "config.toml"); + if (!lstatSync(path).isFile()) return false; + const region = findManagedRegion(applyEol(readFileSync(path, "utf8"), "\n")); + return region != null && !region.orphaned; + } catch { + return false; + } +} + /** * A TOML key segment as it may be spelled in a table header: bare, basic string, or literal * string. All three spellings of the same key address the SAME table, so both segments of a @@ -1074,12 +1087,27 @@ export function buildGrokManagedBlock( return lines.join("\n"); } +class GrokRefreshOnlyRefusal extends Error {} + +function refreshOnlySkipped(): GrokInjectResult { + return { ok: true, changed: false, message: "Grok refresh skipped", skippedReason: "refresh-only" }; +} + +function isSymlink(path: string): boolean { + try { return lstatSync(path).isSymbolicLink(); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + export function injectGrokConfig( port: number, models: GrokInjectModel[], opts: { grokHome?: string; hostname?: string; + refreshOnly?: { admit: () => boolean }; excluded?: ReadonlySet; /** Unfiltered known ids used only to distinguish hidden current models from retired ones. */ catalogModelIds?: ReadonlySet; @@ -1108,6 +1136,7 @@ export function injectGrokConfig( // at all here; the user configures models manually, outside our fence, where nothing we do // can clobber their credential. if (!isLoopbackHostname(opts.hostname)) { + if (opts.refreshOnly) return refreshOnlySkipped(); const removed = stripGrokConfig({ ...(opts.grokHome !== undefined ? { grokHome: opts.grokHome } : {}) }); const cleanup = removed.changed ? " Removed the previously generated block, which pointed at a loopback address." @@ -1131,6 +1160,10 @@ export function injectGrokConfig( const eol = dominantEol(rawContent); const originalContent = applyEol(rawContent, "\n"); const originalRegion = findManagedRegion(originalContent); + if (opts.refreshOnly && (!configExisted || !originalRegion || originalRegion.orphaned + || !opts.refreshOnly.admit() || isSymlink(configPath) || isSymlink(backupPath))) { + return refreshOnlySkipped(); + } // Ambiguous fence: refuse before the sweep, or "outside the region" could mean the // entire file. if (originalRegion?.orphaned) return orphanedMarkerResult("injection"); @@ -1240,8 +1273,20 @@ export function injectGrokConfig( // deletes a table the user has in their file. Previously the adjacent-orphan layout got a // backup only as a side effect of the fence being destroyed (which made `region` falsy); // preserving the fence must not silently drop that safety net. - if (configExisted && (!region || orphans.length > 0)) copyBackupOnce(configPath, backupPath); - atomicWriteFile(configPath, output); + if (opts.refreshOnly) { + const refreshOnly = opts.refreshOnly; + atomicWriteFileNoFollow(configPath, output, undefined, { + validateBeforeRename: () => { + try { + if (!refreshOnly.admit() || !lstatSync(configPath).isFile() + || readFileSync(configPath, "utf8") !== rawContent) throw new Error(); + } catch { throw new GrokRefreshOnlyRefusal(); } + }, + }); + } else { + if (configExisted && (!region || orphans.length > 0)) copyBackupOnce(configPath, backupPath); + atomicWriteFile(configPath, output); + } return { ok: true, changed: true, @@ -1250,6 +1295,7 @@ export function injectGrokConfig( : "Added the opencodex managed block to Grok config.", }; } catch (error) { + if (error instanceof GrokRefreshOnlyRefusal) return refreshOnlySkipped(); return errorResult("inject", error); } } diff --git a/src/grok/sync.ts b/src/grok/sync.ts index 59fc1992b75..f57f8d36354 100644 --- a/src/grok/sync.ts +++ b/src/grok/sync.ts @@ -31,7 +31,7 @@ async function defaultFetchAllModels(config: OcxConfig): Promise export async function syncGrokConfig( port: number, config: OcxConfig, - opts: { hostname?: string; grokHome?: string } = {}, + opts: { hostname?: string; grokHome?: string; refreshOnly?: { admit: () => boolean } } = {}, deps: GrokSyncDeps = { fetchAllModels: defaultFetchAllModels, injectGrokConfig }, ): Promise { let projection: ReturnType; @@ -58,6 +58,7 @@ export async function syncGrokConfig( ? (opts.hostname ?? config.hostname) : targetUrl.hostname, ...(opts.grokHome !== undefined ? { grokHome: opts.grokHome } : {}), + ...opts.refreshOnly ? { refreshOnly: opts.refreshOnly } : {}, excluded: new Set(config.grokExcludedModels ?? []), catalogModelIds: projection.catalogModelIds, disabledProviderNamespaces: projection.disabledProviderNamespaces, diff --git a/src/integrations/aside-profiles.ts b/src/integrations/aside-profiles.ts index 2d5e2a8e9df..ca61506c74c 100644 --- a/src/integrations/aside-profiles.ts +++ b/src/integrations/aside-profiles.ts @@ -8,7 +8,7 @@ import { readIntegrationState, type IntegrationState, type IntegrationStatus } f import { applyIntegrationCoordinated, disableIntegrationCoordinated, overwriteIntegrationCoordinated, refreshIntegrationCoordinated, - type IntegrationWriteInput, + type CoordinatedIntegrationOptions, type IntegrationWriteInput, } from "./writer"; import { asideProfileEnabled, asideProfileFailure, asideProfileScope, asideWriteInput, @@ -260,7 +260,10 @@ export function mutateAsideProfiles( }); } -export function refreshAsideProfiles(input: AsideProfilesInput): Promise> { +export function refreshAsideProfiles( + input: AsideProfilesInput, + options?: { refreshOnly?: boolean; guard?: CoordinatedIntegrationOptions["guard"] }, +): Promise> { const policy = input.config.asideProfileSync; const selected = Object.values(policy?.profiles ?? {}).some(enabled => enabled === true); if (!selected && (policy?.allProfiles === false @@ -272,11 +275,15 @@ export function refreshAsideProfiles(input: AsideProfilesInput): Promise, clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo"], + options: { refreshOnly?: boolean; admit?: () => boolean } = {}, ): Promise { // Client files are shared with the live proxy a sibling instance runs beside; their entries // point at the owner's port, and refreshing them here would re-point them at this one. @@ -20,14 +22,28 @@ export async function refreshOwnedCatalogIntegrations( const loadModels = () => models ??= Promise.resolve().then(() => typeof input.models === "function" ? input.models() : input.models); const outcomes: OwnedIntegrationRefreshOutcome[] = []; + const admit = options.admit; + const writeOptions: CoordinatedIntegrationOptions | undefined = admit ? { + guard: (frozen): WriteOutcome | null => admit() ? null : { + ok: false, reason: "superseded_store", state: "current", clientId: frozen.clientId, + message: "Background refresh superseded", + }, + } : undefined; for (const clientId of clientIds) { + if (admit?.() === false) break; try { if (clientId === "aside") { const { refreshAsideProfiles } = await import("./aside-profiles"); - outcomes.push(...await refreshAsideProfiles({ ...input, models: loadModels })); + const asideOptions = options.refreshOnly === undefined ? writeOptions + : { ...writeOptions, refreshOnly: options.refreshOnly }; + const bound = { ...input, models: loadModels }; + outcomes.push(...await (asideOptions === undefined + ? refreshAsideProfiles(bound) : refreshAsideProfiles(bound, asideOptions))); continue; } - const result = await refreshOwnedIntegration({ ...input, clientId, models: loadModels }); + const bound = { ...input, clientId, models: loadModels }; + const result = await (writeOptions === undefined + ? refreshOwnedIntegration(bound) : refreshOwnedIntegration(bound, writeOptions)); if (result) outcomes.push(result); } catch (error) { const busy = error !== null && typeof error === "object" diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index 73949af0926..f7d71c7a271 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -801,6 +801,11 @@ export interface CoordinatedIntegrationOptions { * non-null result refuses without writing anything. */ revalidate?: (frozen: IntegrationWriteInput) => Promise; + /** + * Synchronous last word, evaluated with no await between it and the transaction (after + * `revalidate` and any lock acquisition). A non-null result refuses without writing. + */ + guard?: (frozen: IntegrationWriteInput) => WriteOutcome | null; } /** Freeze all mutable resolution seams before the first lock await. */ @@ -942,21 +947,22 @@ async function coordinatedWrite( if (!prepared.ok) return prepared.refusal; const frozen = prepared.value; const spec = INTEGRATION_CLIENTS[frozen.clientId]; + const guarded = (bound: IntegrationWriteInput) => options?.guard?.(bound) ?? operation(bound); if (!spec.writerLock) { const refused = await options?.revalidate?.(frozen); - return refused ?? operation(frozen); + return refused ?? guarded(frozen); } // An absent client home is not created merely to acquire a sibling lock. if (frozen.io.statKind(frozen.resolvedPaths.detectDir) !== "dir") { const refused = await options?.revalidate?.(frozen); if (refused) return refused; - if (frozen.io.statKind(frozen.resolvedPaths.detectDir) !== "dir") return operation(frozen); + if (frozen.io.statKind(frozen.resolvedPaths.detectDir) !== "dir") return guarded(frozen); } return withClientLocks( frozen, spec.writerLock.suffix, - () => operation(frozen), + () => guarded(frozen), options, ); } diff --git a/src/server/background-lifecycle.ts b/src/server/background-lifecycle.ts index 8b524454101..eaf56a3afc8 100644 --- a/src/server/background-lifecycle.ts +++ b/src/server/background-lifecycle.ts @@ -14,6 +14,7 @@ import { } from "../storage/policy-scheduler"; import { startQuotaResetPoller, stopQuotaResetPoller } from "../quota/reset-poller"; import { + setCatalogAutoRefreshClientFanout, startCatalogAutoRefresh, stopCatalogAutoRefresh, syncCatalogAutoRefreshCadence, @@ -40,6 +41,7 @@ type ProcessLoops = { type LeaseOwner = { token: symbol; + fanoutRevoked: boolean; applyPolicy: PolicyApply; resources: ServerResourceOwnerLease; lowQuota: LowQuotaRegistration; @@ -48,6 +50,7 @@ type LeaseOwner = { export type ServerBackgroundLifecycleLease = { scheduleStartupRun(): void; listLowQuotaEvents(limit?: number): LowQuotaEvent[]; + revokeClientFanout(): void; release(): Promise; releaseAfterFailedStart(): void; }; @@ -61,6 +64,25 @@ function setLivePolicyOwner(applyPolicy: PolicyApply | null): void { setStorageCleanupPolicyJobLiveApply(applyPolicy); } +/** Resolve the current process owner at delivery time so lease hand-over cannot retain an old port. */ +async function refreshConnectedClientsAfterCatalogChange(isCurrent: () => boolean) { + const captured = owners.findLast(owner => !owner.fanoutRevoked); + if (!captured) return []; + const current = () => isCurrent() && !captured.fanoutRevoked && owners.includes(captured); + const [{ readRuntimePort }, { loadConfig }, { localClientSyncAllowed }, { syncEnabledClientIntegrations }] = + await Promise.all([ + import("../config/process-state"), + import("../config"), + import("../codex/desired-state"), + import("./management/config-routes"), + ]); + const runtime = readRuntimePort(process.pid); + const config = loadConfig(); + // An explicit catalog tick on a hub is not permission to enroll its local clients. + if (!runtime || !current() || !localClientSyncAllowed(config)) return []; + return syncEnabledClientIntegrations(runtime.port, config, {}, { unattended: { isCurrent: current } }); +} + function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops { let memoryWatchdog: MemoryWatchdog | null = null; let stateStoreSweeper: ReturnType | null = null; @@ -83,6 +105,7 @@ function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops { // and interval timers are unref'd. The scheduler // module keeps every heavy import inside its tick, so naming it statically here // costs a module record and nothing else. + setCatalogAutoRefreshClientFanout(refreshConnectedClientsAfterCatalogChange); startCatalogAutoRefresh(); // The scheduler starts at its default cadence because resolving the operator's value // reads the config barrel. Fire-and-forget: startup must not await an optional @@ -106,6 +129,7 @@ function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops { stopStorageCleanupScheduler(); stopQuotaResetPoller(); stopCatalogAutoRefresh(); + setCatalogAutoRefreshClientFanout(null); setLivePolicyOwner(null); throw error; } @@ -119,6 +143,7 @@ function stopProcessLoops(): void { stopStorageCleanupScheduler(); stopQuotaResetPoller(); stopCatalogAutoRefresh(); + setCatalogAutoRefreshClientFanout(null); setLivePolicyOwner(null); } @@ -181,6 +206,7 @@ export function acquireServerBackgroundLifecycle( const owner: LeaseOwner = { token: Symbol("server-background-lifecycle"), + fanoutRevoked: false, applyPolicy, resources: acquireServerResourceOwner(), lowQuota: registerCodexLowQuotaProtection(config), @@ -206,6 +232,7 @@ export function acquireServerBackgroundLifecycle( return stopStoragePolicyWorker().finally(() => { cleanupInProgress = false; }); } return { + revokeClientFanout() { owner.fanoutRevoked = true; }, listLowQuotaEvents(limit) { return owner.lowQuota.listEvents(limit); }, scheduleStartupRun() { if (owners.some(candidate => candidate.token === owner.token)) { diff --git a/src/server/index.ts b/src/server/index.ts index e5e2d0d4563..183549bddc8 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -750,6 +750,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe Object.defineProperty(server, "stop", { configurable: true, value: async (closeActiveConnections?: boolean): Promise => { + backgroundLifecycle?.revokeClientFanout(); remoteWorkspaceStopping = true; liveCallBindings.clear(); // Disarm the package-tree restart timer before teardown can schedule another restart. diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 532237d164b..1ea79b09d4a 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -9,6 +9,7 @@ import { isAnthropicInstanceId } from "../../providers/anthropic-instance-id"; import { captureConfigTopLevelRollback } from "../../config/rebase-provenance"; import type { IntegrationClientId } from "../../integrations/registry"; import { randomUUID } from "node:crypto"; +import { isDeepStrictEqual } from "node:util"; import { readFileSync } from "node:fs"; import type { CatalogModel } from "../../codex/catalog"; import { catalogModelSlug, invalidateCodexModelsCache, nativeContextLimits, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; @@ -182,6 +183,17 @@ interface ClientIntegrationSyncOutcome { readonly profileId?: number; } +/** Only the Desktop write's own applied markers may change during a background projection. */ +function clientProjectionConfig(config: OcxConfig): OcxConfig { + const snapshot = structuredClone(config); + const profile = snapshot.claudeCode?.desktopProfile; + if (profile) { + delete profile.appliedFingerprint; + delete profile.appliedAt; + } + return snapshot; +} + /** * Re-inject native clients that are switched ON and every file integration whose * OpenCodex ownership record is the operator's durable opt-in. @@ -205,17 +217,41 @@ export async function syncEnabledClientIntegrations( config: OcxConfig, deps: Pick = {}, + options: { unattended?: { isCurrent: () => boolean } } = {}, ): Promise { // A sibling instance passes its OWN port here; the Grok fence and the Desktop gateway profile // stay on the live owner's (`src/codex/sibling-start.ts`). if (port === undefined || siblingOfLivePort() !== null) return []; - const { claudeDesktopIntegrationEnabled, grokIntegrationEnabled } = await import("../../codex/desired-state"); + const projectionConfig = options.unattended ? clientProjectionConfig(config) : undefined; + const { claudeDesktopIntegrationEnabled, grokIntegrationEnabled, localClientSyncAllowed } = await import("../../codex/desired-state"); const out: ClientIntegrationSyncOutcome[] = []; + const unattended = options.unattended; + const admit = () => { + if (!unattended!.isCurrent()) return false; + const fresh = loadConfig(); + return localClientSyncAllowed(fresh) && isDeepStrictEqual(clientProjectionConfig(fresh), projectionConfig); + }; + const stale = () => unattended !== undefined && !admit(); + if (stale()) return out; + const grok = unattended ? await import("../../grok/inject") : undefined; - if (grokIntegrationEnabled(config)) { + if (grokIntegrationEnabled(config) && (!grok || grok.grokManagedBlockPresent())) { try { const { syncGrokConfig } = await import("../../grok/sync"); - const r = await syncGrokConfig(port, config, config.hostname ? { hostname: config.hostname } : {}); + if (stale()) return out; + const grokDeps = grok ? { + fetchAllModels: deps.fetchAllModels ?? (await import("../management-api")).fetchAllModels, + injectGrokConfig: ((...args: Parameters) => + admit() && grokIntegrationEnabled(loadConfig()) && grok.grokManagedBlockPresent() + ? grok.injectGrokConfig(...args) + : { ok: true, changed: false, message: "Grok refresh skipped" }), + } : undefined; + const r = unattended + ? await syncGrokConfig(port, config, { + ...config.hostname ? { hostname: config.hostname } : {}, + refreshOnly: { admit }, + }, grokDeps) + : await syncGrokConfig(port, config, config.hostname ? { hostname: config.hostname } : {}, grokDeps); out.push(r.ok ? { client: "grok", ok: true, changed: r.changed === true } : { client: "grok", ok: false, reason: r.message }); @@ -224,9 +260,11 @@ export async function syncEnabledClientIntegrations( } } + if (stale()) return out; const { observeClaudeDesktopMode, resolveClaudeDesktopMode } = await import("../../claude/desktop-first-party"); // A first-party Desktop must never get a gateway profile written and selected by a sync. - if (claudeDesktopIntegrationEnabled(config) && resolveClaudeDesktopMode(config, observeClaudeDesktopMode(config)) !== "first-party") { + if (claudeDesktopIntegrationEnabled(config) && resolveClaudeDesktopMode(config, observeClaudeDesktopMode(config)) !== "first-party" + && (!unattended || !!config.claudeCode?.desktopProfile?.appliedFingerprint)) { try { const { writeDesktop3pConfig } = await import("../../claude/desktop-3p"); const { desktopVisibleNativeSlugs, filterCatalogVisibleModels } = await import("../../codex/catalog"); @@ -240,12 +278,15 @@ export async function syncEnabledClientIntegrations( const latest = loadConfig(); // Discovery awaited: the mode may have changed meanwhile. Re-resolve on the fresh read, // immediately before the writer, so a first-party switch during fetchAllModels still wins. - if (claudeDesktopIntegrationEnabled(latest) && resolveClaudeDesktopMode(latest, observeClaudeDesktopMode(latest)) !== "first-party") { + if (stale()) return; + if (claudeDesktopIntegrationEnabled(latest) && resolveClaudeDesktopMode(latest, observeClaudeDesktopMode(latest)) !== "first-party" + && (!unattended || !!latest.claudeCode?.desktopProfile?.appliedFingerprint)) { const routed = filterCatalogVisibleModels(models, latest) .map(model => ({ provider: model.provider, id: model.id, contextWindow: model.contextWindow })); const writtenProfile = latest.claudeCode?.desktopProfile; const markerBaseline = captureDesktopAppliedMarker(writtenProfile); - const r = (deps.writeDesktop3pConfig ?? writeDesktop3pConfig)( + // Keep the attended writer's argument count unchanged, including its lock test seam. + const writeArgs: Parameters = [ port, [...desktopVisibleNativeSlugs(latest)], routed, @@ -253,7 +294,13 @@ export async function syncEnabledClientIntegrations( "static", writtenProfile, nativeContextLimits(latest), - ); + ]; + if (unattended) { + writeArgs[7] = undefined; + writeArgs[8] = { appliedFingerprint: writtenProfile!.appliedFingerprint!, admit }; + } + const r = (deps.writeDesktop3pConfig ?? writeDesktop3pConfig)(...writeArgs); + if (unattended && r.reason === "desktop_refresh_only_skipped") return; if (!r.written || !r.fingerprint) { out.push({ client: "claude-desktop", ok: false, reason: r.reason ?? "Claude Desktop write failed" }); } else { @@ -271,6 +318,7 @@ export async function syncEnabledClientIntegrations( } } + if (stale()) return out; const { refreshOwnedCatalogIntegrations } = await import("../../integrations/catalog-refresh"); const refreshOwned = deps.refreshOwnedCatalogIntegrations ?? refreshOwnedCatalogIntegrations; out.push(...await refreshOwned({ @@ -280,7 +328,10 @@ export async function syncEnabledClientIntegrations( }, config, port, - }, ["mcode", "pi", "aside", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo"])); + }, unattended + ? ["mcode", "pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo"] + : ["mcode", "pi", "aside", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo"], + ...unattended ? [{ refreshOnly: true, admit }] as const : [])); return out; } diff --git a/structure/catalog.md b/structure/catalog.md index 4b5d85a2411..c799bd7070c 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -99,7 +99,7 @@ On the default `opencodex-catalog.json` path, sync deliberately uses two catalog required because empty or partial provider discovery must preserve routed entries and genuine user-native rows from the file that will be overwritten; a bundled catalog never contains those rows. Retained sync and evidence-bound convergence share an explicit observed-state merge policy and restore native priorities from the once-only pristine backup rather than from a catalog whose priorities may already have been rewritten. A configured custom catalog remains the native metadata/template authority even when a bundled-catalog memo is warm. Both paths may use an admitted matching bundled memo only as installed-runtime capability evidence to remove unsupported reasoning efforts; convergence never probes Codex -itself. The default-on scheduler in `src/codex/catalog-auto-refresh.ts` settles bundled runtime and authenticated Codex roster observations before admission, using dynamic imports, a 15-second source wait and asynchronous selected-runtime/model probes. Request metadata and templates use the same-selection last confirmed bundled memo, refresh its runtime observation every 15 seconds without awaiting it, and fall back to existing catalog/cache sources while a cold or changed selection settles. The bundled evidence keeps its 60-second freshness bound for convergence; failed request-triggered refreshes retain confirmed rows with a 60-second retry cooldown. Shared flights have a 45-second aggregate deadline, cancel obsolete selections and reject late catalog or runtime persistence after observed input/epoch changes. Explicit CLI synchronous loading and compatibility checks remain synchronous. Retained sync settles the async source before reading the content and evidence it later revalidates. Source failure uses existing evidence, while a stopped generation cannot start the next source or converge. A changed set records `reloadRequired` through `src/codex/catalog-refresh-status.ts` for observed running app-servers and logs one content-free restart hint; a no-op keeps it while processes remain stale or the restart observation is unknown and clears it when they are fresh or gone. Automatic refresh never restarts processes. `tests/codex-integration/catalog-auto-refresh-scheduler.test.ts` covers these boundaries. `src/codex/catalog/bundled.ts` and `src/codex/runtime.ts` prefer the recorded native `launcherPath` from validated schema-2 PATH-overlay state within their shim candidates, retaining legacy candidates and other priorities. `src/codex/features.ts` follows that backing only when the selected wrapper matches validated state; it never probes unrelated installations. Overlay state and activation follow [the runtime lifecycle contract](runtime.md#lifecycle). +itself. The default-on scheduler in `src/codex/catalog-auto-refresh.ts` settles bundled runtime and authenticated Codex roster observations before admission, using dynamic imports, a 15-second source wait and asynchronous selected-runtime/model probes. Request metadata and templates use the same-selection last confirmed bundled memo, refresh its runtime observation every 15 seconds without awaiting it, and fall back to existing catalog/cache sources while a cold or changed selection settles. The bundled evidence keeps its 60-second freshness bound for convergence; failed request-triggered refreshes retain confirmed rows with a 60-second retry cooldown. Shared flights have a 45-second aggregate deadline, cancel obsolete selections and reject late catalog or runtime persistence after observed input/epoch changes. Explicit CLI synchronous loading and compatibility checks remain synchronous. Retained sync settles the async source before reading the content and evidence it later revalidates. Source failure uses existing evidence, while a stopped generation cannot start the next source or converge. A changed set records `reloadRequired` through `src/codex/catalog-refresh-status.ts` for observed running app-servers and logs one content-free restart hint; a no-op keeps it while processes remain stale or the restart observation is unknown and clears it when they are fresh or gone. Automatic refresh never restarts processes. A changed set also runs the client fan-out the server lifecycle registers through `setCatalogAutoRefreshClientFanout`; it refreshes only already-connected clients ([integrations](clients/integrations.md#owned-catalog-convergence)), binds each delivery to the newest unrevoked server owner and stops with the generation or that owner's revocation or release, refuses configuration drift except Desktop applied-marker updates, and logs only a failure count; `tests/codex-integration/catalog-auto-refresh-client-fanout.test.ts` covers it. `tests/codex-integration/catalog-auto-refresh-scheduler.test.ts` covers these boundaries. `src/codex/catalog/bundled.ts` and `src/codex/runtime.ts` prefer the recorded native `launcherPath` from validated schema-2 PATH-overlay state within their shim candidates, retaining legacy candidates and other priorities. `src/codex/features.ts` follows that backing only when the selected wrapper matches validated state; it never probes unrelated installations. Overlay state and activation follow [the runtime lifecycle contract](runtime.md#lifecycle). Custom Astra and Daybreak rows acquire native identity -- Responses Lite, multi-agent, context windows, display names -- only through the canonical `openai` forward destination and explicit diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index c460d9464c0..dbc87ee40ae 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -90,6 +90,7 @@ traffic through a local interception proxy, which Anthropic may treat as a terms owned settings are still observed; otherwise the field is `null`. `/api/sync` and roster-update auto-apply never write a gateway profile while the resolved mode is first-party; both re-resolve after model discovery before writing. +Background refresh preserves the selected owned gateway profile's mode, refuses symlinked library, metadata, profile or backup paths and non-regular profiles, and rechecks admission, the selected owned metadata entry and its applied fingerprint in the no-follow writer's pre-rename hook; it leaves metadata and backup bytes unchanged. Mode switches establish the replacement before removing the previous connection. A failed first-party apply (disabled intercept, CA failure, unreadable settings or foreign env) preserves diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index 1195be04435..681098bd89c 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -217,7 +217,7 @@ their existing visibility rules. Visibility, selected-model and preset writes refresh already-owned catalog contributions (including OpenCode, Kilo and Command Code) after persisting the selection. Explicit sync also refreshes owned OpenCode, Kilo and Command Code blocks. The shared catalog-refresh fan-out loads the filtered roster lazily once, leaves unowned clients alone, and reports each -refusal independently. Existing coordinated writers retain all no-clobber and ownership checks. +refusal independently. Existing coordinated writers retain all no-clobber and ownership checks. A background catalog auto-refresh that changes the served set calls `syncEnabledClientIntegrations` unattended from `src/server/background-lifecycle.ts`: it refreshes only an existing Grok fence, a Desktop gateway profile that is still selected and byte-identical to the last applied write (keeping its static, hybrid or discovery mode), and owned file and Aside blocks without a first apply; it skips Cline, honours the hub and sibling gate, and the coordinated writer's synchronous `guard` refuses any write once the scheduler generation has stopped or the owner captured for that delivery has revoked fan-out admission or released its lease; a later delivery may select a surviving owner. Each write also requires the projection's configuration snapshot to remain current, ignoring only Desktop's applied fingerprint and timestamp; Grok rechecks its complete fence and refuses symlinked config or backup paths inside the synchronous injector, then revalidates admission and unchanged regular-file bytes in the no-follow writer's pre-rename hook without copying a backup. Implicit refresh operations use distinct flight keys: overlapping desired catalogs return busy rather than joining a write of a different catalog and reporting false success. On a sibling instance ([Codex home](../codex-home.md#codex-home)), including one identified from another home's managed client destination, `src/integrations/catalog-refresh.ts` diff --git a/tests/clients/aside-profiles-catalog-refresh.test.ts b/tests/clients/aside-profiles-catalog-refresh.test.ts new file mode 100644 index 00000000000..e18c7308d2e --- /dev/null +++ b/tests/clients/aside-profiles-catalog-refresh.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import type { ExportModel } from "../../src/clients/config-export"; +import { refreshOwnedCatalogIntegrations } from "../../src/integrations/catalog-refresh"; +import * as aside from "../../src/integrations/aside-profiles"; +import * as owned from "../../src/integrations/owned-refresh"; +import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; +import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; +import { applyIntegration, type IntegrationWriteInput } from "../../src/integrations/writer"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const models: ExportModel[] = ["alpha", "beta"].map(id => ({ namespaced: `mock/${id}`, provider: "mock", id, contextWindow: 128_000 })); +const original = JSON.stringify({ theme: "dark", providers: { personal: { models: [{ id: "mine" }] } } }); +let root: string; +let home: string; +let store: IntegrationStateStore; +let config: OcxConfig; +let priorOcxHome: string | undefined; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-catalog-admission-")); + home = join(root, "home"); + store = createIntegrationStateStore(join(root, "state", "integrations")); + priorOcxHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = join(root, "config"); + for (const id of [0, 1]) { + mkdirSync(dirname(profilePath(id)), { recursive: true }); + writeFileSync(profilePath(id), original); + } + writeFileSync(join(home, ".aside", "accounts.json"), JSON.stringify({ + currentAccountId: 0, accounts: [{ id: 0, name: "First" }, { id: 1, name: "Second" }], + })); + config = { port: 12345, hostname: "127.0.0.1", defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1" } }, + asideProfileSync: { allProfiles: true } } as OcxConfig; +}); +afterEach(() => { + if (priorOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = priorOcxHome; + removeTreeWithRetry(root); +}); + +function profilePath(id: number): string { return join(home, ".aside", "u", String(id), "models.json"); } +function input() { return { config, models, port: 12345, env: {}, home, store }; } +function seedAside(): void { expect(applyIntegration({ ...input(), clientId: "aside" }).ok).toBe(true); } +function seedPi(): string { + const spec = INTEGRATION_CLIENTS.pi; + mkdirSync(spec.detectDir({}, home), { recursive: true }); + mkdirSync(dirname(spec.configPath({}, home)), { recursive: true }); + expect(applyIntegration({ ...input(), clientId: "pi" }).ok).toBe(true); + return spec.configPath({}, home); +} + +test("refreshOnly skips an enabled unowned Aside profile before loading models", async () => { + let loads = 0; + expect(await aside.refreshAsideProfiles({ ...input(), models: async () => { loads += 1; return models; } }, + { refreshOnly: true })).toEqual([]); + expect(loads).toBe(0); + expect([0, 1].map(id => readFileSync(profilePath(id), "utf8"))).toEqual([original, original]); + expect(existsSync(store.root)).toBe(false); +}); + +test("catalog refreshOnly updates the owned Aside profile and leaves its unowned peer alone", async () => { + seedAside(); + const outcomes = await refreshOwnedCatalogIntegrations({ ...input(), models: models.slice(0, 1) }, + ["aside"], { refreshOnly: true, admit: () => true }); + expect(outcomes).toEqual([{ client: "aside", profileId: 0, ok: true, changed: true }]); + const doc = JSON.parse(readFileSync(profilePath(0), "utf8")); + expect(doc.providers.opencodex.models.map((model: { id: string }) => model.id)).toEqual(["mock/alpha"]); + expect(readFileSync(profilePath(1), "utf8")).toBe(original); + expect(existsSync(join(store.root, "aside-profiles", "1"))).toBe(false); +}); + +test("false admission stops the catalog loop before model loading or any client write", async () => { + seedAside(); + const piPath = seedPi(); + const before = [piPath, profilePath(0), profilePath(1)].map(path => readFileSync(path, "utf8")); + const records = store.readRecords(); + const operations = store.listOperations(); + let loads = 0; + expect(await refreshOwnedCatalogIntegrations({ ...input(), models: async () => { loads += 1; return []; } }, + ["pi", "aside"], { refreshOnly: true, admit: () => false })).toEqual([]); + expect(loads).toBe(0); + expect([piPath, profilePath(0), profilePath(1)].map(path => readFileSync(path, "utf8"))).toEqual(before); + expect(store.readRecords()).toEqual(records); + expect(store.listOperations()).toEqual(operations); +}); + +test.each(["pi", "aside"] as const)("admission lost during discovery refuses %s and stops later clients", async client => { + seedAside(); + const piPath = seedPi(); + const before = [piPath, profilePath(0), profilePath(1)].map(path => readFileSync(path, "utf8")); + const operations = store.listOperations(); + let admitted = true; + let loads = 0; + const outcomes = await refreshOwnedCatalogIntegrations({ ...input(), models: async () => { + loads += 1; + admitted = false; + return models.slice(0, 1); + } }, [client, client === "pi" ? "aside" : "pi"], { refreshOnly: true, admit: () => admitted }); + expect(outcomes).toEqual([{ + client, ok: false, reason: "Background refresh superseded", refusalReason: "superseded_store", state: "current", + ...(client === "aside" ? { profileId: 0 } : {}), + }]); + expect(loads).toBe(1); + expect([piPath, profilePath(0), profilePath(1)].map(path => readFileSync(path, "utf8"))).toEqual(before); + expect(store.listOperations()).toEqual(operations); +}); + +test("Aside carries a synchronous guard refusal's profile identity even on first apply", async () => { + const checked: string[] = []; + const outcomes = await aside.refreshAsideProfiles(input(), { guard: (frozen: IntegrationWriteInput) => { + checked.push(frozen.resolvedPaths!.configPath); + return { ok: false, reason: "superseded_store", state: "current", clientId: frozen.clientId, + message: "Background refresh superseded" }; + } }); + expect(checked).toEqual([profilePath(0), profilePath(1)]); + expect(outcomes.map(row => [row.profileId, row.ok, row.refusalReason])).toEqual([ + [0, false, "superseded_store"], [1, false, "superseded_store"], + ]); + expect([0, 1].map(id => readFileSync(profilePath(id), "utf8"))).toEqual([original, original]); + expect(existsSync(store.root)).toBe(false); +}); + +test("attended catalog refresh passes no guard and still first-applies enabled Aside profiles", async () => { + seedPi(); + const refreshOwned = spyOn(owned, "refreshOwnedIntegration"); + const refreshAside = spyOn(aside, "refreshAsideProfiles"); + try { + const outcomes = await refreshOwnedCatalogIntegrations(input(), ["pi", "aside"]); + expect(refreshOwned.mock.calls).toHaveLength(1); + expect(refreshOwned.mock.calls[0]).toHaveLength(1); + expect(refreshAside.mock.calls).toHaveLength(1); + expect(refreshAside.mock.calls[0]).toHaveLength(1); + expect(outcomes.map(row => [row.client, row.profileId, row.ok])).toEqual([ + ["pi", undefined, true], ["aside", 0, true], ["aside", 1, true], + ]); + for (const id of [0, 1]) expect(readFileSync(profilePath(id), "utf8")).toContain("opencodex"); + } finally { + refreshOwned.mockRestore(); + refreshAside.mockRestore(); + } +}); diff --git a/tests/clients/integrations-writer-guard.test.ts b/tests/clients/integrations-writer-guard.test.ts new file mode 100644 index 00000000000..0cc3b3003e1 --- /dev/null +++ b/tests/clients/integrations-writer-guard.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import type { ExportModel } from "../../src/clients/config-export"; +import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; +import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; +import { + applyIntegrationCoordinated, disableIntegrationCoordinated, overwriteIntegrationCoordinated, + refreshIntegrationCoordinated, restoreIntegrationCoordinated, type IntegrationWriteInput, type WriteOutcome, +} from "../../src/integrations/writer"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const models: ExportModel[] = [{ namespaced: "mock/alpha", provider: "mock", id: "alpha", contextWindow: 128_000 }]; +const config = { port: 12345, hostname: "127.0.0.1", defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1" } } } as OcxConfig; +const paths = ["no-lock", "absent-home", "locked"] as const; +let root: string; +let home: string; +let store: IntegrationStateStore; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-writer-guard-")); + home = join(root, "home"); + mkdirSync(home); + store = createIntegrationStateStore(join(root, "state", "integrations")); +}); +afterEach(() => removeTreeWithRetry(root)); + +function input(path: typeof paths[number]): IntegrationWriteInput { + const clientId = path === "no-lock" ? "pi" : "commandcode"; + const spec = INTEGRATION_CLIENTS[clientId]; + if (path !== "absent-home") { + mkdirSync(spec.detectDir({}, home), { recursive: true }); + mkdirSync(dirname(spec.configPath({}, home)), { recursive: true }); + writeFileSync(spec.configPath({}, home), "{}\n"); + } + return { clientId, models, config, port: 12345, env: {}, home, store }; +} + +function refusal(frozen: IntegrationWriteInput): WriteOutcome { + return { ok: false, reason: "superseded_store", state: "current", clientId: frozen.clientId, + message: "Background refresh superseded" }; +} + +for (const path of paths) { + for (const [name, operation] of Object.entries({ + apply: applyIntegrationCoordinated, refresh: refreshIntegrationCoordinated, + overwrite: overwriteIntegrationCoordinated, disable: disableIntegrationCoordinated, + })) { + test(`${name} guard refuses without a transaction on ${path}`, async () => { + const bound = input(path); + const spec = INTEGRATION_CLIENTS[bound.clientId]; + const target = spec.configPath({}, home); + const before = existsSync(target) ? readFileSync(target, "utf8") : null; + let guards = 0; + const result = await operation(bound, { guard: frozen => { + guards += 1; + expect(frozen.clientId).toBe(bound.clientId); + expect(existsSync(`${target}.lock`)).toBe(path === "locked"); + return refusal(frozen); + } }); + expect(result).toEqual(refusal(bound)); + expect(guards).toBe(1); + expect(existsSync(target) ? readFileSync(target, "utf8") : null).toBe(before); + expect(existsSync(store.root)).toBe(false); + expect(existsSync(`${target}.lock`)).toBe(false); + if (path === "absent-home") expect(existsSync(spec.detectDir({}, home))).toBe(false); + }); + } + + test(`admission revoked after revalidate refuses on ${path}`, async () => { + const bound = input(path); + const target = INTEGRATION_CLIENTS[bound.clientId].configPath({}, home); + const before = existsSync(target) ? readFileSync(target, "utf8") : null; + let admitted = true; + let validations = 0; + const result = await applyIntegrationCoordinated(bound, { + revalidate: async () => { + validations += 1; + expect(admitted).toBe(true); + // A retired generation can arrive after an async confirmation has agreed to the write. + queueMicrotask(() => { admitted = false; }); + return null; + }, + guard: frozen => admitted ? null : refusal(frozen), + }); + expect(validations).toBe(1); + expect(result).toEqual(refusal(bound)); + expect(existsSync(target) ? readFileSync(target, "utf8") : null).toBe(before); + expect(existsSync(store.root)).toBe(false); + }); +} + +test.each(["no-lock", "locked"] as const)("a null guard admits a real apply on %s", async path => { + const bound = input(path); + expect(await applyIntegrationCoordinated(bound, { guard: () => null })) + .toMatchObject({ ok: true, changed: true }); + expect(readFileSync(INTEGRATION_CLIENTS[bound.clientId].configPath({}, home), "utf8")).toContain("opencodex"); + expect(store.readRecords()[bound.clientId]).toBeDefined(); +}); + +test.each(["no-lock", "locked"] as const)("the transaction cannot yield after its final guard on %s", async path => { + const bound = input(path); + const io = store.io(); + let retired = false; + let writes = 0; + const result = await applyIntegrationCoordinated({ ...bound, io: { + ...io, + writeText: (target, text) => { + expect(retired).toBe(false); + writes += 1; + io.writeText(target, text); + }, + } }, { guard: () => { + // Any await after this decision would let the next generation retire it before mutation. + queueMicrotask(() => { retired = true; }); + return null; + } }); + expect(result).toMatchObject({ ok: true, changed: true }); + expect(writes).toBeGreaterThan(0); + expect(retired).toBe(true); +}); + +test.each(["no-lock", "locked"] as const)("restore keeps its existing contract on %s", async path => { + const bound = input(path); + const applied = await applyIntegrationCoordinated(bound); + expect(applied.ok).toBe(true); + if (!applied.ok || !applied.opId) throw new Error("fixture apply needs a restorable operation"); + let guards = 0; + expect(await restoreIntegrationCoordinated({ ...bound, opId: applied.opId }, { guard: frozen => { + guards += 1; + return refusal(frozen); + } })).toMatchObject({ ok: true, changed: true }); + expect(guards).toBe(0); + expect(readFileSync(INTEGRATION_CLIENTS[bound.clientId].configPath({}, home), "utf8")).toBe("{}\n"); +}); diff --git a/tests/clients/sync-client-integrations-unattended.test.ts b/tests/clients/sync-client-integrations-unattended.test.ts new file mode 100644 index 00000000000..b76d9776b0e --- /dev/null +++ b/tests/clients/sync-client-integrations-unattended.test.ts @@ -0,0 +1,448 @@ +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig, getConfigPath } from "../../src/config"; +import { buildDesktop3pRegistry, writeDesktop3pConfig, type Desktop3pConfigMode } from "../../src/claude/desktop-3p"; +import * as atomicWrites from "../../src/config/atomic-write"; +import * as desktopLibrary from "../../src/claude/desktop-3p-library"; +import { syncGrokConfig } from "../../src/grok/sync"; +import { grokManagedBlockPresent, injectGrokConfig } from "../../src/grok/inject"; +import { markSiblingStart, resetSiblingStartForTests } from "../../src/codex/sibling-start"; +import { syncEnabledClientIntegrations } from "../../src/server/management/config-routes"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const envKeys = ["HOME", "USERPROFILE", "OPENCODEX_HOME", "CLAUDE_CONFIG_DIR", "GROK_HOME", "OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR"] as const; +let root: string; +let library: string; +let grokHome: string; +let config: OcxConfig; +let previous: Partial>; +let current: boolean; +const unattended = { unattended: { isCurrent: () => current } }; +const models = [{ provider: "mock", id: "new-model", contextWindow: 123_000 }]; +const refresh = mock[2]>["refreshOwnedCatalogIntegrations"]>>(async () => []); +const writer = mock((...args) => { + // Production lock identity ignores HOME by design; exercise the real writer with its explicit seam. + args[7] = { lockPath: join(root, "locks", "desktop.sqlite") }; + return writeDesktop3pConfig(...args); +}); +function persist() { writeFileSync(getConfigPath(), JSON.stringify(config)); config = loadConfig(); } +function deps(fetchAllModels = async () => models) { + return { fetchAllModels, refreshOwnedCatalogIntegrations: refresh, writeDesktop3pConfig: writer }; +} +function seed(mode: Desktop3pConfigMode = "static") { + const result = writeDesktop3pConfig(12345, [], [{ provider: "mock", id: "old-model" }], "fixture-key", mode, + undefined, undefined, { lockPath: join(root, "locks", "desktop.sqlite") }); + expect(result.written).toBe(true); + config.claudeCode = { desktopMode: "gateway", desktopProfile: { + version: 1, assignments: {}, defaults: { opus: null, fable: null, sonnet: null, haiku: null }, + appliedFingerprint: result.fingerprint!, + } }; + persist(); + return result; +} +function metadata() { return JSON.parse(readFileSync(join(library, "_meta.json"), "utf8")); } +function snapshot() { + return readdirSync(library).sort().map(name => [name, readFileSync(join(library, name), "utf8")]); +} + +beforeEach(() => { + previous = {}; + root = mkdtempSync(join(tmpdir(), "ocx-unattended-sync-")); + for (const key of envKeys) previous[key] = process.env[key]; + process.env.HOME = root; process.env.USERPROFILE = root; + process.env.OPENCODEX_HOME = join(root, "ocx"); + process.env.CLAUDE_CONFIG_DIR = join(root, "claude"); + library = join(root, "desktop"); grokHome = join(root, "grok"); + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = library; process.env.GROK_HOME = grokHome; + mkdirSync(process.env.OPENCODEX_HOME, { recursive: true }); + mkdirSync(library); mkdirSync(grokHome); + config = { port: 12345, hostname: "127.0.0.1", defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "https://example.test/v1", liveModels: false, models: ["old-model", "new-model"] } }, + clientIntegrations: { grok: false }, claudeCode: { desktopMode: "gateway" }, + } as OcxConfig; + persist(); current = true; refresh.mockClear(); writer.mockClear(); resetSiblingStartForTests(); +}); +afterEach(() => { + resetSiblingStartForTests(); buildDesktop3pRegistry([], []); + for (const key of envKeys) { + if (previous[key] === undefined) delete process.env[key]; else process.env[key] = previous[key]; + } + removeTreeWithRetry(root); +}); + +describe("unattended native and file client admission", () => { + test("no Desktop marker skips discovery, and file clients exclude Cline with a fresh admit", async () => { + const fetch = mock(async () => models); + expect(await syncEnabledClientIntegrations(12345, config, deps(fetch), unattended)).toEqual([]); + expect(fetch).not.toHaveBeenCalled(); expect(writer).not.toHaveBeenCalled(); + const args = refresh.mock.calls[0]!; + expect(args[1]).toEqual(["mcode", "pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo"]); + expect(args[2]?.refreshOnly).toBe(true); expect(args[2]?.admit?.()).toBe(true); + config.runtimeRole = "hub"; persist(); expect(args[2]?.admit?.()).toBe(false); + config.unauthenticatedLoopbackListener = { enabled: true, port: 12346 }; persist(); + expect(args[2]?.admit?.()).toBe(false); current = false; expect(args[2]?.admit?.()).toBe(false); + }); + + test("stale generation, hub and sibling skip all clients", async () => { + current = false; + expect(await syncEnabledClientIntegrations(12345, config, deps(), unattended)).toEqual([]); + current = true; config.runtimeRole = "hub"; persist(); + expect(await syncEnabledClientIntegrations(12345, config, deps(), unattended)).toEqual([]); + config.runtimeRole = "standalone"; persist(); markSiblingStart(12344); + expect(await syncEnabledClientIntegrations(12345, config, deps(), unattended)).toEqual([]); + expect(refresh).not.toHaveBeenCalled(); expect(writer).not.toHaveBeenCalled(); + }); + + test("Grok refresh requires a complete managed block", async () => { + config.clientIntegrations = { grok: true, "claude-desktop": false }; persist(); + writeFileSync(join(grokHome, "config.toml"), 'theme = "personal"\n'); + const fetch = mock(async () => models); + await syncEnabledClientIntegrations(12345, config, deps(fetch), unattended); + expect(fetch).not.toHaveBeenCalled(); expect(grokManagedBlockPresent()).toBe(false); + expect(injectGrokConfig(12345, [{ id: "old-model" }], { grokHome }).ok).toBe(true); + expect(grokManagedBlockPresent()).toBe(true); + const results = await syncEnabledClientIntegrations(12345, config, deps(fetch), unattended); + expect(results).toContainEqual({ client: "grok", ok: true, changed: true }); + expect(readFileSync(join(grokHome, "config.toml"), "utf8")).toContain("new-model"); + }); + + test("orphaned Grok fence is not owned refresh permission", () => { + writeFileSync(join(grokHome, "config.toml"), '# >>> opencodex managed block — do not edit (removed by `ocx stop`) >>>\n'); + expect(grokManagedBlockPresent()).toBe(false); + }); + + for (const change of ["remove-block", "stop", "disable", "hub"] as const) { + test(`Grok ${change} during discovery prevents the synchronous write`, async () => { + config.clientIntegrations = { grok: true, "claude-desktop": false }; persist(); + expect(injectGrokConfig(12345, [{ id: "old-model" }], { grokHome }).ok).toBe(true); + let expected = readFileSync(join(grokHome, "config.toml"), "utf8"); + await syncEnabledClientIntegrations(12345, config, deps(async () => { + if (change === "remove-block") { expected = 'theme = "personal"\n'; writeFileSync(join(grokHome, "config.toml"), expected); } + if (change === "stop") current = false; + if (change === "disable") { config.clientIntegrations!.grok = false; persist(); } + if (change === "hub") { config.runtimeRole = "hub"; persist(); } + return models; + }), unattended); + expect(readFileSync(join(grokHome, "config.toml"), "utf8")).toBe(expected); + if (change === "stop") expect(refresh).not.toHaveBeenCalled(); + }); + } + + test("attended sync still first-applies Desktop in static mode and passes Cline without refresh options", async () => { + const results = await syncEnabledClientIntegrations(12345, config, deps()); + expect(results).toContainEqual({ client: "claude-desktop", ok: true, changed: true }); + expect(writer.mock.calls[0]).toHaveLength(7); + const selected = metadata().appliedId; + expect(JSON.parse(readFileSync(join(library, `${selected}.json`), "utf8")).modelDiscoveryEnabled).toBe(false); + expect(refresh.mock.calls[0]).toHaveLength(2); expect(refresh.mock.calls[0]?.[1]).toContain("cline"); + }); + + test("attended Grok still first-enrolls without a managed block", async () => { + config.clientIntegrations = { grok: true, "claude-desktop": false }; persist(); + // Attended discovery keeps its existing default seam; stub just that dependency for this call. + const management = await import("../../src/server/management-api"); + const fetch = spyOn(management, "fetchAllModels").mockResolvedValue(models); + try { + expect(await syncEnabledClientIntegrations(12345, config, deps())).toContainEqual({ client: "grok", ok: true, changed: true }); + expect(grokManagedBlockPresent()).toBe(true); + } finally { fetch.mockRestore(); } + }); +}); + +describe("Desktop refresh-only retains ownership and selected bytes", () => { + for (const mode of ["static", "hybrid", "discovery"] as const) { + test(`an applied ${mode} profile retains its mode and selected id`, async () => { + const applied = seed(mode); const before = metadata(); + const results = await syncEnabledClientIntegrations(12345, config, deps(), unattended); + expect(results).toContainEqual({ client: "claude-desktop", ok: true, changed: true }); + expect(metadata()).toEqual(before); + const written = JSON.parse(readFileSync(applied.path, "utf8")); + expect(written.modelDiscoveryEnabled).toBe(mode !== "static"); + expect(Array.isArray(written.inferenceModels)).toBe(mode !== "discovery"); + expect(loadConfig().claudeCode?.desktopProfile?.appliedFingerprint).not.toBe(applied.fingerprint); + expect(writer.mock.calls[0]?.[7]).toBeUndefined(); expect(writer.mock.calls[0]?.[8]?.admit()).toBe(true); + }); + } + + for (const drift of ["no-marker", "stale-marker", "foreign", "standard", "deleted", "edited", "stopped", "hub"] as const) { + test(`${drift} refuses without changing metadata, recreating a profile or allocating an id`, async () => { + const applied = seed(); + if (drift === "no-marker") delete config.claudeCode!.desktopProfile!.appliedFingerprint; + if (drift === "stale-marker") config.claudeCode!.desktopProfile!.appliedFingerprint = "0000000000000000"; + if (drift === "foreign" || drift === "standard") { + const meta = metadata(); meta.appliedId = "other"; + meta.entries.push({ id: "other", name: drift === "standard" ? "opencodex-standard" : "personal" }); + writeFileSync(join(library, "_meta.json"), JSON.stringify(meta)); + writeFileSync(join(library, "other.json"), drift === "standard" ? "{}" : readFileSync(applied.path, "utf8")); + } + if (drift === "deleted") unlinkSync(applied.path); + if (drift === "edited") writeFileSync(applied.path, readFileSync(applied.path, "utf8") + " "); + if (drift === "stopped") current = false; + if (drift === "hub") config.runtimeRole = "hub"; + persist(); const before = snapshot(); + const results = await syncEnabledClientIntegrations(12345, config, deps(), unattended); + expect(results.find(row => row.client === "claude-desktop")).toBeUndefined(); expect(snapshot()).toEqual(before); + // Invoke the writer too: the cheap fan-out gate alone must not be the ownership boundary. + const result = writeDesktop3pConfig(12345, [], models, "fixture-key", "static", config.claudeCode?.desktopProfile, + undefined, { lockPath: join(root, "locks", "desktop.sqlite") }, + { appliedFingerprint: config.claudeCode?.desktopProfile?.appliedFingerprint ?? "", admit: () => current && config.runtimeRole !== "hub" }); + expect(result).toEqual({ written: false, path: library, reason: "desktop_refresh_only_skipped" }); + expect(snapshot()).toEqual(before); expect(existsSync(applied.path)).toBe(drift !== "deleted"); + }); + } + + for (const drift of ["stop", "marker", "hub", "first-party"] as const) { + test(`Desktop ${drift} during discovery wins over the captured profile`, async () => { + seed("hybrid"); const before = snapshot(); + const results = await syncEnabledClientIntegrations(12345, config, deps(async () => { + if (drift === "stop") current = false; + if (drift === "marker") delete config.claudeCode!.desktopProfile!.appliedFingerprint; + if (drift === "hub") config.runtimeRole = "hub"; + if (drift === "first-party") config.claudeCode!.desktopMode = "first-party"; + persist(); return models; + }), unattended); + expect(results.find(row => row.client === "claude-desktop")).toBeUndefined(); expect(snapshot()).toEqual(before); + }); + } + + test("writer ignores caller mode and rejects a persisted marker change at its in-lock gate", () => { + const applied = seed("discovery"); + const successful = writeDesktop3pConfig(12345, [], models, "fixture-key", "static", config.claudeCode?.desktopProfile, + undefined, { lockPath: join(root, "locks", "desktop.sqlite") }, { appliedFingerprint: applied.fingerprint!, admit: () => true }); + expect(successful.written).toBe(true); + expect(JSON.parse(readFileSync(successful.path, "utf8")).inferenceModels).toBeUndefined(); + const before = snapshot(); config.claudeCode!.desktopProfile!.appliedFingerprint = "0000000000000000"; persist(); + const refused = writeDesktop3pConfig(12345, [], models, "fixture-key", "hybrid", config.claudeCode?.desktopProfile, + undefined, { lockPath: join(root, "locks", "desktop.sqlite") }, { appliedFingerprint: successful.fingerprint!, admit: () => true }); + expect(refused.reason).toBe("desktop_refresh_only_skipped"); expect(snapshot()).toEqual(before); + }); +}); + + +describe("refresh transaction revalidation", () => { + test("Grok exclusion persisted during discovery keeps the excluded model out", async () => { + config.clientIntegrations = { grok: true, "claude-desktop": false }; + config.grokExcludedModels = ["mock/new-model"]; persist(); + await syncGrokConfig(12345, config, { grokHome }, { fetchAllModels: async () => models, injectGrokConfig }); + const before = readFileSync(join(grokHome, "config.toml"), "utf8"); + config.grokExcludedModels = []; persist(); + const results = await syncEnabledClientIntegrations(12345, config, deps(async () => { + config.grokExcludedModels = ["mock/new-model"]; persist(); return models; + }), unattended); + expect(results).toContainEqual({ client: "grok", ok: true, changed: false }); + expect(readFileSync(join(grokHome, "config.toml"), "utf8")).toBe(before); + expect(before).not.toContain('model = "mock/new-model"'); + expect(refresh).not.toHaveBeenCalled(); + }); + + for (const key of ["hostname", "disabledModels"] as const) { + test(`changed ${key} refuses the remaining fan-out`, async () => { + seed(); const before = snapshot(); + await syncEnabledClientIntegrations(12345, config, deps(async () => { + if (key === "hostname") config.hostname = "localhost"; + else config.disabledModels = ["mock/new-model"]; + persist(); return models; + }), unattended); + expect(snapshot()).toEqual(before); expect(refresh).not.toHaveBeenCalled(); + }); + } + + for (const invalid of ["missing", "orphaned", "revoked", "non-loopback", "dangling-backup"] as const) { + test(`Grok transaction refuses ${invalid} without a write`, () => { + const path = join(grokHome, "config.toml"); + if (invalid === "orphaned") writeFileSync(path, '# >>> opencodex managed block — do not edit (removed by `ocx stop`) >>>\n'); + else if (invalid !== "missing") injectGrokConfig(12345, [{ id: "old" }], { grokHome }); + const before = existsSync(path) ? readFileSync(path, "utf8") : null; + if (invalid === "dangling-backup") symlinkSync(join(root, "missing-target"), `${path}.bak-opencodex`); + const result = injectGrokConfig(12345, [{ id: "new" }], { + grokHome, hostname: invalid === "non-loopback" ? "proxy.test" : "127.0.0.1", + refreshOnly: { admit: () => invalid !== "revoked" }, + }); + expect(result).toMatchObject({ ok: true, changed: false, skippedReason: "refresh-only" }); + expect(existsSync(path) ? readFileSync(path, "utf8") : null).toBe(before); + expect(existsSync(join(root, "missing-target"))).toBe(false); + }); + } + + test("Grok fence removed after pre-check is not re-created by the injector", async () => { + expect(injectGrokConfig(12345, [{ id: "old" }], { grokHome }).changed).toBe(true); + expect(grokManagedBlockPresent(grokHome)).toBe(true); + const path = join(grokHome, "config.toml"); + const personal = 'theme = "personal"\n'; + const result = await syncGrokConfig(12345, config, { grokHome, refreshOnly: { admit: () => true } }, { + fetchAllModels: async () => models, + injectGrokConfig: (...args) => { writeFileSync(path, personal); return injectGrokConfig(...args); }, + }); + expect(result).toMatchObject({ ok: true, changed: false, skippedReason: "refresh-only" }); + expect(readFileSync(path, "utf8")).toBe(personal); + }); + + for (const target of ["config", "backup"] as const) { + test(`Grok symlinked ${target} is untouched`, () => { + injectGrokConfig(12345, [{ id: "old" }], { grokHome }); + const path = join(grokHome, "config.toml"); const before = readFileSync(path, "utf8"); + const linked = target === "config" ? path : `${path}.bak-opencodex`; + const external = join(root, "grok-target"); writeFileSync(external, before); + if (existsSync(linked)) unlinkSync(linked); + symlinkSync(external, linked); + if (target === "config") expect(grokManagedBlockPresent(grokHome)).toBe(false); + expect(injectGrokConfig(12345, [{ id: "new" }], { grokHome, refreshOnly: { admit: () => true } })) + .toMatchObject({ ok: true, changed: false, skippedReason: "refresh-only" }); + expect(readFileSync(external, "utf8")).toBe(before); expect(readFileSync(path, "utf8")).toBe(before); + }); + } + + for (const target of ["profile", "backup", "metadata", "library"] as const) { + test(`Desktop symlinked ${target} refuses with the target untouched`, () => { + const applied = seed(); const before = snapshot(); + const linked = target === "profile" ? applied.path : target === "backup" ? `${applied.path}.bak` + : target === "metadata" ? join(library, "_meta.json") : library; + const external = join(root, `desktop-target-${target}`); + if (target === "library") renameSync(library, external); + else { + writeFileSync(external, target === "metadata" ? readFileSync(linked) : readFileSync(applied.path)); + if (existsSync(linked)) unlinkSync(linked); + } + symlinkSync(external, linked); + const result = writeDesktop3pConfig(12345, [], models, "fixture-key", "static", config.claudeCode?.desktopProfile, + undefined, { lockPath: join(root, "locks", "desktop.sqlite") }, + { appliedFingerprint: applied.fingerprint!, admit: () => true }); + expect(result.reason).toBe("desktop_refresh_only_skipped"); + if (target === "backup") { + expect(readFileSync(external, "utf8")).toBe(readFileSync(applied.path, "utf8")); + } else expect(snapshot()).toEqual(before); + }); + } + + test("Desktop rejects a non-regular selected profile", () => { + const applied = seed(); unlinkSync(applied.path); mkdirSync(applied.path); + const meta = readFileSync(join(library, "_meta.json"), "utf8"); + const result = writeDesktop3pConfig(12345, [], models, "fixture-key", "static", config.claudeCode?.desktopProfile, + undefined, { lockPath: join(root, "locks", "desktop.sqlite") }, + { appliedFingerprint: applied.fingerprint!, admit: () => true }); + expect(result.reason).toBe("desktop_refresh_only_skipped"); + expect(readFileSync(join(library, "_meta.json"), "utf8")).toBe(meta); + }); + + test("Desktop edit after inspection refuses before backup and replacement", () => { + const applied = seed(); const edited = readFileSync(applied.path, "utf8") + " "; + const meta = readFileSync(join(library, "_meta.json"), "utf8"); + const realRead = desktopLibrary.readDesktopProfileForeignKeys; + const read = spyOn(desktopLibrary, "readDesktopProfileForeignKeys").mockImplementation(path => { + const preserved = realRead(path); writeFileSync(path, edited); return preserved; + }); + try { + const result = writeDesktop3pConfig(12345, [], models, "fixture-key", "static", config.claudeCode?.desktopProfile, + undefined, { lockPath: join(root, "locks", "desktop.sqlite") }, + { appliedFingerprint: applied.fingerprint!, admit: () => true }); + expect(read).toHaveBeenCalled(); expect(result.reason).toBe("desktop_refresh_only_skipped"); + expect(readFileSync(applied.path, "utf8")).toBe(edited); expect(existsSync(`${applied.path}.bak`)).toBe(false); + expect(readFileSync(join(library, "_meta.json"), "utf8")).toBe(meta); + } finally { read.mockRestore(); } + }); + + test("Desktop refresh preserves exact metadata bytes and concurrent native selection", () => { + const applied = seed(); const metaPath = join(library, "_meta.json"); + const customMeta = JSON.stringify(metadata()) + " "; writeFileSync(metaPath, customMeta); + const run = () => writeDesktop3pConfig(12345, [], models, "fixture-key", "static", config.claudeCode?.desktopProfile, + undefined, { lockPath: join(root, "locks", "desktop.sqlite") }, + { appliedFingerprint: config.claudeCode!.desktopProfile!.appliedFingerprint!, admit: () => true }); + const result = run(); expect(result.written).toBe(true); expect(result.fingerprint).toBeDefined(); + expect(readFileSync(metaPath, "utf8")).toBe(customMeta); + config.claudeCode!.desktopProfile!.appliedFingerprint = result.fingerprint!; persist(); + const selectedElsewhere = JSON.stringify({ ...metadata(), appliedId: "native-choice" }); + const profileBefore = readFileSync(applied.path, "utf8"); + const backupPath = `${applied.path}.bak`; + writeFileSync(backupPath, "original backup bytes"); + const backupBefore = readFileSync(backupPath, "utf8"); + const realRead = desktopLibrary.readDesktopProfileForeignKeys; + const read = spyOn(desktopLibrary, "readDesktopProfileForeignKeys").mockImplementation(path => { + const preserved = realRead(path); writeFileSync(metaPath, selectedElsewhere); return preserved; + }); + try { + expect(run().reason).toBe("desktop_refresh_only_skipped"); + expect(readFileSync(metaPath, "utf8")).toBe(selectedElsewhere); + expect(readFileSync(applied.path, "utf8")).toBe(profileBefore); + expect(readFileSync(backupPath, "utf8")).toBe(backupBefore); + } + finally { read.mockRestore(); } + }); +}); + + +describe("refresh-only publication hook", () => { + test("Desktop hardlinked backup and the other link stay untouched", () => { + const applied = seed(); const backupPath = `${applied.path}.bak`; + const other = join(root, "other-backup-link"); writeFileSync(other, "previous backup"); linkSync(other, backupPath); + const result = writeDesktop3pConfig(12345, [], models, "fixture-key", "static", config.claudeCode?.desktopProfile, + undefined, { lockPath: join(root, "locks", "desktop.sqlite") }, + { appliedFingerprint: applied.fingerprint!, admit: () => true }); + expect(result.written).toBe(true); expect(readFileSync(other, "utf8")).toBe("previous backup"); + expect(readFileSync(backupPath, "utf8")).toBe("previous backup"); + }); + + for (const drift of ["profile", "symlink", "selection", "ownership", "revoked"] as const) { + test(`Desktop ${drift} after temp write refuses publication`, () => { + const applied = seed(); const profileBefore = readFileSync(applied.path, "utf8"); + const backupPath = `${applied.path}.bak`; writeFileSync(backupPath, "old backup"); + const target = join(root, "profile-target"); writeFileSync(target, profileBefore); + const realWrite = atomicWrites.atomicWriteFileNoFollow; + let called = false; + const write = spyOn(atomicWrites, "atomicWriteFileNoFollow").mockImplementation((path, content, io, hooks) => { + called = true; + return realWrite(path, content, io, { ...hooks, afterTempWrite: () => { + if (drift === "profile") writeFileSync(path, profileBefore + " "); + if (drift === "symlink") { unlinkSync(path); symlinkSync(target, path); } + if (drift === "selection" || drift === "ownership") { + const meta = metadata(); + if (drift === "selection") meta.appliedId = "native-choice"; + else meta.entries[0].name = "personal"; + writeFileSync(join(library, "_meta.json"), JSON.stringify(meta)); + } + if (drift === "revoked") current = false; + } }); + }); + try { + const result = writeDesktop3pConfig(12345, [], models, "fixture-key", "static", config.claudeCode?.desktopProfile, + undefined, { lockPath: join(root, "locks", "desktop.sqlite") }, + { appliedFingerprint: applied.fingerprint!, admit: () => current }); + expect(called).toBe(true); expect(result.reason).toBe("desktop_refresh_only_skipped"); + expect(readFileSync(applied.path, "utf8")).toBe(profileBefore + (drift === "profile" ? " " : "")); + expect(readFileSync(target, "utf8")).toBe(profileBefore); + expect(readFileSync(backupPath, "utf8")).toBe("old backup"); + expect(readdirSync(library).some(name => name.endsWith(".tmp"))).toBe(false); + } finally { write.mockRestore(); } + }); + } + + for (const drift of ["edit", "symlink", "revoked"] as const) { + test(`Grok ${drift} after temp write refuses publication`, () => { + const path = join(grokHome, "config.toml"); + injectGrokConfig(12345, [{ id: "old" }], { grokHome }); + const before = readFileSync(path, "utf8"); const edited = before + '# another process\n'; + const backupPath = `${path}.bak-opencodex`; const target = join(root, "grok-target"); + writeFileSync(backupPath, "old backup"); writeFileSync(target, before); + const realWrite = atomicWrites.atomicWriteFileNoFollow; + let called = false; + const write = spyOn(atomicWrites, "atomicWriteFileNoFollow").mockImplementation((path, content, io, hooks) => { + called = true; + return realWrite(path, content, io, { ...hooks, afterTempWrite: () => { + if (drift === "edit") writeFileSync(path, edited); + if (drift === "symlink") { unlinkSync(path); symlinkSync(target, path); } + if (drift === "revoked") current = false; + } }); + }); + try { + const result = injectGrokConfig(12345, [{ id: "new" }], { grokHome, refreshOnly: { admit: () => current } }); + expect(called).toBe(true); + expect(result).toMatchObject({ ok: true, changed: false, skippedReason: "refresh-only" }); + expect(readFileSync(path, "utf8")).toBe(drift === "edit" ? edited : before); + expect(readFileSync(target, "utf8")).toBe(before); expect(readFileSync(backupPath, "utf8")).toBe("old backup"); + expect(readdirSync(grokHome).some(name => name.endsWith(".tmp"))).toBe(false); + } finally { write.mockRestore(); } + }); + } +}); diff --git a/tests/codex-integration/catalog-auto-refresh-client-fanout.test.ts b/tests/codex-integration/catalog-auto-refresh-client-fanout.test.ts new file mode 100644 index 00000000000..a2dfdb463bb --- /dev/null +++ b/tests/codex-integration/catalog-auto-refresh-client-fanout.test.ts @@ -0,0 +1,226 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as scheduler from "../../src/codex/catalog-auto-refresh"; +import { markSiblingStart, resetSiblingStartForTests } from "../../src/codex/sibling-start"; +import * as convergence from "../../src/codex/management-convergence"; +import * as processes from "../../src/codex/app-server-processes"; +import * as runtime from "../../src/config/process-state"; +import * as routes from "../../src/server/management/config-routes"; +import { lastCatalogAutoRefreshOutcome, resetCatalogAutoRefreshStatusForTests } from "../../src/codex/catalog-refresh-status"; +import { acquireServerBackgroundLifecycle, type ServerBackgroundLifecycleLease } from "../../src/server/background-lifecycle"; +import { getConfigPath, getDefaultConfig, loadConfig } from "../../src/config"; +import type { CatalogOnlyOutcome } from "../../src/codex/convergence-types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +function outcome(changed: boolean): CatalogOnlyOutcome { + return { kind: "catalog-only", changed, + catalogRefresh: { status: "committed", changed, degraded: false, notices: [] } } as CatalogOnlyOutcome; +} +let root: string; +let previousHome: string | undefined; +let codexHome: IsolatedCodexHome; +let converge: () => Promise; +let spies: Array<{ mockRestore(): void }>; +let leases: ServerBackgroundLifecycleLease[]; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + root = mkdtempSync(join(tmpdir(), "ocx-catalog-fanout-")); + process.env.OPENCODEX_HOME = root; + codexHome = installIsolatedCodexHome(); + scheduler.resetCatalogAutoRefreshForTests(); + scheduler.setCatalogAutoRefreshClientFanout(null); + resetSiblingStartForTests(); + resetCatalogAutoRefreshStatusForTests(); + // Explicit refresh with native Codex off avoids live runtime and entitlement discovery. + writeFileSync(getConfigPath(), JSON.stringify({ ...getDefaultConfig(), + clientIntegrations: { codex: false }, catalogAutoRefresh: { enabled: true } })); + converge = async () => outcome(true); + leases = []; + spies = [ + spyOn(convergence, "createManagementConvergeCodex").mockImplementation(() => () => converge()), + spyOn(processes, "collectCodexAppServerCatalogStateWithin") + .mockResolvedValue({ state: "not_running", processes: [], catalogMtimeMs: null }), + spyOn(console, "info").mockImplementation(() => {}), + ]; +}); +afterEach(async () => { + for (const lease of leases) await lease.release(); + scheduler.setCatalogAutoRefreshClientFanout(null); + scheduler.resetCatalogAutoRefreshForTests(); + resetCatalogAutoRefreshStatusForTests(); + for (const spy of spies.reverse()) spy.mockRestore(); + resetSiblingStartForTests(); + codexHome.restore(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(root); +}); + +test("changed outcomes are recorded before one client delivery; unchanged outcomes have none", async () => { + let calls = 0; + scheduler.setCatalogAutoRefreshClientFanout(async current => { + calls++; + expect(current()).toBe(true); + expect(lastCatalogAutoRefreshOutcome()?.changed).toBe(true); + return [{ ok: true }]; + }); + await scheduler.runCatalogAutoRefreshTickForTests(); + expect(calls).toBe(1); + converge = async () => outcome(false); + await scheduler.runCatalogAutoRefreshTickForTests(); + expect(calls).toBe(1); +}); + +test("failed results and rejection log only counts and a retry hint", async () => { + const warnings: unknown[][] = []; + spies.push(spyOn(console, "warn").mockImplementation((...args) => { warnings.push(args); })); + scheduler.setCatalogAutoRefreshClientFanout(async () => [ + { ok: false, reason: "/private/client/identifier" }, { ok: true }, { ok: false }, + ]); + await scheduler.runCatalogAutoRefreshTickForTests(); + expect(warnings).toEqual([["[catalog-auto-refresh] 2 client integration(s) were not refreshed; ocx sync retries them"]]); + scheduler.setCatalogAutoRefreshClientFanout(async () => { throw new Error("private secret path"); }); + await scheduler.runCatalogAutoRefreshTickForTests(); + expect(warnings[1]).toEqual(["[catalog-auto-refresh] client integrations were not refreshed; ocx sync retries them"]); + expect(JSON.stringify(warnings)).not.toContain("private"); +}); + +test("stop during converge prevents delivery", async () => { + let enter!: () => void; + let release!: (value: CatalogOnlyOutcome) => void; + const entered = new Promise(resolve => { enter = resolve; }); + converge = () => { enter(); return new Promise(resolve => { release = resolve; }); }; + let calls = 0; + scheduler.setCatalogAutoRefreshClientFanout(async () => { calls++; return []; }); + const tick = scheduler.runCatalogAutoRefreshTickForTests(); + try { await entered; scheduler.stopCatalogAutoRefresh(); } + finally { release(outcome(true)); await tick; } + expect(calls).toBe(0); +}); + +test("stop during delivery invalidates the writer admission and suppresses stale warnings", async () => { + let enter!: () => void; + let release!: () => void; + let current!: () => boolean; + const entered = new Promise(resolve => { enter = resolve; }); + const gate = new Promise(resolve => { release = resolve; }); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + spies.push(warn); + scheduler.setCatalogAutoRefreshClientFanout(async isCurrent => { + current = isCurrent; enter(); await gate; return [{ ok: false }]; + }); + const tick = scheduler.runCatalogAutoRefreshTickForTests(); + try { + await entered; expect(current()).toBe(true); + scheduler.stopCatalogAutoRefresh(); expect(current()).toBe(false); + scheduler.startCatalogAutoRefresh(); expect(current()).toBe(false); + } finally { release(); await tick; } + expect(warn).not.toHaveBeenCalled(); +}); + +test("process leases register before scheduler start and clear only on last release", async () => { + const registrations: Array = []; + const realSet = scheduler.setCatalogAutoRefreshClientFanout; + spies.push(spyOn(scheduler, "setCatalogAutoRefreshClientFanout").mockImplementation(hook => { + registrations.push(hook); realSet(hook); + })); + const realStart = scheduler.startCatalogAutoRefresh; + spies.push(spyOn(scheduler, "startCatalogAutoRefresh").mockImplementation(() => { + expect(typeof registrations.at(-1)).toBe("function"); realStart(); + })); + const first = acquireServerBackgroundLifecycle(() => {}, loadConfig()); + const second = acquireServerBackgroundLifecycle(() => {}, loadConfig()); + leases.push(first, second); + expect(registrations).toHaveLength(1); + await first.release(); expect(registrations).toHaveLength(1); + await second.release(); expect(registrations.at(-1)).toBeNull(); +}); + +test("lifecycle hook uses current runtime port and skips missing runtime, stale generation and hub", async () => { + let hook!: scheduler.CatalogChangedClientFanout; + const realSet = scheduler.setCatalogAutoRefreshClientFanout; + spies.push(spyOn(scheduler, "setCatalogAutoRefreshClientFanout").mockImplementation(value => { + if (value) hook = value; realSet(value); + })); + const lease = acquireServerBackgroundLifecycle(() => {}, loadConfig()); leases.push(lease); + const read = spyOn(runtime, "readRuntimePort").mockReturnValue(null); spies.push(read); + const sync = spyOn(routes, "syncEnabledClientIntegrations").mockResolvedValue([]); spies.push(sync); + expect(await hook(() => true)).toEqual([]); expect(sync).not.toHaveBeenCalled(); + read.mockReturnValue({ pid: process.pid, port: 12345 }); + expect(await hook(() => false)).toEqual([]); expect(sync).not.toHaveBeenCalled(); + writeFileSync(getConfigPath(), JSON.stringify({ ...loadConfig(), runtimeRole: "hub" })); + expect(await hook(() => true)).toEqual([]); expect(sync).not.toHaveBeenCalled(); + writeFileSync(getConfigPath(), JSON.stringify({ ...loadConfig(), runtimeRole: "standalone" })); + await hook(() => true); expect(sync.mock.calls[0]?.[0]).toBe(12345); + read.mockReturnValue({ pid: process.pid, port: 12346 }); + await hook(() => true); expect(sync.mock.calls[1]?.[0]).toBe(12346); + expect(sync.mock.calls[1]?.[3]?.unattended?.isCurrent()).toBe(true); + markSiblingStart(12344); + expect(await hook(() => true)).toEqual([]); expect(sync).toHaveBeenCalledTimes(2); +}); + +test("a loop startup failure clears its registered hook", () => { + const registrations: Array = []; + const realSet = scheduler.setCatalogAutoRefreshClientFanout; + spies.push(spyOn(scheduler, "setCatalogAutoRefreshClientFanout").mockImplementation(hook => { + registrations.push(hook); realSet(hook); + })); + spies.push(spyOn(scheduler, "startCatalogAutoRefresh").mockImplementation(() => { throw new Error("start failure"); })); + expect(() => acquireServerBackgroundLifecycle(() => {}, loadConfig())).toThrow("start failure"); + expect(typeof registrations[0]).toBe("function"); expect(registrations.at(-1)).toBeNull(); +}); + + +test("shutdown revokes delivery synchronously but another live owner keeps admission", async () => { + let hook!: scheduler.CatalogChangedClientFanout; + const realSet = scheduler.setCatalogAutoRefreshClientFanout; + spies.push(spyOn(scheduler, "setCatalogAutoRefreshClientFanout").mockImplementation(value => { + if (value) hook = value; realSet(value); + })); + const first = acquireServerBackgroundLifecycle(() => {}, loadConfig()); leases.push(first); + spies.push(spyOn(runtime, "readRuntimePort").mockReturnValue({ pid: process.pid, port: 12345 })); + let enter!: () => void; let release!: () => void; + const entered = new Promise(resolve => { enter = resolve; }); + const gate = new Promise(resolve => { release = resolve; }); + let writes = 0; + const sync = spyOn(routes, "syncEnabledClientIntegrations").mockImplementation(async (_port, _config, _deps, options) => { + enter(); await gate; if (options?.unattended?.isCurrent()) writes++; return []; + }); spies.push(sync); + const flight = hook(() => true); + try { await entered; first.revokeClientFanout(); } + finally { release(); await flight; } + expect(writes).toBe(0); sync.mockClear(); + expect(await hook(() => true)).toEqual([]); expect(sync).not.toHaveBeenCalled(); + const second = acquireServerBackgroundLifecycle(() => {}, loadConfig()); leases.push(second); + await hook(() => true); expect(writes).toBe(1); + second.revokeClientFanout(); sync.mockClear(); + expect(await hook(() => true)).toEqual([]); expect(sync).not.toHaveBeenCalled(); +}); + + +test("delivery binds the newest owner and cannot transfer authority mid-flight", async () => { + let hook!: scheduler.CatalogChangedClientFanout; + const realSet = scheduler.setCatalogAutoRefreshClientFanout; + spies.push(spyOn(scheduler, "setCatalogAutoRefreshClientFanout").mockImplementation(value => { + if (value) hook = value; realSet(value); + })); + const first = acquireServerBackgroundLifecycle(() => {}, loadConfig()); leases.push(first); + const newest = acquireServerBackgroundLifecycle(() => {}, loadConfig()); leases.push(newest); + spies.push(spyOn(runtime, "readRuntimePort").mockReturnValue({ pid: process.pid, port: 12345 })); + let enter!: () => void; let release!: () => void; + const entered = new Promise(resolve => { enter = resolve; }); + const gate = new Promise(resolve => { release = resolve; }); + let writes = 0; + const sync = spyOn(routes, "syncEnabledClientIntegrations").mockImplementation(async (_port, _config, _deps, options) => { + enter(); await gate; if (options?.unattended?.isCurrent()) writes++; return []; + }); spies.push(sync); + const flight = hook(() => true); + try { await entered; newest.revokeClientFanout(); } + finally { release(); await flight; } + expect(writes).toBe(0); + await hook(() => true); expect(writes).toBe(1); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c6bea9df023..2ec78f200e9 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -6,6 +6,8 @@ "anthropic-revoked-token-continuation.test.ts": "adapters/anthropic", "anthropic-revoked-token-sidecars.test.ts": "adapters/anthropic", "anthropic-revoked-token-boundaries.test.ts": "adapters/anthropic", + "integrations-writer-guard.test.ts": "clients", + "aside-profiles-catalog-refresh.test.ts": "clients", "azure-vendor-metadata.test.ts": "providers", "anthropic-instance-isolation.test.ts": "adapters/anthropic", "anthropic-instance-pool-parity.test.ts": "adapters/anthropic", "anthropic-instance-quota.test.ts": "adapters/anthropic", "anthropic-instance-recovery.test.ts": "adapters/anthropic", @@ -209,7 +211,7 @@ "build-desktop-icon-set.test.ts": "ci-workflows", "build-release-changelog.test.ts": "ci-workflows", "ci-gui-typecheck-gate.test.ts": "ci-workflows", "bump-dev-version.test.ts": "ci-workflows", "bun-runtime.test.ts": "ci-workflows", "bun-stream-caps.test.ts": "lib", "cache-diagnostic.test.ts": "usage", - "cancel-body-on-abort.test.ts": "server", "catalog-auto-refresh-scheduler.test.ts": "codex-integration", + "cancel-body-on-abort.test.ts": "server", "catalog-auto-refresh-client-fanout.test.ts": "codex-integration", "catalog-auto-refresh-scheduler.test.ts": "codex-integration", "catalog-cursor-search.test.ts": "codex-integration", "catalog-custom-ordering.test.ts": "codex-integration", "catalog-duplicate-slug-dedup.test.ts": "codex-integration", "catalog-free-pricing-status.test.ts": "codex-integration", "catalog-full-picker-order.test.ts": "codex-integration", @@ -1063,7 +1065,7 @@ "lazycodex-detection.test.ts": "clients", "codex-agent-role-routes.test.ts": "server", "codex-role-auto-assign-routes.test.ts": "server", "local-chat-completion.test.ts": "lib", "injection-model-suggest-routes.test.ts": "codex-integration", "subagent-roster-retention.test.ts": "routing", - "sync-client-integrations.test.ts": "clients", "synced-state-location.test.ts": "lib", + "sync-client-integrations-unattended.test.ts": "clients", "sync-client-integrations.test.ts": "clients", "synced-state-location.test.ts": "lib", "synthetic-tool.test.ts": "images", "system-env.test.ts": "server", "system-restart-client-package-tree.test.ts": "cli", "system-restart-client.test.ts": "cli", "system-restart-contract-security.test.ts": "lib", "system-restart-admission.test.ts": "server", "system-restart.test.ts": "server", From 595f3e556f1f53ba4d924a1b58bf2d0eb6a9ed8f Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 10 Oct 2026 05:33:22 +0900 Subject: [PATCH 45/59] fix(spend): warn when Google Drive syncs Desktop or Documents (#6846) Refs #6314. The startup advisory already named iCloud and CloudStorage paths. Google Drive for desktop can sync the native Desktop and Documents folders, which is the layout that produced the nlink==2 journal refusals. Treat a DriveFS directory or a GoogleDrive-* CloudStorage folder as that hint, still without relaxing the hard-link guard or logging paths. Co-authored-by: Cursor Agent --- .../spend-ledger-synced-folder.md | 12 +++-- src/lib/synced-state-location.ts | 52 ++++++++++++++++--- src/server/index/spend-ledger-lifecycle.ts | 3 +- structure/transports/responses-spend.md | 4 +- tests/lib/synced-state-location.test.ts | 48 ++++++++++++++++- 5 files changed, 106 insertions(+), 13 deletions(-) diff --git a/docs-site/src/content/docs/troubleshooting/spend-ledger-synced-folder.md b/docs-site/src/content/docs/troubleshooting/spend-ledger-synced-folder.md index d9bc77574fc..73f0f45238b 100644 --- a/docs-site/src/content/docs/troubleshooting/spend-ledger-synced-folder.md +++ b/docs-site/src/content/docs/troubleshooting/spend-ledger-synced-folder.md @@ -45,10 +45,14 @@ the journal can have two links for a moment after an ordinary write. A request t that moment is refused, and the next one may succeed. Once the sync settles, the file is back to one link, so inspecting it afterwards shows nothing wrong. -At startup opencodex now warns when the state directory resolves inside iCloud Drive -(`~/Library/Mobile Documents`), a File Provider folder (`~/Library/CloudStorage`), or Desktop -or Documents while iCloud Desktop & Documents sync appears to be on. The warning is advisory. -The detection reads the folder layout and can be wrong in either direction. +At startup opencodex warns when the state directory resolves inside iCloud Drive +(`~/Library/Mobile Documents`), a File Provider folder (`~/Library/CloudStorage`), Desktop +or Documents while iCloud Desktop & Documents sync appears to be on, or Desktop or Documents +while Google Drive for desktop appears to be in use. The Google Drive hint is the presence +of `~/Library/CloudStorage/GoogleDrive-*` or `~/Library/Application Support/Google/DriveFS`. +It does not read which folders the app was told to sync, so Drive being installed can warn +even when Documents is not selected. The warning is advisory. The detection reads the folder +layout and can be wrong in either direction. ## Fix diff --git a/src/lib/synced-state-location.ts b/src/lib/synced-state-location.ts index 1da04f759c7..8f0e726b7d2 100644 --- a/src/lib/synced-state-location.ts +++ b/src/lib/synced-state-location.ts @@ -1,4 +1,4 @@ -import { lstatSync, realpathSync } from "node:fs"; +import { lstatSync, readdirSync, realpathSync } from "node:fs"; import { homedir } from "node:os"; // POSIX path operations on purpose: every path this module classifies is a macOS path. The // host's own path module would build backslash or drive-letter paths on Windows and make the @@ -15,7 +15,11 @@ const { join, resolve } = posix; * synced folder turns ordinary requests into intermittent 502s (#6314). This only answers * "is it likely synced"; the guard itself stays strict. */ -export type SyncedStateLocation = "icloud-drive" | "file-provider" | "icloud-desktop-documents"; +export type SyncedStateLocation = + | "icloud-drive" + | "file-provider" + | "icloud-desktop-documents" + | "google-drive-desktop-documents"; export interface SyncedStateLocationProbe { readonly platform?: NodeJS.Platform; @@ -24,23 +28,34 @@ export interface SyncedStateLocationProbe { readonly realpath?: (path: string) => string; /** Whether a directory entry exists at the path, without following it. */ readonly entryExists?: (path: string) => boolean; + /** Entry names in a directory. An unreadable directory is empty, not an error. */ + readonly listEntries?: (dir: string) => readonly string[]; } const defaultEntryExists = (path: string): boolean => { try { lstatSync(path); return true; } catch { return false; } }; +const defaultListEntries = (dir: string): readonly string[] => { + try { return readdirSync(dir); } catch { return []; } +}; + /** * Advisory only. Apple publishes no API a CLI can ask, so this reads the observed layout: * iCloud Drive lives under ~/Library/Mobile Documents, File Provider clients (OneDrive, Dropbox, * Google Drive) under ~/Library/CloudStorage, and with "Desktop & Documents Folders" turned on - * iCloud Drive holds a Desktop/Documents entry of its own. A false positive costs one warning - * line; nothing is refused on the strength of it. + * iCloud Drive holds a Desktop/Documents entry of its own. Google Drive for desktop can also + * sync the native Desktop and Documents folders without moving them under CloudStorage (#6314). + * The cheap signals for that are a DriveFS directory or a `GoogleDrive-*` CloudStorage folder. + * They do not read which folders the app was told to sync, so Drive being installed can warn + * even when Documents is not selected. A false positive costs one warning line; nothing is + * refused on the strength of it. */ export function syncedStateLocation(dir: string, probe: SyncedStateLocationProbe = {}): SyncedStateLocation | undefined { if ((probe.platform ?? process.platform) !== "darwin") return undefined; const realpath = probe.realpath ?? ((path: string) => realpathSync.native(path)); const entryExists = probe.entryExists ?? defaultEntryExists; + const listEntries = probe.listEntries ?? defaultListEntries; const canonical = (path: string): string => { try { return realpath(path); } catch { return resolve(path); } }; @@ -56,17 +71,42 @@ export function syncedStateLocation(dir: string, probe: SyncedStateLocationProbe if (within(mobileDocuments)) return "icloud-drive"; if (within(join(home, "Library", "CloudStorage"))) return "file-provider"; for (const folder of ["Desktop", "Documents"]) { - if (within(join(home, folder)) && entryExists(join(mobileDocuments, "com~apple~CloudDocs", folder))) { - return "icloud-desktop-documents"; + if (!within(join(home, folder))) continue; + if (entryExists(join(mobileDocuments, "com~apple~CloudDocs", folder))) return "icloud-desktop-documents"; + if (googleDriveAppearsToSyncDesktopDocuments(home, entryExists, listEntries)) { + return "google-drive-desktop-documents"; } } return undefined; } +/** + * Presence only. Drive for desktop keeps its config under DriveFS and, on current macOS, + * a `GoogleDrive-*` directory in CloudStorage. Either one means the app is in use. Missing + * or unreadable markers are "not detected", never a startup failure. + */ +function googleDriveAppearsToSyncDesktopDocuments( + home: string, + entryExists: (path: string) => boolean, + listEntries: (dir: string) => readonly string[], +): boolean { + const driveFs = join(home, "Library", "Application Support", "Google", "DriveFS"); + try { + if (entryExists(driveFs)) return true; + } catch { /* unreadable marker */ } + try { + return listEntries(join(home, "Library", "CloudStorage")) + .some((name) => name.toLowerCase().startsWith("googledrive-")); + } catch { + return false; + } +} + const LOCATION_LABEL: Record = { "icloud-drive": "inside iCloud Drive", "file-provider": "inside a cloud-storage (File Provider) folder", "icloud-desktop-documents": "in Desktop or Documents, which iCloud Drive appears to sync", + "google-drive-desktop-documents": "in Desktop or Documents, which Google Drive appears to sync", }; /** The startup warning. Names the location kind, never the path. */ diff --git a/src/server/index/spend-ledger-lifecycle.ts b/src/server/index/spend-ledger-lifecycle.ts index b11045db43a..d656bba284d 100644 --- a/src/server/index/spend-ledger-lifecycle.ts +++ b/src/server/index/spend-ledger-lifecycle.ts @@ -37,7 +37,8 @@ export interface SpendLedgerServerLifecycle { export function acquireSpendLedgerServerLifecycle(configDir: string): SpendLedgerServerLifecycle { const owner: SpendLedgerOwnerLease = acquireSpendLedgerOwner(configDir); // Advisory: a synced state directory makes the journal's hard-link guard refuse intermittently - // (#6314). Said once at startup instead of being discovered from a 502. + // (#6314), including Google Drive syncing the native Desktop or Documents folder. Said once + // at startup instead of being discovered from a 502. The nlink guard itself is unchanged. warnIfSyncedStateDirectory(configDir); // Each entry returns whatever the listener's own stop returned. Typed as void-or-promise // because the rollback below has to WAIT on it: declaring it `() => void` let the call site diff --git a/structure/transports/responses-spend.md b/structure/transports/responses-spend.md index f8bdb20de16..0eb13a54857 100644 --- a/structure/transports/responses-spend.md +++ b/structure/transports/responses-spend.md @@ -365,7 +365,9 @@ daemon briefly holding a second link to a journal inside a synced folder could t diagnosed from an instrumented build. The guard is unchanged. `src/lib/synced-state-location.ts` is the advisory half. `acquireSpendLedgerServerLifecycle` warns once at startup when the state directory resolves inside iCloud Drive, a File Provider -folder, or Desktop/Documents with iCloud Desktop & Documents sync detected, and it refuses +folder, Desktop/Documents with iCloud Desktop & Documents sync detected, or Desktop/Documents +while Google Drive for desktop is present (`GoogleDrive-*` under CloudStorage, or DriveFS), +and it refuses nothing on that basis. `tests/lib/spend-ledger-file-journal.test.ts` pins the refusal shape, and `tests/lib/synced-state-location.test.ts` pins the classification. diff --git a/tests/lib/synced-state-location.test.ts b/tests/lib/synced-state-location.test.ts index 5b157471d08..84ec16b6ef1 100644 --- a/tests/lib/synced-state-location.test.ts +++ b/tests/lib/synced-state-location.test.ts @@ -58,11 +58,57 @@ describe("synced state directory detection (#6314)", () => { }); test("the warning names the location kind and never a path", () => { - for (const location of ["icloud-drive", "file-provider", "icloud-desktop-documents"] as const) { + for (const location of ["icloud-drive", "file-provider", "icloud-desktop-documents", "google-drive-desktop-documents"] as const) { const text = syncedStateWarning(location).join("\n"); expect(text).toContain("OPENCODEX_HOME"); expect(text).not.toContain("/Users/"); } + expect(syncedStateWarning("google-drive-desktop-documents").join("\n")).toContain("Google Drive"); + }); + + test("Documents or Desktop counts when Google Drive for desktop is present (#6314)", () => { + const driveFs = `${HOME}/Library/Application Support/Google/DriveFS`; + const withDriveFs = probe({ entryExists: (path) => path === driveFs }); + expect(syncedStateLocation(`${HOME}/Documents/ocx-trial/state`, withDriveFs)).toBe("google-drive-desktop-documents"); + expect(syncedStateLocation(`${HOME}/Desktop/state`, withDriveFs)).toBe("google-drive-desktop-documents"); + expect(syncedStateLocation(`${HOME}/.opencodex`, withDriveFs)).toBeUndefined(); + expect(syncedStateLocation(`${HOME}/Documents-local/state`, withDriveFs)).toBeUndefined(); + + const listed = probe({ + listEntries: (dir) => dir.endsWith("/CloudStorage") ? ["Dropbox", "GoogleDrive-user@example.com"] : [], + }); + expect(syncedStateLocation(`${HOME}/Documents/state`, listed)).toBe("google-drive-desktop-documents"); + expect(syncedStateLocation(`${HOME}/documents/state`, listed)).toBe("google-drive-desktop-documents"); + }); + + test("a CloudStorage folder that is not Google Drive does not mark Documents as synced", () => { + const listed = probe({ listEntries: () => ["Dropbox", "OneDrive-work"] }); + expect(syncedStateLocation(`${HOME}/Documents/state`, listed)).toBeUndefined(); + const folded = probe({ listEntries: () => ["googledrive-work"] }); + expect(syncedStateLocation(`${HOME}/Desktop/state`, folded)).toBe("google-drive-desktop-documents"); + }); + + test("iCloud Desktop & Documents still wins when Google Drive is also present", () => { + const driveFs = `${HOME}/Library/Application Support/Google/DriveFS`; + const entryExists = (path: string): boolean => desktopDocumentsSynced(path) || path === driveFs; + expect(syncedStateLocation(`${HOME}/Documents/state`, probe({ entryExists }))).toBe("icloud-desktop-documents"); + }); + + test("an unreadable Google Drive marker does not throw and does not warn", () => { + const entryExists = (path: string): boolean => { + if (path.endsWith("/Google/DriveFS")) throw Object.assign(new Error("unreadable"), { code: "EACCES" }); + return false; + }; + const listEntries = (): readonly string[] => { + throw Object.assign(new Error("unreadable"), { code: "EACCES" }); + }; + expect(syncedStateLocation(`${HOME}/Documents/state`, probe({ entryExists, listEntries }))).toBeUndefined(); + }); + + test("Google Drive markers are ignored off macOS", () => { + const driveFs = `${HOME}/Library/Application Support/Google/DriveFS`; + const marked = probe({ platform: "linux", entryExists: (path) => path === driveFs }); + expect(syncedStateLocation(`${HOME}/Documents/state`, marked)).toBeUndefined(); }); test("classification uses macOS path rules whatever host runs it", () => { From 93ed1a40b4026c2f802107858725aa352b4a0df9 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 10 Oct 2026 05:45:43 +0900 Subject: [PATCH 46/59] test(responses): make the periodic spill sweep resume case deterministic (#6848) The window case assumed three 25ms liveness ticks could finish a real directory. On a slow runner the deadline stopped each tick early, so the assertion saw 3 removals instead of every orphan. Drive order through the existing readdir seam, hold the clock still, and assert the first tick stops at the scan window while the next tick removes only the orphans. Reset the process-global sweep cursor around each case. Co-authored-by: Cursor Agent --- .../responses-spill-orphan-sweep.test.ts | 61 ++++++++++++++----- 1 file changed, 46 insertions(+), 15 deletions(-) diff --git a/tests/responses/responses-spill-orphan-sweep.test.ts b/tests/responses/responses-spill-orphan-sweep.test.ts index 435e0e6bf2c..0fa4f3a622e 100644 --- a/tests/responses/responses-spill-orphan-sweep.test.ts +++ b/tests/responses/responses-spill-orphan-sweep.test.ts @@ -19,6 +19,7 @@ import { import { PERIODIC_SPILL_SWEEP_OPTS, RESPONSE_SPILL_DIR_NAME, + resetPeriodicSpillSweepCursorForTests, responseSpillDirectory, setSpillIoForTest, sweepOrphanedResponseSpillsPeriodically, @@ -78,6 +79,9 @@ describe("Periodic orphan response-spill sweep", () => { const priorHome = process.env["OPENCODEX_HOME"]; beforeEach(() => { + // The periodic cursor is process-global. A leftover handle from the previous + // case would resume a directory this case does not own. + resetPeriodicSpillSweepCursorForTests(); home = mkdtempSync(join(tmpdir(), "ocx-orphan-sweep-test-")); process.env["OPENCODEX_HOME"] = home; clearResponseStateMemoryForTests(); @@ -86,6 +90,7 @@ describe("Periodic orphan response-spill sweep", () => { }); afterEach(() => { + resetPeriodicSpillSweepCursorForTests(); setSpillIoForTest(null); setAsyncIcaclsRunnerForTests(null); setIcaclsRunnerForTests(null); @@ -194,23 +199,49 @@ describe("Periodic orphan response-spill sweep", () => { }); test("resumes past a full window of owned files on the next tick", () => { - const dir = responseSpillDirectory(home); - const owned = new Set(); - const orphans: string[] = []; - const total = PERIODIC_SPILL_SWEEP_OPTS.scanMax + 64; - for (let i = 0; i < total; i += 1) { - const ref = writeResponseSpillDurably(`resp_window_${i}`, { createdAt: Date.now(), items: [i] }); - agePastGrace(join(dir, ref.fileName)); - if (i % 20 === 0) orphans.push(ref.fileName); - else owned.add(ref.fileName); - } + // The liveness tick stops at a 25ms wall deadline. Filling a real directory + // and hoping three ticks finish is what failed on a slow runner (3 removals + // instead of every orphan). The seam below fixes the order, and the clock + // stays still so this case measures the scan window rather than the deadline. + const clock = 1_700_000_000_000; + const nowSpy = spyOn(Date, "now").mockImplementation(() => clock); + try { + const dir = responseSpillDirectory(home); + const ownedSpills: string[] = []; + const orphans: string[] = []; + for (let i = 0; i < 2; i += 1) { + const ownedRef = writeResponseSpillDurably(`resp_window_owned_${i}`, { createdAt: clock, items: [i] }); + const orphanRef = writeResponseSpillDurably(`resp_window_orphan_${i}`, { createdAt: clock, items: [i] }); + agePastGrace(join(dir, ownedRef.fileName)); + agePastGrace(join(dir, orphanRef.fileName)); + ownedSpills.push(ownedRef.fileName); + orphans.push(orphanRef.fileName); + } + const prefix = Array.from({ length: PERIODIC_SPILL_SWEEP_OPTS.scanMax }, (_, i) => `owned-prefix-${i}.txt`); + const names = [...prefix, ...ownedSpills, ...orphans]; + const owned = new Set([...prefix, ...ownedSpills]); + let served = 0; + setSpillIoForTest({ + readdirEntry() { + return served < names.length ? names[served++]! : null; + }, + }); - let removed = 0; - for (let tick = 0; tick < 3; tick += 1) removed += sweepOrphanedResponseSpillsPeriodically(owned, dir).removed; + const first = sweepOrphanedResponseSpillsPeriodically(owned, dir); + expect(first.removed).toBe(0); + expect(served).toBe(PERIODIC_SPILL_SWEEP_OPTS.scanMax); + expect(orphans.filter(name => existsSync(join(dir, name)))).toEqual(orphans); + expect(ownedSpills.filter(name => existsSync(join(dir, name)))).toEqual(ownedSpills); - expect(removed).toBe(orphans.length); - expect(orphans.filter(name => existsSync(join(dir, name)))).toEqual([]); - expect(spillFileNames(home)).toHaveLength(owned.size); + const second = sweepOrphanedResponseSpillsPeriodically(owned, dir); + expect(second.removed).toBe(orphans.length); + expect(served).toBe(names.length); + expect(orphans.filter(name => existsSync(join(dir, name)))).toEqual([]); + expect(ownedSpills.filter(name => existsSync(join(dir, name)))).toEqual(ownedSpills); + expect(spillFileNames(home).sort()).toEqual([...ownedSpills].sort()); + } finally { + nowSpy.mockRestore(); + } }); test("keeps advancing when enumeration alone would exhaust the tick deadline", () => { From f091942278a4f8b268e204c478a3d6ad768a5e91 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 10 Oct 2026 06:06:03 +0900 Subject: [PATCH 47/59] fix: carry remaining #6723 change (picker CA persistence + log admission test) (#6847) * fix: carry remaining #6723 log admission split and fixture drain 39663ff31's BigInt picker identity check is already on dev via #6772, so this keeps that implementation. The rest of the commit that still applies moves the log-cursor cases into a registered sibling under the current fixture drain, waits for ACL work before model-arrival fixture removal, and records the bigint file-id comparison in the Claude Desktop contract. The diagnostic-crate hunks stay with #6806. Co-authored-by: mashfromband * fix: keep the test-layout map under the file-size ratchet The new log-admission entry shares a line with the management-auth entry so scripts/test-layout/layout.json stays at 1999 lines. Co-authored-by: mashfromband --------- Co-authored-by: Cursor Agent Co-authored-by: mashfromband --- scripts/test-layout/layout.json | 2 +- structure/clients/claude-desktop.md | 3 +- tests/fixtures/test-layout-expected.json | 1 + tests/server/server-management-auth.test.ts | 59 --------- .../server-management-log-admission.test.ts | 112 ++++++++++++++++++ .../server-new-model-policy-arrival.test.ts | 6 + 6 files changed, 122 insertions(+), 61 deletions(-) create mode 100644 tests/server/server-management-log-admission.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index adb7db7b2b6..b762f52b9f4 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1675,7 +1675,7 @@ "server-live-realtime-fixtures.test.ts": "server", "server-live.test.ts": "server", "server-loopback-host-gate.test.ts": "server", - "server-management-auth.test.ts": "server", + "server-management-auth.test.ts": "server", "server-management-log-admission.test.ts": "server", "server-opencode-go-goal-streaming.test.ts": "server", "server-rate-limit-retry-e2e.test.ts": "server", "server-request-body-size.test.ts": "server", diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index dbc87ee40ae..a220bbb7098 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -250,7 +250,8 @@ application-access dialogs are controlled by macOS; restart or upgrade does not `picker-ca-store.ts` owns the versioned OS credential service, canonical-config identity namespace, bounded exact-shape payload, full constrained CA profile, validity and P-256 private-key match validation. `picker-ca-persistence.ts` validates public `authority.json` and `authority-init.json` records under -the canonical CA lock, rejecting symlinks and mismatched pre-open/path and descriptor identities. Initialization journals the config identity, new fingerprint and public +the canonical CA lock, rejecting symlinks and mismatched pre-open/path and descriptor identities. +Device and inode comparisons use bigint stats so distinct full-width file IDs cannot alias through numeric rounding. Initialization journals the config identity, new fingerprint and public predecessor before writing the credential, verifies readback, then commits metadata and publication; it removes the journal last. Recovery requires matching journal/store identity; missing initialized credentials, unavailable storage or inconsistent metadata fail closed without publishing a replacement. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 2ec78f200e9..4a2e042337b 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1005,6 +1005,7 @@ "server-live-frame-log.test.ts": "server", "server-live-realtime-fixtures.test.ts": "server", "server-live.test.ts": "server", "server-loopback-host-gate.test.ts": "server", "server-management-auth.test.ts": "server", + "server-management-log-admission.test.ts": "server", "server-opencode-go-goal-streaming.test.ts": "server", "server-rate-limit-retry-e2e.test.ts": "server", "server-request-body-size.test.ts": "server", "server-search.test.ts": "server", "server-startup-reconcile-resilience.test.ts": "server", "server-stop-config-hardening.test.ts": "server", diff --git a/tests/server/server-management-auth.test.ts b/tests/server/server-management-auth.test.ts index cb2f72d8a97..4a6b24c7557 100644 --- a/tests/server/server-management-auth.test.ts +++ b/tests/server/server-management-auth.test.ts @@ -1835,65 +1835,6 @@ describe("codex app-server restart routes ride the management gate", () => { }); -test("log cursors remain behind management admission and origin gates", async () => { - const config = remoteConfig(); - saveConfig(config); - const state = initializeManagementAuthState(config); - if (!state.available) throw new Error("expected management auth state"); - const server = startServer(0, { managementAuthState: state }); - const origin = server.url.origin; - const token = "ocx_session_log_cursor_test"; - state.sessions.set(token, { - serverOrigin: origin, browserOrigin: origin, csrfToken: "csrf-log-test", - expiresAt: Date.now() + 60_000, issuance: "loopback", - }); - const adminHeaders = { "x-opencodex-api-key": "admin-secret" }; - const acceptedHeaders: HeadersInit[] = [adminHeaders, { - Origin: origin, "x-opencodex-api-key": token, "x-opencodex-gui-origin": origin, - }]; - try { - const initial = await fetch(new URL("/api/logs", server.url), { headers: adminHeaders }); - expect(initial.status).toBe(200); - const body = await initial.json() as { cursor: string }; - expect(typeof body.cursor).toBe("string"); - for (const suffix of ["", `?cursor=${body.cursor}`, "?cursor=malformed"]) { - const url = new URL(`/api/logs${suffix}`, server.url); - for (const credential of [undefined, "data-secret", "wrong-admin"]) { - const response = await fetch(url, { headers: credential ? { "x-opencodex-api-key": credential } : {} }); - expect(response.status).toBe(401); - expect(await response.json()).toEqual({ error: "opencodex admin token required" }); - } - const foreign = await fetch(url, { headers: { ...adminHeaders, Origin: "https://attacker.test" } }); - expect(foreign.status).toBe(403); - await foreign.text(); - for (const headers of acceptedHeaders) { - const allowed = await fetch(url, { headers }); - expect(allowed.status).toBe(suffix.includes("malformed") ? 400 : 200); - await allowed.text(); - } - } - } finally { - await server.stop(true); - } -}, SERVER_BUDGET_MS); - -test("unavailable management authority rejects log cursors before parsing", async () => { - saveConfig(remoteConfig()); - const server = startServer(0, { managementAuthState: { available: false, reason: "fixture unavailable" } }); - try { - const legacy = Buffer.from(JSON.stringify({ v: 1, t: 1, id: "fixture" })).toString("base64url"); - for (const suffix of ["", `?cursor=${legacy}`, "?cursor=malformed"]) { - const response = await fetch(new URL(`/api/logs${suffix}`, server.url), { - headers: { "x-opencodex-api-key": "admin-secret" }, - }); - expect(response.status).toBe(503); - expect(await response.json()).toMatchObject({ error: "management API unavailable" }); - } - } finally { - await server.stop(true); - } -}, SERVER_BUDGET_MS); - test("a body exactly at the limit is still accepted for parsing", async () => { saveConfig(remoteConfig()); const server = startServer(0); diff --git a/tests/server/server-management-log-admission.test.ts b/tests/server/server-management-log-admission.test.ts new file mode 100644 index 00000000000..2f0b1e5184a --- /dev/null +++ b/tests/server/server-management-log-admission.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { resetContextRelayActivationForTests } from "../../src/codex/context-compat"; +import { startServer } from "../../src/server"; +import { initializeManagementAuthState } from "../../src/server/management-auth"; +import { + resetHardenedStateForTests, setIcaclsRunnerForTests, setPlatformForTests, +} from "../../src/lib/windows-secret-acl"; +import { setSystemRestartIoForTests } from "../../src/server/management/system-restart"; +import { drainAndRemoveFixtureRoots } from "../helpers/fixture-teardown"; +import { remoteConfig } from "../helpers/management-auth-fixture"; +import { SERVER_BUDGET_MS } from "../helpers/test-budget"; + +const previousHome = process.env.OPENCODEX_HOME; +const previousCodexHome = process.env.CODEX_HOME; +const previousDataToken = process.env.OPENCODEX_API_AUTH_TOKEN; +const previousAdminToken = process.env.OPENCODEX_ADMIN_AUTH_TOKEN; +let testHome = ""; + +beforeEach(() => { + testHome = mkdtempSync(join(tmpdir(), "ocx-management-auth-")); + process.env.OPENCODEX_HOME = testHome; + process.env.CODEX_HOME = testHome; + resetContextRelayActivationForTests(); + process.env.OPENCODEX_API_AUTH_TOKEN = "data-secret"; + process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "admin-secret"; +}); + +afterEach(async () => { + const root = testHome; + testHome = ""; + resetContextRelayActivationForTests(); + await drainAndRemoveFixtureRoots({ + roots: root ? [{ path: root }] : [], + restoreEnvironment: () => { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + setSystemRestartIoForTests(); + setIcaclsRunnerForTests(null); + setPlatformForTests(null); + resetHardenedStateForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousDataToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; + else process.env.OPENCODEX_API_AUTH_TOKEN = previousDataToken; + if (previousAdminToken === undefined) delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + else process.env.OPENCODEX_ADMIN_AUTH_TOKEN = previousAdminToken; + }, + }); +}); + +test("log cursors remain behind management admission and origin gates", async () => { + const config = remoteConfig(); + saveConfig(config); + const state = initializeManagementAuthState(config); + if (!state.available) throw new Error("expected management auth state"); + const server = startServer(0, { managementAuthState: state }); + const origin = server.url.origin; + const token = "ocx_session_log_cursor_test"; + state.sessions.set(token, { + serverOrigin: origin, browserOrigin: origin, csrfToken: "csrf-log-test", + expiresAt: Date.now() + 60_000, issuance: "loopback", + }); + const adminHeaders = { "x-opencodex-api-key": "admin-secret" }; + const acceptedHeaders: HeadersInit[] = [adminHeaders, { + Origin: origin, "x-opencodex-api-key": token, "x-opencodex-gui-origin": origin, + }]; + try { + const initial = await fetch(new URL("/api/logs", server.url), { headers: adminHeaders }); + expect(initial.status).toBe(200); + const body = await initial.json() as { cursor: string }; + expect(typeof body.cursor).toBe("string"); + for (const suffix of ["", `?cursor=${body.cursor}`, "?cursor=malformed"]) { + const url = new URL(`/api/logs${suffix}`, server.url); + for (const credential of [undefined, "data-secret", "wrong-admin"]) { + const response = await fetch(url, { headers: credential ? { "x-opencodex-api-key": credential } : {} }); + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "opencodex admin token required" }); + } + const foreign = await fetch(url, { headers: { ...adminHeaders, Origin: "https://attacker.test" } }); + expect(foreign.status).toBe(403); + await foreign.text(); + for (const headers of acceptedHeaders) { + const allowed = await fetch(url, { headers }); + expect(allowed.status).toBe(suffix.includes("malformed") ? 400 : 200); + await allowed.text(); + } + } + } finally { + await server.stop(true); + } +}, SERVER_BUDGET_MS); + +test("unavailable management authority rejects log cursors before parsing", async () => { + saveConfig(remoteConfig()); + const server = startServer(0, { managementAuthState: { available: false, reason: "fixture unavailable" } }); + try { + const legacy = Buffer.from(JSON.stringify({ v: 1, t: 1, id: "fixture" })).toString("base64url"); + for (const suffix of ["", `?cursor=${legacy}`, "?cursor=malformed"]) { + const response = await fetch(new URL(`/api/logs${suffix}`, server.url), { + headers: { "x-opencodex-api-key": "admin-secret" }, + }); + expect(response.status).toBe(503); + expect(await response.json()).toMatchObject({ error: "management API unavailable" }); + } + } finally { + await server.stop(true); + } +}, SERVER_BUDGET_MS); diff --git a/tests/server/server-new-model-policy-arrival.test.ts b/tests/server/server-new-model-policy-arrival.test.ts index 62240d76474..b6ce62457b9 100644 --- a/tests/server/server-new-model-policy-arrival.test.ts +++ b/tests/server/server-new-model-policy-arrival.test.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { loadConfig, saveConfig } from "../../src/config"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import { flushNativeMainStartupReleases } from "../../src/codex/native-profile-startup"; +import { flushWindowsSecretAclReapsBeforeRemoval } from "../../src/lib/windows-secret-acl"; import { setPersistedConfigMutationBeforeCommitForTests } from "../../src/config/persisted-mutation"; import { clearModelCache } from "../../src/codex/model-cache"; import type { OcxConfig } from "../../src/types"; @@ -28,6 +31,9 @@ afterEach(async () => { await lifecycle.close(); } finally { clearModelCache(provider); + await flushNativeMainStartupReleases(); + await flushConfigDirHardeningForTests(); + await flushWindowsSecretAclReapsBeforeRemoval(home.root); home.remove(); } }); From 182b4a95e4bc2c8963f11057a6f558b7399064a6 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 10 Oct 2026 06:30:01 +0900 Subject: [PATCH 48/59] feat(claude): treat long windows as 1M on every Claude surface (#6780) * feat(claude): treat long windows as 1M on every Claude surface Claude Code accounts an unmarked gateway id at 200k, so GPT-6 natives opted into their 872k window showed as 200k in the Desktop Code-tab picker, the cc picker, Desktop 3P and generated subagents. Those surfaces only marked windows of 1M or more, because their runner may lack CLAUDE_CODE_AUTO_COMPACT_WINDOW. With overflow now answered as "prompt is too long", which Claude Code compacts on, they share one rule (src/claude/long-context.ts): a window of 1M, or one that can host the 829,800 default compact window, gets the [1m] selector, supports1m/prefer1m, a discovery 1M row and a marked subagent selector. The floor is fixed, so a custom compact window cannot re-admit a 372k route (#854). Anthropic rows of either pool still need a genuine 1M, and a discovery variant reports the real input ceiling. Co-Authored-By: Claude Opus 5.5 * fix(claude): key the genuine-1M exception on Claude models, not the pool name Review follow-up. The real-Anthropic exception checked only the provider name, so a configured gateway named anthropic2 serving other models lost the long-window rule, while generated and forced subagent markers ignored the exception entirely and could widen a capped Pool 2 Claude row listed by a connected launch's catalog windows. isAnthropicClaudeRoute now requires an Anthropic instance name and a claude-* model id; pickers, discovery, Desktop 3P, the launch window map and both subagent paths use it, and a bare claude-* selector counts as Anthropic. Co-Authored-By: Claude Opus 5.5 * fix(claude): leave the launch window map filter as it was The launch path pairs [1m] with CLAUDE_CODE_AUTO_COMPACT_WINDOW and is not one of the unpaired surfaces this layer widens, so its Anthropic filter goes back to the original provider === "anthropic" check. The Claude-model exception stays on the unpaired surfaces, where a capped Claude row keeps its previous 200k accounting. Co-Authored-By: Claude Opus 5.5 * docs(claude): document long-window 1M marking in the Claude Code guides English and the six translated guides now say that a window of 1M or at least the 829,800 default compaction threshold gets the [1m] row, that the floor is fixed, that Claude models on an Anthropic route need a genuine 1M, and that an overflow is recovered by Claude Code's own compaction. Co-Authored-By: Claude Opus 5.5 * docs(claude): separate the fixed picker floor from launch-slot marking Discovery and Desktop picker rows use the fixed 829,800-token floor. Launch env slots follow the configured compact threshold. Forced subagents mark a non-Anthropic selector at that floor and keep the genuine-1M rule for Anthropic Claude and bare claude-* ids. Co-authored-by: JUN * fix(test-layout): keep the long-context registration under the line cap scripts/test-layout/layout.json is one line under the 2000-line ratchet. The new eligibility entry shares a line with the surface-matrix registration, the same pairing this file already uses, so the map stays at 1999 lines. Co-authored-by: JUN * docs(claude): limit the genuine-1M exception to Claude models The picker contract requires a genuine 1M only for Claude models on either Anthropic pool. A non-Claude model on that route still follows the 829,800 floor. The Desktop structure note, the eligibility test header, and the Chinese and Turkish guides said otherwise. Co-authored-by: JUN --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Cursor Agent --- .../src/content/docs/fr/guides/claude-code.md | 22 +++-- .../src/content/docs/guides/claude-code.md | 26 +++--- .../src/content/docs/ja/guides/claude-code.md | 16 ++-- .../src/content/docs/ko/guides/claude-code.md | 19 ++-- .../src/content/docs/ru/guides/claude-code.md | 21 +++-- .../src/content/docs/tr/guides/claude-code.md | 35 ++++---- .../content/docs/zh-cn/guides/claude-code.md | 21 ++--- .../content/docs/zh-tw/guides/claude-code.md | 19 ++-- scripts/test-layout/layout.json | 2 +- src/claude/context-windows.ts | 25 ++---- src/claude/desktop-3p.ts | 13 +-- src/claude/desktop-profile.ts | 3 +- src/claude/intercept/picker-models.ts | 18 ++-- src/claude/long-context.ts | 66 ++++++++++++++ src/claude/model-info.ts | 35 ++++---- src/claude/subagent-model.ts | 37 ++++++-- src/server/management/shared.ts | 6 +- structure/clients/claude-desktop.md | 2 +- structure/subagents.md | 2 +- .../claude-long-context-eligibility.test.ts | 89 +++++++++++++++++++ .../claude-picker-models.test.ts | 17 ++-- .../claude-subagent-model-force.test.ts | 5 +- tests/clients/desktop-3p.test.ts | 13 ++- tests/fixtures/test-layout-expected.json | 1 + 24 files changed, 361 insertions(+), 152 deletions(-) create mode 100644 src/claude/long-context.ts create mode 100644 tests/claude-integration/claude-long-context-eligibility.test.ts diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index 22f57ac1954..02b1641d58b 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -423,7 +423,7 @@ d'OpenCodex, de réappliquer ce profil et de démarrer une nouvelle conversation dépannage, sans garantie de résolution. OpenCodex ne peut pas observer l'état du sélecteur ; il achemine l'identifiant du modèle porté par chaque requête. Vérifiez ce que le client envoie sous **Logs → requestedModel**. -Les modèles dont la fenêtre de contexte de référence atteint 1M obtiennent une ligne supplémentaire `…[1m]` dans le sélecteur. +Les modèles dont la fenêtre de contexte de référence atteint 1M ou au moins le seuil de compactage par défaut (829 800) obtiennent une ligne supplémentaire `…[1m]` dans le sélecteur. Sa sélection indique à Claude Code la fenêtre complète de 1M pour ce modèle, tout en maintenant le compactage automatique ; le proxy retire le marqueur avant le routage. La sélection est conservée dans le champ `model` de `settings.json` ; pour les requêtes entrantes, l'alias est de nouveau @@ -458,8 +458,10 @@ conservent leurs identifiants canoniques sur les deux interfaces. ### Marqueur `[1m]` de variante contextuelle -Les modèles dont la fenêtre de contexte de référence atteint 1M — ou, avec le contexte automatique, dépasse 200k tout en atteignant -au moins le seuil de compactage — obtiennent une ligne supplémentaire `…[1m]` dans le sélecteur. En la sélectionnant, Claude Code +Les modèles dont la fenêtre de contexte de référence atteint 1M ou au moins le seuil de compactage par défaut (829 800) +obtiennent une ligne supplémentaire `…[1m]` dans le sélecteur. Ce plancher est fixe : modifier la valeur de compactage ne l'abaisse pas, +et un modèle Claude sur une route Anthropic doit réellement atteindre 1M. Sans contexte automatique, seule une fenêtre de 1M compte. +Au-delà de la fenêtre réelle, l'erreur `prompt is too long` déclenche le compactage automatique de Claude Code. En la sélectionnant, Claude Code tient compte d'un contexte complet de 1M. Le proxy supprime le suffixe `[1m]`, sans tenir compte de la casse, avant la résolution de l'alias et le routage. @@ -468,8 +470,9 @@ résolution de l'alias et le routage. Claude Code attribue une limite de 200k jetons à tout modèle qu'il ne reconnaît pas. Le **contexte automatique**, activé par défaut, corrige ce comportement : -1. Les modèles dont la fenêtre réelle dépasse 200k **et** atteint au moins le seuil de compactage automatique obtiennent le - marqueur `[1m]` dans les lignes du sélecteur et les variables d'environnement qui les désignent. +1. Les variables d'environnement de lancement reçoivent le marqueur `[1m]` lorsque la fenêtre réelle dépasse 200k **et** + atteint au moins le seuil de compactage automatique configuré. Les lignes de découverte et du sélecteur Desktop ne suivent pas + ce seuil : elles utilisent le plancher fixe de 829 800 jetons (1M réel pour un modèle Claude sur une route Anthropic). 2. `CLAUDE_CODE_AUTO_COMPACT_WINDOW` (`829800` par défaut, plage `100000`–`1000000`) est injecté afin que la conversation soit automatiquement résumée à ce seuil. @@ -482,8 +485,9 @@ Trois états de configuration : La valeur de compactage est réglable dans **Connexion → Claude**. **Avertissement :** une valeur supérieure à la fenêtre réelle d'un modèle rend ce modèle inutilisable : les tours échouent avant que le résumé puisse se déclencher. -Les modèles Anthropic natifs dont le contexte est inférieur à 1M ne sont jamais marqués automatiquement. Les valeurs que vous exportez vous-même -restent prioritaires ; le proxy s'appuie sur votre valeur pour déterminer les modèles qui peuvent recevoir le marqueur sans risque. +Les modèles Anthropic natifs dont le contexte est inférieur à 1M ne sont jamais marqués automatiquement. Une fenêtre de compactage que vous exportez vous-même +reste prioritaire pour le marquage des variables de lancement ; le proxy s'appuie sur votre valeur pour déterminer les modèles qui peuvent recevoir le marqueur sans risque. +Les lignes de découverte ignorent cet export et conservent le plancher fixe. Les valeurs de configuration invalides définies manuellement reviennent à 829,800. ### Environnement effectif des modèles @@ -744,7 +748,7 @@ une valeur `ANTHROPIC_BASE_URL` obsolète. Ouvrez un nouveau terminal ou réexé **Plafond de contexte 200k malgré un grand modèle** — Sélectionnez la variante `[1m]` dans le sélecteur ou activez le contexte automatique, activé par défaut. Si le sélecteur n'affiche aucune ligne `[1m]`, la fenêtre de contexte de référence du modèle -peut être inférieure au seuil de compactage automatique. +peut être inférieure au plancher fixe de 829 800 jetons. **Nombre élevé de jetons provenant du chargement des compétences** — La compétence `claude-api` fournie (~136k jetons) se charge automatiquement quand un modèle Claude est mentionné. Ce comportement est normal avec le transfert natif ; pour les modèles routés, opencodex la remplace @@ -768,7 +772,7 @@ The Subagents page offers **Force all subagents onto one model**, off by default `ocx agent subagents force combo/tev-auto` sets `claudeCode.subagentModelForce`; `ocx agent subagents force -` clears it. `ocx agent status` reports the setting. `GET /api/subagent-models` returns `force`, `forceAvailable`, and `forceStatus`; `PUT` accepts `{ "force": "combo/tev-auto" }` or `{ "force": null }` without changing the roster. Omitting `force` leaves it unchanged. Invalid or unexposed targets are rejected on write; stale targets are reported and skipped at launch. -This takes effect on the **next routed `ocx claude` launch**, injecting `CLAUDE_CODE_SUBAGENT_MODEL` as an explicit proxy alias (with `[1m]` only for an authoritative million-token window; native Claude targets use a reversible native alias) and `CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1`. Each nonempty shell-exported variable independently wins. Native launches inject neither variable; plain `claude` is not affected. No plugin files or `settings.json` are modified by this setting. +This takes effect on the **next routed `ocx claude` launch**, injecting `CLAUDE_CODE_SUBAGENT_MODEL` as an explicit proxy alias (with `[1m]` when the authoritative window reaches the fixed 829,800-token floor, and only for a genuine 1M window on Anthropic Claude models and bare `claude-*` selectors; native Claude targets use a reversible native alias) and `CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1`. Each nonempty shell-exported variable independently wins. Native launches inject neither variable; plain `claude` is not affected. No plugin files or `settings.json` are modified by this setting. Claude Code **2.1.257 or newer** is required for FORCE. Plugin and built-in agents (including Explore/Plan) and per-call model arguments are overridden. Forks and subagent skills with `model: inherit` keep the main conversation model. The main loop and Haiku/small-fast sidecars are unaffected. Existing roster files remain available. diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index ecdc703d3ce..9922dde3fa5 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -596,7 +596,8 @@ reapplying the profile, and starting a new conversation. This is a troubleshooti guaranteed fix. OpenCodex cannot observe picker state; it routes the model id carried by each request. Confirm what the client sends under **Logs → requestedModel**. -Models with an authoritative 1M context window get an extra `…[1m]` picker row: selecting it makes +Models with a long context window (1M, or at least the 829,800-token default compaction threshold; +Claude models on an Anthropic route need a genuine 1M) get an extra `…[1m]` picker row: selecting it makes Claude Code account a full 1M context for that model (auto-compaction stays on) — the proxy strips the marker before routing. Selecting one persists it to Claude Code's `settings.json` `model` field; inbound requests resolve @@ -632,8 +633,10 @@ keep their canonical ids on both surfaces. ### Context-variant `[1m]` marker -Models with an authoritative context window of 1M (or, under auto-context, above 200k and at -least the compaction threshold) get an extra `…[1m]` picker row. Selecting it makes Claude Code +Models with an authoritative context window of 1M, or at least the 829,800-token default +compaction threshold, get an extra `…[1m]` picker row; the floor is fixed, so a custom compaction +value does not lower it, and Claude models on an Anthropic route need a genuine 1M. Turning +auto-context off limits the row to 1M windows. Selecting it makes Claude Code account a full 1M context. The proxy strips the case-insensitive `[1m]` suffix before alias resolution and routing. @@ -642,8 +645,10 @@ resolution and routing. Claude Code accounts 200k tokens for any model it does not recognize. **Auto context** (on by default) fixes that: -1. Models whose real window is above 200k **and** at least the auto-compact threshold get the - `[1m]` marker on their picker rows and env slots. +1. Launch env slots get the `[1m]` marker when the model's real window is above 200k **and** at + least the configured auto-compact threshold. Discovery and Desktop picker rows do not follow + that threshold: they use the fixed 829,800-token floor (a genuine 1M for Claude models on an + Anthropic route). 2. `CLAUDE_CODE_AUTO_COMPACT_WINDOW` (default `829800`, range `100000`–`1000000`) is injected so the conversation auto-summarizes at that point. @@ -668,8 +673,9 @@ history and try compacting a fork with fewer enabled tool or MCP schemas, if you that workflow. A `[1m]` marker or larger client accounting setting does not raise the upstream limit, and OpenCodex does not silently remove history or tools to make it fit. -Sub-1M native Anthropic models are never auto-marked. Values you export yourself always win (the -proxy uses YOUR value to decide which models are safe to mark). Invalid hand-edited config values +Sub-1M native Anthropic models are never auto-marked. A compact window you export yourself wins +for launch-slot marking (the proxy uses YOUR value to decide which models are safe to mark). +Discovery rows ignore that export and keep the fixed floor. Invalid hand-edited config values fall back to 829,800. ### Effective model environment @@ -1001,7 +1007,7 @@ set (automatic with `ocx claude`). Run `ocx claude` to refresh the gateway model **200k context ceiling despite big model** — Select the `[1m]` variant in the picker, or enable auto-context (on by default). If the picker shows no `[1m]` row, the model's authoritative context -window may be below the auto-compact threshold. +window may be below the fixed 829,800-token floor. **High token count from skill loads** — The bundled `claude-api` skill (~136k tokens) auto-loads on Claude model mentions. This is normal for native passthrough; on routed models, opencodex stubs @@ -1059,7 +1065,7 @@ the confirmed obsolete token file and applying first-party mode again; never del ### First-party picker context markers -The Desktop Code-tab picker adds `[1m]` to routed models whose authoritative context window is at least one million tokens, so Claude uses its 1M accounting instead of the smaller custom-model fallback. Labels, profile order, and provider routes stay unchanged. Unknown and sub-million windows remain unmarked, including native long-window opt-ins: the picker cannot guarantee that a Desktop or remote runner receives the matching compaction environment. The paired auto-context setup for `ocx claude` is unchanged. An existing conversation keeps its saved selector until you select the model again from the refreshed picker. +The Desktop Code-tab picker adds `[1m]` to routed models whose authoritative context window is at least one million tokens or at least the 829,800-token default compaction threshold (for example a native GPT-6 model opted into its 872k window), so Claude uses its 1M accounting instead of the smaller custom-model fallback. Claude models on an Anthropic route still need a genuine 1M. Labels, profile order, and provider routes stay unchanged. Unknown and shorter windows remain unmarked. The picker cannot guarantee that a Desktop or remote runner receives the matching compaction environment, so a long-window model that outgrows its real window gets a `prompt is too long` error, which Claude Code answers by compacting. Desktop 3P `supports1m`/`prefer1m` and generated subagents follow the same rule. The paired auto-context setup for `ocx claude` is unchanged. An existing conversation keeps its saved selector until you select the model again from the refreshed picker. ### Forced Claude Code subagent model @@ -1067,7 +1073,7 @@ The Subagents page offers **Force all subagents onto one model**, off by default `ocx agent subagents force combo/tev-auto` sets `claudeCode.subagentModelForce`; `ocx agent subagents force -` clears it. `ocx agent status` reports the setting. `GET /api/subagent-models` returns `force`, `forceAvailable`, and `forceStatus`; `PUT` accepts `{ "force": "combo/tev-auto" }` or `{ "force": null }` without changing the roster. Omitting `force` leaves it unchanged. Invalid or unexposed targets are rejected on write; stale targets are reported and skipped at launch. -This takes effect on the **next routed `ocx claude` launch**, injecting `CLAUDE_CODE_SUBAGENT_MODEL` as an explicit proxy alias (with `[1m]` only for an authoritative million-token window; native Claude targets use a reversible native alias) and `CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1`. Each nonempty shell-exported variable independently wins. Native launches inject neither variable; plain `claude` is not affected. No plugin files or `settings.json` are modified by this setting. +This takes effect on the **next routed `ocx claude` launch**, injecting `CLAUDE_CODE_SUBAGENT_MODEL` as an explicit proxy alias (with `[1m]` when the authoritative window reaches the fixed 829,800-token floor, and only for a genuine 1M window on Anthropic Claude models and bare `claude-*` selectors; native Claude targets use a reversible native alias) and `CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1`. Each nonempty shell-exported variable independently wins. Native launches inject neither variable; plain `claude` is not affected. No plugin files or `settings.json` are modified by this setting. Claude Code **2.1.257 or newer** is required for FORCE. Plugin and built-in agents (including Explore/Plan) and per-call model arguments are overridden. Forks and subagent skills with `model: inherit` keep the main conversation model. The main loop and Haiku/small-fast sidecars are unaffected. Existing roster files remain available. diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index c6308ab24b8..c5690dc3a67 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -311,8 +311,9 @@ v2 エイリアスはエスケープを展開します。読みやすい形式 ### コンテキスト変種 `[1m]` 標識 -公式コンテキストウィンドウが 1M のモデルには `…[1m]` ピッカー行がもう一つできます。自動コンテキスト使用時は -コンテキストが 200k を超え、圧縮しきい値以上のモデルも該当します。この行を選ぶと Claude Code が +公式コンテキストウィンドウが 1M、またはデフォルトの圧縮しきい値(829,800)以上のモデルには `…[1m]` ピッカー行がもう一つできます。 +この基準は固定で、圧縮値を変えても下がりません。Anthropic 経路の Claude モデルは実際に 1M である必要があります。自動コンテキストをオフにすると +1M のモデルだけが対象です。実際のウィンドウを超えると `prompt is too long` エラーが返り、Claude Code が自動で圧縮します。この行を選ぶと Claude Code が 全体 1M コンテキストを計算します。プロキシはエイリアス解決とルーティング前に大文字小文字を区別せず `[1m]` 接尾辞を削除します。 @@ -321,8 +322,9 @@ v2 エイリアスはエスケープを展開します。読みやすい形式 Claude Code は未知モデルのコンテキストを 200k トークンとして計算します。デフォルトでオンの**自動 コンテキスト**がこの問題を解決します。 -1. 実際のコンテキストウィンドウが 200k より大きく自動圧縮しきい値以上のモデルのピッカー行と環境スロットに - `[1m]` 標識が付きます。 +1. 起動時の環境スロットには、実際のコンテキストウィンドウが 200k より大きく、設定した自動圧縮しきい値以上のとき + `[1m]` 標識が付きます。ディスカバリーと Desktop のピッカー行はこのしきい値に従わず、固定の 829,800 トークン下限 + (Anthropic 経路の Claude モデルは実際の 1M)を使います。 2. `CLAUDE_CODE_AUTO_COMPACT_WINDOW`(デフォルト `829800`、範囲 `100000`–`1000000`)を注入し、該当 地点で会話を自動要約します。 @@ -335,7 +337,7 @@ Claude Code は未知モデルのコンテキストを 200k トークンとし **接続 → Claude** で圧縮値を調整できます。**警告:** モデルの実際のコンテキストウィンドウより大きく上げると 要約を開始する前にチャットエラーが発生します。 -1M 未満のネイティブ Anthropic モデルには自動で標識を付けません。直接 export した値が常に優先し、プロキシは**ユーザーが指定した**値を基準にどのモデルに安全に標識を付けるか決定します。 +1M 未満のネイティブ Anthropic モデルには自動で標識を付けません。自分で export した圧縮値は起動スロットの標識に優先し、プロキシは**ユーザーが指定した**値を基準にどのモデルに安全に標識を付けるか決定します。ディスカバリー行はその export を無視し、固定の下限を保ちます。 直接編集した設定値が不正な場合は 829,800 に戻ります。 ### 実モデル環境 @@ -603,7 +605,7 @@ Anthropic バックエンドを明示すると意図的に失敗後停止しま **大型モデルなのにコンテキストが 200k に制限される** — ピッカーで `[1m]` 変種を選ぶか、デフォルトでオンの 自動コンテキストを使ってください。ピッカーに `[1m]` 行がない場合はモデルの公式コンテキストウィンドウが -自動圧縮しきい値より小さい可能性があります。 +固定の 829,800 トークン下限より小さい可能性があります。 **スキル呼び出し時のトークン数が多い** — バンドル `claude-api` スキル(約 136k トークン)は Claude モデルに 言及すると自動で読み込まれます。ネイティブパススルーでは正常で、ルーティングモデルでは opencodex が @@ -623,7 +625,7 @@ The Subagents page offers **Force all subagents onto one model**, off by default `ocx agent subagents force combo/tev-auto` sets `claudeCode.subagentModelForce`; `ocx agent subagents force -` clears it. `ocx agent status` reports the setting. `GET /api/subagent-models` returns `force`, `forceAvailable`, and `forceStatus`; `PUT` accepts `{ "force": "combo/tev-auto" }` or `{ "force": null }` without changing the roster. Omitting `force` leaves it unchanged. Invalid or unexposed targets are rejected on write; stale targets are reported and skipped at launch. -This takes effect on the **next routed `ocx claude` launch**, injecting `CLAUDE_CODE_SUBAGENT_MODEL` as an explicit proxy alias (with `[1m]` only for an authoritative million-token window; native Claude targets use a reversible native alias) and `CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1`. Each nonempty shell-exported variable independently wins. Native launches inject neither variable; plain `claude` is not affected. No plugin files or `settings.json` are modified by this setting. +This takes effect on the **next routed `ocx claude` launch**, injecting `CLAUDE_CODE_SUBAGENT_MODEL` as an explicit proxy alias (with `[1m]` when the authoritative window reaches the fixed 829,800-token floor, and only for a genuine 1M window on Anthropic Claude models and bare `claude-*` selectors; native Claude targets use a reversible native alias) and `CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1`. Each nonempty shell-exported variable independently wins. Native launches inject neither variable; plain `claude` is not affected. No plugin files or `settings.json` are modified by this setting. Claude Code **2.1.257 or newer** is required for FORCE. Plugin and built-in agents (including Explore/Plan) and per-call model arguments are overridden. Forks and subagent skills with `model: inherit` keep the main conversation model. The main loop and Haiku/small-fast sidecars are unaffected. Existing roster files remain available. diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index 1de6c56ba53..91de7d969bd 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -283,7 +283,7 @@ import/export는 로컬 설정만 다뤄요. 허브 프로필을 바꾸지 않 능력 정보(추론 강도 사다리, thinking 타입)를 실어 보냅니다 — Claude Desktop의 서드파티 게이트웨이 모드가 추론 강도 선택 UI를 열 수 있게 하기 위해서입니다. 실제 Anthropic 모델은 원래 id를 그대로 유지합니다. 합성된 2026 날짜는 내부 슬롯이며 출시일이 아닙니다. 구버전의 -해시 별칭과 `claude-ocx---`, `claude-ocx2---` 별칭도 계속 해석됩니다. 저장된 `claude-ocx-`는 `ocx-claude-`로, 이스케이프된 `claude-ocx2-`는 `ocx-claude2-`로 한 번 다시 고르면 실제 컨텍스트 창과 compact가 함께 적용됩니다. 컨텍스트가 1M인 모델에는 +해시 별칭과 `claude-ocx---`, `claude-ocx2---` 별칭도 계속 해석됩니다. 저장된 `claude-ocx-`는 `ocx-claude-`로, 이스케이프된 `claude-ocx2-`는 `ocx-claude2-`로 한 번 다시 고르면 실제 컨텍스트 창과 compact가 함께 적용됩니다. 컨텍스트가 1M이거나 기본 압축 임곗값(829,800) 이상인 모델에는 `…[1m]` 행이 하나 더 생깁니다 — 이걸 고르면 Claude Code가 그 모델의 컨텍스트를 1M로 계산합니다 (자동 요약 유지, 프록시가 표식을 떼고 라우팅). 선택하면 Claude Code의 `settings.json` `model` 필드에 저장되고, 인바운드 요청에서 @@ -343,8 +343,9 @@ v2 별칭은 이스케이프를 펼쳐요. 읽기 쉬운 형식으로 표현할 ### 컨텍스트 변형 `[1m]` 표식 -공식 컨텍스트 창이 1M인 모델에는 `…[1m]` 선택기 행이 하나 더 생겨요. 자동 컨텍스트를 사용할 -때는 컨텍스트가 200k를 넘고 압축 임곗값 이상인 모델도 해당해요. 이 행을 선택하면 Claude Code가 +공식 컨텍스트 창이 1M이거나 기본 압축 임곗값(829,800) 이상인 모델에는 `…[1m]` 선택기 행이 하나 더 +생겨요. 이 기준은 고정이라 압축 값을 직접 바꿔도 낮아지지 않고, Anthropic 경로의 Claude 모델은 실제 1M이어야 +해요. 자동 컨텍스트를 끄면 1M 모델만 해당해요. 실제 창을 넘으면 `prompt is too long` 오류가 오고 Claude Code가 자동으로 압축해요. 이 행을 선택하면 Claude Code가 전체 1M 컨텍스트를 계산해요. 프록시는 별칭 해석과 라우팅 전에 대소문자를 구분하지 않고 `[1m]` 접미사를 제거해요. @@ -353,8 +354,9 @@ v2 별칭은 이스케이프를 펼쳐요. 읽기 쉬운 형식으로 표현할 Claude Code는 알 수 없는 모델의 컨텍스트를 200k 토큰으로 계산해요. 기본으로 켜져 있는 **자동 컨텍스트**는 이 문제를 해결해요. -1. 실제 컨텍스트 창이 200k보다 크고 자동 압축 임곗값 이상인 모델의 선택기 행과 환경 슬롯에 - `[1m]` 표식이 붙어요. +1. 실행 환경 슬롯에는 실제 컨텍스트 창이 200k보다 크고 설정한 자동 압축 임곗값 이상일 때 + `[1m]` 표식이 붙어요. 디스커버리와 Desktop 선택기 행은 그 임곗값을 따르지 않고, 고정된 829,800 토큰 하한 + (Anthropic 경로의 Claude 모델은 실제 1M)을 써요. 2. `CLAUDE_CODE_AUTO_COMPACT_WINDOW`(기본값 `829800`, 범위 `100000`–`1000000`)를 주입해 해당 지점에서 대화를 자동으로 요약해요. @@ -367,8 +369,9 @@ Claude Code는 알 수 없는 모델의 컨텍스트를 200k 토큰으로 계산 **연결 → Claude**에서 압축 값을 조절할 수 있어요. **경고:** 모델의 실제 컨텍스트 창보다 크게 올리면 요약을 시작하기 전에 채팅 오류가 발생해요. -1M 미만인 네이티브 Anthropic 모델에는 자동으로 표식을 붙이지 않아요. 직접 내보낸 값이 항상 +1M 미만인 네이티브 Anthropic 모델에는 자동으로 표식을 붙이지 않아요. 직접 내보낸 압축 값은 실행 슬롯 표식에서 항상 우선하며, 프록시는 **사용자가 지정한** 값을 기준으로 어떤 모델에 안전하게 표식을 붙일지 결정해요. +디스커버리 행은 그 값을 무시하고 고정 하한을 유지해요. 직접 편집한 설정값이 잘못되면 829,800로 돌아가요. ### 실제 모델 환경 @@ -666,7 +669,7 @@ Anthropic 백엔드를 명시하면 의도적으로 실패 후 중단해요. **대형 모델인데도 컨텍스트가 200k로 제한됨** — 선택기에서 `[1m]` 변형을 고르거나 기본으로 켜져 있는 자동 컨텍스트를 사용하세요. 선택기에 `[1m]` 행이 없다면 모델의 공식 컨텍스트 창이 -자동 압축 임곗값보다 작을 수 있어요. +고정된 829,800 토큰 하한보다 작을 수 있어요. **스킬을 불러올 때 토큰 수가 많음** — 번들 `claude-api` 스킬(약 136k 토큰)은 Claude 모델을 언급하면 자동으로 불러와요. 네이티브 패스스루에서는 정상이며, 라우팅 모델에서는 opencodex가 @@ -686,7 +689,7 @@ The Subagents page offers **Force all subagents onto one model**, off by default `ocx agent subagents force combo/tev-auto` sets `claudeCode.subagentModelForce`; `ocx agent subagents force -` clears it. `ocx agent status` reports the setting. `GET /api/subagent-models` returns `force`, `forceAvailable`, and `forceStatus`; `PUT` accepts `{ "force": "combo/tev-auto" }` or `{ "force": null }` without changing the roster. Omitting `force` leaves it unchanged. Invalid or unexposed targets are rejected on write; stale targets are reported and skipped at launch. -This takes effect on the **next routed `ocx claude` launch**, injecting `CLAUDE_CODE_SUBAGENT_MODEL` as an explicit proxy alias (with `[1m]` only for an authoritative million-token window; native Claude targets use a reversible native alias) and `CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1`. Each nonempty shell-exported variable independently wins. Native launches inject neither variable; plain `claude` is not affected. No plugin files or `settings.json` are modified by this setting. +This takes effect on the **next routed `ocx claude` launch**, injecting `CLAUDE_CODE_SUBAGENT_MODEL` as an explicit proxy alias (with `[1m]` when the authoritative window reaches the fixed 829,800-token floor, and only for a genuine 1M window on Anthropic Claude models and bare `claude-*` selectors; native Claude targets use a reversible native alias) and `CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1`. Each nonempty shell-exported variable independently wins. Native launches inject neither variable; plain `claude` is not affected. No plugin files or `settings.json` are modified by this setting. Claude Code **2.1.257 or newer** is required for FORCE. Plugin and built-in agents (including Explore/Plan) and per-call model arguments are overridden. Forks and subagent skills with `model: inherit` keep the main conversation model. The main loop and Haiku/small-fast sidecars are unaffected. Existing roster files remain available. diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index 6bb458df309..dce282cfcc9 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -330,8 +330,10 @@ Anthropic сохраняют канонические id в обоих инте ### Маркер контекстного варианта `[1m]` -Модели с подтверждённым контекстным окном 1M (или, при автоконтексте, больше 200k и не ниже -порога сжатия) получают дополнительную строку `…[1m]` в селекторе. Её выбор заставляет Claude Code +Модели с подтверждённым контекстным окном 1M или не ниже порога сжатия по умолчанию (829 800) +получают дополнительную строку `…[1m]` в селекторе. Этот порог фиксирован: изменение значения сжатия его +не понижает, а модель Claude на маршруте Anthropic должна действительно иметь 1M. Без автоконтекста учитываются +только окна 1M. При выходе за реальное окно приходит ошибка `prompt is too long`, и Claude Code сам сжимает контекст. Её выбор заставляет Claude Code учитывать полный контекст 1M. Прокси удаляет суффикс `[1m]` (без учёта регистра) до разрешения алиасов и маршрутизации. @@ -340,8 +342,9 @@ Anthropic сохраняют канонические id в обоих инте Для любой незнакомой модели Claude Code считает контекст равным 200k токенов. **Автоконтекст** (включён по умолчанию) исправляет это: -1. Модели, чьё реальное окно больше 200k **и** не меньше порога автосжатия, получают маркер - `[1m]` в строках селектора и слотах окружения. +1. Слоты окружения при запуске получают маркер `[1m]`, когда реальное окно больше 200k **и** не меньше + настроенного порога автосжатия. Строки обнаружения и селектора Desktop этому порогу не следуют: + для них действует фиксированный пол 829 800 токенов (настоящий 1M для моделей Claude на маршруте Anthropic). 2. Внедряется `CLAUDE_CODE_AUTO_COMPACT_WINDOW` (по умолчанию `829800`, диапазон `100000`–`1000000`), чтобы в этой точке диалог автоматически резюмировался. @@ -355,9 +358,9 @@ Anthropic сохраняют канонические id в обоих инте реального окна модели, эта модель ломается — чат завершится ошибкой раньше, чем успеет сработать резюмирование. -Нативные модели Anthropic с окном меньше 1M никогда не помечаются автоматически. Экспортированные -вами значения всегда имеют приоритет (прокси использует ВАШЕ значение, чтобы решить, какие модели -можно безопасно пометить). Некорректные значения, внесённые в конфигурацию вручную, откатываются +Нативные модели Anthropic с окном меньше 1M никогда не помечаются автоматически. Экспортированное +вами окно сжатия имеет приоритет при пометке слотов запуска (прокси использует ВАШЕ значение, чтобы решить, какие модели +можно безопасно пометить). Строки обнаружения игнорируют этот экспорт и сохраняют фиксированный пол. Некорректные значения, внесённые в конфигурацию вручную, откатываются к 829,800. ### Эффективное окружение моделей @@ -637,7 +640,7 @@ Responses `web_search_call` в парные блоки Anthropic `server_tool_us **Потолок контекста 200k, хотя модель больше** — выберите в селекторе вариант `[1m]` или включите автоконтекст (включён по умолчанию). Если строки `[1m]` в селекторе нет, подтверждённое -контекстное окно модели может быть меньше порога автосжатия. +контекстное окно модели может быть меньше фиксированного пола в 829 800 токенов. **Большой расход токенов при загрузке скиллов** — встроенный скилл `claude-api` (~136k токенов) автоматически загружается при упоминании моделей Claude. Для нативного проброса это нормально; @@ -658,7 +661,7 @@ The Subagents page offers **Force all subagents onto one model**, off by default `ocx agent subagents force combo/tev-auto` sets `claudeCode.subagentModelForce`; `ocx agent subagents force -` clears it. `ocx agent status` reports the setting. `GET /api/subagent-models` returns `force`, `forceAvailable`, and `forceStatus`; `PUT` accepts `{ "force": "combo/tev-auto" }` or `{ "force": null }` without changing the roster. Omitting `force` leaves it unchanged. Invalid or unexposed targets are rejected on write; stale targets are reported and skipped at launch. -This takes effect on the **next routed `ocx claude` launch**, injecting `CLAUDE_CODE_SUBAGENT_MODEL` as an explicit proxy alias (with `[1m]` only for an authoritative million-token window; native Claude targets use a reversible native alias) and `CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1`. Each nonempty shell-exported variable independently wins. Native launches inject neither variable; plain `claude` is not affected. No plugin files or `settings.json` are modified by this setting. +This takes effect on the **next routed `ocx claude` launch**, injecting `CLAUDE_CODE_SUBAGENT_MODEL` as an explicit proxy alias (with `[1m]` when the authoritative window reaches the fixed 829,800-token floor, and only for a genuine 1M window on Anthropic Claude models and bare `claude-*` selectors; native Claude targets use a reversible native alias) and `CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1`. Each nonempty shell-exported variable independently wins. Native launches inject neither variable; plain `claude` is not affected. No plugin files or `settings.json` are modified by this setting. Claude Code **2.1.257 or newer** is required for FORCE. Plugin and built-in agents (including Explore/Plan) and per-call model arguments are overridden. Forks and subagent skills with `model: inherit` keep the main conversation model. The main loop and Haiku/small-fast sidecars are unaffected. Existing roster files remain available. diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index b0015531c14..1a99421d774 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -470,10 +470,11 @@ giderme adımıdır; kesin çözüm değildir. OpenCodex seçici durumunu gözle her isteğin taşıdığı model kimliğini yönlendirir. İstemcinin ne gönderdiğini **Logs → requestedModel** altında kontrol edin. -Yetkili 1M bağlam penceresine sahip modeller fazladan bir `…[1m]` seçici satırı -alır: bunu seçmek Claude Code'un bu model için tam 1M bağlam hesabı yapmasını -sağlar (otomatik sıkıştırma açık kalır) — proxy yönlendirmeden önce işaretçiyi -kaldırır. +Uzun bağlam penceresine sahip modeller (1M, veya en az 829.800 token varsayılan +sıkıştırma eşiği; Anthropic rotasındaki Claude modellerinin gerçekten 1M olması +gerekir) fazladan bir `…[1m]` seçici satırı alır: bunu seçmek Claude Code'un bu +model için tam 1M bağlam hesabı yapmasını sağlar (otomatik sıkıştırma açık kalır) +— proxy yönlendirmeden önce işaretçiyi kaldırır. Birini seçmek, onu Claude Code'un `settings.json` `model` alanına kaydeder; gelen istekler takma adı yönlendirilen modele geri çözer. Eski Claude Code sürümlerinde seçici yerel kalır — `ANTHROPIC_MODEL` aracılığıyla yuvaları @@ -512,10 +513,12 @@ kimliklerini korur. ### Bağlam değişkeni `[1m]` işaretçisi -Yetkili bağlam penceresi 1M olan (veya otomatik bağlam altında 200k üzerinde ve -en az sıkıştırma eşiğinde olan) modeller fazladan bir `…[1m]` seçici satırı -alır. Bunu seçmek Claude Code'un tam 1M bağlam hesabı yapmasını sağlar. Proxy, -takma ad çözümleme ve yönlendirmeden önce büyük/küçük harfe duyarsız `[1m]` +Yetkili bağlam penceresi 1M olan veya varsayılan sıkıştırma eşiği olan 829.800 +tokene ulaşan modeller fazladan bir `…[1m]` seçici satırı alır. Bu taban sabittir: +sıkıştırma değerini değiştirmek onu düşürmez ve Anthropic rotasındaki Claude +modellerinin gerçekten 1M olması gerekir. Otomatik bağlam kapatılırsa satır yalnızca +1M pencerelere kalır. Bunu seçmek Claude Code'un tam 1M bağlam hesabı yapmasını sağlar. +Proxy, takma ad çözümleme ve yönlendirmeden önce büyük/küçük harfe duyarsız `[1m]` sonekini kaldırır. ## Otomatik bağlam (200k tavanı olmayan büyük bağlamlı modeller) @@ -523,9 +526,10 @@ sonekini kaldırır. Claude Code, tanımadığı herhangi bir model için 200k token hesabı yapar. **Otomatik bağlam** (varsayılan olarak açık) bunu düzeltir: -1. Gerçek penceresi 200k'nın üzerinde **ve** en az otomatik sıkıştırma eşiğinde - olan modeller, seçici satırlarında ve ortam yuvalarında `[1m]` işaretçisini - alır. +1. Başlatma ortam yuvaları, gerçek pencere 200k'nın üzerinde **ve** en az yapılandırılmış + otomatik sıkıştırma eşiğindeyken `[1m]` işaretçisini alır. Keşif ve Desktop seçici + satırları bu eşiği izlemez: sabit 829.800 token tabanını kullanırlar (Anthropic + rotasındaki Claude modelleri için gerçek 1M). 2. Görüşmenin bu noktada otomatik olarak özetlenmesi için `CLAUDE_CODE_AUTO_COMPACT_WINDOW` (varsayılan `829800`, aralık `100000`–`1000000`) enjekte edilir. @@ -542,8 +546,9 @@ penceresinin üzerine çıkarmak o modeli bozar — sohbet özetleme tetiklenmed önce hata verir. 1M altı yerel Anthropic modelleri hiçbir zaman otomatik olarak işaretlenmez. -Kendi dışa aktardığınız değerler her zaman kazanır (proxy hangi modellerin +Kendi dışa aktardığınız sıkıştırma penceresi, başlatma yuvası işaretlemesinde her zaman kazanır (proxy hangi modellerin işaretlenmesinin güvenli olduğuna karar vermek için SİZİN değerinizi kullanır). +Keşif satırları bu dışa aktarımı yok sayar ve sabit tabanı korur. Geçersiz elle düzenlenen yapılandırma değerleri 829,800'ya geri döner. ### Geçerli model ortamı (Effective model environment) @@ -852,8 +857,8 @@ veya `ocx claude` komutunu yeniden çalıştırın. **Büyük modele rağmen 200k bağlam tavanı** — Seçicide `[1m]` varyantını seçin veya otomatik bağlamı etkinleştirin (varsayılan olarak açık). Seçici hiçbir -`[1m]` satırı göstermiyorsa, modelin yetkili bağlam penceresi otomatik -sıkıştırma eşiğinin altında olabilir. +`[1m]` satırı göstermiyorsa, modelin yetkili bağlam penceresi sabit 829.800 +token tabanının altında olabilir. **Yetenek yüklemelerinden kaynaklanan yüksek token sayısı** — Paketlenmiş `claude-api` yeteneği (~136k token) Claude model adları anıldığında otomatik @@ -880,7 +885,7 @@ The Subagents page offers **Force all subagents onto one model**, off by default `ocx agent subagents force combo/tev-auto` sets `claudeCode.subagentModelForce`; `ocx agent subagents force -` clears it. `ocx agent status` reports the setting. `GET /api/subagent-models` returns `force`, `forceAvailable`, and `forceStatus`; `PUT` accepts `{ "force": "combo/tev-auto" }` or `{ "force": null }` without changing the roster. Omitting `force` leaves it unchanged. Invalid or unexposed targets are rejected on write; stale targets are reported and skipped at launch. -This takes effect on the **next routed `ocx claude` launch**, injecting `CLAUDE_CODE_SUBAGENT_MODEL` as an explicit proxy alias (with `[1m]` only for an authoritative million-token window; native Claude targets use a reversible native alias) and `CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1`. Each nonempty shell-exported variable independently wins. Native launches inject neither variable; plain `claude` is not affected. No plugin files or `settings.json` are modified by this setting. +This takes effect on the **next routed `ocx claude` launch**, injecting `CLAUDE_CODE_SUBAGENT_MODEL` as an explicit proxy alias (with `[1m]` when the authoritative window reaches the fixed 829,800-token floor, and only for a genuine 1M window on Anthropic Claude models and bare `claude-*` selectors; native Claude targets use a reversible native alias) and `CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1`. Each nonempty shell-exported variable independently wins. Native launches inject neither variable; plain `claude` is not affected. No plugin files or `settings.json` are modified by this setting. Claude Code **2.1.257 or newer** is required for FORCE. Plugin and built-in agents (including Explore/Plan) and per-call model arguments are overridden. Forks and subagent skills with `model: inherit` keep the main conversation model. The main loop and Haiku/small-fast sidecars are unaffected. Existing roster files remain available. diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index aafb1f310a7..9f791d54db0 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -239,7 +239,7 @@ UI。真实 Anthropic 模型保留其原始 id。合成的 2026 日期是内部 别名和 `claude-ocx---` 别名仍可解析,转义的 `claude-ocx2---` 也同样可解析。 已保存的旧 id 仍会路由,但 Claude Code 对它仍按 200k 计算。把已保存的 `claude-ocx-` 重新选一次对应的 `ocx-claude-`,转义的 `claude-ocx2-` 重新选一次 `ocx-claude2-`,即可同时用上真实上下文窗口和 compact。 -拥有 1M 上下文的模型会多出一行 `…[1m]`: +上下文为 1M 或不低于默认压缩阈值(829,800)的模型会多出一行 `…[1m]`: 选中后 Claude Code 会按 1M 计算该模型的上下文(自动压缩保留,代理在路由前去掉该标记)。 选中后会保存到 Claude Code 的 `settings.json` `model` 字段;入站请求会将别名解析回路由 模型。旧版 Claude Code 中选择器保持原生 — 通过 `ANTHROPIC_MODEL` 设置槽位,或直接在 `/model` @@ -293,8 +293,9 @@ v1 别名按字面解码(历史上 model ID 中包含的两字符序列 `~s` / ### 上下文变体 `[1m]` 标记 -权威上下文窗口为 1M 的模型(或者启用自动上下文时,窗口大于 200k 且至少达到压缩阈值的模型) -会多出一个带 `…[1m]` 的选择器条目。选择它后,Claude Code 会按完整的 1M 上下文计算。 +权威上下文窗口为 1M 或不低于默认压缩阈值(829,800)的模型会多出一个带 `…[1m]` 的选择器条目。 +该下限是固定的,修改压缩值不会降低它;Anthropic 路由上的 Claude 模型必须真正达到 1M;关闭自动上下文后只对 1M 模型生效。 +超出真实窗口时会返回 `prompt is too long` 错误,Claude Code 会自动压缩。选择它后,Claude Code 会按完整的 1M 上下文计算。 代理会在进行别名解析和路由之前移除不区分大小写的 `[1m]` 后缀。 ## 自动上下文(突破 200k 上限的大上下文模型) @@ -302,8 +303,8 @@ v1 别名按字面解码(历史上 model ID 中包含的两字符序列 `~s` / 对于任何无法识别的模型,Claude Code 都会按 200k token 计算。默认开启的**自动上下文**可解决 这一问题: -1. 实际窗口大于 200k **且**至少达到自动压缩阈值的模型,其选择器条目和环境变量槽位会带有 - `[1m]` 标记。 +1. 启动环境槽位会在实际窗口大于 200k **且**至少达到所配置的自动压缩阈值时带上 + `[1m]` 标记。发现列表和 Desktop 选择器条目不跟随该阈值:它们使用固定的 829,800 token 下限(Anthropic 路由上的 Claude 模型必须真正达到 1M)。 2. 系统会注入 `CLAUDE_CODE_AUTO_COMPACT_WINDOW`(默认 `829800`,范围 `100000`–`1000000`), 使对话在该位置自动进行摘要。 @@ -316,8 +317,8 @@ v1 别名按字面解码(历史上 model ID 中包含的两字符序列 `~s` / 可以在 Claude 页面调整压缩值。**警告:**如果将其提高到超过模型的实际窗口,该模型将无法正常 工作——聊天会在触发摘要之前报错。 -低于 1M 的原生 Anthropic 模型绝不会被自动标记。你自行导出的值始终优先(代理会使用**你的** -值来判断哪些模型可以安全标记)。手动编辑配置时填入的无效值会回退到 829,800。 +低于 1M 的原生 Anthropic 模型绝不会被自动标记。你自行导出的压缩值在启动槽位标记上始终优先(代理会使用**你的** +值来判断哪些模型可以安全标记)。发现列表忽略该导出值并保持固定下限。手动编辑配置时填入的无效值会回退到 829,800。 ### 有效模型环境变量 @@ -566,7 +567,7 @@ OAuth 活动账户未标记 `needsReauth`。显式选择 Anthropic 却没有可 `ANTHROPIC_BASE_URL` 可能已经过时。请打开一个新终端,或重新运行 `ocx claude`。 **大模型仍受 200k 上下文上限限制**——在选择器中选择 `[1m]` 变体,或启用自动上下文 -(默认开启)。如果选择器中没有 `[1m]` 条目,该模型的权威上下文窗口可能低于自动压缩阈值。 +(默认开启)。如果选择器中没有 `[1m]` 条目,该模型的权威上下文窗口可能低于固定的 829,800 token 下限。 **技能加载导致 token 数量过高**——内置的 `claude-api` 技能(约 136k token)会在提及 Claude 模型时自动加载。对于原生透传,这是正常现象;对于已路由模型,opencodex 默认会将其 @@ -581,7 +582,7 @@ Claude 模型时自动加载。对于原生透传,这是正常现象;对于 ### 第一方模型选择器的上下文标记 -对于权威上下文窗口至少为一百万 token 的路由模型,Desktop Code 标签页的模型选择器会添加 `[1m]`,使 Claude 按 1M 窗口计量,而不是使用自定义模型较小的默认窗口。标签、配置中的顺序和提供方路由保持不变。窗口未知或小于一百万 token 的模型(包括原生模型的大窗口选项)不添加标记,因为选择器无法保证 Desktop 或远程运行器收到配套的压缩环境设置。`ocx claude` 的自动上下文与压缩配套设置保持不变。现有对话会保留已保存的选择器,直到你在刷新后的模型选择器中重新选择该模型。 +对于权威上下文窗口至少为一百万 token,或不低于默认压缩阈值 829,800(例如选择了 872k 窗口的原生 GPT-6 模型)的路由模型,Desktop Code 标签页的模型选择器会添加 `[1m]`,使 Claude 按 1M 窗口计量,而不是使用自定义模型较小的默认窗口。Anthropic 路由上的 Claude 模型仍必须真正达到 1M。标签、配置中的顺序和提供方路由保持不变。窗口未知或更短的模型不添加标记。选择器无法保证 Desktop 或远程运行器收到配套的压缩环境设置,因此超出真实窗口的长窗口模型会收到 `prompt is too long` 错误,Claude Code 会据此压缩。Desktop 3P 的 `supports1m`/`prefer1m` 和生成的子代理遵循同一规则。`ocx claude` 的自动上下文与压缩配套设置保持不变。现有对话会保留已保存的选择器,直到你在刷新后的模型选择器中重新选择该模型。 ### Forced Claude Code subagent model @@ -589,7 +590,7 @@ The Subagents page offers **Force all subagents onto one model**, off by default `ocx agent subagents force combo/tev-auto` sets `claudeCode.subagentModelForce`; `ocx agent subagents force -` clears it. `ocx agent status` reports the setting. `GET /api/subagent-models` returns `force`, `forceAvailable`, and `forceStatus`; `PUT` accepts `{ "force": "combo/tev-auto" }` or `{ "force": null }` without changing the roster. Omitting `force` leaves it unchanged. Invalid or unexposed targets are rejected on write; stale targets are reported and skipped at launch. -This takes effect on the **next routed `ocx claude` launch**, injecting `CLAUDE_CODE_SUBAGENT_MODEL` as an explicit proxy alias (with `[1m]` only for an authoritative million-token window; native Claude targets use a reversible native alias) and `CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1`. Each nonempty shell-exported variable independently wins. Native launches inject neither variable; plain `claude` is not affected. No plugin files or `settings.json` are modified by this setting. +This takes effect on the **next routed `ocx claude` launch**, injecting `CLAUDE_CODE_SUBAGENT_MODEL` as an explicit proxy alias (with `[1m]` when the authoritative window reaches the fixed 829,800-token floor, and only for a genuine 1M window on Anthropic Claude models and bare `claude-*` selectors; native Claude targets use a reversible native alias) and `CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1`. Each nonempty shell-exported variable independently wins. Native launches inject neither variable; plain `claude` is not affected. No plugin files or `settings.json` are modified by this setting. Claude Code **2.1.257 or newer** is required for FORCE. Plugin and built-in agents (including Explore/Plan) and per-call model arguments are overridden. Forks and subagent skills with `model: inherit` keep the main conversation model. The main loop and Haiku/small-fast sidecars are unaffected. Existing roster files remain available. diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index c6ec3d79339..4f033558616 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -339,7 +339,7 @@ user-agent 會獲得易讀的 CLI 形式,其他用戶端會獲得 Desktop 代 也同樣可解析。已儲存的舊 id 仍會路由,但 Claude Code 對它仍按 200k 計算。把已儲存的 `claude-ocx-` 重新選一次對應的 `ocx-claude-`,跳脫的 `claude-ocx2-` 重新選一次 `ocx-claude2-`,即可同時用上真實上下文 視窗與 compact。 -擁有權威 1M 上下文視窗的模型會多出一個 `…[1m]` 選擇器列:選中後 Claude Code 會按完整 1M 上下文 +權威上下文視窗為 1M 或不低於預設壓縮閾值(829,800)的模型會多出一個 `…[1m]` 選擇器列:選中後 Claude Code 會按完整 1M 上下文 計算該模型(自動壓縮仍開啟)——代理在路由前會去掉該標記。 選中後會儲存到 Claude Code 的 `settings.json` `model` 欄位;入站請求會將別名解析回路由 模型。在較舊的 Claude Code 版本中,選擇器保持原生——可透過 `ANTHROPIC_MODEL` 設定槽位,或在 @@ -369,8 +369,9 @@ v1 別名按字面解碼(歷史上 model ID 中包含的兩字元序列 `~s` / ### 上下文變體 `[1m]` 標記 -權威上下文視窗為 1M 的模型(或者啟用自動上下文時,視窗大於 200k 且至少達到壓縮閾值的模型) -會多出一個帶 `…[1m]` 的選擇器條目。選擇它後,Claude Code 會按完整的 1M 上下文計算。 +權威上下文視窗為 1M 或不低於預設壓縮閾值(829,800)的模型會多出一個帶 `…[1m]` 的選擇器條目。 +該下限是固定的,修改壓縮值不會降低它;Anthropic 路由上的 Claude 模型必須真正達到 1M;關閉自動上下文後只對 1M 模型生效。 +超出真實視窗時會回傳 `prompt is too long` 錯誤,Claude Code 會自動壓縮。選擇它後,Claude Code 會按完整的 1M 上下文計算。 代理會在進行別名解析和路由之前移除不區分大小寫的 `[1m]` 字尾。 ## 自動上下文(突破 200k 上限的大上下文模型) @@ -378,8 +379,8 @@ v1 別名按字面解碼(歷史上 model ID 中包含的兩字元序列 `~s` / 對於任何無法識別的模型,Claude Code 都會按 200k token 計算。預設開啟的**自動上下文**可解決 這一問題: -1. 實際視窗大於 200k **且**至少達到自動壓縮閾值的模型,其選擇器條目和環境變數槽位會帶有 - `[1m]` 標記。 +1. 啟動環境槽位會在實際視窗大於 200k **且**至少達到所設定的自動壓縮閾值時帶上 + `[1m]` 標記。探索列表和 Desktop 選擇器條目不跟隨該閾值:它們使用固定的 829,800 token 下限(Anthropic 路由上的 Claude 模型必須真正達到 1M)。 2. 系統會注入 `CLAUDE_CODE_AUTO_COMPACT_WINDOW`(預設 `829800`,範圍 `100000`–`1000000`), 使對話在該位置自動進行摘要。 @@ -392,8 +393,8 @@ v1 別名按字面解碼(歷史上 model ID 中包含的兩字元序列 `~s` / 可以在 Claude 頁面調整壓縮值。**警告:**如果將其提高到超過模型的實際視窗,該模型將無法正常 工作——聊天會在觸發摘要之前報錯。 -低於 1M 的原生 Anthropic 模型絕不會被自動標記。你自行匯出的值始終優先(代理會使用**你的** -值來判斷哪些模型可以安全標記)。手動編輯設定時填入的無效值會回退到 829,800。 +低於 1M 的原生 Anthropic 模型絕不會被自動標記。你自行匯出的壓縮值在啟動槽位標記上始終優先(代理會使用**你的** +值來判斷哪些模型可以安全標記)。探索列表忽略該匯出值並保持固定下限。手動編輯設定時填入的無效值會回退到 829,800。 ### 有效模型環境變數 @@ -642,7 +643,7 @@ OAuth 活動帳號未標記 `needsReauth`。顯式選擇 Anthropic 卻沒有可 `ANTHROPIC_BASE_URL` 可能已經過時。請開啟一個新終端,或重新執行 `ocx claude`。 **大模型仍受 200k 上下文上限限制**——在選擇器中選擇 `[1m]` 變體,或啟用自動上下文 -(預設開啟)。如果選擇器中沒有 `[1m]` 條目,該模型的權威上下文視窗可能低於自動壓縮閾值。 +(預設開啟)。如果選擇器中沒有 `[1m]` 條目,該模型的權威上下文視窗可能低於固定的 829,800 token 下限。 **技能載入導致 token 數量過高**——內建的 `claude-api` 技能(約 136k token)會在提及 Claude 模型時自動載入。對於原生透傳,這是正常現象;對於已路由模型,opencodex 預設會將其 @@ -665,7 +666,7 @@ The Subagents page offers **Force all subagents onto one model**, off by default `ocx agent subagents force combo/tev-auto` sets `claudeCode.subagentModelForce`; `ocx agent subagents force -` clears it. `ocx agent status` reports the setting. `GET /api/subagent-models` returns `force`, `forceAvailable`, and `forceStatus`; `PUT` accepts `{ "force": "combo/tev-auto" }` or `{ "force": null }` without changing the roster. Omitting `force` leaves it unchanged. Invalid or unexposed targets are rejected on write; stale targets are reported and skipped at launch. -This takes effect on the **next routed `ocx claude` launch**, injecting `CLAUDE_CODE_SUBAGENT_MODEL` as an explicit proxy alias (with `[1m]` only for an authoritative million-token window; native Claude targets use a reversible native alias) and `CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1`. Each nonempty shell-exported variable independently wins. Native launches inject neither variable; plain `claude` is not affected. No plugin files or `settings.json` are modified by this setting. +This takes effect on the **next routed `ocx claude` launch**, injecting `CLAUDE_CODE_SUBAGENT_MODEL` as an explicit proxy alias (with `[1m]` when the authoritative window reaches the fixed 829,800-token floor, and only for a genuine 1M window on Anthropic Claude models and bare `claude-*` selectors; native Claude targets use a reversible native alias) and `CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1`. Each nonempty shell-exported variable independently wins. Native launches inject neither variable; plain `claude` is not affected. No plugin files or `settings.json` are modified by this setting. Claude Code **2.1.257 or newer** is required for FORCE. Plugin and built-in agents (including Explore/Plan) and per-call model arguments are overridden. Forks and subagent skills with `model: inherit` keep the main conversation model. The main loop and Haiku/small-fast sidecars are unaffected. Existing roster files remain available. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b762f52b9f4..84580dd3501 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -418,7 +418,7 @@ "messages-native-decline-trace.test.ts": "claude-integration", "messages-native-oauth.test.ts": "claude-integration", "messages-native-opaque-state.test.ts": "claude-integration", - "messages-surface-matrix.test.ts": "claude-integration", + "messages-surface-matrix.test.ts": "claude-integration", "claude-long-context-eligibility.test.ts": "claude-integration", "claude-model-info.test.ts": "claude-integration", "claude-models-discovery.test.ts": "claude-integration", "claude-native-affinity.test.ts": "claude-integration", diff --git a/src/claude/context-windows.ts b/src/claude/context-windows.ts index 858f62d1818..5c3caeda824 100644 --- a/src/claude/context-windows.ts +++ b/src/claude/context-windows.ts @@ -15,8 +15,9 @@ import { desktop3pAlias } from "./desktop-3p"; import { nativeOpenAiContextWindow, type CatalogModel, type NativeContextLimitsInput } from "../codex/catalog"; import { ANTHROPIC_MODEL_CONTEXT_WINDOWS } from "../providers/registry/model-seeds"; import type { OcxClaudeCodeConfig } from "../types"; +import { AUTO_COMPACT_WINDOW_DEFAULT, AUTO_CONTEXT_FLOOR, ONE_MILLION } from "./long-context"; -const ONE_MILLION = 1_000_000; +export { AUTO_COMPACT_WINDOW_DEFAULT, AUTO_CONTEXT_FLOOR } from "./long-context"; /** * The native id each Claude Code tier alias resolves to (2.1.282: `--model opus` sends @@ -27,21 +28,6 @@ const ONE_MILLION = 1_000_000; */ const CLAUDE_CODE_NATIVE_TIERS = { opus: "claude-opus-5-5", sonnet: "claude-sonnet-5", fable: "claude-fable-5-1" } as const; -/** - * Auto-context defaults (devlog 260712 020, user-approved). - * - * The compact window is the token count at which Claude Code starts compacting, and it is - * also the floor `shouldMarkOneMillion` uses — a model may only carry the marker if it can - * host this window. 350,000 was chosen when the widest native row advertised 372,000. - * - * It now matches the auto-compaction limit the Codex catalog ships for the same models - * (`nativeAutoCompactLimit`: 829,800 against the 922,000 native window). Leaving the two - * apart meant one model compacting at 350k under Claude Code and at 829,800 under Codex. - * The value stays clear of the measured 922,000 ceiling by ~92k, so compaction still has - * room to run before the upstream refuses. - */ -export const AUTO_COMPACT_WINDOW_DEFAULT = 829_800; -export const AUTO_CONTEXT_FLOOR = 200_000; /** Binary-verified accepted range for CLAUDE_CODE_AUTO_COMPACT_WINDOW (2.1.207: pSo=1e5, yDs=1e6). */ export const AUTO_COMPACT_WINDOW_MIN = 100_000; export const AUTO_COMPACT_WINDOW_MAX = ONE_MILLION; @@ -63,6 +49,13 @@ export interface AutoContextMode { export const AUTO_CONTEXT_OFF: AutoContextMode = { enabled: false, compactWindow: AUTO_COMPACT_WINDOW_DEFAULT }; +/** + * Marking mode for surfaces whose runner may not inherit the compaction env (Desktop pickers, + * discovery, generated subagents). shouldMarkOneMillion under it equals isLongContextWindow + * (long-context.ts); kept as a mode so the existing marker helpers serve every surface. + */ +export const UNPAIRED_AUTO_CONTEXT: AutoContextMode = { enabled: true, compactWindow: AUTO_COMPACT_WINDOW_DEFAULT }; + interface AutoContextConfigSlice { autoContext?: boolean; autoCompactWindow?: number; diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index e2f0eec30e6..26eb3fbfb2c 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -25,6 +25,7 @@ import { import { nativeOpenAiContextWindow, type NativeContextLimitsInput } from "../codex/catalog"; import { localAdmissionToken, localInferenceDestination } from "../lib/local-destinations"; import { assertDesktop3pModelsValid } from "./desktop-3p-guard"; +import { claudeSurfaceSupportsOneMillion, ONE_MILLION } from "./long-context"; export interface Desktop3pModelEntry { name: string; @@ -56,12 +57,12 @@ export interface Desktop3pRoutedModel { } /** - * 1M-context eligibility, shared with the Desktop DTO so the dashboard's 1M chip can - * never disagree with what the writer emits. The DTO imports this from here — keeping - * the constant in this module avoids a cycle, since shared.ts already reads - * claude/desktop-profile. + * The genuine-1M threshold real Anthropic rows still need. Every other row follows the + * long-window rule; the writer, the profile renderer and the dashboard DTO all decide through + * `claudeSurfaceSupportsOneMillion` (long-context.ts), so the 1M chip cannot disagree with the + * written config. */ -export const DESKTOP_SUPPORTS_1M_THRESHOLD = 1_000_000; +export const DESKTOP_SUPPORTS_1M_THRESHOLD = ONE_MILLION; /** CLI arg parsing for `ocx claude desktop` mode flags (mutually exclusive). */ export function parseDesktop3pModeArgs(flags: string[]): { mode: Desktop3pConfigMode } | { error: string } { @@ -252,7 +253,7 @@ function collectDesktop3pModels( for (const { provider, id, contextWindow } of candidates) { const route = `${provider}/${id}`; const alias = desktop3pAlias(provider, id); - const supports1m = typeof contextWindow === "number" && contextWindow >= DESKTOP_SUPPORTS_1M_THRESHOLD + const supports1m = claudeSurfaceSupportsOneMillion(provider, id, contextWindow) ? { supports1m: true as const } : {}; if (alias === id) { diff --git a/src/claude/desktop-profile.ts b/src/claude/desktop-profile.ts index 9f617f59181..f160cda9430 100644 --- a/src/claude/desktop-profile.ts +++ b/src/claude/desktop-profile.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { routeSupportsOneMillion } from "./long-context"; import type { OcxClaudeDesktopAssignment, OcxClaudeDesktopFamily, @@ -359,7 +360,7 @@ export function renderDesktopProfile( name: isRealAnthropicRoute(route) ? assignment.alias : desktopProfileWireAlias(assignment.alias), family: assignment.family, isFamilyDefault: effectiveDefaults[assignment.family] === route, - supports1m: typeof model.contextWindow === "number" && model.contextWindow >= 1_000_000, + supports1m: routeSupportsOneMillion(route, model.contextWindow), }; }); } diff --git a/src/claude/intercept/picker-models.ts b/src/claude/intercept/picker-models.ts index 79c85ca8f39..65d3ca93e13 100644 --- a/src/claude/intercept/picker-models.ts +++ b/src/claude/intercept/picker-models.ts @@ -7,7 +7,8 @@ import { dirname } from "node:path"; import { nativeOpenAiContextWindow, type NativeContextLimitsInput } from "../../codex/catalog"; import type { OcxClaudeDesktopProfile } from "../../types"; import { aliasForRoute, claudeCodeNativeAlias } from "../alias"; -import { AUTO_CONTEXT_OFF, stripOneMillionMarker, withOneMillionMarker } from "../context-windows"; +import { AUTO_CONTEXT_OFF, stripOneMillionMarker, UNPAIRED_AUTO_CONTEXT, withOneMillionMarker } from "../context-windows"; +import { isAnthropicClaudeRoute } from "../long-context"; import { activeDesktop3pAlias, displayModelId, resolveDesktop3pAlias, type Desktop3pRoutedModel } from "../desktop-3p"; import { reconcileDesktopProfile, renderDesktopProfile, type DesktopProfileModel } from "../desktop-profile"; import type { PickerModelEntry } from "./picker-bootstrap"; @@ -46,9 +47,14 @@ function splitRoute(route: string): { provider: string; id: string } { return { provider: route.slice(0, slash), id: route.slice(slash + 1) }; } -/** Desktop runners do not inherit the proxy's compaction env: mark only real >=1M windows. */ -function pickerSelector(alias: string, contextWindow: number | undefined): string { - return withOneMillionMarker(alias, contextWindow === undefined ? {} : { [alias]: contextWindow }, AUTO_CONTEXT_OFF)!; +/** + * Desktop runners do not inherit the proxy's compaction env, so a long window (>= the default + * compact window) is marked on the strength of the `prompt is too long` recovery instead + * (devlog/_plan/261009_claude_1m_default/020). Real Anthropic models need a genuine 1M. + */ +function pickerSelector(alias: string, contextWindow: number | undefined, provider: string, id: string): string { + const mode = isAnthropicClaudeRoute(provider, id) ? AUTO_CONTEXT_OFF : UNPAIRED_AUTO_CONTEXT; + return withOneMillionMarker(alias, contextWindow === undefined ? {} : { [alias]: contextWindow }, mode)!; } export function buildPickerModels(input: PickerRouteInput): PickerModelEntry[] { @@ -61,7 +67,7 @@ export function buildPickerModels(input: PickerRouteInput): PickerModelEntry[] { const alias = provider === "native" ? claudeCodeNativeAlias(id) : aliasForRoute(provider, id); if (!alias || seen.has(alias)) continue; seen.add(alias); - out.push({ id: pickerSelector(alias, model.contextWindow), name: model.label, + out.push({ id: pickerSelector(alias, model.contextWindow, provider, id), name: model.label, ...(model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }) }); } return out; @@ -81,7 +87,7 @@ export function buildCliPickerModels(input: PickerRouteInput): PickerModelEntry[ const alias = activeDesktop3pAlias(provider, id); if (seen.has(alias) || resolveDesktop3pAlias(alias) !== model.route) continue; seen.add(alias); - out.push({ id: pickerSelector(alias, model.contextWindow), name: model.label, + out.push({ id: pickerSelector(alias, model.contextWindow, provider, id), name: model.label, description: `opencodex · ${model.route}`, route: model.route, ...(model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }) }); } diff --git a/src/claude/long-context.ts b/src/claude/long-context.ts new file mode 100644 index 00000000000..9b1a922d23e --- /dev/null +++ b/src/claude/long-context.ts @@ -0,0 +1,66 @@ +/** + * Long-context thresholds shared by every Claude surface + * (devlog/_plan/261009_claude_1m_default/020_long_context_eligibility.md). + * + * A leaf module: desktop-3p.ts and desktop-profile.ts need the predicate, and context-windows.ts + * already imports desktop-3p.ts, so the predicate cannot live there. + */ +import { isAnthropicInstanceId } from "../providers/anthropic-instance-id"; + +export const ONE_MILLION = 1_000_000; + +/** A window at or below this is Claude Code's own default accounting; widening it gains nothing. */ +export const AUTO_CONTEXT_FLOOR = 200_000; + +/** + * Auto-context defaults (devlog 260712 020, user-approved). + * + * The compact window is the token count at which Claude Code starts compacting, and it is + * also the floor `shouldMarkOneMillion` uses — a model may only carry the marker if it can + * host this window. 350,000 was chosen when the widest native row advertised 372,000. + * + * It now matches the auto-compaction limit the Codex catalog ships for the same models + * (`nativeAutoCompactLimit`: 829,800 against the 922,000 native window). Leaving the two + * apart meant one model compacting at 350k under Claude Code and at 829,800 under Codex. + * The value stays clear of the measured 922,000 ceiling by ~92k, so compaction still has + * room to run before the upstream refuses. + */ +export const AUTO_COMPACT_WINDOW_DEFAULT = 829_800; + +/** + * Whether a window earns the 1M selection on a surface whose Claude Code runner may not inherit + * CLAUDE_CODE_AUTO_COMPACT_WINDOW (Desktop pickers, discovery, Desktop 3P, generated subagents). + * A window >= 1M always does. A smaller one does when it can host the default compact window, so + * a runner that has the variable compacts in time, and one that lacks it overflows into the + * `prompt is too long` envelope (anthropicErrorBody) and compacts reactively. The floor is fixed + * on purpose: a custom compact window must not re-admit a 372k route (#854). + */ +export function isLongContextWindow(window: number | undefined): boolean { + if (typeof window !== "number" || window <= 0) return false; + return window >= ONE_MILLION || (window > AUTO_CONTEXT_FLOOR && window >= AUTO_COMPACT_WINDOW_DEFAULT); +} + +/** + * A Claude model on an Anthropic-named route. Claude windows are 200k or a genuine 1M and a bare + * id rides the native passthrough, so the long-window rule never widens one: a Claude row listed + * under 1M is a capped one, and on a runner without the compaction env it keeps the 200k + * accounting it had before long windows were widened. Keyed on the model id as well, so a + * configured gateway that only shares the name `anthropic2` and serves other models keeps the + * long-window rule. A custom `anthropic2` gateway serving Claude below 1M is treated like a pool + * row on these surfaces, which is the pre-existing (unwidened) behavior. + */ +export function isAnthropicClaudeRoute(provider: string, modelId: string): boolean { + return isAnthropicInstanceId(provider) && modelId.startsWith("claude-"); +} + +/** Desktop 3P / picker / discovery / dashboard 1M eligibility for one provider route. */ +export function claudeSurfaceSupportsOneMillion(provider: string, modelId: string, window: number | undefined): boolean { + if (isAnthropicClaudeRoute(provider, modelId)) return typeof window === "number" && window >= ONE_MILLION; + return isLongContextWindow(window); +} + +/** Same, for a `provider/id` route string. */ +export function routeSupportsOneMillion(route: string, window: number | undefined): boolean { + const slash = route.indexOf("/"); + return claudeSurfaceSupportsOneMillion(route.slice(0, slash), route.slice(slash + 1), window); +} diff --git a/src/claude/model-info.ts b/src/claude/model-info.ts index 7f5fe0f7266..21162fc269c 100644 --- a/src/claude/model-info.ts +++ b/src/claude/model-info.ts @@ -19,7 +19,8 @@ import { orderForModelPicker, catalogModelEfforts, nativeEffortClamp, nativeOpen import { claudeCodeAlias, claudeCodeNativeAlias } from "./alias"; import { cursorFastIdFor } from "../adapters/cursor/catalog"; import { desktop3pAlias } from "./desktop-3p"; -import { AUTO_CONTEXT_OFF, type AutoContextMode } from "./context-windows"; +import { AUTO_CONTEXT_OFF, shouldMarkOneMillion, UNPAIRED_AUTO_CONTEXT, type AutoContextMode } from "./context-windows"; +import { isAnthropicClaudeRoute } from "./long-context"; const MODEL_INFO_CREATED_AT = "2026-01-01T00:00:00Z"; const ANTHROPIC_EFFORT_RUNGS = new Set(["low", "medium", "high", "xhigh", "max"]); @@ -144,23 +145,25 @@ export function buildAnthropicModelInfos( : aliasForRoute(m.provider, m.id); }), ]); - // [1m] picker variant (devlog 260712 B1): Claude Code accounts exactly 1M for ids - // carrying the marker (2.1.207 binary: /\[1m\]/i → 1e6, compaction preserved), so - // ONLY models with an authoritative >=1M window get a second selectable row — - // the auto-context widening that let a 372K route carry the marker (and be - // over-filled) is the #854 defect and does not come back. Guards (audit R1#11): - // same dedupe set, never double-suffix. + // [1m] picker variant (devlog 260712 B1; widened in devlog/_plan/261009_claude_1m_default/020): + // Claude Code accounts exactly 1M for ids carrying the marker (2.1.207 binary: /\[1m\]/i -> 1e6, + // compaction preserved). A discovery client may lack CLAUDE_CODE_AUTO_COMPACT_WINDOW, so the + // rule is the fixed unpaired one (long-context.ts), not the configurable launch compact window: + // a custom 350k window must not re-admit a 372k route (#854). The server's auto mode only turns + // widening off (autoContext: false / maxContextTokens), falling back to >= 1M. Base and Fast + // rows stay unmarked: this list is a choice list. Guards (audit R1#11): same dedupe set, never + // double-suffix. + const variantMode = auto.enabled ? UNPAIRED_AUTO_CONTEXT : AUTO_CONTEXT_OFF; const push1mVariant = ( base: AnthropicModelInfo, contextWindow: number | undefined, maxInputTokens?: number, selectorId?: string, + mode: AutoContextMode = variantMode, ) => { - // The [1m] marker makes Claude Code account 1e6 tokens for the row, so it - // may only name models whose AUTHORITATIVE effective window is >= 1M — - // never the auto-context widening, which would mark a 372K route and have - // Claude Code over-fill it (the #854 defect). - if (contextWindow === undefined || contextWindow < ONE_MILLION) return; + // A window >= 1M always earns the row; a long window earns it under the unpaired rule, + // because an overflow is answered as `prompt is too long`, which Claude Code compacts on. + if (!shouldMarkOneMillion(contextWindow, mode)) return; if (base.id.includes("[1m]")) return; const id = selectorId ?? `${base.id}[1m]`; if (seen.has(id)) return; @@ -170,9 +173,9 @@ export function buildAnthropicModelInfos( // (measured — see devlog/_plan/260817_native_gpt56_1m_context/001_measurement_evidence.md). // Advertising the flat 1e6 there would invite mid-session context_length_exceeded, so the // variant reports whichever of the two is smaller. - const advertised = typeof maxInputTokens === "number" && maxInputTokens > 0 - ? Math.min(ONE_MILLION, maxInputTokens) - : ONE_MILLION; + // A long window under 1M must not be advertised as 1e6 input either. + const ceiling = typeof maxInputTokens === "number" && maxInputTokens > 0 ? maxInputTokens : contextWindow; + const advertised = typeof ceiling === "number" && ceiling > 0 ? Math.min(ONE_MILLION, ceiling) : ONE_MILLION; out.push({ ...base, id, display_name: `${base.display_name} · 1M`, max_input_tokens: advertised }); }; /** @@ -251,7 +254,7 @@ export function buildAnthropicModelInfos( && listedModelId.startsWith("claude-fable-") ? `${claudeCodeNativeAlias(listedModelId)}[1m]` : undefined; - push1mVariant(info, m.contextWindow, routedMaxInput, oneMillionSelector); + push1mVariant(info, m.contextWindow, routedMaxInput, oneMillionSelector, isAnthropicClaudeRoute(m.provider, m.id) ? AUTO_CONTEXT_OFF : variantMode); // The whole model is passed, not a (provider, id) pair: a combo row lives in its own // namespace with no config.providers entry, so the caller classifies it from the // aggregated supportsServiceTier the row already carries. diff --git a/src/claude/subagent-model.ts b/src/claude/subagent-model.ts index db0ee655455..f5efe44f4d4 100644 --- a/src/claude/subagent-model.ts +++ b/src/claude/subagent-model.ts @@ -1,6 +1,7 @@ import type { OcxConfig } from "../types"; import { claudeCodeAlias, claudeCodeNativeAlias, resolveAlias } from "./alias"; -import { AUTO_CONTEXT_OFF, shouldMarkOneMillion, stripOneMillionMarker, withOneMillionMarker } from "./context-windows"; +import { AUTO_CONTEXT_OFF, shouldMarkOneMillion, stripOneMillionMarker, UNPAIRED_AUTO_CONTEXT, withOneMillionMarker, type AutoContextMode } from "./context-windows"; +import { isAnthropicClaudeRoute } from "./long-context"; import { hasOwnProvider } from "../config"; import { knownModelIdsForProvider } from "../router"; import { decodeRoutedModelIdOrThrow } from "../providers/slug-codec"; @@ -14,20 +15,38 @@ export { SAFE_AGENT_MODEL_ID } from "../config/subagent-models"; /** * Generated subagent defs cannot rely on the parent's auto-context compaction - * pairing, so their [1m] marker follows the AUTHORITATIVE window only: mark when - * the effective window (exact selector, then the canonical [1m] form, then bare) - * is genuinely >= 1M; strip an inherited unsafe marker back to the bare selector; - * with no window information, keep the selector as it was. Genuine routed [1m] - * ids are preserved through the canonical-exact lookup. (#854) + * pairing, so their [1m] marker follows the unpaired long-context rule + * (long-context.ts): mark when the effective window (exact selector, then the + * canonical [1m] form, then bare) is >= 1M or can host the default compact window, + * whose overflow Claude Code compacts on as `prompt is too long`; strip an inherited + * marker the window cannot carry; with no window information, keep the selector as it + * was. Genuine routed [1m] ids are preserved through the canonical-exact lookup. + * (#854; devlog/_plan/261009_claude_1m_default/020) */ +/** + * The marking mode a subagent selector gets: real Anthropic models (a bare `claude-*` passthrough + * id, or a Claude row on either Anthropic pool) need a genuine 1M; everything else follows the + * unpaired long-context rule. Connected launches read windows from the published catalog, which + * does not drop capped Anthropic rows, so this cannot rely on the window map alone. + */ +function subagentSelectorMode(bare: string): AutoContextMode { + const route = resolveAlias(bare) ?? resolveDesktop3pAlias(bare) ?? bare; + const slash = route.indexOf("/"); + const anthropic = slash < 0 + ? route.startsWith("claude-") + : isAnthropicClaudeRoute(route.slice(0, slash), route.slice(slash + 1)); + return anthropic ? AUTO_CONTEXT_OFF : UNPAIRED_AUTO_CONTEXT; +} + export function withSubagentContextMarker(selector: string, windows: Record): string { const bare = stripOneMillionMarker(selector); const wasMarked = selector !== bare; const canonicalExact = wasMarked ? `${bare}[1m]` : selector; const authoritativeWindow = windows[selector] ?? windows[canonicalExact] ?? windows[bare]; if (typeof authoritativeWindow === "number" && authoritativeWindow > 0) { - return shouldMarkOneMillion(authoritativeWindow, AUTO_CONTEXT_OFF) - ? (withOneMillionMarker(selector, windows) ?? selector) + const mode = subagentSelectorMode(bare); + return shouldMarkOneMillion(authoritativeWindow, mode) + ? (withOneMillionMarker(selector, windows, mode) ?? selector) : bare; } return wasMarked ? selector : bare; @@ -77,7 +96,7 @@ export function resolveSubagentForceModel(config: OcxConfig, windows: Record= 1_000_000)) return null; + if (requestedMarker && !exactMarkedExposure && !(typeof window === "number" && Number.isFinite(window) && shouldMarkOneMillion(window, subagentSelectorMode(bare)))) return null; const marked = exactMarkedExposure ? parts.alias : withSubagentContextMarker(parts.alias, windows); // Bare Anthropic names are indistinguishable from the old CLI's fallback. // Encode only the force selector; handlers restore native identity before diff --git a/src/server/management/shared.ts b/src/server/management/shared.ts index d3ca423f4d7..f5a0621922c 100644 --- a/src/server/management/shared.ts +++ b/src/server/management/shared.ts @@ -364,7 +364,7 @@ export function stripRegistryOnlyStaticHeaders(name: string, provider: OcxProvid /** Shared Desktop profile DTO builder for the management API and CLI. */ export async function buildClaudeDesktopState(config: OcxConfig, stored?: OcxClaudeDesktopProfile) { const { filterCatalogVisibleModels, nativeContextLimits, nativeOpenAiContextWindow, desktopVisibleNativeSlugs } = await import("../../codex/catalog"); - const { DESKTOP_SUPPORTS_1M_THRESHOLD } = await import("../../claude/desktop-3p"); + const { routeSupportsOneMillion } = await import("../../claude/long-context"); const { reconcileDesktopProfile, renderDesktopProfile } = await import("../../claude/desktop-profile"); const routed = filterCatalogVisibleModels(await fetchAllModels(config), config); const profileModels: DesktopProfileModel[] = [ @@ -413,8 +413,8 @@ export async function buildClaudeDesktopState(config: OcxConfig, stored?: OcxCla ...(modelByRoute.get(route)?.contextWindow ? { contextWindow: modelByRoute.get(route)!.contextWindow } : {}), effortSupported: effortByRoute.get(route) ?? false, // Read-only view of the 1M capability the written Desktop config already emits, - // derived from the SAME threshold so the dashboard chip can never disagree. - supports1m: (modelByRoute.get(route)?.contextWindow ?? 0) >= DESKTOP_SUPPORTS_1M_THRESHOLD, + // derived from the SAME predicate so the dashboard chip can never disagree. + supports1m: routeSupportsOneMillion(route, modelByRoute.get(route)?.contextWindow), assignment: profile.assignments[route]!, })); return { diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index a220bbb7098..2953bf88032 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -288,7 +288,7 @@ intent and cannot repair the URL a running Desktop already pinned. The `claude.a certificate, streams every body and upgrade unchanged, and rewrites only the bootstrap response's local Code picker surfaces, `ccd` (what the Desktop Code tab reads) and its `code` fallback, never the remote `ccr` (`picker-bootstrap.ts`), failing open to the original bytes; the model list -comes from a persisted snapshot (`picker-models.ts`), so a bootstrap never waits on discovery. Picker aliases carry `[1m]` only for authoritative windows of at least 1M, using the shared context marker helper with auto-context disabled. Sub-million opt-ins remain unmarked because the picker cannot guarantee the Desktop runner's compaction environment. A +comes from a persisted snapshot (`picker-models.ts`), so a bootstrap never waits on discovery. Picker aliases carry `[1m]` for long windows (`src/claude/long-context.ts`: at least 1M, or at least the 829,800 default compact window), and Claude models on either Anthropic pool only at a genuine 1M. The picker cannot guarantee the Desktop runner's compaction environment, so an 872k route that outgrows its window relies on the `prompt is too long` envelope, which Claude Code compacts on; Desktop 3P `supports1m`/`prefer1m`, discovery `· 1M` rows and generated subagent markers use the same rule. A CONNECT to claude.ai that arrives before the first refresh waits at most 3 s, then goes blind. A picker proxy bind failure only disables picker mode; a picker construction or start failure closes every socket the start had bound before rethrowing. Ordinary session cookies within the header diff --git a/structure/subagents.md b/structure/subagents.md index bdaa57cae19..906ce2149e4 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -546,6 +546,6 @@ Automatic account exhaustion and recovery use the [spendable Codex credit eviden ### Forced Claude Code subagent model -`src/claude/subagent-model.ts` shares the roster alias and authoritative context-marker resolver with routed launch force. `claudeCode.subagentModelForce` is opt-in and default-off. Force availability excludes retained unavailable roster entries. Caller-added `[1m]` suffixes require finite authoritative million-token capacity or an exact advertised marked identity; exact upstream ids remain literal, while legacy roster marker handling is unchanged. The generated-agent legacy directive cannot replace a wire selector matching the configured forced alias. `src/claude/subagent-force-status.ts` performs bounded, read-only server-local version and settings-key inspection; unknown is not supported. +`src/claude/subagent-model.ts` shares the roster alias and authoritative context-marker resolver with routed launch force. `claudeCode.subagentModelForce` is opt-in and default-off. Force availability excludes retained unavailable roster entries. Caller-added `[1m]` suffixes require a finite authoritative long window (`src/claude/long-context.ts`: 1M, or at least the 829,800 default compact window; a genuine 1M for Claude models on either Anthropic pool or a bare `claude-*` id) or an exact advertised marked identity; generated markers follow the same rule; exact upstream ids remain literal, while legacy roster marker handling is unchanged. The generated-agent legacy directive cannot replace a wire selector matching the configured forced alias. `src/claude/subagent-force-status.ts` performs bounded, read-only server-local version and settings-key inspection; unknown is not supported. Explicit gateway selectors outrank generated-agent `ocx-route` fallback independently of saved force state. `src/claude/inbound-model-options.ts` shares this precedence across Messages and count-tokens; bare Claude fallback and requests without directives retain their existing behavior. Native Claude force targets use reversible native aliases, restored before existing credential/model-map checks. Connected launch exposure comes from fresh authenticated gateway rows, not cached context-window keys; acquisition is independent of cache-write success. diff --git a/tests/claude-integration/claude-long-context-eligibility.test.ts b/tests/claude-integration/claude-long-context-eligibility.test.ts new file mode 100644 index 00000000000..ed41dc2e72f --- /dev/null +++ b/tests/claude-integration/claude-long-context-eligibility.test.ts @@ -0,0 +1,89 @@ +/** + * Long windows (>= the default compact window) are 1M on every Claude surface whose runner may + * lack CLAUDE_CODE_AUTO_COMPACT_WINDOW; Claude models on either Anthropic pool still need a genuine + * 1M (devlog/_plan/261009_claude_1m_default/020_long_context_eligibility.md). + */ +import { describe, expect, test } from "bun:test"; +import { claudeSurfaceSupportsOneMillion, isLongContextWindow, routeSupportsOneMillion } from "../../src/claude/long-context"; +import { AUTO_CONTEXT_OFF, UNPAIRED_AUTO_CONTEXT } from "../../src/claude/context-windows"; +import { buildAnthropicModelInfos } from "../../src/claude/model-info"; +import { generateDesktop3pModels } from "../../src/claude/desktop-3p"; +import { withSubagentContextMarker } from "../../src/claude/subagent-model"; + +const ASTRA_872K = { modelWindows: { "gpt-6-astra": 872_000 } }; + +describe("isLongContextWindow / claudeSurfaceSupportsOneMillion", () => { + test("the floor is the default compact window, never a configurable one", () => { + for (const window of [undefined, 0, 200_000, 262_144, 400_000, 829_799]) expect(isLongContextWindow(window)).toBe(false); + for (const window of [829_800, 872_000, 922_000, 1_000_000, 2_000_000]) expect(isLongContextWindow(window)).toBe(true); + }); + + test("Claude models on either Anthropic pool need a genuine 1M; a gateway that shares the name does not", () => { + expect(claudeSurfaceSupportsOneMillion("anthropic", "claude-sonnet-5", 872_000)).toBe(false); + expect(claudeSurfaceSupportsOneMillion("anthropic2", "claude-sonnet-5", 872_000)).toBe(false); + expect(claudeSurfaceSupportsOneMillion("anthropic2", "claude-sonnet-5", 1_000_000)).toBe(true); + // A configured gateway named anthropic2 serving another vendor's model is not an Anthropic pool. + expect(claudeSurfaceSupportsOneMillion("anthropic2", "kimi-k3", 900_000)).toBe(true); + expect(routeSupportsOneMillion("native/gpt-6-astra", 872_000)).toBe(true); + expect(routeSupportsOneMillion("kimi/k3", 262_144)).toBe(false); + }); +}); + +describe("Anthropic discovery 1M variants", () => { + const variants = (infos: ReturnType) => infos.filter(info => info.id.endsWith("[1m]")); + + test("an 872k native gets a variant that reports its real input ceiling", () => { + const infos = buildAnthropicModelInfos(["gpt-6-astra"], [], UNPAIRED_AUTO_CONTEXT, "readable", undefined, ASTRA_872K); + const [variant] = variants(infos); + expect(variant?.display_name).toBe("gpt-6-astra (native) · 1M"); + expect(variant?.max_input_tokens).toBe(872_000); + expect(variants(buildAnthropicModelInfos(["gpt-6-astra"], [], AUTO_CONTEXT_OFF, "readable", undefined, ASTRA_872K))).toHaveLength(0); + }); + + test("a custom compact window does not lower the floor (#854)", () => { + const routed = [{ provider: "example", id: "mid", contextWindow: 400_000 }]; + expect(variants(buildAnthropicModelInfos([], routed, { enabled: true, compactWindow: 350_000 }, "readable"))).toHaveLength(0); + }); + + test("a long routed row without an input ceiling advertises its window, not 1e6", () => { + const infos = buildAnthropicModelInfos([], [{ provider: "example", id: "long", contextWindow: 900_000 }], UNPAIRED_AUTO_CONTEXT, "readable"); + expect(variants(infos).map(info => info.max_input_tokens)).toEqual([900_000]); + }); + + test("Anthropic rows and short windows get no widened variant", () => { + const routed = [ + { provider: "anthropic", id: "claude-x", contextWindow: 872_000 }, + { provider: "anthropic2", id: "claude-y", contextWindow: 872_000 }, + { provider: "kimi", id: "k3", contextWindow: 262_144 }, + ]; + expect(variants(buildAnthropicModelInfos([], routed, UNPAIRED_AUTO_CONTEXT, "readable"))).toHaveLength(0); + }); +}); + +describe("Desktop 3P", () => { + test("an 872k native offers and prefers 1M; a 262k route does not", () => { + const [astra] = generateDesktop3pModels(["gpt-6-astra"], [], undefined, ASTRA_872K); + expect(astra).toMatchObject({ supports1m: true, prefer1m: true }); + const [k3] = generateDesktop3pModels([], [{ provider: "kimi", id: "k3", contextWindow: 262_144 }]); + expect(k3!.supports1m).toBeUndefined(); + expect(k3!.prefer1m).toBeUndefined(); + }); +}); + +describe("generated subagent markers", () => { + const alias = "ocx-claude-native--gpt-6-astra"; + + test("an 872k window is marked, a 262k window is not, and an unsafe marker is stripped", () => { + expect(withSubagentContextMarker(alias, { [alias]: 872_000 })).toBe(`${alias}[1m]`); + expect(withSubagentContextMarker(alias, { [alias]: 262_144 })).toBe(alias); + expect(withSubagentContextMarker(`${alias}[1m]`, { [alias]: 262_144 })).toBe(alias); + }); + + test("a capped Claude row on Pool 2 stays unmarked even when a connected window map lists it", () => { + // Connected launches read the published catalog, which keeps capped Anthropic rows. + const pool2 = "ocx-claude-anthropic2--claude-sonnet-5"; + expect(withSubagentContextMarker(pool2, { [pool2]: 900_000 })).toBe(pool2); + expect(withSubagentContextMarker(`${pool2}[1m]`, { [pool2]: 900_000 })).toBe(pool2); + expect(withSubagentContextMarker(pool2, { [pool2]: 1_000_000 })).toBe(`${pool2}[1m]`); + }); +}); diff --git a/tests/claude-integration/claude-picker-models.test.ts b/tests/claude-integration/claude-picker-models.test.ts index fd1ad3445c6..7fee7abe587 100644 --- a/tests/claude-integration/claude-picker-models.test.ts +++ b/tests/claude-integration/claude-picker-models.test.ts @@ -79,25 +79,30 @@ test("picker preserves real million-token routed windows without promoting small expect(rows[1]?.contextWindow).toBe(1_048_576); }); -test("picker leaves sub-million native opt-ins unmarked without a runner compaction guarantee", () => { +test("picker marks long native opt-ins on the prompt-too-long recovery and leaves 272k unmarked", () => { + // Desktop runners lack CLAUDE_CODE_AUTO_COMPACT_WINDOW; an 872k window is marked anyway because an + // overflow comes back as `prompt is too long`, which Claude Code compacts on (261009 020). const rows = buildPickerModels({ nativeSlugs: ["gpt-5.5", "gpt-6-astra", "gpt-6-sol", "gpt-6-luna", "gpt-5.6-terra"], routedModels: [], nativeContextCap: { modelWindows: { "gpt-6-astra": 872_000, "gpt-6-sol": 872_000, "gpt-6-luna": 872_000, "gpt-5.6-terra": 922_000, } }, }); - expect(rows.every(row => !row.id.endsWith("[1m]"))).toBe(true); + expect(rows.map(row => row.id.endsWith("[1m]"))).toEqual([false, true, true, true, true]); expect(rows.map(row => row.contextWindow)).toEqual([272_000, 872_000, 872_000, 872_000, 922_000]); }); -test("picker marks the million-token boundary and leaves smaller routed windows unmarked", () => { +test("picker marks windows from the default compact floor up and leaves smaller routed windows unmarked", () => { const rows = buildPickerModels({ nativeSlugs: [], routedModels: [ - { provider: "example", id: "below", contextWindow: 999_999 }, + { provider: "example", id: "short", contextWindow: 262_144 }, + { provider: "example", id: "below", contextWindow: 829_799 }, + { provider: "example", id: "floor", contextWindow: 829_800 }, { provider: "example", id: "exact", contextWindow: 1_000_000 }, { provider: "example", id: "larger", contextWindow: 2_000_000 }, + { provider: "anthropic2", id: "claude-pool-two", contextWindow: 872_000 }, ] }); expect(rows.map(row => row.id)).toEqual([ - "ocx-claude-example--below", "ocx-claude-example--exact[1m]", "ocx-claude-example--larger[1m]", + "ocx-claude-example--short", "ocx-claude-example--below", "ocx-claude-example--floor[1m]", + "ocx-claude-example--exact[1m]", "ocx-claude-example--larger[1m]", "ocx-claude-anthropic2--claude-pool-two", ]); - expect(rows.map(row => row.contextWindow)).toEqual([999_999, 1_000_000, 2_000_000]); }); diff --git a/tests/claude-integration/claude-subagent-model-force.test.ts b/tests/claude-integration/claude-subagent-model-force.test.ts index be3c07403ca..670beb458ca 100644 --- a/tests/claude-integration/claude-subagent-model-force.test.ts +++ b/tests/claude-integration/claude-subagent-model-force.test.ts @@ -39,11 +39,12 @@ test("exported values independently win and native launches retain exports", () } }); -test("alias resolution reuses roster provider/native semantics and authoritative million boundary", () => { +test("alias resolution reuses roster provider/native semantics and the long-context boundary", () => { for (const entry of deps.forceAvailable) { const c = config(entry); const { alias } = entryParts(entry, c); - expect(resolveSubagentForceModel(c, { [alias]: 999999 }, { entries: deps.forceAvailable })).toBe(alias); + expect(resolveSubagentForceModel(c, { [alias]: 829_799 }, { entries: deps.forceAvailable })).toBe(alias); + expect(resolveSubagentForceModel(c, { [alias]: 829_800 }, { entries: deps.forceAvailable })).toBe(`${alias}[1m]`); expect(resolveSubagentForceModel(c, { [alias]: 1000000 }, { entries: deps.forceAvailable })).toBe(`${alias}[1m]`); } }); diff --git a/tests/clients/desktop-3p.test.ts b/tests/clients/desktop-3p.test.ts index 8e61aeaf48a..0156ee1ab12 100644 --- a/tests/clients/desktop-3p.test.ts +++ b/tests/clients/desktop-3p.test.ts @@ -212,19 +212,18 @@ describe("Claude Desktop 3P models", () => { }); test("an openai context cap reaches the Desktop writer, not just the dashboard", () => { - // No surviving native advertises a 1M window (gpt-5.4 was the last). Sol's - // opt-in ceiling is 922k, so even a 1M provider cap must not invent - // supports1m — nativeOpenAiContextWindow clamps it under the threshold. - // A 272k cap has to take the same path, or the written Desktop config - // would promise a window the proxy will not serve (#854's effective-window + // Sol's default window is 272k: no 1M. A 1M provider cap opts it into its 922k + // ceiling, a long window, so the written config now offers and prefers 1M; an + // overflow comes back as `prompt is too long`, which Claude compacts on (261009 020). + // A 272k cap has to take the same path as the default (#854's effective-window // contract). const uncapped = generateDesktop3pModels(["gpt-5.6-sol"], []); expect(uncapped[0]!.supports1m).toBeUndefined(); expect(uncapped[0]!.prefer1m).toBeUndefined(); const optedIn = generateDesktop3pModels(["gpt-5.6-sol"], [], undefined, 1_000_000); - expect(optedIn[0]!.supports1m).toBeUndefined(); - expect(optedIn[0]!.prefer1m).toBeUndefined(); + expect(optedIn[0]!.supports1m).toBe(true); + expect(optedIn[0]!.prefer1m).toBe(true); const capped = generateDesktop3pModels(["gpt-5.6-sol"], [], undefined, 272_000); expect(capped[0]!.supports1m).toBeUndefined(); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 4a2e042337b..c8db6127857 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -303,6 +303,7 @@ "claude-model-info.test.ts": "claude-integration", "claude-models-discovery.test.ts": "claude-integration", "claude-native-affinity.test.ts": "claude-integration", "claude-native-passthrough.test.ts": "claude-integration", "claude-native-rate-limit-headers.test.ts": "claude-integration", "claude-outbound.test.ts": "claude-integration", + "claude-long-context-eligibility.test.ts": "claude-integration", "claude-prompt-too-long.test.ts": "claude-integration", "claude-shell-hook.test.ts": "claude-integration", "claude-sidecar-override.test.ts": "claude-integration", "claude-source-envelope.test.ts": "claude-integration", "claude-system-env-auto.test.ts": "claude-integration", From 24ee3e3fcef8b50524415ff8075bef7eb35f93c1 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 10 Oct 2026 06:38:33 +0900 Subject: [PATCH 49/59] feat(claude): add a 200k context-accounting opt-in (#6781) * docs(devlog): amend the context-accounting plan after layer 2 review Co-Authored-By: Claude Opus 5.5 * feat(claude): add a 200k context-accounting opt-in claudeCode.contextAccounting decides what opencodex picks by default on Claude surfaces. Absent (1m, the default) keeps long-window models at 1M. "200k" opts out everywhere at once: resolveAutoContext returns a mode under which nothing is marked [1m] automatically and no compact window is injected, the Desktop pickers leave rows unmarked, Desktop 3P keeps supports1m but drops prefer1m, and generated or forced subagents keep an unmarked selector bare. A selector the user marks [1m] keeps it, and discovery still lists genuine 1M rows as a choice. Settable with `ocx claude config set --context-accounting <1m|200k>` and PUT /api/claude-code ("1m" drops the key, other values are a 400); GET reports it and a change re-applies the system env. Documented in the Claude Code guides. Co-Authored-By: Claude Opus 5.5 * docs(claude): state what the 200k opt-in keeps and what it leaves alone Review follow-up: an explicit [1m] stays available but generated and forced subagents still drop a marker the window cannot carry, and an exported compact window no longer re-enables automatic marking while opencodex leaves the export itself in place. Co-Authored-By: Claude Opus 5.5 * docs(structure): record the 200k context-accounting opt-in The Claude Desktop, subagent, config, and management-API contracts now state that claudeCode.contextAccounting "200k" stops automatic 1M marking while an explicit marker and genuine 1M discovery rows stay. Co-authored-by: JUN --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Cursor Agent --- .../030_context_accounting_policy.md | 22 ++++ .../src/content/docs/fr/guides/claude-code.md | 14 +++ .../src/content/docs/guides/claude-code.md | 15 +++ .../src/content/docs/ja/guides/claude-code.md | 13 ++ .../src/content/docs/ko/guides/claude-code.md | 14 +++ .../src/content/docs/ru/guides/claude-code.md | 14 +++ .../content/docs/zh-cn/guides/claude-code.md | 12 ++ .../content/docs/zh-tw/guides/claude-code.md | 12 ++ scripts/test-layout/layout.json | 2 +- .../ocx/references/01_surface_integrations.md | 6 +- src/claude/agents-inject.ts | 6 +- src/claude/context-windows.ts | 11 +- src/claude/desktop-3p.ts | 22 +++- src/claude/intercept/picker-models.ts | 12 +- src/claude/subagent-model.ts | 14 ++- src/cli/capabilities-base.ts | 4 +- src/cli/capabilities-integrations.ts | 4 +- src/cli/integrations.ts | 8 +- .../index/claude-intercept-lifecycle.ts | 4 +- src/server/index/serve-options.ts | 2 + .../management/agent-settings-routes.ts | 14 ++- src/types/config.ts | 9 ++ structure/clients/claude-desktop.md | 2 +- structure/config.md | 2 +- structure/gui-and-management-api.md | 2 +- structure/subagents.md | 2 +- .../claude-context-accounting.test.ts | 115 ++++++++++++++++++ .../claude-management-api.test.ts | 23 ++++ tests/fixtures/test-layout-expected.json | 1 + 29 files changed, 352 insertions(+), 29 deletions(-) create mode 100644 tests/claude-integration/claude-context-accounting.test.ts diff --git a/devlog/_plan/261009_claude_1m_default/030_context_accounting_policy.md b/devlog/_plan/261009_claude_1m_default/030_context_accounting_policy.md index c0589d909a5..db869e26247 100644 --- a/devlog/_plan/261009_claude_1m_default/030_context_accounting_policy.md +++ b/devlog/_plan/261009_claude_1m_default/030_context_accounting_policy.md @@ -123,3 +123,25 @@ NEW `tests/claude-integration/claude-context-accounting.test.ts` (registered in - `bun run typecheck` - focused files above + `tests/ci-workflows/skill-ocx.test.ts` (capability map) + `bun run skill:surface:check` - `bun run structure:check` + +## P amendment (wp3 entry, after wp2 review) + +wp2 changed two interfaces this doc relied on; the policy threads through them instead: + +- `withSubagentContextMarker(selector, windows, accounting200k = false)`: the marking mode is now + per selector (`subagentSelectorMode`: genuine 1M for Claude models, unpaired otherwise). Under + `200k` an unmarked selector stays bare; an explicit `[1m]` keeps following its selector mode + (kept when the window can carry it, stripped otherwise). `agents-inject.ts:100,133` and + `resolveSubagentForceModel` pass `resolveAutoContext(config.claudeCode).accounting200k === true`; + the `subagentMarkingMode` helper is not needed. +- Desktop eligibility is `claudeSurfaceSupportsOneMillion(provider, modelId, window)` / + `routeSupportsOneMillion(route, window)`; the policy only drops `prefer1m`, so those predicates + are unchanged. +- Discovery needs no change: `variantMode = auto.enabled ? UNPAIRED : OFF` already keeps the + >= 1M variants under `ACCOUNTING_200K` (enabled false). + +Audit of the amendment (PASS) notes: the earlier `withSubagentContextMarker(..., mode = UNPAIRED_AUTO_CONTEXT)` +snippet and the `subagentMarkingMode` chain row above are **superseded** by this amendment; the +picker keeps its Claude-model guard and takes `ACCOUNTING_200K` first; `shouldMarkOneMillion` +checks `accounting200k` before its >= 1M early return. wp3 B also carries the docs-site sync the +layer 1 and layer 2 branches still owe (cascaded onto those branches). diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index 02b1641d58b..98a4f98e41d 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -490,6 +490,20 @@ reste prioritaire pour le marquage des variables de lancement ; le proxy s'appu Les lignes de découverte ignorent cet export et conservent le plancher fixe. Les valeurs de configuration invalides définies manuellement reviennent à 829,800. +### Comptabilisation du contexte (1M par défaut, 200k sur option) + +`claudeCode.contextAccounting` fixe ce qu'opencodex choisit par défaut. Absent (`1m`, valeur par défaut), les modèles à +longue fenêtre sont proposés à 1M dans les emplacements d'environnement du lancement, les sélecteurs Desktop, Desktop 3P +(`prefer1m`) et les sous-agents générés. Avec `200k`, rien n'est marqué `[1m]` automatiquement, `CLAUDE_CODE_AUTO_COMPACT_WINDOW` +n'est pas injecté et Desktop 3P retire `prefer1m` tout en gardant `supports1m`. Un sélecteur que vous marquez vous-même `[1m]` +reste disponible (les sous-agents générés ou forcés retirent toujours un marqueur que la fenêtre du modèle ne peut pas porter), et +la découverte liste toujours les lignes `· 1M` des modèles réellement à 1M. `200k` l'emporte sur le contexte automatique : une valeur +de compactage que vous exportez ne réactive pas le marquage automatique, mais opencodex laisse votre export en place. + +```bash +ocx claude config set --context-accounting 200k +``` + ### Environnement effectif des modèles `effectiveModelEnv` calcule six emplacements injectés par `ocx claude`, l'environnement système ou le fichier d'environnement du shell : diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 9922dde3fa5..1cc6b683441 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -678,6 +678,21 @@ for launch-slot marking (the proxy uses YOUR value to decide which models are sa Discovery rows ignore that export and keep the fixed floor. Invalid hand-edited config values fall back to 829,800. +### Context accounting (1M by default, 200k opt-in) + +`claudeCode.contextAccounting` decides what opencodex picks by default. Absent (`1m`, the +default), long-window models are offered at 1M in the launch env slots, the Desktop pickers, +Desktop 3P (`prefer1m`) and generated subagents. Set `200k` to opt out: nothing is marked `[1m]` +automatically, `CLAUDE_CODE_AUTO_COMPACT_WINDOW` is not injected, and Desktop 3P drops `prefer1m` +while keeping `supports1m`. A selector you mark `[1m]` yourself stays available (generated and +forced subagents still drop a marker the model's window cannot carry), and discovery still lists +the `· 1M` rows of genuine 1M models. `200k` wins over auto-context: a compact window you export no +longer turns automatic marking on, though opencodex leaves your own export in place. + +```bash +ocx claude config set --context-accounting 200k +``` + ### Effective model environment `effectiveModelEnv` computes six slots injected by `ocx claude` / system env / shell file: diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index c5690dc3a67..793367e24c5 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -340,6 +340,19 @@ Claude Code は未知モデルのコンテキストを 200k トークンとし 1M 未満のネイティブ Anthropic モデルには自動で標識を付けません。自分で export した圧縮値は起動スロットの標識に優先し、プロキシは**ユーザーが指定した**値を基準にどのモデルに安全に標識を付けるか決定します。ディスカバリー行はその export を無視し、固定の下限を保ちます。 直接編集した設定値が不正な場合は 829,800 に戻ります。 +### コンテキスト計算方式(既定は 1M、200k はオプトイン) + +`claudeCode.contextAccounting` は opencodex が既定で選ぶ値を決めます。未設定(`1m`、既定)では、 +長いコンテキストのモデルが起動環境スロット、Desktop ピッカー、Desktop 3P(`prefer1m`)、生成されたサブエージェントで 1M として扱われます。 +`200k` にすると `[1m]` 標識を自動では付けず、`CLAUDE_CODE_AUTO_COMPACT_WINDOW` も注入せず、Desktop 3P は `supports1m` を残して +`prefer1m` だけを外します。自分で `[1m]` を付けた選択は引き続き使えます(生成されたサブエージェントと強制サブエージェントは、モデルのウィンドウが支えられない標識を引き続き外します)。 +ディスカバリーには実際に 1M のモデルの `· 1M` 行が引き続き表示されます。`200k` は自動コンテキストより優先され、自分で export した圧縮値があっても自動標識は +再び有効になりません。その export 値自体は opencodex が変更しません。 + +```bash +ocx claude config set --context-accounting 200k +``` + ### 実モデル環境 `effectiveModelEnv` は `ocx claude` / システム環境 / シェルファイルが注入するスロット 6 つを計算します。 diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index 91de7d969bd..677bf26cbe1 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -374,6 +374,20 @@ Claude Code는 알 수 없는 모델의 컨텍스트를 200k 토큰으로 계산 디스커버리 행은 그 값을 무시하고 고정 하한을 유지해요. 직접 편집한 설정값이 잘못되면 829,800로 돌아가요. +### 컨텍스트 계산 방식 (기본 1M, 200k는 옵트인) + +`claudeCode.contextAccounting`은 opencodex가 기본으로 고르는 값을 정해요. 설정하지 않으면(`1m`, 기본값) +긴 컨텍스트 모델이 실행 환경 슬롯, Desktop 선택기, Desktop 3P(`prefer1m`), 생성된 서브에이전트에서 1M으로 +잡혀요. `200k`로 바꾸면 자동 `[1m]` 표식을 붙이지 않고 `CLAUDE_CODE_AUTO_COMPACT_WINDOW`도 넣지 않으며, +Desktop 3P는 `supports1m`은 남기고 `prefer1m`만 빼요. 직접 `[1m]`을 붙인 선택은 계속 쓸 수 있어요(생성된 +서브에이전트와 강제 서브에이전트는 모델 창이 감당하지 못하는 표식을 여전히 떼요). 디스커버리 목록에는 실제 1M 모델의 +`· 1M` 행이 계속 나와요. `200k`는 자동 컨텍스트보다 우선해요. 직접 export한 압축 값이 있어도 자동 표식은 다시 켜지지 +않고, 그 export 값은 opencodex가 건드리지 않아요. + +```bash +ocx claude config set --context-accounting 200k +``` + ### 실제 모델 환경 `effectiveModelEnv`는 `ocx claude` / 시스템 환경 / 셸 파일이 주입할 슬롯 여섯 개를 계산해요. diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index dce282cfcc9..6eaee530207 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -363,6 +363,20 @@ Anthropic сохраняют канонические id в обоих инте можно безопасно пометить). Строки обнаружения игнорируют этот экспорт и сохраняют фиксированный пол. Некорректные значения, внесённые в конфигурацию вручную, откатываются к 829,800. +### Учёт контекста (1M по умолчанию, 200k по выбору) + +`claudeCode.contextAccounting` определяет, что opencodex выбирает по умолчанию. Без значения (`1m`, по умолчанию) модели +с длинным окном предлагаются как 1M в слотах окружения запуска, селекторах Desktop, Desktop 3P (`prefer1m`) и созданных +субагентах. Значение `200k` это отключает: маркер `[1m]` автоматически не ставится, `CLAUDE_CODE_AUTO_COMPACT_WINDOW` не +передаётся, а Desktop 3P убирает `prefer1m`, оставляя `supports1m`. Селектор, который вы сами пометили `[1m]`, остаётся +доступным (созданные и принудительные субагенты по-прежнему снимают маркер, который окно модели не выдерживает), а в списке +обнаружения остаются строки `· 1M` для моделей с настоящим окном 1M. `200k` важнее автоконтекста: экспортированное вами +значение сжатия не включает автоматическую разметку снова, но opencodex ваш экспорт не трогает. + +```bash +ocx claude config set --context-accounting 200k +``` + ### Эффективное окружение моделей `effectiveModelEnv` вычисляет шесть слотов, внедряемых `ocx claude`, системным окружением и diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index 9f791d54db0..c5676ce26bb 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -320,6 +320,18 @@ v1 别名按字面解码(历史上 model ID 中包含的两字符序列 `~s` / 低于 1M 的原生 Anthropic 模型绝不会被自动标记。你自行导出的压缩值在启动槽位标记上始终优先(代理会使用**你的** 值来判断哪些模型可以安全标记)。发现列表忽略该导出值并保持固定下限。手动编辑配置时填入的无效值会回退到 829,800。 +### 上下文计算方式(默认 1M,200k 需选择启用) + +`claudeCode.contextAccounting` 决定 opencodex 默认选用的值。未设置(`1m`,默认)时,长上下文模型在启动环境槽位、 +Desktop 选择器、Desktop 3P(`prefer1m`)和生成的子代理中按 1M 提供。设为 `200k` 即可退出:不再自动添加 `[1m]` 标记, +不注入 `CLAUDE_CODE_AUTO_COMPACT_WINDOW`,Desktop 3P 保留 `supports1m` 但去掉 `prefer1m`。你自己标记 `[1m]` 的选择仍可使用 +(生成的子代理和强制子代理仍会去掉模型窗口无法承载的标记),发现列表中真正 1M 模型的 `· 1M` 条目也仍然存在。`200k` 优先于 +自动上下文:你自行导出的压缩值不会重新启用自动标记,但 opencodex 不会改动你导出的值。 + +```bash +ocx claude config set --context-accounting 200k +``` + ### 有效模型环境变量 `effectiveModelEnv` 会计算由 `ocx claude` / 系统环境 / shell 文件注入的六个槽位: diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index 4f033558616..c3f05c79778 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -396,6 +396,18 @@ v1 別名按字面解碼(歷史上 model ID 中包含的兩字元序列 `~s` / 低於 1M 的原生 Anthropic 模型絕不會被自動標記。你自行匯出的壓縮值在啟動槽位標記上始終優先(代理會使用**你的** 值來判斷哪些模型可以安全標記)。探索列表忽略該匯出值並保持固定下限。手動編輯設定時填入的無效值會回退到 829,800。 +### 上下文計算方式(預設 1M,200k 需選擇啟用) + +`claudeCode.contextAccounting` 決定 opencodex 預設選用的值。未設定(`1m`,預設)時,長上下文模型在啟動環境槽位、 +Desktop 選擇器、Desktop 3P(`prefer1m`)和產生的子代理中按 1M 提供。設為 `200k` 即可退出:不再自動加上 `[1m]` 標記, +不注入 `CLAUDE_CODE_AUTO_COMPACT_WINDOW`,Desktop 3P 保留 `supports1m` 但移除 `prefer1m`。你自己標記 `[1m]` 的選擇仍可使用 +(產生的子代理和強制子代理仍會移除模型視窗無法承載的標記),探索清單中真正 1M 模型的 `· 1M` 條目也仍然存在。`200k` 優先於 +自動上下文:你自行匯出的壓縮值不會重新啟用自動標記,但 opencodex 不會改動你匯出的值。 + +```bash +ocx claude config set --context-accounting 200k +``` + ### 有效模型環境變數 `effectiveModelEnv` 會計算由 `ocx claude` / 系統環境 / shell 檔案注入的六個槽位: diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 84580dd3501..900e1ab1176 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -419,7 +419,7 @@ "messages-native-oauth.test.ts": "claude-integration", "messages-native-opaque-state.test.ts": "claude-integration", "messages-surface-matrix.test.ts": "claude-integration", "claude-long-context-eligibility.test.ts": "claude-integration", - "claude-model-info.test.ts": "claude-integration", + "claude-context-accounting.test.ts": "claude-integration", "claude-model-info.test.ts": "claude-integration", "claude-models-discovery.test.ts": "claude-integration", "claude-native-affinity.test.ts": "claude-integration", "claude-native-passthrough.test.ts": "claude-integration", diff --git a/skills/ocx/references/01_surface_integrations.md b/skills/ocx/references/01_surface_integrations.md index dce2495042c..bd8079e18b5 100644 --- a/skills/ocx/references/01_surface_integrations.md +++ b/skills/ocx/references/01_surface_integrations.md @@ -12,7 +12,7 @@ Declared capabilities: 41. ### `ocx claude config` -Usage: `ocx claude config status [--json]; ocx claude config set [--enabled ] [--auth-mode ] [--system-env ] [--fast-mode ] [--auto-context ] [--compact-window ] [--inject-agents ] [--small-fast-model ] [--model-map ] [--blocked-skills ] [--web-model ] [--web-backend ] [--vision-model ] [--vision-backend ] [--json]; or ocx claude config set --first-party [--json]` +Usage: `ocx claude config status [--json]; ocx claude config set [--enabled ] [--auth-mode ] [--system-env ] [--fast-mode ] [--auto-context ] [--compact-window ] [--context-accounting <1m|200k>] [--inject-agents ] [--small-fast-model ] [--model-map ] [--blocked-skills ] [--web-model ] [--web-backend ] [--vision-model ] [--vision-backend ] [--json]; or ocx claude config set --first-party [--json]` Read or update Claude Code settings, including independent CLI first-party routing. @@ -31,6 +31,7 @@ State-changing: yes. | `--fast-mode` | string | on or off. | | `--auto-context` | string | on or off. | | `--compact-window` | string | Positive token count or default to clear. | +| `--context-accounting` | string | 1m (default) or 200k. | | `--inject-agents` | string | on or off. | | `--small-fast-model` | string | Model ID; - clears. | | `--model-map` | string | Comma-separated from=to mappings; - clears. | @@ -566,7 +567,7 @@ JSON mode: `payload`. ### `ocx claude config set` -Usage: `ocx claude config set [--enabled ] [--auth-mode ] [--system-env ] [--fast-mode ] [--auto-context ] [--compact-window ] [--inject-agents ] [--small-fast-model ] [--model-map ] [--blocked-skills ] [--web-model ] [--web-backend ] [--vision-model ] [--vision-backend ] [--json]; or ocx claude config set --first-party [--json]` +Usage: `ocx claude config set [--enabled ] [--auth-mode ] [--system-env ] [--fast-mode ] [--auto-context ] [--compact-window ] [--context-accounting <1m|200k>] [--inject-agents ] [--small-fast-model ] [--model-map ] [--blocked-skills ] [--web-model ] [--web-backend ] [--vision-model ] [--vision-backend ] [--json]; or ocx claude config set --first-party [--json]` Change Claude Code settings through the runtime owner. @@ -584,6 +585,7 @@ State-changing: yes. | `--fast-mode` | string | on or off. | | `--auto-context` | string | on or off. | | `--compact-window` | string | Positive token count or default to clear. | +| `--context-accounting` | string | 1m (default) or 200k. | | `--inject-agents` | string | on or off. | | `--small-fast-model` | string | Model ID; - clears. | | `--model-map` | string | Comma-separated from=to mappings; - clears. | diff --git a/src/claude/agents-inject.ts b/src/claude/agents-inject.ts index 3ed355f2842..4ed16454f3e 100644 --- a/src/claude/agents-inject.ts +++ b/src/claude/agents-inject.ts @@ -16,7 +16,7 @@ import { join } from "node:path"; import type { OcxConfig } from "../types"; import { renameAtomicFile } from "../lib/windows-atomic-replace"; import { assertNotRealClaudeConfigUnderTest } from "../lib/test-home-guard"; -import { entryParts, SAFE_AGENT_MODEL_ID, withSubagentContextMarker } from "./subagent-model"; +import { accountsAt200k, entryParts, SAFE_AGENT_MODEL_ID, withSubagentContextMarker } from "./subagent-model"; import { stripOneMillionMarker } from "./context-windows"; import { claudeConfigDir } from "./gateway-cache"; import { DEFAULT_SUBAGENT_MODELS } from "../config"; @@ -98,7 +98,7 @@ export function buildClaudeAgentDefs( // Generated defs mark [1m] on the authoritative window only — never the // main-session auto-context predicate (a 372K route marked [1m] would be // accounted at 1M with no compaction safety net in the subagent). - const model = withSubagentContextMarker(alias, windows); + const model = withSubagentContextMarker(alias, windows, accountsAt200k(config)); const bare = alias.toLowerCase(); if (coveredModels.has(bare)) return; coveredModels.add(bare); @@ -131,7 +131,7 @@ export function buildClaudeAgentDefs( // the next launch sync — documented limit. No resolvable default -> no self def. const selfModel = pickerDefaultModel(configDir) ?? (config.claudeCode?.model?.trim() || null); if (selfModel) { - const marked = withSubagentContextMarker(selfModel, windows); + const marked = withSubagentContextMarker(selfModel, windows, accountsAt200k(config)); defs.push({ file: `${OWNED_PREFIX}self.md`, name: `${OWNED_PREFIX}self`, diff --git a/src/claude/context-windows.ts b/src/claude/context-windows.ts index 5c3caeda824..75428abc06e 100644 --- a/src/claude/context-windows.ts +++ b/src/claude/context-windows.ts @@ -45,6 +45,8 @@ export interface AutoContextMode { enabled: boolean; /** Effective CLAUDE_CODE_AUTO_COMPACT_WINDOW value (tokens). */ compactWindow: number; + /** claudeCode.contextAccounting "200k": nothing is marked [1m] automatically, whatever its window. */ + accounting200k?: true; } export const AUTO_CONTEXT_OFF: AutoContextMode = { enabled: false, compactWindow: AUTO_COMPACT_WINDOW_DEFAULT }; @@ -56,10 +58,14 @@ export const AUTO_CONTEXT_OFF: AutoContextMode = { enabled: false, compactWindow */ export const UNPAIRED_AUTO_CONTEXT: AutoContextMode = { enabled: true, compactWindow: AUTO_COMPACT_WINDOW_DEFAULT }; +/** The "200k" opt-in: no widening, no compact-window injection, no automatic marker. */ +export const ACCOUNTING_200K: AutoContextMode = { enabled: false, compactWindow: AUTO_COMPACT_WINDOW_DEFAULT, accounting200k: true }; + interface AutoContextConfigSlice { autoContext?: boolean; autoCompactWindow?: number; maxContextTokens?: number; + contextAccounting?: string; } function inAutoCompactRange(value: number): boolean { @@ -83,6 +89,8 @@ function inAutoCompactRange(value: number): boolean { * to AUTO_COMPACT_WINDOW_DEFAULT (the management API rejects them; this guards hand-edits). */ export function resolveAutoContext(claudeCode: AutoContextConfigSlice | undefined, envOverride?: string): AutoContextMode { + // The 200k opt-in wins over every other lever, a user-exported compact window included. + if (claudeCode?.contextAccounting === "200k") return ACCOUNTING_200K; if (claudeCode?.autoContext === false) return AUTO_CONTEXT_OFF; const maxCtx = claudeCode?.maxContextTokens; if (typeof maxCtx === "number" && Number.isFinite(maxCtx) && maxCtx > 0) return AUTO_CONTEXT_OFF; @@ -125,6 +133,7 @@ export function claudeToolSearchEnv(value: boolean | string | undefined): string */ export function shouldMarkOneMillion(window: number | undefined, auto: AutoContextMode): boolean { if (typeof window !== "number" || window <= 0) return false; + if (auto.accounting200k) return false; if (window >= ONE_MILLION) return true; return auto.enabled && window > AUTO_CONTEXT_FLOOR && window >= auto.compactWindow; } @@ -241,7 +250,7 @@ export interface ClaudeTierModels { * value injected into BOTH haiku variables. */ export function effectiveModelEnv( - claudeCode: { model?: string; smallFastModel?: string; tierModels?: ClaudeTierModels; autoContext?: boolean; autoCompactWindow?: number; maxContextTokens?: number } | undefined, + claudeCode: { model?: string; smallFastModel?: string; tierModels?: ClaudeTierModels; autoContext?: boolean; autoCompactWindow?: number; maxContextTokens?: number; contextAccounting?: string } | undefined, windows: Record, autoOverride?: AutoContextMode, ): Record { diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index 26eb3fbfb2c..79c9fb312e6 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -319,16 +319,30 @@ export function buildDesktop3pRegistry( } /** Generate Claude Desktop 3P model entries from the proxy's available models. */ +export interface Desktop3pModelOptions { + /** + * false under claudeCode.contextAccounting "200k": a long-window model stays selectable at 1M + * (`supports1m`) but no longer defaults to it (`prefer1m` dropped). + */ + preferOneMillion?: boolean; +} + export function generateDesktop3pModels( nativeSlugs: string[], routedModels: Array, profile?: OcxClaudeDesktopProfile, nativeContextCap?: NativeContextLimitsInput, + options: Desktop3pModelOptions = {}, ): Desktop3pModelEntry[] { const { models, registry, realAnthropicIds } = collectDesktop3pModels(nativeSlugs, routedModels, profile, nativeContextCap); desktop3pRegistry = registry; desktop3pRealAnthropicIds = realAnthropicIds; - return models; + return options.preferOneMillion === false ? models.map(({ prefer1m: _prefer1m, ...model }) => model) : models; +} + +/** The Desktop model options a config asks for (one place, so writer and export agree). */ +export function desktop3pModelOptions(claudeCode: { contextAccounting?: string } | undefined): Desktop3pModelOptions { + return { preferOneMillion: claudeCode?.contextAccounting !== "200k" }; } /** Resolve an alias using the most recently generated Desktop model registry. */ @@ -384,6 +398,7 @@ export function generateDesktop3pConfig( mode: Desktop3pConfigMode = "static", profile?: OcxClaudeDesktopProfile, nativeContextCap?: NativeContextLimitsInput, + options: Desktop3pModelOptions = {}, ): object { const base = { inferenceProvider: "gateway", @@ -400,7 +415,7 @@ export function generateDesktop3pConfig( ...base, modelDiscoveryEnabled: mode === "hybrid", inferenceModels: (() => { - const models = generateDesktop3pModels(nativeSlugs, routedModels, profile, nativeContextCap); + const models = generateDesktop3pModels(nativeSlugs, routedModels, profile, nativeContextCap, options); // Fail loud at the write boundary rather than ship a config Desktop rejects: // the output counterpart of the request-path guards. assertDesktop3pModelsValid(models); @@ -723,7 +738,8 @@ export function writeDesktop3pConfig( ); } return writeDesktop3pConfigWithGenerator(() => ( - generateDesktop3pConfig(destination.origin, nativeSlugs, routedModels, gatewayKey, mode, profile, nativeContextCap) + generateDesktop3pConfig(destination.origin, nativeSlugs, routedModels, gatewayKey, mode, profile, nativeContextCap, + desktop3pModelOptions(latest.config.claudeCode)) ), requiredId && refreshOnly ? { requiredId, expectedFingerprint: refreshOnly.appliedFingerprint, admit: refreshOnly.admit } : undefined); }), lifecycleLockDeps); } catch { return { written: false, path: resolveDesktop3pConfigLibraryPath(), reason: "desktop_lifecycle_busy_or_unsafe" }; } diff --git a/src/claude/intercept/picker-models.ts b/src/claude/intercept/picker-models.ts index 65d3ca93e13..17ae9c3d9f9 100644 --- a/src/claude/intercept/picker-models.ts +++ b/src/claude/intercept/picker-models.ts @@ -7,7 +7,7 @@ import { dirname } from "node:path"; import { nativeOpenAiContextWindow, type NativeContextLimitsInput } from "../../codex/catalog"; import type { OcxClaudeDesktopProfile } from "../../types"; import { aliasForRoute, claudeCodeNativeAlias } from "../alias"; -import { AUTO_CONTEXT_OFF, stripOneMillionMarker, UNPAIRED_AUTO_CONTEXT, withOneMillionMarker } from "../context-windows"; +import { AUTO_CONTEXT_OFF, stripOneMillionMarker, UNPAIRED_AUTO_CONTEXT, withOneMillionMarker, type AutoContextMode } from "../context-windows"; import { isAnthropicClaudeRoute } from "../long-context"; import { activeDesktop3pAlias, displayModelId, resolveDesktop3pAlias, type Desktop3pRoutedModel } from "../desktop-3p"; import { reconcileDesktopProfile, renderDesktopProfile, type DesktopProfileModel } from "../desktop-profile"; @@ -18,6 +18,8 @@ export interface PickerRouteInput { routedModels: Desktop3pRoutedModel[]; profile?: OcxClaudeDesktopProfile; nativeContextCap?: NativeContextLimitsInput; + /** resolveAutoContext(config.claudeCode); only its contextAccounting "200k" opt-in matters here. */ + auto?: AutoContextMode; } /** Candidate routes in the gateway profile's order and with its labels. */ @@ -52,8 +54,8 @@ function splitRoute(route: string): { provider: string; id: string } { * compact window) is marked on the strength of the `prompt is too long` recovery instead * (devlog/_plan/261009_claude_1m_default/020). Real Anthropic models need a genuine 1M. */ -function pickerSelector(alias: string, contextWindow: number | undefined, provider: string, id: string): string { - const mode = isAnthropicClaudeRoute(provider, id) ? AUTO_CONTEXT_OFF : UNPAIRED_AUTO_CONTEXT; +function pickerSelector(alias: string, contextWindow: number | undefined, provider: string, id: string, auto?: AutoContextMode): string { + const mode = auto?.accounting200k ? auto : isAnthropicClaudeRoute(provider, id) ? AUTO_CONTEXT_OFF : UNPAIRED_AUTO_CONTEXT; return withOneMillionMarker(alias, contextWindow === undefined ? {} : { [alias]: contextWindow }, mode)!; } @@ -67,7 +69,7 @@ export function buildPickerModels(input: PickerRouteInput): PickerModelEntry[] { const alias = provider === "native" ? claudeCodeNativeAlias(id) : aliasForRoute(provider, id); if (!alias || seen.has(alias)) continue; seen.add(alias); - out.push({ id: pickerSelector(alias, model.contextWindow, provider, id), name: model.label, + out.push({ id: pickerSelector(alias, model.contextWindow, provider, id, input.auto), name: model.label, ...(model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }) }); } return out; @@ -87,7 +89,7 @@ export function buildCliPickerModels(input: PickerRouteInput): PickerModelEntry[ const alias = activeDesktop3pAlias(provider, id); if (seen.has(alias) || resolveDesktop3pAlias(alias) !== model.route) continue; seen.add(alias); - out.push({ id: pickerSelector(alias, model.contextWindow, provider, id), name: model.label, + out.push({ id: pickerSelector(alias, model.contextWindow, provider, id, input.auto), name: model.label, description: `opencodex · ${model.route}`, route: model.route, ...(model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }) }); } diff --git a/src/claude/subagent-model.ts b/src/claude/subagent-model.ts index f5efe44f4d4..e7c04c8e5ed 100644 --- a/src/claude/subagent-model.ts +++ b/src/claude/subagent-model.ts @@ -38,12 +38,17 @@ function subagentSelectorMode(bare: string): AutoContextMode { return anthropic ? AUTO_CONTEXT_OFF : UNPAIRED_AUTO_CONTEXT; } -export function withSubagentContextMarker(selector: string, windows: Record): string { +/** + * `accounting200k` (claudeCode.contextAccounting "200k") stops automatic marking only: an unmarked + * selector stays bare, while an explicit [1m] still follows its selector's safety rule. + */ +export function withSubagentContextMarker(selector: string, windows: Record, accounting200k = false): string { const bare = stripOneMillionMarker(selector); const wasMarked = selector !== bare; const canonicalExact = wasMarked ? `${bare}[1m]` : selector; const authoritativeWindow = windows[selector] ?? windows[canonicalExact] ?? windows[bare]; if (typeof authoritativeWindow === "number" && authoritativeWindow > 0) { + if (accounting200k && !wasMarked) return bare; const mode = subagentSelectorMode(bare); return shouldMarkOneMillion(authoritativeWindow, mode) ? (withOneMillionMarker(selector, windows, mode) ?? selector) @@ -75,6 +80,11 @@ function selectorIdentity(selector: string): string { return fullSelectorIdentity(stripOneMillionMarker(selector)); } +/** Whether the config opted into 200k accounting (shared by roster, self and force markers). */ +export function accountsAt200k(config: OcxConfig): boolean { + return config.claudeCode?.contextAccounting === "200k"; +} + /** Resolve only currently exposed entries; retained roster rows are not exposure proof. */ export function resolveSubagentForceModel(config: OcxConfig, windows: Record, available: SubagentForceExposure): string | null { const entry = config.claudeCode?.subagentModelForce; @@ -97,7 +107,7 @@ export function resolveSubagentForceModel(config: OcxConfig, windows: Record] [--auth-mode ] [--system-env ] [--fast-mode ] [--auto-context ] [--compact-window ] [--inject-agents ] [--small-fast-model ] [--model-map ] [--blocked-skills ] [--web-model ] [--web-backend ] [--vision-model ] [--vision-backend ] [--json]; or ocx claude config set --first-party [--json]", + usage: "ocx claude config status [--json]; ocx claude config set [--enabled ] [--auth-mode ] [--system-env ] [--fast-mode ] [--auto-context ] [--compact-window ] [--context-accounting <1m|200k>] [--inject-agents ] [--small-fast-model ] [--model-map ] [--blocked-skills ] [--web-model ] [--web-backend ] [--vision-model ] [--vision-backend ] [--json]; or ocx claude config set --first-party [--json]", }, { command: ["claude","intercept","start"], diff --git a/src/cli/capabilities-integrations.ts b/src/cli/capabilities-integrations.ts index ba27f091265..2626f0c7301 100644 --- a/src/cli/capabilities-integrations.ts +++ b/src/cli/capabilities-integrations.ts @@ -139,10 +139,10 @@ export const INTEGRATION_CAPABILITIES: readonly Capability[] = [ }, { command: ["claude", "config", "set"], - usage: "ocx claude config set [--enabled ] [--auth-mode ] [--system-env ] [--fast-mode ] [--auto-context ] [--compact-window ] [--inject-agents ] [--small-fast-model ] [--model-map ] [--blocked-skills ] [--web-model ] [--web-backend ] [--vision-model ] [--vision-backend ] [--json]; or ocx claude config set --first-party [--json]", + usage: "ocx claude config set [--enabled ] [--auth-mode ] [--system-env ] [--fast-mode ] [--auto-context ] [--compact-window ] [--context-accounting <1m|200k>] [--inject-agents ] [--small-fast-model ] [--model-map ] [--blocked-skills ] [--web-model ] [--web-backend ] [--vision-model ] [--vision-backend ] [--json]; or ocx claude config set --first-party [--json]", summary: "Change Claude Code settings through the runtime owner.", routes: [{"method": "PUT", "path": "/api/claude-code"}], - flags: [{"name": "--enabled", "value": "string", "summary": "on or off."}, {"name": "--auth-mode", "value": "string", "summary": "auto, proxy or subscription."}, {"name": "--system-env", "value": "string", "summary": "on or off."}, {"name": "--fast-mode", "value": "string", "summary": "on or off."}, {"name": "--auto-context", "value": "string", "summary": "on or off."}, {"name": "--compact-window", "value": "string", "summary": "Positive token count or default to clear."}, {"name": "--inject-agents", "value": "string", "summary": "on or off."}, {"name": "--small-fast-model", "value": "string", "summary": "Model ID; - clears."}, {"name": "--model-map", "value": "string", "summary": "Comma-separated from=to mappings; - clears."}, {"name": "--blocked-skills", "value": "string", "summary": "Comma-separated names; - clears."}, {"name": "--web-model", "value": "string", "summary": "Model ID; - clears."}, {"name": "--web-backend", "value": "string", "summary": "openai, anthropic, xai, gemini, exa or -."}, {"name": "--vision-model", "value": "string", "summary": "Model ID; - clears."}, {"name": "--vision-backend", "value": "string", "summary": "openai, anthropic or -."}, {"name": "--first-party", "value": "string", "summary": "on or off; must be the only setting."}, {"name": "--json", "value": "boolean", "summary": "Emit the result as JSON."}], + flags: [{"name": "--enabled", "value": "string", "summary": "on or off."}, {"name": "--auth-mode", "value": "string", "summary": "auto, proxy or subscription."}, {"name": "--system-env", "value": "string", "summary": "on or off."}, {"name": "--fast-mode", "value": "string", "summary": "on or off."}, {"name": "--auto-context", "value": "string", "summary": "on or off."}, {"name": "--compact-window", "value": "string", "summary": "Positive token count or default to clear."}, {"name": "--context-accounting", "value": "string", "summary": "1m (default) or 200k."}, {"name": "--inject-agents", "value": "string", "summary": "on or off."}, {"name": "--small-fast-model", "value": "string", "summary": "Model ID; - clears."}, {"name": "--model-map", "value": "string", "summary": "Comma-separated from=to mappings; - clears."}, {"name": "--blocked-skills", "value": "string", "summary": "Comma-separated names; - clears."}, {"name": "--web-model", "value": "string", "summary": "Model ID; - clears."}, {"name": "--web-backend", "value": "string", "summary": "openai, anthropic, xai, gemini, exa or -."}, {"name": "--vision-model", "value": "string", "summary": "Model ID; - clears."}, {"name": "--vision-backend", "value": "string", "summary": "openai, anthropic or -."}, {"name": "--first-party", "value": "string", "summary": "on or off; must be the only setting."}, {"name": "--json", "value": "boolean", "summary": "Emit the result as JSON."}], mutates: true, json: "payload", details: ["At least one setting is required. --first-party must be set alone and writes Claude settings immediately.", "Clear both helper model and backend with - to restore inheritance. Preserve shared_proxy_retained warnings."], diff --git a/src/cli/integrations.ts b/src/cli/integrations.ts index c553068e0f0..5c880bbb478 100644 --- a/src/cli/integrations.ts +++ b/src/cli/integrations.ts @@ -20,7 +20,7 @@ const CLAUDE_USAGE = `Usage: ocx claude config [status] [--json] ocx claude config set [--enabled ] [--auth-mode ] [--system-env ] [--fast-mode ] [--auto-context ] - [--compact-window ] [--inject-agents ] + [--compact-window ] [--context-accounting <1m|200k>] [--inject-agents ] [--small-fast-model ] [--model-map ] [--blocked-skills ] [--web-model ] [--web-backend ] [--vision-model ] [--vision-backend ] [--json] @@ -76,6 +76,7 @@ export async function handleClaudeConfigCommand(argv: string[], deps: RuntimeApi const fastMode = takeBooleanOption(args, "--fast-mode"); const autoContext = takeBooleanOption(args, "--auto-context"); const compact = takeOption(args, "--compact-window"); + const accounting = takeOption(args, "--context-accounting"); const injectAgents = takeBooleanOption(args, "--inject-agents"); const smallFastModel = takeOption(args, "--small-fast-model"); const modelMap = takeOption(args, "--model-map"); @@ -98,6 +99,11 @@ export async function handleClaudeConfigCommand(argv: string[], deps: RuntimeApi body.autoCompactWindow = value; } } + if (accounting !== undefined) { + const value = accounting.toLowerCase(); + if (value !== "1m" && value !== "200k") throw new CliUsageError("--context-accounting must be 1m or 200k", CLAUDE_USAGE); + body.contextAccounting = value; + } if (injectAgents !== undefined) body.injectAgents = injectAgents; if (smallFastModel !== undefined) body.smallFastModel = smallFastModel === "-" ? "" : smallFastModel; if (modelMap !== undefined) body.modelMap = parseMap(modelMap); diff --git a/src/server/index/claude-intercept-lifecycle.ts b/src/server/index/claude-intercept-lifecycle.ts index fc9cea47ee5..b67ad17ed48 100644 --- a/src/server/index/claude-intercept-lifecycle.ts +++ b/src/server/index/claude-intercept-lifecycle.ts @@ -41,10 +41,11 @@ export function buildInterceptDesiredClients(config: OcxConfig, observed: Claude * imports keep the catalog and discovery off the synchronous startup path. */ export async function loadPickerRoutesFromCatalog(): Promise { - const [{ loadConfig }, { fetchAllModels }, catalog] = await Promise.all([ + const [{ loadConfig }, { fetchAllModels }, catalog, { resolveAutoContext }] = await Promise.all([ import("../../config"), import("../management-api"), import("../../codex/catalog"), + import("../../claude/context-windows"), ]); const config = loadConfig(); const models = await fetchAllModels(config); @@ -54,6 +55,7 @@ export async function loadPickerRoutesFromCatalog(): Promise { .map(model => ({ provider: model.provider, id: model.id, contextWindow: model.contextWindow })), ...(config.claudeCode?.desktopProfile ? { profile: config.claudeCode.desktopProfile } : {}), nativeContextCap: catalog.nativeContextLimits(config), + auto: resolveAutoContext(config.claudeCode), }; } diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index 78705b5782a..748b27d5a62 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -119,6 +119,7 @@ import { handleChatCompletions } from "../chat-completions"; import { anthropicErrorResponse } from "../../claude/outbound"; import { buildDesktop3pRegistry, + desktop3pModelOptions, generateDesktop3pModels, } from "../../claude/desktop-3p"; import { buildDesktopDiscoveryInputs } from "../../claude/desktop-discovery-inputs"; @@ -1025,6 +1026,7 @@ export function createServeOptions(ctx: ServeOptionsContext) { const models = config.claudeCode?.enabled === false ? [] : generateDesktop3pModels( desktopInputs.nativeSlugs, desktopInputs.routedModels, config.claudeCode?.desktopProfile, desktopInputs.nativeContextCap, + desktop3pModelOptions(config.claudeCode), ); const response = jsonResponse({ version: 1, models }, 200, req, policy); response.headers.set("Cache-Control", "no-store"); diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index d436565d8a2..2f23ba14a11 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -1472,6 +1472,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise alwaysEnableEffort: config.claudeCode?.alwaysEnableEffort === true, autoContext: config.claudeCode?.autoContext !== false, autoCompactWindow: config.claudeCode?.autoCompactWindow ?? null, + contextAccounting: config.claudeCode?.contextAccounting === "200k" ? "200k" : "1m", blockedSkills: config.claudeCode?.blockedSkills ?? null, injectAgents: config.claudeCode?.injectAgents !== false, sidecarPools: { @@ -1508,7 +1509,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise return prototype === Object.prototype || prototype === null; }; if (!isPlainObject(parsedBody)) return jsonResponse({ error: "body must be an object" }, 400); - const body = parsedBody as { enabled?: unknown; cliFirstParty?: unknown; authMode?: unknown; model?: unknown; smallFastModel?: unknown; modelMap?: unknown; classifierModel?: unknown; classifierFallbacks?: unknown; systemEnv?: unknown; fastMode?: unknown; maxContextTokens?: unknown; alwaysEnableEffort?: unknown; tierModels?: unknown; autoContext?: unknown; autoCompactWindow?: unknown; blockedSkills?: unknown; injectAgents?: unknown; webSearchSidecar?: unknown; visionSidecar?: unknown }; + const body = parsedBody as { enabled?: unknown; cliFirstParty?: unknown; authMode?: unknown; model?: unknown; smallFastModel?: unknown; modelMap?: unknown; classifierModel?: unknown; classifierFallbacks?: unknown; systemEnv?: unknown; fastMode?: unknown; maxContextTokens?: unknown; alwaysEnableEffort?: unknown; tierModels?: unknown; autoContext?: unknown; autoCompactWindow?: unknown; contextAccounting?: unknown; blockedSkills?: unknown; injectAgents?: unknown; webSearchSidecar?: unknown; visionSidecar?: unknown }; if (body.cliFirstParty !== undefined && typeof body.cliFirstParty !== "boolean") return jsonResponse({ error: "cliFirstParty must be a boolean" }, 400); if (body.cliFirstParty !== undefined && Object.keys(body).length !== 1) @@ -1778,6 +1779,15 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise if (body.autoContext) delete next.autoContext; else next.autoContext = false; } + if (body.contextAccounting !== undefined) { + // Sparse like autoContext (devlog/_plan/261009_claude_1m_default/030): "1m" is the default + // and drops the key; "200k" is the only stored value. + if (body.contextAccounting !== "1m" && body.contextAccounting !== "200k") { + return jsonResponse({ error: "contextAccounting must be \"1m\" or \"200k\"" }, 400); + } + if (body.contextAccounting === "200k") next.contextAccounting = "200k"; + else delete next.contextAccounting; + } if (body.injectAgents !== undefined) { // Default-on boolean (devlog 260712 070): true = drop the key, false = store. if (typeof body.injectAgents !== "boolean") return jsonResponse({ error: "injectAgents must be a boolean" }, 400); @@ -1892,7 +1902,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise // levers feed the same injection, so a changed or cleared slot must not linger in // launchd until the next restart. const systemEnvInputs = ["systemEnv", "authMode", "model", "smallFastModel", "tierModels", - "maxContextTokens", "alwaysEnableEffort", "autoContext", "autoCompactWindow"] as const; + "maxContextTokens", "alwaysEnableEffort", "autoContext", "autoCompactWindow", "contextAccounting"] as const; if (systemEnvInputs.some(field => body[field] !== undefined)) { try { await applySystemEnvToggle(config, config.port); diff --git a/src/types/config.ts b/src/types/config.ts index c5afa71c8b4..39be8ffeb9b 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -174,6 +174,15 @@ export interface OcxClaudeCodeConfig { autoContext?: boolean; /** Compact-window tokens for auto-context. Default 829_800 (AUTO_COMPACT_WINDOW_DEFAULT). */ autoCompactWindow?: number; + /** + * Context accounting on Claude surfaces (devlog/_plan/261009_claude_1m_default/030). Absent + * (the default) = "1m": long-window models are offered at 1M wherever opencodex picks the + * default (launch env slots, Desktop pickers, Desktop 3P prefer1m, generated subagents). + * "200k" is the opt-in: nothing is marked [1m] automatically and no compact window is injected; + * a selector the user marks [1m] keeps it, and discovery and Desktop 3P keep offering 1M as a + * choice. The only stored value; "1m" deletes the key. + */ + contextAccounting?: "200k"; /** * Local CONNECT proxy + TLS listener that intercepts Claude Code's own `api.anthropic.com` * traffic without any `ANTHROPIC_BASE_URL` rewrite (src/claude/intercept). Claude Code reaches diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 2953bf88032..75c69a9dcb3 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -288,7 +288,7 @@ intent and cannot repair the URL a running Desktop already pinned. The `claude.a certificate, streams every body and upgrade unchanged, and rewrites only the bootstrap response's local Code picker surfaces, `ccd` (what the Desktop Code tab reads) and its `code` fallback, never the remote `ccr` (`picker-bootstrap.ts`), failing open to the original bytes; the model list -comes from a persisted snapshot (`picker-models.ts`), so a bootstrap never waits on discovery. Picker aliases carry `[1m]` for long windows (`src/claude/long-context.ts`: at least 1M, or at least the 829,800 default compact window), and Claude models on either Anthropic pool only at a genuine 1M. The picker cannot guarantee the Desktop runner's compaction environment, so an 872k route that outgrows its window relies on the `prompt is too long` envelope, which Claude Code compacts on; Desktop 3P `supports1m`/`prefer1m`, discovery `· 1M` rows and generated subagent markers use the same rule. A +comes from a persisted snapshot (`picker-models.ts`), so a bootstrap never waits on discovery. Picker aliases carry `[1m]` for long windows (`src/claude/long-context.ts`: at least 1M, or at least the 829,800 default compact window), and Claude models on either Anthropic pool only at a genuine 1M. The picker cannot guarantee the Desktop runner's compaction environment, so an 872k route that outgrows its window relies on the `prompt is too long` envelope, which Claude Code compacts on; Desktop 3P `supports1m`/`prefer1m`, discovery `· 1M` rows and generated subagent markers use the same rule. `claudeCode.contextAccounting` (`src/types/config.ts`) is absent for the 1m default; the only stored value is `"200k"`, which stops automatic `[1m]` marking and `CLAUDE_CODE_AUTO_COMPACT_WINDOW` injection, drops Desktop 3P `prefer1m` while keeping `supports1m`, and still lists genuine 1M discovery rows. An explicit `[1m]` selector stays. `ocx claude config set --context-accounting` and `PUT /api/claude-code` write it (`"1m"` deletes the key). A CONNECT to claude.ai that arrives before the first refresh waits at most 3 s, then goes blind. A picker proxy bind failure only disables picker mode; a picker construction or start failure closes every socket the start had bound before rethrowing. Ordinary session cookies within the header diff --git a/structure/config.md b/structure/config.md index 378811247a3..86a27b4f835 100644 --- a/structure/config.md +++ b/structure/config.md @@ -120,7 +120,7 @@ Its client annotation compares only the bounded service-token fingerprint with t All config publication continues through the existing required ACL-hardened writers above. `claudeCode.desktopProfile` follows the same preserve-the-rest rule. JSON `null` (or any non-string) `appliedFingerprint` / `appliedAt` is treated as unset. A profile that is still invalid after that is dropped as a whole — `src/config/salvage.ts` already does this for independent `routingProfiles` / `combos` entries — so one bad Desktop marker cannot replace the operator's providers with `getDefaultConfig()`. A `claudeCode` value that is not an object still fails the document, because there is no safe subtree to keep. -`claudeCode.cliFirstParty` is an optional boolean in `src/types/config.ts`. The schema passes it through; the load normalizer (`src/config/load-degrade.ts`) drops a non-boolean hand edit, every reader treats only `true` as on, and `PUT /api/claude-code` accepts only a boolean. Absence means off. It is independent of `claudeCode.desktopMode`; changing CLI first-party pins an absent Desktop mode from the observation that will apply after the flag flips — an opt-out observes with `cliFirstParty` already cleared, so a shared env that predates the marker stays attributed to Desktop instead of being pinned `gateway` and removed from under it, and an owned env cannot be mistaken for CLI-only intent. The flag is written by a standalone `PUT /api/claude-code { cliFirstParty }`, including `ocx claude config set --first-party`; the mutation pins an absent `desktopMode` at the same time. The shared settings proxy status follows the ordered classifier in `src/claude/first-party-settings.ts`: unreadable settings are `unknown`; absent or unrecognized proxy URLs are `none`; a token-bearing opencodex URL beside a foreign CA is `foreign`, while a tokenless loopback URL beside that CA is `local` with unconfirmed ownership. An attributed proxy with no bound listener is `stopped`; a usable applied pair on a bound listener is `disabled` when Claude routing is ineligible and `live` when eligible; remaining mismatches are `broken` regardless of eligibility. Inspection never mints a token. A separate `ocx ensure` may write a config-derived port while this server remains bound elsewhere; status is then `broken` until the server restarts or ensure runs after restart. +`claudeCode.cliFirstParty` is an optional boolean in `src/types/config.ts`. The schema passes it through; the load normalizer (`src/config/load-degrade.ts`) drops a non-boolean hand edit, every reader treats only `true` as on, and `PUT /api/claude-code` accepts only a boolean. Absence means off. It is independent of `claudeCode.desktopMode`; changing CLI first-party pins an absent Desktop mode from the observation that will apply after the flag flips — an opt-out observes with `cliFirstParty` already cleared, so a shared env that predates the marker stays attributed to Desktop instead of being pinned `gateway` and removed from under it, and an owned env cannot be mistaken for CLI-only intent. The flag is written by a standalone `PUT /api/claude-code { cliFirstParty }`, including `ocx claude config set --first-party`; the mutation pins an absent `desktopMode` at the same time. The shared settings proxy status follows the ordered classifier in `src/claude/first-party-settings.ts`: unreadable settings are `unknown`; absent or unrecognized proxy URLs are `none`; a token-bearing opencodex URL beside a foreign CA is `foreign`, while a tokenless loopback URL beside that CA is `local` with unconfirmed ownership. An attributed proxy with no bound listener is `stopped`; a usable applied pair on a bound listener is `disabled` when Claude routing is ineligible and `live` when eligible; remaining mismatches are `broken` regardless of eligibility. Inspection never mints a token. A separate `ocx ensure` may write a config-derived port while this server remains bound elsewhere; status is then `broken` until the server restarts or ensure runs after restart. `claudeCode.contextAccounting` in `src/types/config.ts` stores only `"200k"`; absence is the 1m default and `"1m"` deletes the key. `ocx claude config set --context-accounting` writes it. The surface contract is [Claude Desktop picker mode](clients/claude-desktop.md#picker-mode-the-desktop-egress-proxy). `showCodexCredits` is an optional, display-only boolean in `src/types/config.ts`, default off. The load schema degrades malformed values to false; `src/config/diagnostics.ts` rejects malformed write candidates. `GET /api/settings`, its successful PUT response, and the safe `/api/config` DTO always project a boolean. PUT accepts a partial boolean update, persists it, and restores both the previous value and key presence if saving fails. The switch gates account DTO exposure without changing probes or routing; [credits identity binding](providers/openai-accounts.md#display-only-codex-credits) owns the observation contract. The former `showCodexSparkQuota` key is inert passthrough data when loading an old config. It is absent from the typed settings contract and cannot re-enable Spark quota through the management API. Retirement does not migrate user-selected model ids or erase usage history. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 46690e75ad2..ab8e8032e3b 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -306,7 +306,7 @@ effective window when absent, and offers a read-only Reload after an unknown sav confirmed save whose list reload failed; a failed or retryable Codex catalog refresh is a warning after the save, and a non-retryable skip (no managed catalog) is a clean save. Close returns focus to the opening row control. -`GET /api/claude-code` reports `cliFirstParty`, `desktopFirstParty`, `cliFirstPartyApplied`, `interceptEligible`, `interceptRunning`, and the eight-value `sharedProxy: FirstPartyProxyStatus` from observed settings and the bound listener. `interceptEligible = claudeInterceptEnabled(config)` uses the same GET snapshot as `sharedProxy` and `interceptRunning`; the latter remains bound listener present AND eligible. The ordered classifier gives unreadable → `unknown`, absent or non-loopback URL → `none`, foreign CA with an opencodex token → `foreign`, foreign CA with a tokenless loopback URL → `local`, no bound listener → `stopped`, ineligible applied settings at the bound port → `disabled`, other ineligible or stale/mismatched settings → `broken`, and eligible applied settings at the bound port → `live`. `cliFirstPartyApplied` requires CLI intent and `live`. `PUT /api/claude-code` accepts standalone `cliFirstParty`; CLI-on repeats eligibility and port checks inside the locked persisted mutation, reconciles, and conditionally rolls back its own fields on failure. CLI-off deletes intent, pins an absent `desktopMode` from the post-clear observation, and retains an env Desktop still desires; when that retained env's ownership was ambiguous (an owned shared proxy suppressed by the flag), the success response warns `shared_proxy_retained` so the operator can pin `gateway` explicitly to release it. A successful nothing-desired reconcile returns `settings_residual` for every status except `none`, including `local`; unreadable cleanup returns the coded 500. Native Claude routing enable persists its setting and reports `interceptReason` when local start authority is absent; disable and no-op paths never attempt a start. `enabled:false` alone leaves the env untouched, and a mixed body returns 400 before save. GUI normalization maps only missing `sharedProxy:undefined` to `none`, invalid statuses including `null` to `unknown`; `interceptEligible:undefined` from an older cache maps to `true`, while present values use `=== true`. The source coverage map checks all eight statuses. Notice order is unknown, foreign, local, residual when undesired, disabled, routingOff for stopped/broken with ineligible routing, stopped, broken, notApplied, shared, null. Unknown copy states uncertainty, local copy names the unconfirmed 127.0.0.1 proxy and manual HTTPS_PROXY removal, disabled copy retains the first-party-off remedy, foreign copy directs manual CA/proxy repair, routingOff says to restore Claude routing or turn first-party off, stopped says to start opencodex, and eligible broken advises `ocx ensure` or restart. +`GET /api/claude-code` reports `cliFirstParty`, `desktopFirstParty`, `cliFirstPartyApplied`, `interceptEligible`, `interceptRunning`, and the eight-value `sharedProxy: FirstPartyProxyStatus` from observed settings and the bound listener. `interceptEligible = claudeInterceptEnabled(config)` uses the same GET snapshot as `sharedProxy` and `interceptRunning`; the latter remains bound listener present AND eligible. The ordered classifier gives unreadable → `unknown`, absent or non-loopback URL → `none`, foreign CA with an opencodex token → `foreign`, foreign CA with a tokenless loopback URL → `local`, no bound listener → `stopped`, ineligible applied settings at the bound port → `disabled`, other ineligible or stale/mismatched settings → `broken`, and eligible applied settings at the bound port → `live`. `cliFirstPartyApplied` requires CLI intent and `live`. `PUT /api/claude-code` accepts standalone `cliFirstParty`; CLI-on repeats eligibility and port checks inside the locked persisted mutation, reconciles, and conditionally rolls back its own fields on failure. CLI-off deletes intent, pins an absent `desktopMode` from the post-clear observation, and retains an env Desktop still desires; when that retained env's ownership was ambiguous (an owned shared proxy suppressed by the flag), the success response warns `shared_proxy_retained` so the operator can pin `gateway` explicitly to release it. A successful nothing-desired reconcile returns `settings_residual` for every status except `none`, including `local`; unreadable cleanup returns the coded 500. Native Claude routing enable persists its setting and reports `interceptReason` when local start authority is absent; disable and no-op paths never attempt a start. `enabled:false` alone leaves the env untouched, and a mixed body returns 400 before save. GUI normalization maps only missing `sharedProxy:undefined` to `none`, invalid statuses including `null` to `unknown`; `interceptEligible:undefined` from an older cache maps to `true`, while present values use `=== true`. The source coverage map checks all eight statuses. Notice order is unknown, foreign, local, residual when undesired, disabled, routingOff for stopped/broken with ineligible routing, stopped, broken, notApplied, shared, null. Unknown copy states uncertainty, local copy names the unconfirmed 127.0.0.1 proxy and manual HTTPS_PROXY removal, disabled copy retains the first-party-off remedy, foreign copy directs manual CA/proxy repair, routingOff says to restore Claude routing or turn first-party off, stopped says to start opencodex, and eligible broken advises `ocx ensure` or restart. `GET /api/claude-code` reports `contextAccounting` as `"200k"` or `"1m"`; `PUT` accepts only those two, stores `"200k"`, deletes the key for `"1m"`, and reconciles system env with the other levers. The marking contract is [Claude Desktop picker mode](clients/claude-desktop.md#picker-mode-the-desktop-egress-proxy). | File-integration plans | `src/server/management/integration-routes.ts` and `aside-profile-routes.ts` — `POST /api/client-integrations/preview`, `POST /api/client-integrations/restore/preview`, and `POST /api/client-integrations/aside/profiles/{profileId}/preview`. Management-authenticated, declared non-mutating, and they write nothing: no snapshot, no lock, no maintenance, no recovery. They answer `409 integration_preview_unavailable` rather than gathering a model roster, because discovery refreshes credentials and writes the provider cache. Responses carry only declared managed schema paths, closed change kinds and an opaque fingerprint; no value, filesystem location or selected member identity appears. Mutation routes accept `operation` and `planFingerprint` together or not at all, reject a half-bound request and an operation that disagrees with the change, and answer `409 integration_preview_stale` with a freshly computed plan. Binding is an optimistic token, never authorization. [The integration contract](clients/integrations.md) owns the ordering. | | Grok reset coupons | `src/server/management/grok-coupon-routes.ts` — `GET /api/grok/reset-coupons`, `POST /api/grok/reset-coupons/consume`. The dashboard owner is `gui/src/hooks/useGrokResetCoupons.ts` with `gui/src/components/provider-workspace/GrokResetCoupons.tsx`, wired into the xAI OAuth rows of `ProviderAuthPanel`. Redemption truth is the settled ledger `code`, not the HTTP status: a replayed failure returns 200 with `replayed: true`. See [`providers/xai-grok.md`](providers/xai-grok.md). | diff --git a/structure/subagents.md b/structure/subagents.md index 906ce2149e4..71692ad7228 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -546,6 +546,6 @@ Automatic account exhaustion and recovery use the [spendable Codex credit eviden ### Forced Claude Code subagent model -`src/claude/subagent-model.ts` shares the roster alias and authoritative context-marker resolver with routed launch force. `claudeCode.subagentModelForce` is opt-in and default-off. Force availability excludes retained unavailable roster entries. Caller-added `[1m]` suffixes require a finite authoritative long window (`src/claude/long-context.ts`: 1M, or at least the 829,800 default compact window; a genuine 1M for Claude models on either Anthropic pool or a bare `claude-*` id) or an exact advertised marked identity; generated markers follow the same rule; exact upstream ids remain literal, while legacy roster marker handling is unchanged. The generated-agent legacy directive cannot replace a wire selector matching the configured forced alias. `src/claude/subagent-force-status.ts` performs bounded, read-only server-local version and settings-key inspection; unknown is not supported. +`src/claude/subagent-model.ts` shares the roster alias and authoritative context-marker resolver with routed launch force. `claudeCode.subagentModelForce` is opt-in and default-off. Force availability excludes retained unavailable roster entries. Caller-added `[1m]` suffixes require a finite authoritative long window (`src/claude/long-context.ts`: 1M, or at least the 829,800 default compact window; a genuine 1M for Claude models on either Anthropic pool or a bare `claude-*` id) or an exact advertised marked identity; generated markers follow the same rule; exact upstream ids remain literal, while legacy roster marker handling is unchanged. `claudeCode.contextAccounting` `"200k"` stops automatic marking on generated and forced selectors; an explicit `[1m]` still follows the window rule above ([Claude Desktop picker mode](clients/claude-desktop.md#picker-mode-the-desktop-egress-proxy)). The generated-agent legacy directive cannot replace a wire selector matching the configured forced alias. `src/claude/subagent-force-status.ts` performs bounded, read-only server-local version and settings-key inspection; unknown is not supported. Explicit gateway selectors outrank generated-agent `ocx-route` fallback independently of saved force state. `src/claude/inbound-model-options.ts` shares this precedence across Messages and count-tokens; bare Claude fallback and requests without directives retain their existing behavior. Native Claude force targets use reversible native aliases, restored before existing credential/model-map checks. Connected launch exposure comes from fresh authenticated gateway rows, not cached context-window keys; acquisition is independent of cache-write success. diff --git a/tests/claude-integration/claude-context-accounting.test.ts b/tests/claude-integration/claude-context-accounting.test.ts new file mode 100644 index 00000000000..1bec2b42bf7 --- /dev/null +++ b/tests/claude-integration/claude-context-accounting.test.ts @@ -0,0 +1,115 @@ +/** + * `claudeCode.contextAccounting: "200k"` opt-in (devlog/_plan/261009_claude_1m_default/030): no + * automatic [1m] anywhere, no compact-window injection, explicit 1M choices kept. + */ +import { describe, expect, test } from "bun:test"; +import { + ACCOUNTING_200K, + AUTO_COMPACT_WINDOW_DEFAULT, + effectiveModelEnv, + resolveAutoContext, +} from "../../src/claude/context-windows"; +import { buildPickerModels } from "../../src/claude/intercept/picker-models"; +import { buildAnthropicModelInfos } from "../../src/claude/model-info"; +import { desktop3pModelOptions, generateDesktop3pModels } from "../../src/claude/desktop-3p"; +import { withSubagentContextMarker } from "../../src/claude/subagent-model"; +import { buildClaudeEnv as buildClaudeEnvWithIo } from "../../src/cli/claude"; +import { handleClaudeConfigCommand } from "../../src/cli/integrations"; +import type { OcxConfig } from "../../src/types"; + +const ASTRA = "native/gpt-6-astra"; +const LONG_WINDOWS = { [ASTRA]: 872_000, "mock/huge": 1_000_000 }; + +describe("resolveAutoContext", () => { + test("200k wins over every other lever, a user-exported compact window included", () => { + expect(resolveAutoContext({ contextAccounting: "200k" })).toBe(ACCOUNTING_200K); + expect(resolveAutoContext({ contextAccounting: "200k", autoCompactWindow: 400_000 }, "500000")).toBe(ACCOUNTING_200K); + expect(ACCOUNTING_200K.enabled).toBe(false); + }); + + test("1m and unknown values read as the default", () => { + for (const contextAccounting of ["1m", "bogus", undefined]) { + expect(resolveAutoContext({ contextAccounting })).toEqual({ enabled: true, compactWindow: AUTO_COMPACT_WINDOW_DEFAULT }); + } + }); +}); + +describe("launch env", () => { + test("model slots are not marked under 200k, are by default, and a typed [1m] survives", () => { + expect(effectiveModelEnv({ model: ASTRA }, LONG_WINDOWS).ANTHROPIC_MODEL).toBe(`${ASTRA}[1m]`); + expect(effectiveModelEnv({ model: ASTRA, contextAccounting: "200k" }, LONG_WINDOWS).ANTHROPIC_MODEL).toBe(ASTRA); + expect(effectiveModelEnv({ model: "mock/huge", contextAccounting: "200k" }, LONG_WINDOWS).ANTHROPIC_MODEL).toBe("mock/huge"); + expect(effectiveModelEnv({ model: `${ASTRA}[1m]`, contextAccounting: "200k" }, LONG_WINDOWS).ANTHROPIC_MODEL).toBe(`${ASTRA}[1m]`); + }); + + test("no compact window is injected under 200k", () => { + const config = (claudeCode: OcxConfig["claudeCode"]) => ({ + port: 10100, defaultProvider: "mock", providers: { mock: { adapter: "openai-chat", baseUrl: "http://x/v1" } }, claudeCode, + } as OcxConfig); + const build = (claudeCode: OcxConfig["claudeCode"]) => buildClaudeEnvWithIo(config(claudeCode), 10100, {}, {}, { + authDetect: { readClaudeJson: () => undefined, credentialsFileExists: () => false, keychainProbe: () => "absent" as const }, + }); + expect(build({}).CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe("829800"); + expect(build({ contextAccounting: "200k" }).CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined(); + }); +}); + +describe("surfaces", () => { + test("pickers leave 872k and 1M rows unmarked under 200k", () => { + const rows = buildPickerModels({ + nativeSlugs: ["gpt-6-astra"], routedModels: [{ provider: "example", id: "huge", contextWindow: 1_000_000 }], + nativeContextCap: { modelWindows: { "gpt-6-astra": 872_000 } }, auto: ACCOUNTING_200K, + }); + expect(rows.some(row => row.id.endsWith("[1m]"))).toBe(false); + }); + + test("Desktop 3P keeps 1M selectable but drops the default under 200k", () => { + const options = desktop3pModelOptions({ contextAccounting: "200k" }); + const [astra] = generateDesktop3pModels(["gpt-6-astra"], [], undefined, { modelWindows: { "gpt-6-astra": 872_000 } }, options); + expect(astra!.supports1m).toBe(true); + expect(astra!.prefer1m).toBeUndefined(); + expect(desktop3pModelOptions({})).toEqual({ preferOneMillion: true }); + }); + + test("discovery keeps genuine 1M variants as a choice and drops widened ones under 200k", () => { + const infos = buildAnthropicModelInfos(["gpt-6-astra"], [{ provider: "example", id: "huge", contextWindow: 1_000_000 }], + ACCOUNTING_200K, "readable", undefined, { modelWindows: { "gpt-6-astra": 872_000 } }); + const variants = infos.filter(info => info.id.endsWith("[1m]")).map(info => info.display_name); + expect(variants).toEqual(["huge (example) · 1M"]); + }); + + test("subagents: unmarked stays bare under 200k, an explicit marker follows its safety rule", () => { + const alias = "ocx-claude-native--gpt-6-astra"; + const windows = { [alias]: 872_000 }; + expect(withSubagentContextMarker(alias, windows, true)).toBe(alias); + expect(withSubagentContextMarker(`${alias}[1m]`, windows, true)).toBe(`${alias}[1m]`); + expect(withSubagentContextMarker(`${alias}[1m]`, { [alias]: 262_144 }, true)).toBe(alias); + expect(withSubagentContextMarker(alias, windows)).toBe(`${alias}[1m]`); + }); +}); + +describe("CLI", () => { + for (const [value, expected] of [["200k", "200k"], ["1M", "1m"]] as const) { + test(`--context-accounting ${value} sends ${expected}`, async () => { + const requests: unknown[] = []; + const code = await handleClaudeConfigCommand(["set", "--context-accounting", value], { + baseUrl: "http://127.0.0.1:1", + fetchImpl: async (_url, init) => { + requests.push(JSON.parse(String(init?.body))); + return Response.json({ ok: true }); + }, + }); + expect(code).toBe(0); + expect(requests).toEqual([{ contextAccounting: expected }]); + }); + } + + test("an unknown value is a usage error and sends nothing", async () => { + let sent = 0; + const code = await handleClaudeConfigCommand(["set", "--context-accounting", "500k"], { + baseUrl: "http://127.0.0.1:1", fetchImpl: async () => { sent++; return Response.json({ ok: true }); }, + }); + expect(code).toBe(2); + expect(sent).toBe(0); + }); +}); diff --git a/tests/claude-integration/claude-management-api.test.ts b/tests/claude-integration/claude-management-api.test.ts index 498759f3589..23c0794dbc3 100644 --- a/tests/claude-integration/claude-management-api.test.ts +++ b/tests/claude-integration/claude-management-api.test.ts @@ -763,6 +763,7 @@ test.each([ ["alwaysEnableEffort", true], ["autoContext", false], ["autoCompactWindow", 400_000], + ["contextAccounting", "200k"], ] as const)("%s-only PUT triggers system-env reconciliation", async (field, value) => { const applySpy = spyOn(systemEnv, "applySystemEnvToggle").mockResolvedValue({ reverted: false, reason: "test" }); const server = startServer(0); @@ -949,6 +950,28 @@ test("PUT/GET round-trips the context/effort levers (devlog 136 B6)", async () = } }); +test("PUT/GET round-trips contextAccounting; 1m drops the key, anything else is a 400", async () => { + const applySpy = spyOn(systemEnv, "applySystemEnvToggle").mockResolvedValue({ reverted: false, reason: "test" }); + const server = startServer(0); + const put = (contextAccounting: unknown) => fetch(new URL("/api/claude-code", server.url), { + method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contextAccounting }), + }); + const get = async () => (await fetch(new URL("/api/claude-code", server.url)).then(r => r.json()) as Record).contextAccounting; + try { + expect(await get()).toBe("1m"); + expect((await put("200k")).status).toBe(200); + expect(loadConfig().claudeCode?.contextAccounting).toBe("200k"); + expect(await get()).toBe("200k"); + expect((await put("1m")).status).toBe(200); + expect(loadConfig().claudeCode?.contextAccounting).toBeUndefined(); + expect(await get()).toBe("1m"); + expect((await put("500k")).status).toBe(400); + } finally { + applySpy.mockRestore(); + await server.stop(true); + } +}); + test("PUT/GET round-trips auto-context (devlog 260712 020)", async () => { const server = startServer(0); try { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c8db6127857..08ed9a0ba56 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -303,6 +303,7 @@ "claude-model-info.test.ts": "claude-integration", "claude-models-discovery.test.ts": "claude-integration", "claude-native-affinity.test.ts": "claude-integration", "claude-native-passthrough.test.ts": "claude-integration", "claude-native-rate-limit-headers.test.ts": "claude-integration", "claude-outbound.test.ts": "claude-integration", + "claude-context-accounting.test.ts": "claude-integration", "claude-long-context-eligibility.test.ts": "claude-integration", "claude-prompt-too-long.test.ts": "claude-integration", "claude-shell-hook.test.ts": "claude-integration", "claude-sidecar-override.test.ts": "claude-integration", From 006cec99f5fd7ea0086d85e19581a5549f191cae Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 10 Oct 2026 06:40:42 +0900 Subject: [PATCH 50/59] feat(gui): add a Context accounting control to the Claude Code page (#6782) * feat(gui): add a Context accounting control to the Claude Code page The Claude Code settings page gets a 1M (default) / 200k select for claudeCode.contextAccounting. Under 200k the auto-context and auto-summarize rows are hidden (they have no effect there) and the manual env block stops exporting a compact window, matching what the runtime injects. A state cached by an older proxy, or read from one, normalizes to the 1m default. Strings are added to all eleven locales. Co-Authored-By: Claude Opus 5.5 * fix(gui): describe the 200k opt-in precisely and complete test fixtures Review follow-up: the description now says 200k only stops automatic 1M marking (an explicit 1M row and a context override still apply), and the two component fixtures that mount the settings card directly carry the new field. Co-Authored-By: Claude Opus 5.5 * test(gui): treat the 200k token-count label as intentional in the French guard Co-Authored-By: Claude Opus 5.5 * fix(gui): keep the Claude quickstart on one accounting snapshot Selecting 200k dropped the compact window while the pasted model ids still carried the server's automatic [1m] marks. The snippet now strips those marks, keeps an explicit [1m], and omits the compact window until the model env was built under the same policy. Co-authored-by: JUN --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Cursor Agent --- gui/src/i18n/de.ts | 4 + gui/src/i18n/en.ts | 4 + gui/src/i18n/fr.ts | 4 + gui/src/i18n/ja.ts | 4 + gui/src/i18n/ko.ts | 4 + gui/src/i18n/pt.ts | 4 + gui/src/i18n/ru.ts | 4 + gui/src/i18n/tr.ts | 4 + gui/src/i18n/vi.ts | 4 + gui/src/i18n/zh-TW.ts | 4 + gui/src/i18n/zh.ts | 4 + gui/src/pages/ClaudeCode.tsx | 7 +- gui/src/pages/claude-code-save.ts | 2 + gui/src/pages/claude-code-sections.tsx | 33 +++++++-- gui/src/pages/claude-code-types.ts | 19 ++++- gui/src/pages/claude-manual-env.ts | 74 ++++++++++++++++++- ...nthropic-instance-helper-controls.test.tsx | 2 +- gui/tests/claude-code-save.test.ts | 12 ++- gui/tests/claude-code-sidecar-draft.test.tsx | 1 + gui/tests/fr-localization.test.ts | 2 + gui/tests/locale-parity.test.ts | 2 + tests/gui/claude-manual-env.test.ts | 53 +++++++++++++ 22 files changed, 239 insertions(+), 12 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 62e7977622b..8ad26c35ab3 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2423,6 +2423,10 @@ export const de: Record = { "claude.autoContext": "Großen Kontext automatisch nutzen", "claude.autoContextDesc": "Steuert, wie weit die 1M-Markierung geht. AN: jedes Modell, dessen Fenster den Komprimierungsschwellwert fasst, erhält eine Big-Context-Zeile. AUS: nur echte 1M-Modelle.", "claude.autoContextInert": "Inaktiv, weil in der Konfigurationsdatei ein alter Kontextgrößen-Wert (maxContextTokens) steht. Dort entfernen, um es wieder zu aktivieren.", + "claude.contextAccounting": "Kontextberechnung", + "claude.contextAccountingDesc": "1M: Modelle mit langem Kontextfenster sind überall dort standardmäßig 1M, wo opencodex die Vorgabe wählt. 200k: opencodex markiert Modelle nicht mehr automatisch als 1M; eine selbst gewählte 1M-Zeile und eine eigene Kontextvorgabe gelten weiterhin.", + "claude.contextAccounting1m": "1M (Standard)", + "claude.contextAccounting200k": "200k", "claude.autoCompactWindow": "Punkt für Auto-Zusammenfassung", "claude.autoCompactDefault": "{value} (Standard)", "claude.autoCompactWindowDesc": "Ältere Nachrichten werden an diesem Punkt zusammengefasst. Das eigene Limit jedes Modells wird nie überschritten — 200k-Modelle bleiben unberührt.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 705ce95ad76..ce81fed2972 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -3047,6 +3047,10 @@ export const en = { "claude.autoContext": "Use big context automatically", "claude.autoContextDesc": "Controls how far the 1M marking goes. ON: any model whose window can host the compaction threshold gets a big-context row. OFF: only true 1M models get one.", "claude.autoContextInert": "Inactive because a legacy context-size value (maxContextTokens) exists in the config file. Remove it there to re-enable.", + "claude.contextAccounting": "Context accounting", + "claude.contextAccountingDesc": "1M: long-window models default to 1M wherever opencodex picks the default. 200k: opencodex stops marking models 1M automatically; a 1M row you pick yourself and a context override you set still apply.", + "claude.contextAccounting1m": "1M (default)", + "claude.contextAccounting200k": "200k", "claude.autoCompactWindow": "Auto-summarize point", "claude.autoCompactDefault": "{value} (default)", "claude.autoCompactWindowDesc": "Older messages are summarized when the chat reaches this point. It never exceeds each model's own limit, so 200k models are unaffected.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index ce26f281cfa..a63d3c768fd 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2962,6 +2962,10 @@ export const fr: Record = { "claude.autoContext": "Utiliser automatiquement le grand contexte", "claude.autoContextDesc": "Contrôle l’étendue du marquage 1M. ACTIVÉ : tout modèle dont la fenêtre peut accueillir le seuil de compaction obtient une ligne de grand contexte. DÉSACTIVÉ : seuls les véritables modèles 1M en obtiennent une.", "claude.autoContextInert": "Inactif, car une ancienne valeur de taille de contexte (maxContextTokens) existe dans le fichier de configuration. Supprimez-la dans ce fichier pour réactiver l’option.", + "claude.contextAccounting": "Comptabilisation du contexte", + "claude.contextAccountingDesc": "1M : les modèles à longue fenêtre sont à 1M par défaut partout où opencodex choisit la valeur par défaut. 200k : opencodex ne marque plus automatiquement les modèles à 1M ; une ligne 1M choisie par vous et une valeur de contexte que vous avez définie restent appliquées.", + "claude.contextAccounting1m": "1M (par défaut)", + "claude.contextAccounting200k": "200k", "claude.autoCompactWindow": "Seuil de résumé automatique", "claude.autoCompactDefault": "{value} (par défaut)", "claude.autoCompactWindowDesc": "Les anciens messages sont résumés lorsque la conversation atteint ce seuil. Celui-ci ne dépasse jamais la limite propre à chaque modèle; les modèles 200k ne sont donc pas affectés.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index d6f87351d7d..1777cc26017 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2864,6 +2864,10 @@ export const ja: Record = { "claude.autoContext": "大きなコンテキストを自動で使用", "claude.autoContextDesc": "1M マーキングがどこまで及ぶかを制御します。オン: 圧縮しきい値を収められるウィンドウを持つモデルに大型コンテキスト行を付けます。オフ: 真の 1M モデルのみに付けます。", "claude.autoContextInert": "設定ファイルにレガシーのコンテキストサイズ値(maxContextTokens)が存在するため無効です。再び有効化するにはそれを削除してください。", + "claude.contextAccounting": "コンテキスト計算方式", + "claude.contextAccountingDesc": "1M: 長いコンテキストのモデルは、opencodex が既定を選ぶすべての場所で既定で 1M になります。200k: opencodex は自動で 1M の標識を付けません。自分で選んだ 1M の行と自分で設定したコンテキスト上書きはそのまま適用されます。", + "claude.contextAccounting1m": "1M(既定)", + "claude.contextAccounting200k": "200k", "claude.autoCompactWindow": "自動要約ポイント", "claude.autoCompactDefault": "{value}(デフォルト)", "claude.autoCompactWindowDesc": "チャットがこのポイントに達すると古いメッセージが要約されます。各モデル自身の上限を超えることはないので、200k モデルは影響を受けません。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 41276a77ca9..7e295a542c9 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2470,6 +2470,10 @@ export const ko: Record = { "claude.autoContext": "큰 컨텍스트 자동 활용", "claude.autoContextDesc": "1M 표기를 어디까지 붙일지 정합니다. 켜면 컴팩션 기준치를 담을 수 있는 창을 가진 모델에 큰 컨텍스트 행이 생기고, 끄면 진짜 1M 모델에만 생깁니다.", "claude.autoContextInert": "설정 파일에 이전 방식의 컨텍스트 크기 값(maxContextTokens)이 있어 이 기능이 지금은 적용되지 않아요. 설정에서 그 값을 지우면 다시 켜집니다.", + "claude.contextAccounting": "컨텍스트 계산 방식", + "claude.contextAccountingDesc": "1M: 긴 컨텍스트 모델은 opencodex가 기본값을 고르는 모든 곳에서 기본으로 1M이 돼요. 200k: opencodex가 자동으로 1M 표시를 붙이지 않아요. 직접 고른 1M 행과 직접 정한 컨텍스트 설정은 그대로 적용돼요.", + "claude.contextAccounting1m": "1M (기본)", + "claude.contextAccounting200k": "200k", "claude.autoCompactWindow": "자동 요약 지점", "claude.autoCompactDefault": "{value} (기본값)", "claude.autoCompactWindowDesc": "대화가 이 지점에 다다르면 오래된 내용을 자동 요약합니다. 모델마다 자기 한도를 넘지 않는 선에서만 적용되니 200k 모델은 영향받지 않아요.", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index cbc379f7188..b76dfd4d823 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -3005,6 +3005,10 @@ export const pt: Record = { "claude.autoContext": "Usar contexto grande automaticamente", "claude.autoContextDesc": "Controla até onde vai a marcação de 1M. ON: qualquer modelo cuja janela comporte o limite de compactação ganha uma linha de contexto grande. OFF: só os modelos de 1M de verdade ganham uma.", "claude.autoContextInert": "Inativo porque existe um valor legado de tamanho de contexto (maxContextTokens) no arquivo de configuração. Remova-o de lá para reativar.", + "claude.contextAccounting": "Contagem de contexto", + "claude.contextAccountingDesc": "1M: modelos de janela longa ficam com 1M por padrão onde o opencodex escolhe o padrão. 200k: o opencodex deixa de marcar modelos como 1M automaticamente; uma linha 1M escolhida por você e uma substituição de contexto definida por você continuam valendo.", + "claude.contextAccounting1m": "1M (padrão)", + "claude.contextAccounting200k": "200k", "claude.autoCompactWindow": "Ponto de resumo automático", "claude.autoCompactDefault": "{value} (padrão)", "claude.autoCompactWindowDesc": "As mensagens mais antigas são resumidas quando a conversa chega a este ponto. Ele nunca excede o limite de cada modelo, então modelos de 200k não são afetados.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 0e916d3ad77..dbb43b8da43 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2954,6 +2954,10 @@ export const ru: Record = { "claude.autoContext": "Автоматически использовать большой контекст", "claude.autoContextDesc": "Определяет, как широко применяется пометка 1M. ВКЛ: строку с большим контекстом получает каждая модель, окно которой вмещает порог сжатия. ВЫКЛ: её получают только модели с настоящим контекстом 1M.", "claude.autoContextInert": "Неактивно, поскольку в файле конфигурации задано устаревшее значение размера контекста (maxContextTokens). Удалите его там, чтобы снова включить эту настройку.", + "claude.contextAccounting": "Учёт контекста", + "claude.contextAccountingDesc": "1M: модели с длинным окном по умолчанию получают 1M везде, где opencodex выбирает значение по умолчанию. 200k: opencodex больше не помечает модели как 1M автоматически; выбранная вами строка 1M и заданное вами переопределение контекста продолжают действовать.", + "claude.contextAccounting1m": "1M (по умолчанию)", + "claude.contextAccounting200k": "200k", "claude.autoCompactWindow": "Порог автосуммаризации", "claude.autoCompactDefault": "{value} (по умолчанию)", "claude.autoCompactWindowDesc": "Когда чат достигает этого порога, старые сообщения суммаризируются. Порог никогда не превышает собственный лимит модели, поэтому модели с контекстом 200k не затрагиваются.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 9e57c58adc1..7544549a08e 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2983,6 +2983,10 @@ export const tr: Record = { "claude.autoContext": "Otomatik büyük bağlam kullan", "claude.autoContextDesc": "1M işaretinin ne kadar ileri gideceğini kontrol eder. AÇIK: penceresi sıkıştırma eşiğini barındırabilen her model 1M aralığı kazanır. KAPALI: yalnızca gerçek 1M modelleri alır.", "claude.autoContextInert": "Pasif durumdadır çünkü konfigürasyon dosyasında eski bir bağlam boyutu değeri (maxContextTokens) var. Yeniden etkinleştirmek için oradan kaldırın.", + "claude.contextAccounting": "Bağlam hesaplama", + "claude.contextAccountingDesc": "1M: Uzun pencereli modeller, opencodex'in varsayılanı seçtiği her yerde varsayılan olarak 1M olur. 200k: opencodex modelleri artık otomatik olarak 1M işaretlemez; kendi seçtiğiniz 1M satırı ve ayarladığınız bağlam geçersiz kılması geçerli kalır.", + "claude.contextAccounting1m": "1M (varsayılan)", + "claude.contextAccounting200k": "200k", "claude.autoCompactWindow": "Otomatik özetleme noktası", "claude.autoCompactDefault": "{value} (varsayılan)", "claude.autoCompactWindowDesc": "Sohbet bu noktaya ulaştığında eski mesajlar özetlenir. Her modelin kendi sınırını asla aşmaz, bu nedenle 200k modeller etkilenmez.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 220c78d7f7f..6d5e54f6576 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2980,6 +2980,10 @@ export const vi: Record = { "claude.autoContext": "Tự động sử dụng ngữ cảnh lớn (big context)", "claude.autoContextDesc": "Kiểm soát phạm vi áp dụng nhãn 1M. ON: bất kỳ model nào có cửa sổ (window) đủ lớn để chứa ngưỡng nén (compaction threshold) đều sẽ có hàng big-context. OFF: chỉ những model thực sự đạt 1M mới có.", "claude.autoContextInert": "Bị vô hiệu hóa vì giá trị kích thước ngữ cảnh (maxContextTokens) cũ vẫn tồn tại trong tệp cấu hình. Hãy xóa nó ở đó để kích hoạt lại.", + "claude.contextAccounting": "Cách tính ngữ cảnh", + "claude.contextAccountingDesc": "1M: các mô hình cửa sổ dài mặc định là 1M ở mọi nơi opencodex chọn giá trị mặc định. 200k: opencodex không còn tự động đánh dấu mô hình là 1M; dòng 1M bạn tự chọn và giá trị ghi đè ngữ cảnh bạn đặt vẫn được áp dụng.", + "claude.contextAccounting1m": "1M (mặc định)", + "claude.contextAccounting200k": "200k", "claude.autoCompactWindow": "Điểm tự động tóm tắt", "claude.autoCompactDefault": "{value} (mặc định)", "claude.autoCompactWindowDesc": "Các tin nhắn cũ hơn sẽ được tóm tắt khi cuộc trò chuyện đạt đến điểm này. Nó không bao giờ vượt qua giới hạn riêng của từng model, do đó các model 200k sẽ không bị ảnh hưởng.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 2b3c05a750d..561d41d5065 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2184,6 +2184,10 @@ export const zhTW: Record = { "claude.autoContext": "自動利用大上下文", "claude.autoContextDesc": "決定 1M 標記的範圍。開:視窗能容納壓縮門檻的模型都有大上下文條目;關:僅真正的 1M 模型有。", "claude.autoContextInert": "配置檔案中存在舊式上下文大小值(maxContextTokens),此功能暫不生效。刪除該值即可恢復。", + "claude.contextAccounting": "上下文計算方式", + "claude.contextAccountingDesc": "1M:長上下文模型在 opencodex 選擇預設值的所有地方預設按 1M 提供。200k:opencodex 不再自動標記 1M;你自己選擇的 1M 條目和你設定的上下文覆寫仍然生效。", + "claude.contextAccounting1m": "1M(預設)", + "claude.contextAccounting200k": "200k", "claude.autoCompactWindow": "自動摘要觸發點", "claude.autoCompactDefault": "{value}(預設)", "claude.autoCompactWindowDesc": "對話達到該點時自動摘要舊內容。不會超過各模型自身上限,因此 200k 模型不受影響。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index a336d6066c6..55cd5988453 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2443,6 +2443,10 @@ export const zh: Record = { "claude.autoContext": "自动利用大上下文", "claude.autoContextDesc": "决定 1M 标记的范围。开:窗口能容纳压缩阈值的模型都有大上下文条目;关:仅真正的 1M 模型有。", "claude.autoContextInert": "配置文件中存在旧式上下文大小值(maxContextTokens),此功能暂不生效。删除该值即可恢复。", + "claude.contextAccounting": "上下文计算方式", + "claude.contextAccountingDesc": "1M:长上下文模型在 opencodex 选择默认值的所有地方默认按 1M 提供。200k:opencodex 不再自动标记 1M;你自己选择的 1M 条目和你设置的上下文覆盖仍然生效。", + "claude.contextAccounting1m": "1M(默认)", + "claude.contextAccounting200k": "200k", "claude.autoCompactWindow": "自动摘要触发点", "claude.autoCompactDefault": "{value}(默认)", "claude.autoCompactWindowDesc": "对话达到该点时自动摘要旧内容。不会超过各模型自身上限,因此 200k 模型不受影响。", diff --git a/gui/src/pages/ClaudeCode.tsx b/gui/src/pages/ClaudeCode.tsx index f9e5590f8d9..1d6c9b15e36 100644 --- a/gui/src/pages/ClaudeCode.tsx +++ b/gui/src/pages/ClaudeCode.tsx @@ -16,7 +16,7 @@ import { ClaudeCodeQuickstartSection, ClaudeCodeSettingsCard, } from "./claude-code-sections"; -import { AUTO_COMPACT_WINDOW_DEFAULT, formatCompactWindow, newClientId, type ClaudeCodeState, type MapRow } from "./claude-code-types"; +import { AUTO_COMPACT_WINDOW_DEFAULT, formatCompactWindow, newClientId, normalizeContextAccounting, type ClaudeCodeState, type MapRow } from "./claude-code-types"; import { SmallFastModelSetting } from "./claude-code-settings"; import { interceptReasonKey, normalizeSharedProxy, selectFirstPartyNotice, type FirstPartyNotice } from "./claude-code-first-party"; import { @@ -43,6 +43,11 @@ function normalizeFirstPartyState(state: ClaudeCodeState): ClaudeCodeState { interceptRunning: state.interceptRunning === true, interceptEligible: state.interceptEligible === undefined ? true : state.interceptEligible === true, sharedProxy: normalizeSharedProxy(state.sharedProxy), + // Shared by the session cache and the GET read, so a state cached by an older proxy (no + // field) and an older server both read as the default. + contextAccounting: normalizeContextAccounting(state.contextAccounting), + // The GET has no separate field: the env on this read was built under the accounting it reports. + servedContextAccounting: normalizeContextAccounting(state.servedContextAccounting ?? state.contextAccounting), }; } diff --git a/gui/src/pages/claude-code-save.ts b/gui/src/pages/claude-code-save.ts index e24f7a2d147..e9f21d467c8 100644 --- a/gui/src/pages/claude-code-save.ts +++ b/gui/src/pages/claude-code-save.ts @@ -13,6 +13,7 @@ export const EDITABLE_KEYS = [ "fastMode", "autoContext", "autoCompactWindow", + "contextAccounting", "injectAgents", "smallFastModel", "webSearchSidecar", @@ -44,6 +45,7 @@ export function claudeCodeSaveBody(state: ClaudeCodeState, rows: readonly MapRow fastMode: state.fastMode, autoContext: state.autoContext, autoCompactWindow: state.autoCompactWindow, + contextAccounting: state.contextAccounting, injectAgents: state.injectAgents, smallFastModel: state.smallFastModel, modelMap: normalizedModelMap(rows), diff --git a/gui/src/pages/claude-code-sections.tsx b/gui/src/pages/claude-code-sections.tsx index d8efea5b53e..55040beba92 100644 --- a/gui/src/pages/claude-code-sections.tsx +++ b/gui/src/pages/claude-code-sections.tsx @@ -116,14 +116,37 @@ export function ClaudeCodeSettingsCard({
    - {t("claude.autoContext")} - {t("claude.autoContextDesc")} - {state.maxContextTokens !== null && {t("claude.autoContextInert")}} + {t("claude.contextAccounting")} + {t("claude.contextAccountingDesc")} +
    +
    +