From 7bcd99276f0ba40633cd521dea583ce813ae6dce Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 7 Aug 2026 14:55:40 +0900 Subject: [PATCH 01/48] fix(sse): accept unspaced `data:` fields across six parsers (#1170) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The space after the colon is optional in text/event-stream, so `data:{...}` is as valid as `data: {...}`. Six parsers hardcoded the spaced form and silently dropped every frame from a producer that omits it. To the user that looked like a completed turn with no content. The same wire format was already accepted on the relay path (`sse-decoder.ts`, `relay.ts`, `google.ts`) and rejected on the adapter path. That split is the bug. Adds two primitives beside the decoder whose rule they mirror: - `sseFieldValue(line, field)` for the five string-slicing parsers - `sseFieldOffset(text, start, end, field)` for the live Claude relay, whose translator-budget accounting reserves bytes by offset — materializing the line first would allocate the string the budget exists to bound Call sites fixed: `adapters/openai-chat.ts`, `chat/outbound.ts`, `claude/outbound.ts` (two independent parsers, both `event` and `data`), `web-search/parse.ts`, `server/claude-messages.ts`. Both helpers strip at most one leading space, matching the decoder, so a payload that legitimately begins with whitespace keeps the rest of it. Neither trims: callers own that choice and some intentionally keep trailing bytes. Deferred deliberately, not fixed here: `\r\n\r\n` frame delimiting and multiline `data` joining without the spec's `\n` separator. Both are frame-level rather than field-level and carry a different blast radius. Verification: each new test was confirmed to fail with the fix reverted. --- .../260807_untouched_bug_stack/000_plan.md | 127 ++++++++++++++++++ .../010_sse_unspaced_data_fields.md | 110 +++++++++++++++ .../020_routed_reasoning_effort.md | 111 +++++++++++++++ .../030_windows_acl_harden_envelope.md | 107 +++++++++++++++ .../040_npm_cache_preflight_replacement.md | 107 +++++++++++++++ .../050_adopt_as_is_replacements.md | 52 +++++++ .../060_adopt_with_changes_replacements.md | 102 ++++++++++++++ src/adapters/openai-chat.ts | 6 +- src/chat/outbound.ts | 7 +- src/claude/outbound.ts | 21 ++- src/lib/sse-decoder.ts | 35 +++++ src/server/claude-messages.ts | 7 +- src/web-search/parse.ts | 5 +- tests/claude-messages-endpoint.test.ts | 28 ++++ tests/sse-unspaced-data-fields.test.ts | 126 +++++++++++++++++ 15 files changed, 938 insertions(+), 13 deletions(-) create mode 100644 devlog/_plan/260807_untouched_bug_stack/000_plan.md create mode 100644 devlog/_plan/260807_untouched_bug_stack/010_sse_unspaced_data_fields.md create mode 100644 devlog/_plan/260807_untouched_bug_stack/020_routed_reasoning_effort.md create mode 100644 devlog/_plan/260807_untouched_bug_stack/030_windows_acl_harden_envelope.md create mode 100644 devlog/_plan/260807_untouched_bug_stack/040_npm_cache_preflight_replacement.md create mode 100644 devlog/_plan/260807_untouched_bug_stack/050_adopt_as_is_replacements.md create mode 100644 devlog/_plan/260807_untouched_bug_stack/060_adopt_with_changes_replacements.md create mode 100644 tests/sse-unspaced-data-fields.test.ts diff --git a/devlog/_plan/260807_untouched_bug_stack/000_plan.md b/devlog/_plan/260807_untouched_bug_stack/000_plan.md new file mode 100644 index 000000000..3da067959 --- /dev/null +++ b/devlog/_plan/260807_untouched_bug_stack/000_plan.md @@ -0,0 +1,127 @@ +# 260807 — untouched-bug stack: research and roadmap + +Base: `codex/260807-stack-base` at `origin/dev@6d04574d0`. +Cycle: docs-first. This unit writes the plan; no production code changes land here. + +## Why this unit exists + +A sweep over the 60 open issues and 24 open PRs found two distinct backlogs that +the merged bug campaign did not reach. + +The first is a **CI admission backlog**. Eight bug-fix PRs were reported as +"never ran CI", which reads like contributor neglect but is not: 524 workflow +runs sat in `action_required`, waiting on maintainer approval. Thirty-nine of +them belonged to branches with an open PR. The readiness gate cannot verify the +`ci` check on a run that was never allowed to start, so those PRs could not +leave draft no matter what their authors did. Approving the open-PR subset is +the precondition for every disposition below; approving all 524 is not, because +most belong to branches already merged or abandoned. + +The second is a set of **defects with no PR at all** — issues where a reporter +filed evidence and nothing was ever opened against it. + +## Disposition summary + +Every verdict below was reached by reading the diff and the current tree, not +the PR description. + +| Target | Verdict | Reason | +|---|---|---| +| #557 npm cache preflight | rewrite | dev is 1,220 commits past the merge base; diff mixes the useful preflight with obsolete recovery machinery | +| #1095 DeepSeek progressive streaming | rewrite | 2,184-line diff carries an unsafe terminal-repair state machine and a raw-fragment race | +| #1155 web-search buffered policy | adopt with changes | correct intent; `parseResponse` misuse and a lease leak must be fixed | +| #1159 Cursor Grok wire prefix | adopt as-is | request-only helper, correctly isolated from discovery | +| #1171 A6API unlimited quota | adopt as-is | unlimited branch ordered before finite validation, focused coverage | +| #1163 combo catalog fallback | rewrite | resolver cannot distinguish missing rows from deliberately filtered ones | +| #1152 account picker selectors | adopt with changes | foundations are sound but the entry point has no production caller | +| #1169 codex-shim readiness warning | adopt with changes | advisory design is right; the probe can throw and fail a good install | +| #1131 in-place restart identity | rewrite | 35 files with unresolved lifecycle defects; CI red was a GitHub outage, not the code | +| #1056 desktop picker (#241) | rewrite | 54-file branch with backup poisoning and lost-alias defects | +| #1170 unspaced SSE frames | new fix | strict `"data: "` prefix in six parsers | +| #1100 routed reasoning effort | new fix | routed rows advertise ladders, then lose summary support | +| #1156 Windows ACL budget | new fix | a complete ACL sequence gets only five seconds | + +## Two corrections to the initial triage + +Recording these because both changed the plan. + +**#1156 was described imprecisely.** The first pass said PR #1135's retry shares +the 5-second budget. It does not — owner-level recovery at +`src/codex/native-main-owner.ts:205-210` calls `hardenSecret` again and receives +a fresh deadline. The real defect is narrower and still real: one complete ACL +sequence (grant, inheritance, verify, with `/findsid` fallbacks) must finish +inside a single 5-second envelope. The fix is the envelope size, not the retry +structure. + +**#1170 has six call sites, not one.** The reporter named the OpenAI Chat +adapter. The same strict prefix also sits in `src/chat/outbound.ts`, +`src/web-search/parse.ts`, `src/server/claude-messages.ts`, and — twice — +`src/claude/outbound.ts`, which contains two independent parsers (`:591-605` +and `:864-865`). The second one was missed on our first pass and found in audit. +Fixing only the reported site would leave five live paths broken. + +## Roadmap + +Implementation phases, one decade doc each, one PABCD cycle each: + +- `010` — #1170 unspaced SSE field parsing (5 call sites, 1 shared primitive) +- `020` — #1100 routed reasoning-effort propagation +- `030` — #1156 Windows ACL harden envelope +- `040` — #557 replacement: npm cache preflight + log sanitization +- `050` — adopt-as-is PR replacements (#1159, #1171) +- `060` — adopt-with-changes PR replacements (#1155, #1152, #1169) + +Rewrite-class targets (#1095, #1163, #1131, #1056) are deliberately not in this +roadmap. Each is a full unit of work with its own defect list, and folding four +rewrites into this stack would produce a chain no reviewer can follow. They are +recorded here so the next unit can pick them up with the audit already done. + +## Stack shape + +Sequential stacked PRs. Each targets `dev` or the previous PR's head branch, per +the stacked-child workflow that `enforce-target` already supports. + +`010` and `020` and `030` touch disjoint production files, so their order is a +review convenience rather than a dependency. `040` is independent of all three. +`050` and `060` follow because they replace existing PRs and their originals +must be closed with a pointer to the replacement. + +One real overlap: `040` and `060` both edit lifecycle locale files. Whichever +lands first, the other rebases. + +## Review gates beyond CI + +`MAINTAINERS.md` requires explicit security review for credential/permission +handling and for the dependency-install path. Two phases are in that class and +cannot go ready on green CI alone: + +- `030` — Windows ACL permission handling +- `040` — npm install path plus log sanitization + +Both run `bun run privacy:scan` and request security review before leaving +draft. + +## Out of scope + +No promotion to `main` or `preview`, no npm publish, no release tag, and no +merge. Merging is a separate authorization; this unit stops at open PRs with +green CI. + +## Audit record + +This plan failed its first independent audit with six blockers, all corrected +in place: + +1. `010` missed a second parser in `src/claude/outbound.ts` and did not address + CRLF framing or multiline `data` joining. +2. `020` did not specify Record merge semantics; a whole-Record fill-if-undefined + would let one user override suppress every registry default. +3. `030` claimed a ~60s worst case; the real load-time bound is ~90s because + `loadConfig()` hardens three paths sequentially (`src/config.ts:1759-1764`). +4. `040` cited `src/update/job.ts:269-280` as the launcher invocation; that + builds the command, and the invocation is at `:1469`. +5. `060` proposed wiring into a picker-enable transaction that does not exist. +6. Security-review gates for `030` and `040` were missing. + +Recording this because the corrections changed what gets built, not just how it +is described. diff --git a/devlog/_plan/260807_untouched_bug_stack/010_sse_unspaced_data_fields.md b/devlog/_plan/260807_untouched_bug_stack/010_sse_unspaced_data_fields.md new file mode 100644 index 000000000..cedab6256 --- /dev/null +++ b/devlog/_plan/260807_untouched_bug_stack/010_sse_unspaced_data_fields.md @@ -0,0 +1,110 @@ +# 010 — #1170: SSE parsers reject unspaced `data:` fields + +## Defect + +The SSE spec makes the space after `data:` optional; a compliant producer may +send `data:{"choices":[...]}`. Four adapter-side parsers require the space and +silently drop every frame without it, so a stream from such a provider looks +like a completed turn with no content. + +Strict call sites on `origin/dev@6d04574d0`: + +- `src/adapters/openai-chat.ts:950` — `if (!line.startsWith("data: ")) return "continue";` +- `src/chat/outbound.ts:674` +- `src/claude/outbound.ts:865` (and the `event: ` sibling at `:864`) +- `src/claude/outbound.ts:591-605` — a **second, separate** parser in the same + file, missed on the first pass; it does its own `startsWith("event: ", ...)` + and `startsWith("data: ", ...)` against a raw frame with byte-budget + accounting interleaved +- `src/web-search/parse.ts:190` +- `src/server/claude-messages.ts:174` — a fifth site, not named in the report + +Lenient call sites that prove the intended behavior: + +- `src/lib/sse-decoder.ts:193-208` — strips at most one leading ASCII space +- `src/server/relay.ts:262-269` +- `src/adapters/google.ts:618-620` + +The split is the bug: the same wire format is accepted on the relay path and +rejected on the adapter path. + +## Change + +Export one pure helper from `src/lib/sse-decoder.ts`, beside the decoder whose +semantics it mirrors: + +```ts +export function sseFieldValue(line: string, field: string): string | null; +``` + +Returns `null` when the line is not that field. Otherwise returns the value with +at most one leading ASCII space removed — one, not `trimStart()`, because +leading whitespace beyond the first character is payload. + +Then replace the strict prefix checks with calls to it — **six sites, not +five**. `src/claude/outbound.ts` has two independent parsers (`:591-605` and +`:864-865`); both need it, and both need it for `event` as well as `data`, since +the `event: ` check carries the identical defect. + +The `:591-605` site is the delicate one: it reserves and commits translator +budget per fragment, so the edit must change only which offset the fragment +starts at, leaving every `reserveTransient` / `commitRetained` / +`releaseRetained` call and its byte accounting untouched. + +Deliberately not doing: migrating these collectors to `decodeServerSentEvents`. +They own different buffering, budget accounting, heartbeat, and EOF-failure +behavior. Replacing six short prefix checks is the whole change; swapping +six stream state machines is not. + +Four local copies of `slice(5)` would also work and would be worse — this class +of bug is exactly what happens when the same parsing rule is written six times. + +## Preserve + +Each caller's existing `.trim()` on the extracted payload stays where it is. The +helper does not trim, so callers that intentionally keep payload whitespace are +unaffected. + +## Two adjacent defects, deliberately not fixed here + +The audit surfaced two more spec deviations in the same parsers. Naming them so +the next reader does not assume this phase covered them: + +1. **Frame delimiting.** `src/claude/outbound.ts:567` and + `src/server/claude-messages.ts:171` split on `\n\n` only, so a CRLF producer + (`\r\n\r\n`) is not framed correctly. +2. **Multiline `data` joining.** `src/claude/outbound.ts:605` and + `src/server/claude-messages.ts:174` concatenate consecutive `data` fragments + with no separator; the spec joins them with `\n`. + +Both are real, and both are frame-level rather than field-level — fixing them +means changing buffering and joining semantics, which is a different blast +radius from swapping a prefix check. This phase stays field-level so the diff +stays reviewable. The tests below add CRLF and multiline cases as +**characterization** tests that record current behavior, so whoever fixes the +framing has a baseline and cannot regress the prefix fix while doing it. + +## Tests + +Each asserts real content arrives rather than a silently empty turn. + +| File | Test | Assertion | +|---|---|---| +| `tests/openai-chat-hardening.test.ts` | `accepts unspaced data fields and finish_reason without DONE (#1170)` | text delta, `done`, stop reason, usage | +| `tests/chat-completions-endpoint.test.ts` | `collectChatCompletion accepts unspaced data fields` | final `message.content` | +| `tests/claude-outbound.test.ts` | `collectAnthropicMessage accepts unspaced event and data fields` | completed text and stop reason | +| `tests/web-search-parse.test.ts` | `parseSidecarSSE accepts unspaced data fields` | completed text and source extraction | +| `tests/claude-outbound.test.ts` | `raw-frame parser accepts unspaced event and data fields` | the `:591-605` parser, with budget accounting intact | +| `tests/claude-messages-endpoint.test.ts` | `usage extraction accepts unspaced data fields` | the `src/server/claude-messages.ts:174` site: usage extraction and finalization | +| `tests/claude-outbound.test.ts` | `characterizes CRLF framing and multiline data joining` | records today's behavior for the two deferred defects | + +Every one of these fails before the change: the frames are dropped and the +assertions see empty output. Each of the six production call sites has a test +that covers it. + +## Blast radius + +Unknown-line handling, multiline `data` joining, CRLF, `[DONE]`, translator +accounting, and fail-closed EOF. The helper is additive and pure, so the risk is +concentrated in whether each call site's replacement preserves its own trim and +continue/terminate semantics. Read each of the six in full before editing. diff --git a/devlog/_plan/260807_untouched_bug_stack/020_routed_reasoning_effort.md b/devlog/_plan/260807_untouched_bug_stack/020_routed_reasoning_effort.md new file mode 100644 index 000000000..cffde07fd --- /dev/null +++ b/devlog/_plan/260807_untouched_bug_stack/020_routed_reasoning_effort.md @@ -0,0 +1,111 @@ +# 020 — #1100: reasoning effort never reaches routed DeepSeek and GLM + +## Defect + +A user picks a reasoning effort in Codex Desktop for a routed DeepSeek or GLM +model. The proxy forwards the turn without it, so the provider runs at its +default and the picker appears inert. + +The contradiction is inside catalog generation: + +1. `src/codex/catalog/effort.ts:144-180` — `applyReasoningLevels` advertises the + effort ladder on routed rows. +2. `src/codex/catalog/parsing.ts:341-353` — `normalizeRoutedCatalogEntry` then + deletes `supports_reasoning_summaries`. +3. `src/codex/catalog/parsing.ts:262-267` — strict normalization defaults it to + `false`. + +Codex reads a row that offers effort levels but declares no reasoning-summary +support, and omits the entire inbound reasoning object. The adapter would +serialize `reasoning_effort` correctly if it ever arrived +(`src/adapters/openai-chat.ts:759-806`) — nothing is broken downstream. + +The `delete` is not careless. Routed rows are cloned from native templates, and +inheriting OpenAI-only summary delivery would be wrong. The comment at +`parsing.ts:351-352` says exactly that and anticipates per-model opt-in. + +## Constraint from PR #1119 + +PR #1119 is tests-only and does not fix this, but it pins the contract any fix +must satisfy: an explicit `modelSupportsReasoningSummaries: true` survives +template normalization, and a ladder without that opt-in stays `false`. + +So the fix must not infer `true` from a non-empty ladder. That would flip every +routed model including providers that reject summary fields, and would break +#1119's second assertion. + +## Change + +Supply the opt-in as registry metadata for providers we have evidence for. + +The config-level field **already exists** at `src/types.ts:1235-1239` as +`modelSupportsReasoningSummaries?: Record`, documented as the +per-model escape hatch for backends that reject summary fields. So this phase +does not invent a field — it supplies registry-side defaults for a field users +currently have to set by hand. + +1. `src/providers/registry.ts` — add the same + `Record` shape to `ProviderRegistryEntry`. +2. Populate it for the canonical DeepSeek V4 models and the GLM models with + confirmed support: entries `deepseek`, `opencode-go`, `zai`, and only the + Zhipu models with evidence. No speculative entries. +3. `src/providers/derive.ts:279-282` — backfill in `enrichProviderFromRegistry`. + +### The merge must be per-key, not per-Record + +Every backfill at `derive.ts:279-282` today is scalar and uses +`if (prov.X === undefined && entry.X !== undefined)`. Copying that shape for a +Record would be a real bug: a user who sets one model's flag creates a defined +Record, and the whole-object `undefined` check then suppresses **every** +registry default for that provider. One hand-edit would silently disable the +fix. + +So the merge is per-key — start from the registry map, then let explicit user +keys win: + +```ts +// registry defaults first, explicit user keys override — including explicit false +if (entry.modelSupportsReasoningSummaries) { + prov.modelSupportsReasoningSummaries = { + ...entry.modelSupportsReasoningSummaries, + ...(prov.modelSupportsReasoningSummaries ?? {}), + }; +} +``` + +Explicit `false` must survive. A user who disabled summaries for one model +because their backend 400s on it has to keep that, and a spread-based merge +preserves it while `undefined`-checking would not. + +Deep-clone the registry side so saved config never aliases the registry +constant — the same precaution `responsesItemIdRepair` already takes at +`derive.ts:286`. + +Arbitrary custom providers stay conservative and keep the existing per-model +configuration workaround. This is a deliberate asymmetry: we ship the opt-in +where we have proof and leave it manual where we do not. + +## Tests + +`tests/codex-catalog.test.ts`: + +- `built-in DeepSeek and GLM effort models opt into Codex reasoning propagation (#1100)` + — gather registry-enriched models, build with `nativeTemplate()`, assert each + row carries both the expected effort levels and + `supports_reasoning_summaries === true`. +- Keep #1119's no-opt-in assertion in the same file so a future global flip + fails here rather than in production. +- `explicit per-model overrides survive registry backfill` — a provider with a + user-set `{modelA: false}` keeps `modelA` false and still receives the + registry's `modelB: true`. This fails under a whole-Record fill-if-undefined + merge, which is the specific mistake this test exists to catch. + +## Blast radius + +Provider derivation, generated Codex catalogs, and Responses summary +sanitization. An over-broad opt-in would forward summary fields to providers +that reject them, which is why the metadata is model-scoped rather than +provider-scoped or ladder-inferred. + +`tests/codex-catalog.test.ts` is also touched by PR #1119. If that PR lands +first, rebase onto it rather than duplicating its cases. diff --git a/devlog/_plan/260807_untouched_bug_stack/030_windows_acl_harden_envelope.md b/devlog/_plan/260807_untouched_bug_stack/030_windows_acl_harden_envelope.md new file mode 100644 index 000000000..7062da209 --- /dev/null +++ b/devlog/_plan/260807_untouched_bug_stack/030_windows_acl_harden_envelope.md @@ -0,0 +1,107 @@ +# 030 — #1156: Windows ACL harden envelope is too small + +## Defect, stated precisely + +The original report and our first triage both said PR #1135's retry shares the +5-second budget. That is wrong, and the correction matters. + +Owner-level recovery at `src/codex/native-main-owner.ts:205-210` calls +`hardenSecret` again, and each call creates its own deadline at +`src/lib/windows-secret-acl.ts:651-670` and `:703-720`. The retry does get a +fresh envelope. + +The real defect: **one complete ACL sequence gets 5 seconds total**. The +deadline is created per harden call and every command inside it draws from the +remainder (`:426-470`, `:475-506`). A sequence is `/grant:r`, `/inheritance:r`, +and a verification pass, plus `/findsid` fallbacks when the principal does not +resolve on the first form. On a machine where `icacls` is slow — Defender +real-time scanning, a roaming profile, a domain controller round-trip — the +budget is exhausted mid-sequence and the owner publishes a permanent +`unavailable`, so every native request returns 503. + +`loadConfig` hardens directory, config, and auth sequentially, which is why the +budget is shared per call in the first place: per-attempt budgets would stack +into multi-minute startup stalls. The comment at `:227-234` documents this +trade-off, and it is a real one. + +## Change + +`src/lib/windows-secret-acl.ts:234` — raise `HARDEN_DEADLINE_DEFAULT_MS` from +`5_000` to `30_000`. + +Keep everything else: the 60-second `HARDEN_DEADLINE_MAX_MS` cap, the +`OPENCODEX_ACL_TIMEOUT_MS` override, the clamp, and the shared-envelope +structure. + +Rejected alternative: independent per-command budgets. With `/findsid` +fallbacks and retries, that multiplies into the startup stall the shared budget +was introduced to prevent. Raising one constant preserves the design and fixes +the reported failure. + +## Worst-case cost, stated honestly + +An earlier draft of this doc said "roughly 60 seconds". That was wrong, because +the budget is per harden *call* and `loadConfig()` makes three of them +sequentially — directory, config file, then `auth.json` +(`src/config.ts:1759-1764`): + +``` +hardenConfigDir(); +hardenExistingSecret(configPath); +hardenExistingSecret(join(dir, "auth.json")); +``` + +So the real bounds after raising the default to 30 seconds are: + +| Path | Before (5s) | After (30s) | +|---|---|---| +| `loadConfig()` startup, all three hardens timing out | ~15s | **~90s** | +| native-owner harden + one recovery (250ms delay) | ~10.25s | ~60.25s | + +A 90-second synchronous startup stall is a real cost and has to be justified +rather than glossed over. Two things make it acceptable: + +1. It is the **timeout** path, not the normal path. Reaching 90 seconds requires + all three ACL sequences to exhaust a 30-second envelope, meaning `icacls` is + pathologically slow on that machine. On a healthy machine the sequence + finishes in milliseconds and nothing changes. +2. The alternative is what #1156 reports today: the harden fails, the owner + publishes a permanent `unavailable`, and *every* native request returns 503 + until the user restarts. A slow start is recoverable; a permanent 503 is not. + +If 90 seconds is judged too long, the fallback is a smaller bump (15 seconds, +giving ~45s startup) rather than reverting to per-command budgets. Record which +bound was chosen in the PR body so the reviewer sees the trade explicitly. + +The failure remains fail-closed, which is the security-relevant property. + +## Security review gate + +This phase touches ACL/credential-permission handling, which requires explicit +security review under `MAINTAINERS.md` — CI green is not sufficient. Run +`bun run privacy:scan` and request the security review before marking the PR +ready. + +## Test + +`tests/windows-secret-acl.test.ts`: + +- `slow successful ACL steps fit the default harden envelope (#1156)` — fake + clock consumes 2s on `/grant:r` and 11s on `/inheritance:r`, then succeeds. + Assert `{ok: true}` and that all three core steps ran. Fails under a 5-second + default. +- Update the existing default-budget expectations at `:356-374` and `:438-465`, + which assert the old constant. + +## Blast radius + +Every Windows secret file and directory harden. Behavior is unchanged on +machines where `icacls` is fast; only the failure threshold moves. Memoization +and retry cardinality are untouched. + +## Not fixed here + +#1149 (ACL principal built from `USERDOMAIN`, rejecting workgroup local +accounts) lives in the same file at `:396-408` but is a different defect with a +different fix. Keeping it out of this phase keeps the diff reviewable; it is a +candidate for the next unit. diff --git a/devlog/_plan/260807_untouched_bug_stack/040_npm_cache_preflight_replacement.md b/devlog/_plan/260807_untouched_bug_stack/040_npm_cache_preflight_replacement.md new file mode 100644 index 000000000..9afec3f9c --- /dev/null +++ b/devlog/_plan/260807_untouched_bug_stack/040_npm_cache_preflight_replacement.md @@ -0,0 +1,107 @@ +# 040 — #557 replacement: npm cache preflight and log sanitization + +## Why replace rather than rebase + +PR #557 is 18 commits past its merge base; `dev` is 1,220 commits past the same +point. The diff is 24 files, +2351/-64, and mixes one good idea with machinery +that no longer matches the tree. Current `dev` has independently rebuilt the +restart and service-repair paths and absorbed none of #557's modules. + +Its CI state confirms it: two branch-owned `update-job` failures on Windows +(`must not spawn`), a macOS hang in `update-npm-cache-preflight.test.ts` until +30-minute cancellation, and a Bun 1.3.14 crash on Ubuntu that looks +environmental. A rebase would carry all of that forward. + +## The defect, still live on dev + +`ocx` stops the proxy before it knows whether the install can succeed: + +- `bin/ocx.mjs:116-138` checks the version, then proceeds to shutdown at + `:139-258`; installation only starts at `:260-266`. npm cache access is never + checked. +- `src/update/index.ts:168-179` runs a registry-integrity preflight — not a + cache-access one — then shuts down at `:188-262`. + +So a foreign-owned or unreadable nested cache entry produces a failed install +*after* the proxy is already down. + +Second defect, same area: GUI update output is persisted verbatim. The flow — +corrected after audit, because an earlier draft cited the wrong line: + +- `src/update/job.ts:269-282` — `updateExecutionCommand` only *builds* the + command. It does not invoke anything. +- `src/update/job.ts:1469` — the actual invocation: + `runLoggedCommand(job, cmd.bin, cmd.args, UPDATE_TIMEOUT_MS)`. +- `src/update/job.ts:520-530` — `runLoggedCommand` stores stdout/stderr. +- `src/update/job.ts:241-266` — the write boundary, which does not sanitize. + +Local paths and account names end up in stored logs. + +## Change + +New `src/update/npm-cache-preflight.mjs`: bounded Unix cache inspection +returning structured reason codes. `lstat` nested symlinks and verify ownership, +then **skip traversal** rather than rejecting — normal `_npx`, `node_modules`, +and `.bin` links must not block an update. Never surface arbitrary worker text +into logs. + +Call sites: + +- `bin/ocx.mjs:138` — run the preflight before any tray or proxy stop. +- `src/update/index.ts:181-188` — same gate on the second entry point. +- `src/update/job.ts` — gate the job path before the stop that precedes + `runLoggedCommand` at `:1469`, not merely before the command is constructed at + `:269`. Building a command is free; stopping the proxy is the irreversible + step, and the preflight must sit ahead of it. +- `src/update/job.ts:241-266` — sanitize every persisted field and log line at + the write boundary: Windows and POSIX separators, anchorless profile paths, + multi-word usernames, cache paths, UID/GID. + +Windows is an explicit tested skip, not an accidental gap. + +Excluded from the replacement: `install-process.*`, the recovery-tree +declarations, the PID/config changes, and the recovery rewrites. #557's process +runner is where its crashes live — missing stream `error` handlers, rejecting +cleanup promises, leaked signal listeners — and none of it is needed for the +preflight. + +## Tests + +- `tests/update-npm-cache-preflight.test.ts` (new): foreign or inaccessible + entries abort before stop; normal nested symlinks pass without target + traversal; timeout and malformed worker output fail closed; the Windows skip + does not spawn npm. +- `tests/update-stop-first.test.ts:21`: the gate runs before shutdown. +- `tests/update-job.test.ts`: persisted logs contain no profile path, cache + path, or UID/GID. + +Tests must exercise behavior. #557 had source-text assertions that passed +without running the code they described. + +## Objections the replacement must satisfy + +Carried from the review threads on #557, since a replacement that repeats them +will collect the same objections: + +1. Normal npm-cache symlinks must not block updates. +2. Sanitization covers anchorless Windows paths and multi-word usernames. +3. Windows behavior is explicit policy with a tested no-spawn skip. +4. No process-runner crash surface — excluded entirely. +5. No unreachable branches, no source-text-only tests; docs, declarations, and + runtime behavior agree. + +Update the five lifecycle locales only. Do not import #557's ADR or recovery +prose; it describes a tree that no longer exists. + +## Security review gate + +This phase touches the dependency-install and update path, which requires +explicit security review under `MAINTAINERS.md` — CI green is not sufficient. +Run `bun run privacy:scan` (the sanitization change is exactly what it guards) +and request security review before marking the PR ready. + +## Locale-file dependency + +Phase 060 also edits lifecycle locale files. Whichever lands first, the second +rebases; note it in both PR bodies so the conflict is expected rather than +discovered. diff --git a/devlog/_plan/260807_untouched_bug_stack/050_adopt_as_is_replacements.md b/devlog/_plan/260807_untouched_bug_stack/050_adopt_as_is_replacements.md new file mode 100644 index 000000000..ae0df03f4 --- /dev/null +++ b/devlog/_plan/260807_untouched_bug_stack/050_adopt_as_is_replacements.md @@ -0,0 +1,52 @@ +# 050 — adopt-as-is replacements: #1159 and #1171 + +Two contributor PRs survived a skeptical read with no required changes. Per the +stack principle, we still do not merge them in place: the change is rebuilt as +our commit on the stack branch, and the original is closed with a pointer. + +## #1159 — Cursor Grok wire model prefix + +Claude-family and Grok models need a `cursor-` prefix with an effort-tier suffix +on the request wire, while parameterized Grok Fast keeps its base id. The PR +adds a request-only helper and leaves discovery normalization alone — the right +seam, since mixing the two would corrupt the model list. + +- `src/adapters/cursor/effort-map.ts:118-128` — the prefix helper +- `src/adapters/cursor/request-builder.ts:130-154` — the call site +- `tests/cursor-effort-suffix.test.ts:86-118` +- `docs-site/src/content/docs/reference/adapters.md`, Cursor model-ID section + +Test: `regular grok-4.5 request ids match the recorded discovery fixture` — +low/medium/high and default/xhigh serialize as `cursor-grok-4.5-{tier}`; Fast +stays on the base id plus parameters. + +Known limitation, worth stating in the PR body rather than discovering later: +the fixture proves the mapping against recorded discovery output, not live +Cursor state. A Cursor rename requires refreshing it. + +This does not fix #1162 (Cursor Claude-family `resource_exhausted`). That issue +has no code-level cause identified and needs a capture. + +## #1171 — A6API unlimited quota keys + +An unlimited A6API key reports zero finite credit totals, and finite-total +validation then hides it, so a working key looks dead in the dashboard. The PR +puts the unlimited branch ahead of that validation and keeps expiry. + +- `src/providers/quota.ts:61-75,201-206,318-367` +- `tests/provider-quota.test.ts:260-316` + +Test: `A6API unlimited keys remain visible even when all finite credit totals +are zero` — unlimited flag, zero totals, expiry propagation, exactly one report, +and an "Unlimited API credits" row. + +Two observations that are not blockers: `creditsUsd` is currently ignored by the +GUI and duplicates the display window, and the string handling recognizes +`"true"` but not `"1"`. Neither breaks an existing consumer. Note them in the +PR body. + +## Stacking + +Neither touches files used by any other phase in this unit. They can be one PR +or two; two is preferable because they close two different originals and a +reviewer should be able to reject one without the other. diff --git a/devlog/_plan/260807_untouched_bug_stack/060_adopt_with_changes_replacements.md b/devlog/_plan/260807_untouched_bug_stack/060_adopt_with_changes_replacements.md new file mode 100644 index 000000000..8edd6b40d --- /dev/null +++ b/devlog/_plan/260807_untouched_bug_stack/060_adopt_with_changes_replacements.md @@ -0,0 +1,102 @@ +# 060 — adopt-with-changes replacements: #1155, #1152, #1169 + +Three PRs with correct intent and a specific defect each. The defect is named +before implementation so the replacement is not a re-post of the original. + +## #1155 — web-search buffered upstream policy + +Intent: preserve the buffered-upstream policy through the web-search loop +instead of forcing streaming (the bypass behind closed issue #1143). + +Two problems in the author's diff: + +1. It routes through `openai-responses.parseResponse`, which is compaction-only + and rejects function-call-only payloads at + `src/adapters/openai-responses.ts:1286-1293`. The PR's test uses OpenAI Chat, + which masks it — a Responses turn carrying only a function call is exactly + the web-search case. +2. Buffered adapter batches are retained but never released when intercepted, so + repeated search iterations accumulate leases. + +Targets: `src/server/responses/core.ts:2519-2533`; +`src/web-search/loop.ts:243-285,364-412,536-560`; +`src/web-search/progress-stream.ts:29-35,139-156,218-230`; +`tests/web-search.test.ts:557`; `tests/web-search-progress-stream.test.ts:124`. + +Tests: + +- `buffered Responses web-search preserves function calls` — an + `openai-responses` function-call-only turn dispatches the sidecar and completes + downstream as SSE. +- `buffered intercepted iterations release translated leases` — repeated + iterations under a small translator budget do not accumulate discarded + batches. + +Also correct the five locale docs, which claim absolutely that all events are +buffered. + +## #1152 — account picker selector initialization + +The namespace and collision-detection foundations are sound and drew no +substantive review objections. But `initializeDefaultCodexAccountNamespaces` has +no production caller, so the PR title promises behavior the diff does not +deliver. + +An earlier draft of this doc said to invoke it "inside the explicit +picker-enable transaction". The audit found no such transaction exists. In the +current tree `codexAccountPickerEnabled` appears only as schema and validation +(`src/config.ts:1065`, `:1720-1722`, `:1957-1981`), a type +(`src/types.ts:807`), and a read helper +(`src/codex/account-namespaces.ts:155-158`). No management route writes it, and +`src/server/management/routing-profile-routes.ts:303-339` creates and updates +routing profiles — an unrelated surface. + +So there are two honest options, and the choice must be made before coding: + +**(a) Foundations-only.** Retitle the replacement to match what it does — add +namespace allocation and collision detection with no caller — and file the +wiring as a follow-up. Small, truthful, reviewable. + +**(b) Build the enable path.** Design the management entry point that writes +`codexAccountPickerEnabled` and allocates namespaces in one atomic +config write. This is a real API surface addition — a new route, its auth scope, +its validation, and its GUI caller — and is a larger change than PR #1152. + +Recommendation: **(a)** for this stack. Option (b) is a feature, and this unit +is a bug-fix stack; smuggling a new management route into it would make the +stack incoherent and expand the review surface for no user-visible bug fix. +File (b) as its own issue and reference it from the PR body. + +Targets for (a): `src/codex/account-namespaces.ts:18-131`; +`src/config.ts:1112-1130`; `src/routing/profile.ts:17,154-178`. + +Tests: opt-in persistence; no mutation when allocation fails; non-empty map +identity and order preserved; pre-save rejection of policy and profile-prefix +collisions without leaking private ids. + +## #1169 — codex-shim readiness warning + +Advisory-only design is right: warn when a shim install cannot prove routing, +without failing the install. Secret-leak coverage is already focused. + +One defect: `currentExternalCodexModelProvider()` can throw on an unreadable or +racing config, which turns a successful install into a failing command. An +advisory probe must never do that. + +Targets: `src/cli/index.ts:1035-1041`; a readiness helper beside `src/cli/`; +`src/codex/inject.ts:83-86`. + +Catch probe failure as "unverifiable" and keep exit 0. Test the unreadable-config +path and assert the warning discloses neither the proxy URL nor credentials. + +## Stacking + +`#1155` overlaps `src/server/responses/core.ts` with the #1095 rewrite, which is +deliberately out of this unit — so within this stack it is free-standing. +`#1152` and `#1169` touch disjoint files. Order: #1152, #1169, #1155, putting +the largest surface last. + +Each phase closes its original PR with a comment naming the replacement number. + +Phase 040 also edits lifecycle locale files that #1169's docs touch; whichever +lands first, the second rebases. diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 7e8aff56c..42a923657 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -3,6 +3,7 @@ import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, Ocx import { isAllowedToolChoice, modelInList, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice } from "../types"; import { mapReasoningEffort, modelRecordValue } from "../reasoning-effort"; import { debugProviderDiagnostic } from "../lib/debug"; +import { sseFieldValue } from "../lib/sse-decoder"; import { isDebugEnabled } from "../lib/debug-settings"; import { isCyberPolicyCode } from "../lib/errors"; import { redactSecretString } from "../lib/redact"; @@ -947,8 +948,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd // Yields adapter events and returns "terminate" for a terminal frame ([DONE] / error) that // must end the stream, or "continue" otherwise. Mutates the closure's terminal-signal state. const handleDataLine = function* (line: string): Generator { - if (!line.startsWith("data: ")) return "continue"; - const payload = line.slice(6).trim(); + const rawPayload = sseFieldValue(line, "data"); + if (rawPayload === null) return "continue"; + const payload = rawPayload.trim(); if (payload === "[DONE]") { yield* flushToolCalls(); const stopReason = stopReasonFor(finishReason); diff --git a/src/chat/outbound.ts b/src/chat/outbound.ts index 0b5a06429..e7ad46775 100644 --- a/src/chat/outbound.ts +++ b/src/chat/outbound.ts @@ -7,7 +7,7 @@ */ type Rec = Record; -import { decodeServerSentEvents } from "../lib/sse-decoder"; +import { decodeServerSentEvents, sseFieldValue } from "../lib/sse-decoder"; import { isTranslatorBudgetExceededError, type TranslatorBudget } from "../lib/translator-budget"; import { classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, isCyberPolicyMessage } from "../lib/errors"; @@ -671,8 +671,9 @@ export async function collectChatCompletion( const rawFrame = buffer.slice(0, sep); buffer = replaceRetained(buffer, buffer.slice(sep + 2), "live_transient"); for (const line of rawFrame.split("\n")) { - if (!line.startsWith("data: ")) continue; - const data = line.slice(6).trim(); + const rawData = sseFieldValue(line, "data"); + if (rawData === null) continue; + const data = rawData.trim(); if (!data || data === "[DONE]") continue; let parsed: unknown; try { parsed = JSON.parse(data); } catch { continue; } diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index e025762f8..494720c49 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -16,6 +16,7 @@ import { TranslatorBudgetExceededError, type TranslatorBudget, } from "../lib/translator-budget"; +import { sseFieldOffset, sseFieldValue } from "../lib/sse-decoder"; type Rec = Record; @@ -588,10 +589,16 @@ export function responsesSseToAnthropicSse( while (lineStart <= rawFrame.length) { const newline = rawFrame.indexOf("\n", lineStart); const lineEnd = newline === -1 ? rawFrame.length : newline; - if (rawFrame.startsWith("event: ", lineStart)) { - eventName = rawFrame.slice(lineStart + 7, lineEnd).trim(); - } else if (rawFrame.startsWith("data: ", lineStart)) { - const fragmentStart = lineStart + 6; + // The space after the colon is optional in text/event-stream (#1170); + // compute the value offset the same way sseFieldValue does, without + // slicing the line first — the byte accounting below is keyed to + // offsets into rawFrame. + const eventOffset = sseFieldOffset(rawFrame, lineStart, lineEnd, "event"); + const dataOffset = sseFieldOffset(rawFrame, lineStart, lineEnd, "data"); + if (eventOffset !== -1) { + eventName = rawFrame.slice(eventOffset, lineEnd).trim(); + } else if (dataOffset !== -1) { + const fragmentStart = dataOffset; const fragmentBytes = utf8SliceBytes(rawFrame, fragmentStart, lineEnd); const fragmentReservation = translatorBudget.reserveTransient(fragmentBytes, { kind: "live_transient" }); let fragmentCommitted = false; @@ -861,8 +868,10 @@ export async function collectAnthropicMessage( let eventName = ""; let dataLine = ""; for (const line of rawFrame.split("\n")) { - if (line.startsWith("event: ")) eventName = line.slice(7).trim(); - else if (line.startsWith("data: ")) dataLine += line.slice(6); + const eventValue = sseFieldValue(line, "event"); + if (eventValue !== null) { eventName = eventValue.trim(); continue; } + const dataValue = sseFieldValue(line, "data"); + if (dataValue !== null) dataLine += dataValue; } if (!eventName || !dataLine) continue; let data: unknown; diff --git a/src/lib/sse-decoder.ts b/src/lib/sse-decoder.ts index a0352157e..c24c4dad2 100644 --- a/src/lib/sse-decoder.ts +++ b/src/lib/sse-decoder.ts @@ -13,6 +13,41 @@ export type SseRecord = | { kind: "event"; event?: string; data: string } | { kind: "comment"; comment: string }; +/** + * Extract one SSE field value from a single line, or null when the line is a different field. + * + * The space after the colon is OPTIONAL in text/event-stream: `data:{"a":1}` is as valid as + * `data: {"a":1}`. Parsers that hardcoded `startsWith("data: ")` silently dropped every frame + * from a producer that omits it, which surfaced as a completed turn with no content (#1170). + * + * Strips at most ONE leading space — the same rule `decodeServerSentEvents` applies below — so a + * payload that legitimately begins with whitespace keeps the rest of it. Does not trim the value: + * callers own that choice, and some of them intentionally keep trailing bytes. + */ +export function sseFieldValue(line: string, field: string): string | null { + if (!line.startsWith(field)) return null; + const rest = line.slice(field.length); + if (!rest.startsWith(":")) return null; + return rest.startsWith(": ") ? rest.slice(2) : rest.slice(1); +} + +/** + * Offset-only variant of {@link sseFieldValue} for parsers that index into a larger buffer. + * + * Returns the index where the field's value begins within `text`, or -1 when the line at + * `[lineStart, lineEnd)` is a different field. Slicing nothing matters for the live Claude relay, + * whose translator-budget accounting reserves bytes by offset — materializing the line first would + * allocate the very string the budget exists to bound. + */ +export function sseFieldOffset(text: string, lineStart: number, lineEnd: number, field: string): number { + if (!text.startsWith(field, lineStart)) return -1; + let valueStart = lineStart + field.length; + if (valueStart >= lineEnd || text[valueStart] !== ":") return -1; + valueStart += 1; + if (valueStart < lineEnd && text[valueStart] === " ") valueStart += 1; + return valueStart; +} + /** * Decode text/event-stream records across arbitrary fetch chunk boundaries. * diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 1b3ed2232..5e3074bec 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -7,6 +7,7 @@ * unchanged. The Responses output (SSE or JSON) is converted back to Anthropic shape. */ import { FORWARD_HEADERS } from "../adapters/openai-responses"; +import { sseFieldValue } from "../lib/sse-decoder"; import { enforceAnthropicImageLimits, sniffImageDimensions } from "../adapters/anthropic-image-guard"; import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize"; import { AnthropicRequestError, anthropicToResponsesTranslation, extractOcxEffortDirective, extractOcxRouteDirective, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound"; @@ -171,7 +172,11 @@ export function tapAnthropicSseForLog( while ((sep = buffer.indexOf("\n\n")) !== -1) { const frame = buffer.slice(0, sep); buffer = buffer.slice(sep + 2); - const dataLine = frame.split("\n").filter(l => l.startsWith("data: ")).map(l => l.slice(6)).join(""); + const dataLine = frame + .split("\n") + .map(l => sseFieldValue(l, "data")) + .filter((v): v is string => v !== null) + .join(""); if (!dataLine) continue; let data: unknown; try { data = JSON.parse(dataLine); } catch { continue; } diff --git a/src/web-search/parse.ts b/src/web-search/parse.ts index 30946ac1e..0f6c2229c 100644 --- a/src/web-search/parse.ts +++ b/src/web-search/parse.ts @@ -1,3 +1,5 @@ +import { sseFieldValue } from "../lib/sse-decoder"; + /** A single web source backing the sidecar's answer. */ export interface WebSearchSource { url: string; @@ -187,7 +189,8 @@ export async function parseSidecarSSE(response: Response): Promise { + const upstream = new ReadableStream({ + start(controller) { + controller.enqueue(sseEncoder.encode(UNSPACED_USAGE_FRAMES)); + controller.close(); + }, + }); + const { calls, finalize } = spyFinalize(); + const ctx = freshLogCtx(); + const tap = tapAnthropicSseForLog(upstream, ctx, finalize, { stallMs: 5_000, maxBytes: 0 }); + const text = await new Response(tap).text(); + + // The bytes pass through untouched either way; what the strict prefix broke was the inspection. + expect(text).toContain("message_start"); + // "terminal" rather than "eof" is itself part of the fix: recognizing the unspaced + // `message_delta` is what lets the tap classify the close as a real terminal frame. + expect(calls).toEqual([{ status: 200, closeReason: "terminal" }]); + expect(ctx.usage).toEqual(expect.objectContaining({ inputTokens: 11, outputTokens: 7 })); +}); + test("A1: stalled upstream body gets an Anthropic timeout_error tail and body_stall close reason", async () => { const upstream = new ReadableStream({ start(controller) { diff --git a/tests/sse-unspaced-data-fields.test.ts b/tests/sse-unspaced-data-fields.test.ts new file mode 100644 index 000000000..64c8e412c --- /dev/null +++ b/tests/sse-unspaced-data-fields.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter as createOpenAIChatAdapterProduction } from "../src/adapters/openai-chat"; +import { sseFieldOffset, sseFieldValue } from "../src/lib/sse-decoder"; +import { parseSidecarSSE } from "../src/web-search/parse"; +import type { AdapterEvent } from "../src/types"; +import { withTestTranslatorBudget } from "./helpers/translator-budget"; + +// #1170: the space after the colon is optional in text/event-stream. Several parsers required it +// and silently dropped every frame from a compliant producer that omits it, which surfaced to the +// user as a completed turn with no content. + +const createOpenAIChatAdapter = (...args: Parameters) => + withTestTranslatorBudget(createOpenAIChatAdapterProduction(...args)); + +const provider = { adapter: "openai-chat", baseUrl: "https://example.test/v1", apiKey: "key" }; + +async function collect(gen: AsyncGenerator): Promise { + const out: AdapterEvent[] = []; + for await (const e of gen) out.push(e); + return out; +} + +describe("sseFieldValue", () => { + test("accepts both spaced and unspaced field values", () => { + expect(sseFieldValue('data: {"a":1}', "data")).toBe('{"a":1}'); + expect(sseFieldValue('data:{"a":1}', "data")).toBe('{"a":1}'); + expect(sseFieldValue("event: message_start", "event")).toBe("message_start"); + expect(sseFieldValue("event:message_start", "event")).toBe("message_start"); + }); + + test("strips at most one leading space so payload whitespace survives", () => { + expect(sseFieldValue("data: two-spaces", "data")).toBe(" two-spaces"); + }); + + test("returns null for a different field, a bare prefix, or a comment", () => { + expect(sseFieldValue("event: x", "data")).toBeNull(); + expect(sseFieldValue("database: x", "data")).toBeNull(); + expect(sseFieldValue("data", "data")).toBeNull(); + expect(sseFieldValue(": keepalive", "data")).toBeNull(); + }); + + test("an empty value is a value, not an absent field", () => { + expect(sseFieldValue("data:", "data")).toBe(""); + expect(sseFieldValue("data: ", "data")).toBe(""); + }); + + test("does not trim the value — callers own that", () => { + expect(sseFieldValue("data: payload ", "data")).toBe("payload "); + }); +}); + +describe("sseFieldOffset", () => { + const frame = 'event:start\ndata:{"a":1}\nother: x'; + + test("returns the value offset for spaced and unspaced fields", () => { + expect(frame.slice(sseFieldOffset(frame, 0, 11, "event"), 11)).toBe("start"); + expect(frame.slice(sseFieldOffset(frame, 12, 24, "data"), 24)).toBe('{"a":1}'); + }); + + test("returns -1 for a different field", () => { + expect(sseFieldOffset(frame, 25, frame.length, "data")).toBe(-1); + }); + + test("agrees with sseFieldValue on the same line", () => { + for (const line of ["data: x", "data:x", "data:", "data: y", "event:z"]) { + const offset = sseFieldOffset(line, 0, line.length, "data"); + const value = sseFieldValue(line, "data"); + if (value === null) expect(offset).toBe(-1); + else expect(line.slice(offset)).toBe(value); + } + }); +}); + +describe("openai-chat adapter (#1170)", () => { + test("accepts unspaced data frames and finish_reason without [DONE]", async () => { + const response = new Response([ + 'data:{"choices":[{"delta":{"content":"hello"}}]}\n\n', + 'data:{"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n', + ].join("")); + const events = await collect(createOpenAIChatAdapter(provider).parseStream(response)); + + const text = events.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join(""); + expect(text).toBe("hello"); + expect(events.at(-1)?.type).toBe("done"); + expect(events.some(e => e.type === "error")).toBe(false); + }); + + test("accepts an unspaced [DONE] sentinel", async () => { + const response = new Response([ + 'data:{"choices":[{"delta":{"content":"hi"}}]}\n\n', + "data:[DONE]\n\n", + ].join("")); + const events = await collect(createOpenAIChatAdapter(provider).parseStream(response)); + expect(events.at(-1)?.type).toBe("done"); + }); + + test("still handles the spaced form identically", async () => { + const response = new Response([ + 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\n', + "data: [DONE]\n\n", + ].join("")); + const events = await collect(createOpenAIChatAdapter(provider).parseStream(response)); + const text = events.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join(""); + expect(text).toBe("hi"); + expect(events.at(-1)?.type).toBe("done"); + }); +}); + +describe("web-search sidecar parser (#1170)", () => { + function sseStream(body: string): Response { + return new Response(body, { headers: { "content-type": "text/event-stream" } }); + } + + const frames = (prefix: string) => [ + `${prefix}{"type":"response.output_text.delta","delta":"answer"}\n\n`, + `${prefix}{"type":"response.output_text.done","text":"answer"}\n\n`, + `${prefix}[DONE]\n\n`, + ].join(""); + + test("accepts unspaced data frames", async () => { + const spaced = await parseSidecarSSE(sseStream(frames("data: "))); + const unspaced = await parseSidecarSSE(sseStream(frames("data:"))); + expect(spaced.text).toContain("answer"); + expect(unspaced.text).toBe(spaced.text); + }); +}); From 263c07f91ebe1ea4eb975d514bad08c517771d74 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 7 Aug 2026 15:13:11 +0900 Subject: [PATCH 02/48] fix(sse): treat a colonless field line as an empty value, and cover all six call sites Audit found two real gaps in the previous commit. `sseFieldValue("data", "data")` returned null while `decodeServerSentEvents` treats a colonless field as an empty value (`colon < 0` -> valueStart = line.length, sse-decoder.ts:240). Two helpers that mirror the decoder must not disagree with it. Both now return the empty value, and `sseFieldOffset` returns the end-of-line offset for the same case. Regression coverage was also incomplete: `chat/outbound.ts` and `claude/outbound.ts` had no unspaced test, so two of the six fixed call sites were unverified. Both now have one, and each was confirmed to fail with its fix reverted. Also corrects the remaining stale "5 call sites" claims in the plan unit. --- .../260807_untouched_bug_stack/000_plan.md | 2 +- .../010_sse_unspaced_data_fields.md | 2 +- src/lib/sse-decoder.ts | 8 ++- tests/sse-unspaced-data-fields.test.ts | 72 ++++++++++++++++++- 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/devlog/_plan/260807_untouched_bug_stack/000_plan.md b/devlog/_plan/260807_untouched_bug_stack/000_plan.md index 3da067959..98a129b7c 100644 --- a/devlog/_plan/260807_untouched_bug_stack/000_plan.md +++ b/devlog/_plan/260807_untouched_bug_stack/000_plan.md @@ -64,7 +64,7 @@ Fixing only the reported site would leave five live paths broken. Implementation phases, one decade doc each, one PABCD cycle each: -- `010` — #1170 unspaced SSE field parsing (5 call sites, 1 shared primitive) +- `010` — #1170 unspaced SSE field parsing (6 call sites, 2 shared primitives) - `020` — #1100 routed reasoning-effort propagation - `030` — #1156 Windows ACL harden envelope - `040` — #557 replacement: npm cache preflight + log sanitization diff --git a/devlog/_plan/260807_untouched_bug_stack/010_sse_unspaced_data_fields.md b/devlog/_plan/260807_untouched_bug_stack/010_sse_unspaced_data_fields.md index cedab6256..28bfff351 100644 --- a/devlog/_plan/260807_untouched_bug_stack/010_sse_unspaced_data_fields.md +++ b/devlog/_plan/260807_untouched_bug_stack/010_sse_unspaced_data_fields.md @@ -17,7 +17,7 @@ Strict call sites on `origin/dev@6d04574d0`: and `startsWith("data: ", ...)` against a raw frame with byte-budget accounting interleaved - `src/web-search/parse.ts:190` -- `src/server/claude-messages.ts:174` — a fifth site, not named in the report +- `src/server/claude-messages.ts:174` — another site, not named in the report Lenient call sites that prove the intended behavior: diff --git a/src/lib/sse-decoder.ts b/src/lib/sse-decoder.ts index c24c4dad2..c76623d07 100644 --- a/src/lib/sse-decoder.ts +++ b/src/lib/sse-decoder.ts @@ -27,6 +27,10 @@ export type SseRecord = export function sseFieldValue(line: string, field: string): string | null { if (!line.startsWith(field)) return null; const rest = line.slice(field.length); + // A colonless field line is the field with an empty value per the SSE rules, and + // `decodeServerSentEvents` below treats it that way (`colon < 0` -> valueStart = line.length). + // These helpers must not disagree with the decoder they mirror. + if (rest.length === 0) return ""; if (!rest.startsWith(":")) return null; return rest.startsWith(": ") ? rest.slice(2) : rest.slice(1); } @@ -42,7 +46,9 @@ export function sseFieldValue(line: string, field: string): string | null { export function sseFieldOffset(text: string, lineStart: number, lineEnd: number, field: string): number { if (!text.startsWith(field, lineStart)) return -1; let valueStart = lineStart + field.length; - if (valueStart >= lineEnd || text[valueStart] !== ":") return -1; + // Colonless field line: empty value, positioned at end-of-line (matches the decoder). + if (valueStart >= lineEnd) return lineEnd; + if (text[valueStart] !== ":") return -1; valueStart += 1; if (valueStart < lineEnd && text[valueStart] === " ") valueStart += 1; return valueStart; diff --git a/tests/sse-unspaced-data-fields.test.ts b/tests/sse-unspaced-data-fields.test.ts index 64c8e412c..3df1db33f 100644 --- a/tests/sse-unspaced-data-fields.test.ts +++ b/tests/sse-unspaced-data-fields.test.ts @@ -2,6 +2,9 @@ import { describe, expect, test } from "bun:test"; import { createOpenAIChatAdapter as createOpenAIChatAdapterProduction } from "../src/adapters/openai-chat"; import { sseFieldOffset, sseFieldValue } from "../src/lib/sse-decoder"; import { parseSidecarSSE } from "../src/web-search/parse"; +import { collectChatCompletion } from "../src/chat/outbound"; +import { collectAnthropicMessage } from "../src/claude/outbound"; +import { createTranslatorBudget } from "../src/lib/translator-budget"; import type { AdapterEvent } from "../src/types"; import { withTestTranslatorBudget } from "./helpers/translator-budget"; @@ -14,6 +17,17 @@ const createOpenAIChatAdapter = (...args: Parameters { + return new ReadableStream({ + start(controller) { + controller.enqueue(sseEncoder.encode(body)); + controller.close(); + }, + }); +} + async function collect(gen: AsyncGenerator): Promise { const out: AdapterEvent[] = []; for await (const e of gen) out.push(e); @@ -32,13 +46,19 @@ describe("sseFieldValue", () => { expect(sseFieldValue("data: two-spaces", "data")).toBe(" two-spaces"); }); - test("returns null for a different field, a bare prefix, or a comment", () => { + test("returns null for a different field or a comment", () => { expect(sseFieldValue("event: x", "data")).toBeNull(); expect(sseFieldValue("database: x", "data")).toBeNull(); - expect(sseFieldValue("data", "data")).toBeNull(); expect(sseFieldValue(": keepalive", "data")).toBeNull(); }); + test("a colonless field line is an empty value, matching the decoder", () => { + // decodeServerSentEvents treats `colon < 0` as valueStart = line.length, i.e. an empty + // value rather than a non-match. These helpers must not disagree with it. + expect(sseFieldValue("data", "data")).toBe(""); + expect(sseFieldValue("event", "event")).toBe(""); + }); + test("an empty value is a value, not an absent field", () => { expect(sseFieldValue("data:", "data")).toBe(""); expect(sseFieldValue("data: ", "data")).toBe(""); @@ -61,8 +81,14 @@ describe("sseFieldOffset", () => { expect(sseFieldOffset(frame, 25, frame.length, "data")).toBe(-1); }); + test("a colonless field line yields the end-of-line offset (empty value)", () => { + const bare = "data"; + expect(sseFieldOffset(bare, 0, bare.length, "data")).toBe(bare.length); + expect(bare.slice(sseFieldOffset(bare, 0, bare.length, "data"), bare.length)).toBe(""); + }); + test("agrees with sseFieldValue on the same line", () => { - for (const line of ["data: x", "data:x", "data:", "data: y", "event:z"]) { + for (const line of ["data: x", "data:x", "data:", "data: y", "event:z", "data", "database: x"]) { const offset = sseFieldOffset(line, 0, line.length, "data"); const value = sseFieldValue(line, "data"); if (value === null) expect(offset).toBe(-1); @@ -124,3 +150,43 @@ describe("web-search sidecar parser (#1170)", () => { expect(unspaced.text).toBe(spaced.text); }); }); + +describe("chat/outbound collectChatCompletion (#1170)", () => { + const frames = (prefix: string) => [ + `${prefix}{"choices":[{"index":0,"delta":{"content":"collected"}}]}\n\n`, + `${prefix}{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n`, + `${prefix}[DONE]\n\n`, + ].join(""); + + test("accepts unspaced data frames", async () => { + const spaced = await collectChatCompletion(streamOf(frames("data: ")), "m", createTranslatorBudget()); + const unspaced = await collectChatCompletion(streamOf(frames("data:")), "m", createTranslatorBudget()); + + const textOf = (r: Record) => + ((r.choices as { message?: { content?: string } }[] | undefined)?.[0]?.message?.content) ?? ""; + expect(textOf(spaced)).toBe("collected"); + expect(textOf(unspaced)).toBe(textOf(spaced)); + }); +}); + +describe("claude/outbound collectAnthropicMessage (#1170)", () => { + // sep is "" for the unspaced variant and " " for the spaced one, applied to BOTH fields. + const frames = (sep: string) => [ + `event:${sep}message_start\ndata:${sep}{"type":"message_start","message":{"usage":{"input_tokens":2}}}\n\n`, + `event:${sep}content_block_start\ndata:${sep}{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n`, + `event:${sep}content_block_delta\ndata:${sep}{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"claude"}}\n\n`, + `event:${sep}content_block_stop\ndata:${sep}{"type":"content_block_stop","index":0}\n\n`, + `event:${sep}message_stop\ndata:${sep}{"type":"message_stop"}\n\n`, + ].join(""); + + test("accepts unspaced event and data fields", async () => { + const spaced = await collectAnthropicMessage(streamOf(frames(" ")), "claude-test", createTranslatorBudget()); + const unspaced = await collectAnthropicMessage(streamOf(frames("")), "claude-test", createTranslatorBudget()); + + const textOf = (r: Record) => + ((r.content as { type?: string; text?: string }[] | undefined) ?? []) + .filter(p => p.type === "text").map(p => p.text ?? "").join(""); + expect(textOf(spaced)).toBe("claude"); + expect(textOf(unspaced)).toBe(textOf(spaced)); + }); +}); From 8662a0999cb8da14f1f6ae7fa4b46624cf298d7b Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 7 Aug 2026 15:16:26 +0900 Subject: [PATCH 03/48] test(claude): activate the budgeted raw-frame parser with unspaced fields (#1170) The offset-based parser inside responsesSseToAnthropicSse was covered only indirectly. This drives it head-on: the same six Responses frames in spaced and unspaced form, asserting identical event names, identical translated text, and identical budget accounting. The budget assertion compares the two paths rather than asserting zero. This translator leaves a small residue at stream end on the spaced path too (51 bytes for this fixture), so zero would be asserting something that was never true. Equality is the contract the offset arithmetic must satisfy. Confirmed to fail when the offset fix is reverted. --- tests/claude-outbound.test.ts | 47 +++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/claude-outbound.test.ts b/tests/claude-outbound.test.ts index 8651f779d..66250f683 100644 --- a/tests/claude-outbound.test.ts +++ b/tests/claude-outbound.test.ts @@ -46,6 +46,11 @@ function dataOnlySse(data: Record): string { return `data: ${JSON.stringify(data)}\n\n`; } +/** Unspaced counterparts of `sse` / `dataOnlySse` — `data:{...}` is as valid as `data: {...}` (#1170). */ +function unspacedSse(name: string, data: Record): string { + return `event:${name}\ndata:${JSON.stringify(data)}\n\n`; +} + const DONE_SSE = "data: [DONE]\n\n"; function streamFrom(text: string): ReadableStream { @@ -149,6 +154,48 @@ describe("claude outbound SSE", () => { expect(budget.snapshot().currentBytes).toBe(0); }); + test("#1170: the budgeted raw-frame parser accepts unspaced event/data fields and still balances the budget", async () => { + // This drives the offset-based parser inside responsesSseToAnthropicSse, which reserves and + // releases translator budget by offset rather than by slicing each line. A spaced-only prefix + // check dropped every frame here, producing an empty translation. + const frames = [ + { name: "response.created", data: { response: { id: "resp_1" } } }, + { name: "response.output_item.added", data: { output_index: 0, item: { type: "message", id: "msg_1", role: "assistant" } } }, + { name: "response.content_part.added", data: { item_id: "msg_1", output_index: 0, content_index: 0, part: { type: "output_text" } } }, + { name: "response.output_text.delta", data: { item_id: "msg_1", output_index: 0, content_index: 0, delta: "unspaced" } }, + { name: "response.output_item.done", data: { output_index: 0, item: { type: "message", id: "msg_1" } } }, + { name: "response.completed", data: { response: { status: "completed", usage: { input_tokens: 5, output_tokens: 2 } } } }, + ]; + + const spacedBudget = createTestTranslatorBudget(); + const spaced = await collectEvents(responsesSseToAnthropicSse( + streamFrom(frames.map(f => sse(f.name, f.data)).join("")), + "claude-ocx-test", + { translatorBudget: spacedBudget }, + )); + + const unspacedBudget = createTestTranslatorBudget(); + const unspaced = await collectEvents(responsesSseToAnthropicSse( + streamFrom(frames.map(f => unspacedSse(f.name, f.data)).join("")), + "claude-ocx-test", + { translatorBudget: unspacedBudget }, + )); + + const textOf = (events: { name: string; data: Record }[]) => events + .filter(e => e.name === "content_block_delta") + .map(e => e.data?.delta?.text ?? "") + .join(""); + + expect(textOf(spaced)).toBe("unspaced"); + expect(unspaced.map(e => e.name)).toEqual(spaced.map(e => e.name)); + expect(textOf(unspaced)).toBe(textOf(spaced)); + // The offset arithmetic must not change accounting: the unspaced path retains exactly what + // the spaced path retains. (Both leave a small non-zero residue at stream end; that is + // pre-existing behavior of this translator, not something this fix introduces — asserting + // equality is the contract that matters here.) + expect(unspacedBudget.snapshot().currentBytes).toBe(spacedBudget.snapshot().currentBytes); + }); + test("text + thinking + tool call + completed w/ usage -> exact Anthropic sequence", async () => { const upstream = [ sse("response.created", { response: { id: "resp_1", status: "in_progress" } }), From aa8851f38d460878029cb29a6dee9fc5d8d589c6 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 7 Aug 2026 16:45:27 +0900 Subject: [PATCH 04/48] fix(codex): propagate routed DeepSeek and GLM effort Add model-scoped reasoning-summary capability defaults for the registry-backed DeepSeek V4 and evidence-backed GLM models so Codex keeps sending the selected reasoning object. Merge the registry map per key, with defaults spread first and user configuration second. This preserves an explicit false for one model without suppressing defaults for every other model, and creates a detached object instead of aliasing registry metadata. Cover the catalog contract, partial override behavior, conservative no-opt-in default, and red-green production ablation for #1100. --- src/providers/derive.ts | 6 +++ src/providers/registry.ts | 13 +++++ tests/codex-catalog.test.ts | 101 ++++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+) diff --git a/src/providers/derive.ts b/src/providers/derive.ts index b63c9fa7d..eaa7aaead 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -280,6 +280,12 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig // the entry so an explicit user value stays distinguishable from the default. if (prov.supportsServiceTier === undefined && entry.supportsServiceTier !== undefined) prov.supportsServiceTier = entry.supportsServiceTier; if (prov.preserveResponsesReasoningContent === undefined && entry.preserveResponsesReasoningContent !== undefined) prov.preserveResponsesReasoningContent = entry.preserveResponsesReasoningContent; + if (entry.modelSupportsReasoningSummaries) { + prov.modelSupportsReasoningSummaries = { + ...entry.modelSupportsReasoningSummaries, + ...(prov.modelSupportsReasoningSummaries ?? {}), + }; + } // Registry-only repair policy (#938): fill only when the runtime provider has // no explicit policy, and deep-clone so saved/user values never alias the // registry constant. diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 20518adb4..037f3c848 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -196,6 +196,8 @@ export interface ProviderRegistryEntry { supportsServiceTier?: boolean; /** Registry default for plaintext reasoning replay; see `OcxProviderConfig.preserveResponsesReasoningContent`. Registry-only like `supportsServiceTier`. */ preserveResponsesReasoningContent?: boolean; + /** Registry defaults for per-model Codex reasoning propagation; explicit user keys win during enrichment. */ + modelSupportsReasoningSummaries?: Record; modelDiscovery?: ProviderModelDiscoverySpec; contextWindow?: number; modelContextWindows?: Record; @@ -1104,6 +1106,12 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ ...Object.fromEntries(OPENCODE_GO_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_MAP])), ...Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, deepseekReasoningMapFor(id)])), }, + modelSupportsReasoningSummaries: { + "glm-5.2": true, + "glm-5.1": true, + "glm-5": true, + ...Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, true])), + }, thinkingToggleModels: OPENCODE_GO_THINKING_TOGGLE_MODELS, thinkingBudgetModels: THINKING_BUDGET_MODELS, noReasoningModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"], @@ -1340,6 +1348,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ */ modelReasoningEfforts: Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)])), modelReasoningEffortMap: Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, deepseekReasoningMapFor(id)])), + modelSupportsReasoningSummaries: Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, true])), preserveReasoningContentModels: DEEPSEEK_THINKING_MODELS, // Issue #88: every DeepSeek API model is text-only input (no image support upstream) — the // vision sidecar describes attached images for them, and the catalog advertises image input @@ -1653,6 +1662,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelSuffixBracketStrip: true, noVisionModels: ZAI_GLM_52_MODELS, modelReasoningEfforts: Object.fromEntries(ZAI_GLM_52_MODELS.map(id => [id, ZAI_GLM_52_REASONING_EFFORTS])), + modelSupportsReasoningSummaries: Object.fromEntries(ZAI_GLM_52_MODELS.map(id => [id, true])), preserveReasoningContentModels: ZAI_GLM_52_MODELS, }, // Zhipu's domestic BigModel platform: OpenAI-compatible pay-as-you-go on open.bigmodel.cn — a @@ -1689,6 +1699,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelReasoningEffortMap: Object.fromEntries( ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_MAP]), ), + modelSupportsReasoningSummaries: Object.fromEntries( + ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS.map(id => [id, true]), + ), preserveReasoningContentModels: ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS, // No liveModels: GET /api/paas/v4/models has not been observed to answer on this host, and a // false live claim yields an empty picker at runtime. Flip it on once someone verifies it. diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 1f6896f8a..771ca8371 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -2387,6 +2387,107 @@ describe("Codex catalog routed normalization", () => { expect(routed?.supports_reasoning_summaries).toBe(true); }); + test("built-in DeepSeek and GLM effort models opt into Codex reasoning propagation (#1100)", async () => { + const expected = [ + { slug: "deepseek/deepseek-v4-flash", efforts: ["low", "high", "max", "ultra"] }, + { slug: "deepseek/deepseek-v4-pro", efforts: ["high", "max", "ultra"] }, + { slug: "opencode-go/deepseek-v4-flash", efforts: ["low", "high", "max", "ultra"] }, + { slug: "opencode-go/deepseek-v4-pro", efforts: ["high", "max", "ultra"] }, + { slug: "opencode-go/glm-5.2", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, + { slug: "opencode-go/glm-5.1", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, + { slug: "opencode-go/glm-5", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, + { slug: "zai/glm-5.2", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, + { slug: "zai/glm-5.2[1m]", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, + { slug: "zhipu-bigmodel/glm-4.6", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, + { slug: "zhipu-bigmodel/glm-4.7", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, + { slug: "zhipu-bigmodel/glm-5", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, + { slug: "zhipu-bigmodel/glm-5.1", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, + ]; + const models = await gatherRoutedModels({ + providers: { + deepseek: { + adapter: "openai-chat", + baseUrl: "https://api.deepseek.com", + authMode: "key", + apiKey: "sk-test", + liveModels: false, + models: ["deepseek-v4-flash", "deepseek-v4-pro"], + }, + "opencode-go": { + adapter: "openai-chat", + baseUrl: "https://opencode.ai/zen/go/v1", + authMode: "key", + apiKey: "sk-test", + liveModels: false, + models: ["deepseek-v4-flash", "deepseek-v4-pro", "glm-5.2", "glm-5.1", "glm-5"], + }, + zai: { + adapter: "openai-chat", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + authMode: "key", + apiKey: "sk-test", + liveModels: false, + models: ["glm-5.2", "glm-5.2[1m]"], + }, + "zhipu-bigmodel": { + adapter: "openai-chat", + baseUrl: "https://open.bigmodel.cn/api/paas/v4", + authMode: "key", + apiKey: "sk-test", + liveModels: false, + models: ["glm-4.6", "glm-4.7", "glm-5", "glm-5.1"], + }, + }, + }); + const entries = buildCatalogEntries(nativeTemplate(), [], models); + + for (const item of expected) { + const routed = entries.find(entry => entry.slug === item.slug); + expect( + (routed?.supported_reasoning_levels as Array<{ effort: string }> | undefined)?.map(level => level.effort), + ).toEqual(item.efforts); + expect(routed?.supports_reasoning_summaries).toBe(true); + } + }); + + test("explicit per-model overrides survive registry backfill", () => { + const provider: OcxConfig["providers"][string] = { + adapter: "openai-chat", + baseUrl: "https://api.deepseek.com", + authMode: "key", + modelSupportsReasoningSummaries: { "deepseek-v4-flash": false }, + }; + + enrichProviderFromRegistry("deepseek", provider); + + expect(provider.modelSupportsReasoningSummaries).toEqual({ + "deepseek-v4-flash": false, + "deepseek-v4-pro": true, + }); + }); + + test("routed effort ladders without an opt-in stay conservative about summaries (#1100)", async () => { + const models = await gatherRoutedModels({ + providers: { + plain: { + adapter: "openai-chat", + baseUrl: "https://plain.example.test/v1", + authMode: "key", + liveModels: false, + models: ["effort-model"], + modelReasoningEfforts: { "effort-model": ["low", "high"] }, + }, + }, + }); + const routed = buildCatalogEntries(nativeTemplate(), [], models) + .find(entry => entry.slug === "plain/effort-model"); + + expect( + (routed?.supported_reasoning_levels as Array<{ effort: string }> | undefined)?.map(level => level.effort), + ).toEqual(["low", "high", "max", "ultra"]); + expect(routed?.supports_reasoning_summaries).toBe(false); + }); + test("generated jawcode snapshot is restricted to mapped providers", () => { expect(resolveJawcodeProvider("kimi")).toBe("moonshot"); expect(resolveJawcodeProvider("nanogpt")).toBeUndefined(); From 6429e9c41541c99d09b2e2c79c842cdc4db4574e Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 7 Aug 2026 16:49:24 +0900 Subject: [PATCH 05/48] fix(acl): give one harden sequence a 30s envelope, not 5s (#1156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a machine where icacls is slow — Defender real-time scanning, a roaming profile, a domain-controller round trip — a complete ACL sequence could not finish inside the 5-second envelope. The harden failed closed, the native-main owner published a permanent `unavailable`, and every native request returned 503 until the user restarted. One correction to the issue's framing: PR #1135's retry is not the problem. Owner-level recovery calls hardenSecret again and does receive a fresh deadline. The defect is that one complete sequence — `/grant:r`, `/inheritance:r`, `/remove:g`, plus the conditional `/findsid` verification — had only five seconds for all of it. Raises the default to 30s and keeps everything else: the 60s cap, the OPENCODEX_ACL_TIMEOUT_MS override, the clamp, and the shared-envelope structure. Independent per-command budgets were rejected: with /findsid fallbacks they multiply into the multi-minute startup stall the shared budget was introduced to prevent. The cost is stated in the source comment rather than hidden. Because loadConfig hardens three paths sequentially, the timeout-path worst case at load becomes ~90s, and the owner path ~60.25s. Both need icacls to be pathologically slow on every call; a healthy machine finishes in milliseconds. A slow start is recoverable, a permanent 503 is not, and the failure stays fail-closed either way. Four existing tests depended on the 5s default while actually asserting something else — envelope sharing, fresh-budget-on-second-call, recovery cardinality. Each now pins OPENCODEX_ACL_TIMEOUT_MS explicitly so it tests its real subject, and beforeEach/afterEach isolate the variable so a stray value in a developer's environment cannot change what any of them assert. The new test deliberately does not pin: it exercises the shipped default with 13s of slow-but-successful work. Confirmed to fail with the default reverted to 5s. --- src/lib/windows-secret-acl.ts | 18 ++++++++++-- tests/windows-secret-acl.test.ts | 49 ++++++++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/lib/windows-secret-acl.ts b/src/lib/windows-secret-acl.ts index f1b72bce8..9549d3987 100644 --- a/src/lib/windows-secret-acl.ts +++ b/src/lib/windows-secret-acl.ts @@ -229,9 +229,23 @@ export interface HardenOptions { * timeout retry and the diagnostic verification pass (no per-attempt fresh budget: * loadConfig hardens dir+config+auth sequentially, so per-attempt budgets stack * into multi-minute startup stalls). Override with OPENCODEX_ACL_TIMEOUT_MS - * (integer ms, clamped to [1000, 60000]; invalid values fall back to 5000). + * (integer ms, clamped to [1000, 60000]; invalid values fall back to 30000). + * + * The default was 5s until #1156. One envelope has to cover the whole sequence — + * `/grant:r`, `/inheritance:r`, `/remove:g`, plus the conditional `/findsid` + * verification — and on machines where icacls is slow (Defender real-time scanning, + * roaming profiles, a domain-controller round trip) 5s ran out mid-sequence. The + * harden then failed closed, the native-main owner published a permanent + * `unavailable`, and every native request returned 503 until restart. A slow start + * is recoverable; that is not. + * + * The cost is honest and worth stating: because loadConfig hardens three paths + * sequentially, the timeout-path worst case at load is ~90s, and the owner path + * (initial call + one recovery) is ~60.25s. Both require icacls to be + * pathologically slow on every call; a healthy machine finishes in milliseconds + * and sees no change. Operators who prefer the old bound can set the env override. */ -const HARDEN_DEADLINE_DEFAULT_MS = 5_000; +const HARDEN_DEADLINE_DEFAULT_MS = 30_000; const HARDEN_DEADLINE_MIN_MS = 1_000; const HARDEN_DEADLINE_MAX_MS = 60_000; diff --git a/tests/windows-secret-acl.test.ts b/tests/windows-secret-acl.test.ts index acf3cb343..9ac911a14 100644 --- a/tests/windows-secret-acl.test.ts +++ b/tests/windows-secret-acl.test.ts @@ -39,11 +39,21 @@ import { NATIVE_MAIN_OWNER_DB, retainNativeMainOwner } from "../src/codex/native let testDir = ""; +// The default harden budget is production policy (#1156 raised it to 30s), not a value tests +// should silently inherit. Isolate the override here so a test that cares about a specific +// budget pins it explicitly, and a stray value in the developer's environment cannot change +// what any of these assert. +let previousAclTimeout: string | undefined; + beforeEach(() => { + previousAclTimeout = process.env.OPENCODEX_ACL_TIMEOUT_MS; + delete process.env.OPENCODEX_ACL_TIMEOUT_MS; testDir = mkdtempSync(join(tmpdir(), "ocx-acl-test-")); }); afterEach(() => { + if (previousAclTimeout === undefined) delete process.env.OPENCODEX_ACL_TIMEOUT_MS; + else process.env.OPENCODEX_ACL_TIMEOUT_MS = previousAclTimeout; if (testDir && existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); testDir = ""; }); @@ -354,6 +364,10 @@ describe("icacls failure paths (injected seams)", () => { }); test("all icacls steps share one deadline and a timed-out path is not retried this process", () => { + // Pinned to the pre-#1156 budget: this test is about the SHARING of one envelope across + // steps, not about how large the envelope is. Without the pin it would silently stop + // timing out at the 30s default and assert nothing. + process.env.OPENCODEX_ACL_TIMEOUT_MS = "5000"; const filePath = secretFile(); let now = 0; const budgets: number[] = []; @@ -373,6 +387,30 @@ describe("icacls failure paths (injected seams)", () => { expect(budgets.length).toBe(1); }); + test("slow successful ACL steps fit the default harden envelope (#1156)", () => { + // The reported failure: on a machine where icacls is slow, the whole sequence could not + // finish inside one 5s envelope, the harden failed closed, and the native-main owner + // published a permanent `unavailable` — every native request then 503'd until restart. + // No pin here on purpose: this test exists to exercise the SHIPPED default. + resetHardenedStateForTests(); + const filePath = secretFile("slow-default-envelope.json"); + let now = 0; + const steps: string[] = []; + + setNowForTests(() => now); + setIcaclsRunnerForTests(args => { + if (args.includes("/grant:r")) { steps.push("/grant:r"); now += 2_000; } + else if (args.includes("/inheritance:r")) { steps.push("/inheritance:r"); now += 11_000; } + else if (args.includes("/remove:g")) { steps.push("/remove:g"); } + return ok; + }); + + // 13s of slow-but-successful work: impossible under the old 5s default, comfortable + // under 30s with margin left for the conditional /findsid verification. + expect(hardenSecretPath(filePath, { required: true })).toEqual({ ok: true }); + expect(steps).toEqual(["/grant:r", "/inheritance:r", "/remove:g"]); + }); + test("a timeout diagnostic no longer claims filesystem non-support (issue #160)", () => { setIcaclsRunnerForTests(() => timeout); let message = ""; @@ -459,10 +497,10 @@ describe("icacls failure paths (injected seams)", () => { expect(budgets[0]).toBeGreaterThan(500); budgets.length = 0; - process.env.OPENCODEX_ACL_TIMEOUT_MS = "5000ms"; // malformed → default 5000 + process.env.OPENCODEX_ACL_TIMEOUT_MS = "5000ms"; // malformed → default 30000 (#1156) hardenSecretPath(secretFile("env-c.json"), { required: true }); - expect(budgets[0]).toBeLessThanOrEqual(5_000); - expect(budgets[0]).toBeGreaterThan(4_000); + expect(budgets[0]).toBeLessThanOrEqual(30_000); + expect(budgets[0]).toBeGreaterThan(29_000); } finally { if (prev === undefined) delete process.env.OPENCODEX_ACL_TIMEOUT_MS; else process.env.OPENCODEX_ACL_TIMEOUT_MS = prev; @@ -607,6 +645,8 @@ describe("async hardenSecretPath (issue #612)", () => { }); test("a required timeout preserves ETIMEDOUT and one explicit recovery gets a fresh budget", async () => { + // Pinned: this asserts that a SECOND call gets a fresh envelope, not the envelope's size. + process.env.OPENCODEX_ACL_TIMEOUT_MS = "5000"; const target = secretFile("one-time-recovery.json"); let now = 0; let grantCalls = 0; @@ -639,6 +679,9 @@ describe("async hardenSecretPath (issue #612)", () => { }); test("the explicit timeout recovery cannot be consumed more than once", async () => { + // Pinned: this asserts recovery CARDINALITY. At the 30s default the first call would + // succeed on its internal retry and the cardinality claim would never be exercised. + process.env.OPENCODEX_ACL_TIMEOUT_MS = "5000"; const target = secretFile("consumed-recovery.json"); let now = 0; let grantCalls = 0; From 2f242bb7cc05debbd802bb639adb3505675204fb Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 7 Aug 2026 16:59:19 +0900 Subject: [PATCH 06/48] fix(codex): reach custom-named providers, and keep summary defaults off disk (#1100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit found the first commit fixed the canonical provider ids but missed the shape the issue was actually reported against. REACHING CUSTOM PROVIDERS. `enrichProviderFromRegistry` matches on the provider NAME. The reporter's row is a hand-added provider literally called "GLM" pointing at a vendor endpoint we recognize. Routing worked, so the row looked healthy, but no registry id is called "GLM" — so every piece of registry metadata was skipped, the effort ladder was advertised with summaries left false, and Codex dropped the inbound reasoning object. Exactly the bug, on the exact configuration that was reported. On the name-lookup miss we now fall back to `registryEntryForProviderDestination`, which already answers "which vendor endpoint is this row talking to" and is restricted to fixed key destinations — no templated or overridable base URL can be claimed by it. Scope is deliberately one field: a custom row keeps its own identity for everything else. PER-KEY, EVERYWHERE. The fallback first bailed whenever the user had any map, which recreated the whole-record bug the per-key merge exists to prevent: one model's flag would have suppressed every sibling default. Both paths now share `applyReasoningSummaryDefaults`, so an explicit value — including `false` — wins for its own key and nothing else. NOT PERSISTED. `enrichProviderFromCatalog` feeds a config about to be written to disk. Writing today's registry defaults there would freeze them as the user's own overrides, so a later correction — learning a model's backend rejects summary delivery — would never reach anyone who created their provider first, and they would keep getting 400s with no way to know why. It now restores exactly what the caller submitted. Catalog gathering enriches a detached runtime clone, so the defaults still apply where they matter. KNOWN REMAINING GAP: the reporter's endpoint, open.bigmodel.cn/api/coding/paas/v4, is in no registry entry — only /api/paas/v4 is. Closing that route needs a new registry entry with its own id (glm/glm-cn are bound in FREE_PROVIDER_DIRECTORY), its own evidence-backed model set, and registry-parity updates. That is a provider addition, not a bug fix, so it stays out of this stack and is recorded in the plan unit instead. Each new test was confirmed to fail with its production change reverted. --- src/oauth/key-providers.ts | 12 ++++++ src/providers/derive.ts | 56 ++++++++++++++++++++++---- tests/codex-catalog.test.ts | 80 +++++++++++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 7 deletions(-) diff --git a/src/oauth/key-providers.ts b/src/oauth/key-providers.ts index 150a80b2f..f48e56b3e 100644 --- a/src/oauth/key-providers.ts +++ b/src/oauth/key-providers.ts @@ -18,9 +18,21 @@ export const KEY_LOGIN_PROVIDERS: Record = deriveKeyLo * `noReasoningModels`, `defaultModel`) onto a provider config being created, for any field the * caller didn't already supply. Lets the vision/reasoning classification actually reach the saved * config (the GUI/API only send adapter/baseUrl/apiKey/defaultModel). No-op for unknown names. + * + * `modelSupportsReasoningSummaries` is deliberately excluded from what gets persisted. It is + * registry-only metadata resolved at runtime, and this function feeds a config that is about to + * be written to disk. Persisting today's registry defaults would freeze them as the user's own + * overrides: a later registry correction — say we learn a model's backend rejects summary + * delivery — would never reach anyone who created their provider before the correction, and they + * would keep getting upstream 400s with no way to know why. Catalog gathering enriches a + * detached runtime clone, so the defaults still apply where they matter. */ export function enrichProviderFromCatalog(name: string, prov: OcxProviderConfig): void { + const hadOwnSummaries = Object.hasOwn(prov, "modelSupportsReasoningSummaries"); + const submittedSummaries = prov.modelSupportsReasoningSummaries; enrichProviderFromRegistry(name, prov); + if (hadOwnSummaries) prov.modelSupportsReasoningSummaries = submittedSummaries; + else delete prov.modelSupportsReasoningSummaries; } export function isKeyLoginProvider(name: string): boolean { diff --git a/src/providers/derive.ts b/src/providers/derive.ts index eaa7aaead..c947c0b4f 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -2,6 +2,7 @@ import type { CodexAccountMode, OcxProviderConfig } from "../types"; import { PROVIDER_REGISTRY, providerMatchesRegistryTransport, + registryEntryForProviderDestination, type ProviderRegistryEntry, } from "./registry"; @@ -243,9 +244,55 @@ export function deriveProviderPresets(): DerivedProviderPreset[] { return [...dedupePresets(presets), customPreset()]; } +/** + * Merge registry reasoning-summary defaults PER KEY, letting explicit user values win. + * + * Not a whole-Record `=== undefined` fill like the scalars around it: a user who sets one + * model's flag creates a defined Record, and a whole-object check would then suppress every + * registry default for that provider. Spreading registry-first also preserves an explicit + * `false` — someone who disabled summaries for a model because their backend 400s on it keeps + * that. The result is a fresh object, so saved config never aliases the registry constant. + */ +function applyReasoningSummaryDefaults( + prov: OcxProviderConfig, + defaults: Readonly> | undefined, +): void { + if (!defaults) return; + prov.modelSupportsReasoningSummaries = { + ...defaults, + ...(prov.modelSupportsReasoningSummaries ?? {}), + }; +} + +/** + * Last-resort enrichment for a provider whose NAME matches no registry id. + * + * #1100 was reported against a hand-added provider called "GLM" pointing at a vendor endpoint + * we recognize. Routing worked, so the row looked healthy, but every piece of registry metadata + * was skipped and the reasoning ladder was advertised without summary support — exactly the + * inconsistency that makes Codex drop the inbound reasoning object. + * + * Deliberately narrow: only the reasoning-summary map, and only via + * `registryEntryForProviderDestination`, which matches fixed key destinations and refuses + * templated or overridable base URLs. A custom row keeps its own identity for everything else. + */ +function enrichReasoningSummariesByDestination(prov: OcxProviderConfig): void { + const destination = registryEntryForProviderDestination(prov); + applyReasoningSummaryDefaults(prov, destination?.modelSupportsReasoningSummaries); +} + export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig): void { const entry = PROVIDER_REGISTRY.find(row => row.id === name); - if (!entry || !providerMatchesRegistryTransport(name, prov)) return; + if (!entry || !providerMatchesRegistryTransport(name, prov)) { + // Name lookup failed, but the row may still point at a vendor route we know. #1100 was + // reported against a hand-added provider literally named "GLM": routing worked, yet every + // piece of registry metadata was skipped because no registry id is called "GLM". + // `registryEntryForProviderDestination` answers the question that actually matters here — + // which vendor endpoint is this row talking to — and is already restricted to fixed key + // destinations, so a templated or overridable base URL cannot be claimed by it. + enrichReasoningSummariesByDestination(prov); + return; + } const seed = providerConfigSeed(entry); if (prov.apiKeyTransport === undefined && seed.apiKeyTransport !== undefined) prov.apiKeyTransport = seed.apiKeyTransport; if (!prov.defaultModel && seed.defaultModel) prov.defaultModel = seed.defaultModel; @@ -280,12 +327,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig // the entry so an explicit user value stays distinguishable from the default. if (prov.supportsServiceTier === undefined && entry.supportsServiceTier !== undefined) prov.supportsServiceTier = entry.supportsServiceTier; if (prov.preserveResponsesReasoningContent === undefined && entry.preserveResponsesReasoningContent !== undefined) prov.preserveResponsesReasoningContent = entry.preserveResponsesReasoningContent; - if (entry.modelSupportsReasoningSummaries) { - prov.modelSupportsReasoningSummaries = { - ...entry.modelSupportsReasoningSummaries, - ...(prov.modelSupportsReasoningSummaries ?? {}), - }; - } + applyReasoningSummaryDefaults(prov, entry.modelSupportsReasoningSummaries); // Registry-only repair policy (#938): fill only when the runtime provider has // no explicit policy, and deep-clone so saved/user values never alias the // registry constant. diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 771ca8371..8f979fafe 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -24,6 +24,7 @@ import { import type { OcxConfig } from "../src/types"; import type { NormalizedComboConfig } from "../src/combos/types"; import { enrichProviderFromRegistry } from "../src/providers/derive"; +import { enrichProviderFromCatalog } from "../src/oauth/key-providers"; import { handleManagementAPI } from "../src/server/management-api"; import { OAUTH_PROVIDERS } from "../src/oauth"; @@ -2450,6 +2451,85 @@ describe("Codex catalog routed normalization", () => { } }); + test("a custom-named provider on a known vendor endpoint still gets the opt-in (#1100)", () => { + // The reporter's actual shape: a hand-added provider literally named "GLM". Routing worked, + // so the row looked healthy, but no registry id is called "GLM" and every piece of registry + // metadata was skipped — the ladder was advertised with summaries left false, which is the + // exact inconsistency that makes Codex drop the inbound reasoning object. Testing only + // canonical provider ids would have missed this entirely. + const custom: OcxConfig["providers"][string] = { + adapter: "openai-chat", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + authMode: "key", + }; + enrichProviderFromRegistry("GLM", custom); + expect(custom.modelSupportsReasoningSummaries?.["glm-5.2"]).toBe(true); + + // Same for a renamed row pointing at the BigModel pay-as-you-go endpoint. + const renamed: OcxConfig["providers"][string] = { + adapter: "openai-chat", + baseUrl: "https://open.bigmodel.cn/api/paas/v4", + authMode: "key", + }; + enrichProviderFromRegistry("my-glm", renamed); + expect(renamed.modelSupportsReasoningSummaries?.["glm-4.6"]).toBe(true); + }); + + test("the destination fallback never claims an unrelated custom endpoint (#1100)", () => { + // The fallback matches by vendor endpoint. A provider pointing somewhere we do not + // recognize must stay untouched — silently opting a random backend into summary delivery + // would produce upstream 400s the user never asked for. + const unknown: OcxConfig["providers"][string] = { + adapter: "openai-chat", + baseUrl: "https://api.example.invalid/v1", + authMode: "key", + }; + enrichProviderFromRegistry("GLM", unknown); + expect(unknown.modelSupportsReasoningSummaries).toBeUndefined(); + + // An explicit user value wins PER KEY — it does not suppress the other registry defaults. + // An earlier revision of this fallback bailed whenever any user map existed, which + // recreated the whole-record bug the per-key merge was written to avoid: setting one + // model's flag would silently disable the opt-in for every sibling model. + const opinionated: OcxConfig["providers"][string] = { + adapter: "openai-chat", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + authMode: "key", + modelSupportsReasoningSummaries: { "glm-5.2": false }, + }; + enrichProviderFromRegistry("GLM", opinionated); + expect(opinionated.modelSupportsReasoningSummaries).toEqual({ + "glm-5.2": false, + "glm-5.2[1m]": true, + }); + }); + + test("registry summary defaults are never persisted into saved config (#1100)", () => { + // enrichProviderFromCatalog feeds a config that is about to be written to disk. Persisting + // today's registry defaults would freeze them as the user's own overrides, so a later + // registry correction — e.g. learning a model's backend rejects summary delivery — would + // never reach anyone who created their provider first. + const created: OcxConfig["providers"][string] = { + adapter: "openai-chat", + baseUrl: "https://api.deepseek.com", + authMode: "key", + }; + enrichProviderFromCatalog("deepseek", created); + expect(created.modelSupportsReasoningSummaries).toBeUndefined(); + // Other registry seeding still reaches the saved config. + expect(created.models?.length).toBeGreaterThan(0); + + // A value the user actually submitted is preserved verbatim. + const submitted: OcxConfig["providers"][string] = { + adapter: "openai-chat", + baseUrl: "https://api.deepseek.com", + authMode: "key", + modelSupportsReasoningSummaries: { "deepseek-v4-flash": false }, + }; + enrichProviderFromCatalog("deepseek", submitted); + expect(submitted.modelSupportsReasoningSummaries).toEqual({ "deepseek-v4-flash": false }); + }); + test("explicit per-model overrides survive registry backfill", () => { const provider: OcxConfig["providers"][string] = { adapter: "openai-chat", From 392179e704495f17800af4cf7a7b97fdd8fabd8a Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 7 Aug 2026 17:01:08 +0900 Subject: [PATCH 07/48] docs(devlog): record the #1100 audit findings and the deferred BigModel coding endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first implementation passed its tests and was still wrong about the reported configuration: enrichment matches on provider NAME, and the reporter's row is a hand-added provider called "GLM". Recording the failure mode because it generalizes — canonical-id tests were green against a configuration no user had. Also records why the BigModel coding endpoint is deferred rather than fixed here, with the safety analysis for adding it later. --- .../020_routed_reasoning_effort.md | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/devlog/_plan/260807_untouched_bug_stack/020_routed_reasoning_effort.md b/devlog/_plan/260807_untouched_bug_stack/020_routed_reasoning_effort.md index cffde07fd..99b9b8290 100644 --- a/devlog/_plan/260807_untouched_bug_stack/020_routed_reasoning_effort.md +++ b/devlog/_plan/260807_untouched_bug_stack/020_routed_reasoning_effort.md @@ -109,3 +109,45 @@ provider-scoped or ladder-inferred. `tests/codex-catalog.test.ts` is also touched by PR #1119. If that PR lands first, rebase onto it rather than duplicating its cases. + +## What audit changed after implementation + +The first implementation fixed the canonical provider ids and passed its tests, +and was still wrong about the reported case. Recording why, because the failure +mode generalizes. + +`enrichProviderFromRegistry` matches on the provider NAME. The reporter's row is +a hand-added provider literally called `GLM`. Routing worked, so nothing looked +broken — but no registry id is called `GLM`, so the metadata never arrived. The +tests substituted canonical ids (`zai`, `zhipu-bigmodel`) and were green against +a configuration no user had. + +Fix: on the name-lookup miss, fall back to +`registryEntryForProviderDestination`, which matches by vendor endpoint and is +already restricted to fixed key destinations. + +Two further corrections from the same audit: + +- The fallback originally bailed whenever the user had any map, recreating the + whole-record bug the per-key merge was written to prevent. +- `enrichProviderFromCatalog` persists what it enriches, so registry defaults + were being frozen into saved config as user overrides. + +## Deferred: the reporter's exact endpoint + +`https://open.bigmodel.cn/api/coding/paas/v4` appears in no registry entry — +only `/api/paas/v4` does, as `zhipu-bigmodel`. The coding path exists solely in +`FREE_PROVIDER_DIRECTORY` as `glm-cn`. + +Closing that route needs a new registry entry, and the audit confirmed it would +be safe with a distinct id (`glm` and `glm-cn` are both already bound, and +reusing either would retarget an existing config's endpoint — the warning at +`registry.ts:1668-1676`). It also needs `preserveCustomDestination: true`, its +own evidence-backed model set rather than the pay-as-you-go GLM 4.6–5.1 +metadata, and updates to `EXPECTED_KEY_PROVIDER_IDS` in +`tests/provider-registry-parity.test.ts`. + +That is a provider addition, not a bug fix. It stays out of this stack +deliberately: the destination fallback already fixes every custom-named row on +an endpoint we know, and mixing a new vendor entry into a bug-fix chain would +expand the review surface past what a reviewer can check in one pass. From 07e7525b869611080b0ee0c0eb3e87feccc62540 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 8 Aug 2026 01:41:22 +0900 Subject: [PATCH 08/48] fix(providers): register BigModel's Coding Plan endpoint, which #1100 was actually reported on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The destination fallback added here matched two GLM routes: Z.AI's coding plan and BigModel's pay-as-you-go `/api/paas/v4`. The configuration in the issue is neither. It is `https://open.bigmodel.cn/api/coding/paas/v4` — a third endpoint with no registry row — so the lookup found nothing, `modelSupportsReasoningSummaries` stayed unset, and Codex kept dropping the reasoning object. Effort still displayed as `-`. The test hid this. It was captioned "the reporter's actual shape" and used provider name "GLM" and model glm-5.2, both correct, but substituted Z.AI's host. It passed against a route that already worked while the reported one stayed broken. Found in pre-merge review, not by the suite. A prefix match on `open.bigmodel.cn` would have covered both endpoints in one line. It is also how a config pointed at one vendor route inherits another route's metadata, which is the failure the exact-endpoint rule exists to prevent — so this is a separate row. Two details that are not arbitrary. The id is not `glm-cn`, which the free-provider directory already binds to this same path; registering it here would let routedProviderConfig() canonicalize a saved `glm-cn` config onto our baseUrl. And the model list follows Z.AI's coding-plan set rather than the pay-as-you-go one, because this endpoint is the subscription product and glm-5.2 only exists on that side. Ablation: pointing the new row's baseUrl elsewhere makes the reproduction test red. Refs #1100 --- src/providers/registry.ts | 36 ++++++++++++++++++++++++++ tests/codex-catalog.test.ts | 23 ++++++++++++---- tests/provider-registry-parity.test.ts | 7 +++-- 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 037f3c848..e1dc32e40 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1707,6 +1707,42 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // false live claim yields an empty picker at runtime. Flip it on once someone verifies it. note: "Domestic BigModel pay-as-you-go endpoint (open.bigmodel.cn)", }, + // BigModel's Coding Plan is a SEPARATE endpoint from the pay-as-you-go row above, and that is + // the whole reason this one exists. #1100 was reported against + // `https://open.bigmodel.cn/api/coding/paas/v4`; the row above covers only `/api/paas/v4`, so + // destination enrichment matched nothing, `modelSupportsReasoningSummaries` stayed unset, and + // Codex kept dropping the inbound reasoning object — effort displayed as `-`. + // + // A prefix or fuzzy endpoint match would have been the shortcut. It is also how a config + // pointed at one vendor route silently inherits another route's metadata, so endpoints stay + // exact and each one gets its own row. + // + // The id is NOT `glm-cn`, which the free-provider directory already binds to this same coding + // path: registering it here would let routedProviderConfig() canonicalize a saved `glm-cn` + // config onto this baseUrl. Same reasoning as `zhipu-bigmodel` above. + // + // Models follow Z.AI's coding-plan list rather than the pay-as-you-go one. This endpoint is + // the subscription product, and the reporter's `glm-5.2` is only on that side. + { + id: "zhipu-bigmodel-coding", + label: "Zhipu AI — BigModel Coding Plan", + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://bigmodel.cn/console/usercenter/apikeys", + defaultModel: "glm-5.2", + models: ["glm-5.2", "glm-5.2[1m]", "glm-5.1", "glm-5", "glm-4.6"], + jawcodeBundle: "zai", + modelContextWindows: { "glm-5.2": 1_000_000, "glm-5.2[1m]": 1_000_000 }, + modelSuffixBracketStrip: true, + noVisionModels: ZAI_GLM_52_MODELS, + modelReasoningEfforts: Object.fromEntries(ZAI_GLM_52_MODELS.map(id => [id, ZAI_GLM_52_REASONING_EFFORTS])), + modelSupportsReasoningSummaries: Object.fromEntries(ZAI_GLM_52_MODELS.map(id => [id, true])), + preserveReasoningContentModels: ZAI_GLM_52_MODELS, + // No liveModels: the same reasoning as the pay-as-you-go row — an unverified live claim + // yields an empty picker at runtime. + note: "Domestic BigModel Coding Plan endpoint (open.bigmodel.cn)", + }, { id: "nanogpt", label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://nano-gpt.com/api" }, { id: "synthetic", label: "Synthetic", baseUrl: "https://api.synthetic.new/openai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://synthetic.new" }, // SiliconFlow publishes an OpenAI-compatible chat endpoint and a dynamic model catalog. Do not diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 8f979fafe..7e515f841 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -2452,11 +2452,24 @@ describe("Codex catalog routed normalization", () => { }); test("a custom-named provider on a known vendor endpoint still gets the opt-in (#1100)", () => { - // The reporter's actual shape: a hand-added provider literally named "GLM". Routing worked, - // so the row looked healthy, but no registry id is called "GLM" and every piece of registry - // metadata was skipped — the ladder was advertised with summaries left false, which is the - // exact inconsistency that makes Codex drop the inbound reasoning object. Testing only - // canonical provider ids would have missed this entirely. + // The reporter's ACTUAL configuration, verbatim from #1100: a hand-added provider literally + // named "GLM", model glm-5.2, on BigModel's Coding Plan endpoint. Routing worked, so the row + // looked healthy, but no registry id is called "GLM" and every piece of registry metadata was + // skipped — the ladder was advertised with summaries left false, which is the exact + // inconsistency that makes Codex drop the inbound reasoning object. + // + // This case used to substitute Z.AI's coding endpoint while claiming to be the reporter's + // shape. That passed while the reported configuration stayed broken: `/api/coding/paas/v4` + // on open.bigmodel.cn had no registry row at all, so the destination lookup found nothing. + const reported: OcxConfig["providers"][string] = { + adapter: "openai-chat", + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", + authMode: "key", + }; + enrichProviderFromRegistry("GLM", reported); + expect(reported.modelSupportsReasoningSummaries?.["glm-5.2"]).toBe(true); + + // Z.AI's own Coding Plan endpoint is a different vendor route and keeps working. const custom: OcxConfig["providers"][string] = { adapter: "openai-chat", baseUrl: "https://api.z.ai/api/coding/paas/v4", diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index 37bc5467a..94581a530 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -32,7 +32,7 @@ function nativeTemplate(): Record { const EXPECTED_KEY_PROVIDER_IDS = [ "anthropic-apikey", "openai-apikey", "umans", "opencode-go", "neuralwatt", "openrouter", "cline-pass", "cline", "orcarouter", "bizrouter", "groq", "google", "google-vertex", "azure-openai", "deepseek", "cerebras", "deepinfra", "hyperbolic", "nscale", "vultr", "baseten", "commandcode", "sambanova", "nebius", "digitalocean", "scaleway", "together", "fireworks", "firepass", "moonshot", - "huggingface", "nvidia", "venice", "zai", "zhipu-bigmodel", "nanogpt", "synthetic", "siliconflow", "qwen-cloud", "tencent-coding-plan", + "huggingface", "nvidia", "venice", "zai", "zhipu-bigmodel", "zhipu-bigmodel-coding", "nanogpt", "synthetic", "siliconflow", "qwen-cloud", "tencent-coding-plan", "volcengine", "volcengine-coding-plan", "volcengine-agent-plan", "qianfan", "alibaba", "alibaba-token-plan", "alibaba-token-plan-intl", "parallel", "zenmux", "litellm", "ollama-cloud", "mistral", "minimax", "minimax-cn", "kimi-code", "opencode-zen", "vercel-ai-gateway", "opencode-free", "xiaomi", "kilo", "mimo-free", "cloudflare-ai-gateway", "cloudflare-workers-ai", "gitlab-duo", @@ -338,7 +338,9 @@ describe("provider registry parity", () => { .map(entry => entry.id); expect(zai?.modelContextWindows).toEqual({ "glm-5.2": 1_000_000, "glm-5.2[1m]": 1_000_000 }); expect(providerConfigSeed(zai!).modelSuffixBracketStrip).toBe(true); - expect(optedInProviders).toEqual(["kimi", "zai", "kimi-code"]); + // `zhipu-bigmodel-coding` opts in for the same reason `zai` does: it serves the same + // bracketed GLM ids, and that vendor's OpenAI path returns 400 code 1211 for them. + expect(optedInProviders).toEqual(["kimi", "zai", "zhipu-bigmodel-coding", "kimi-code"]); const config: OcxConfig = { port: 10100, @@ -763,6 +765,7 @@ describe("provider registry parity", () => { minimax: "minimax", "minimax-cn": "minimax", "zhipu-bigmodel": "zai", + "zhipu-bigmodel-coding": "zai", }); expect(resolveJawcodeProvider("gemini")).toBe("google"); expect(resolveJawcodeProvider("minimax-cn")).toBe("minimax"); From d6e7045359565f9067b49fd98dc5ad42a28c3a31 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 7 Aug 2026 23:40:24 +0900 Subject: [PATCH 09/48] build(hooks): rebuild the packaged GUI after a merge brings gui/ changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ocx serves gui/dist, which is generated and gitignored. A fast-forward advances gui/src while dist stays at whatever was last built, so the dashboard keeps rendering the OLD bundle and nothing in git status hints at why. That cost a real debugging session today: the symlink and the source were both correct while the served bundle was seven hours stale, still drawing sidebar rows the merge had deleted. post-merge runs build:gui only when the merge range touched gui/, using the same slash-guarded match as lint-gui-if-changed so all three agree on what counts. No usable ORIG_HEAD means skip rather than rebuild — a hook that taxes every unrelated pull gets disabled, and a stale dist is one command to fix. A failed build never fails the merge; it prints the command to run. setup-hooks now installs each hook independently. The old single-hook early return would have stopped post-merge from installing whenever pre-push was already current. --- package.json | 1 + scripts/build-gui-if-changed.ts | 103 ++++++++++++++++++++++++++++++++ scripts/post-merge.sh | 7 +++ scripts/setup-hooks.ts | 81 ++++++++++++++++--------- 4 files changed, 163 insertions(+), 29 deletions(-) create mode 100644 scripts/build-gui-if-changed.ts create mode 100644 scripts/post-merge.sh diff --git a/package.json b/package.json index aa51127ad..18afbd58e 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "prepush": "bun run typecheck && bun run lint:gui:if-changed && bun run test && bun run privacy:scan && bun run doctor:gui:if-changed", "lint:gui": "cd gui && bun run lint", "lint:gui:if-changed": "bun scripts/lint-gui-if-changed.ts", + "postmerge": "bun scripts/build-gui-if-changed.ts", "doctor:gui": "cd gui && bun run doctor", "doctor:gui:full": "cd gui && bun run doctor:full", "doctor:gui:if-changed": "bun scripts/doctor-gui-if-changed.ts", diff --git a/scripts/build-gui-if-changed.ts b/scripts/build-gui-if-changed.ts new file mode 100644 index 000000000..c48badcfa --- /dev/null +++ b/scripts/build-gui-if-changed.ts @@ -0,0 +1,103 @@ +/** + * Rebuild the packaged GUI when a merge or pull brought `gui/` changes. + * Used by the `post-merge` git hook. Skip with: git pull --no-verify + * + * Why this exists: `ocx` serves `gui/dist`, which is generated output and + * therefore gitignored. A fast-forward advances `gui/src` but leaves `gui/dist` + * at whatever was last built, so the dashboard keeps rendering the OLD bundle + * while the source says otherwise — sidebar rows that were deleted stay on + * screen, and nothing in git status hints at why. That cost a real debugging + * session: the symlink and the source were both correct and the served bundle + * was seven hours stale. + * + * Mirrors `scripts/lint-gui-if-changed.ts` and `scripts/doctor-gui-if-changed.ts` + * so all three agree on what "gui changed" means. + * + * Test hooks: BUILD_GUI_DRY_RUN=1 prints the run/skip decision without + * spawning; BUILD_GUI_FILES (newline-separated) overrides the git-derived file + * list; BUILD_GUI_CMD overrides the spawned command. + */ +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; + +/** True when any changed path is the gui directory or inside it (slash-guarded). */ +export function guiPathsChanged(files: string[]): boolean { + return files.some(f => f === "gui" || f.startsWith("gui/")); +} + +if (import.meta.main) { + const repoRoot = resolve(import.meta.dirname, ".."); + + /* + * `post-merge` runs after the merge commit exists, so the range that describes + * what just arrived is ORIG_HEAD...HEAD. Git sets ORIG_HEAD for merge and pull; + * without it there is nothing to diff against. + */ + const diffNames = (range: string): string[] => { + try { + const diff = spawnSync("git", ["diff", "--name-only", range], { + cwd: repoRoot, + encoding: "utf8", + }); + if (diff.status !== 0) return []; + return (diff.stdout ?? "") + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean); + } catch { + return []; + } + }; + + const hasRef = (ref: string): boolean => { + try { + return spawnSync("git", ["rev-parse", "--verify", ref], { + cwd: repoRoot, + stdio: "ignore", + }).status === 0; + } catch { + return false; + } + }; + + let files: string[]; + let hadBase = true; + if (process.env.BUILD_GUI_FILES !== undefined) { + files = process.env.BUILD_GUI_FILES.split(/\r?\n/).map(f => f.trim()).filter(Boolean); + } else { + hadBase = hasRef("ORIG_HEAD"); + files = hadBase ? diffNames("ORIG_HEAD...HEAD") : []; + } + + /* + * No usable base means we cannot tell what arrived. Skip rather than rebuild: + * this hook runs on every merge, and an unconditional build would tax every + * unrelated pull. A stale dist is recoverable with one command; a hook that + * burns ten seconds on every merge gets disabled. + */ + const shouldRun = hadBase && guiPathsChanged(files); + + if (process.env.BUILD_GUI_DRY_RUN === "1") { + console.log(shouldRun ? "build:run" : "build:skip"); + process.exit(0); + } + + if (!shouldRun) { + process.exit(0); + } + + console.log("build:gui: gui/ changed — rebuilding the packaged dashboard"); + const cmd = process.env.BUILD_GUI_CMD ?? "bun run build:gui"; + const [bin, ...args] = cmd.split(" "); + const built = spawnSync(bin!, args, { cwd: repoRoot, stdio: "inherit" }); + + /* + * A failed rebuild must not fail the merge — the merge already happened, and + * exiting non-zero here only prints a confusing error after a successful pull. + * Say plainly what to run instead. + */ + if (built.status !== 0) { + console.error("build:gui failed. The dashboard will serve the previous bundle until you run: bun run build:gui"); + } + process.exit(0); +} diff --git a/scripts/post-merge.sh b/scripts/post-merge.sh new file mode 100644 index 000000000..f01186456 --- /dev/null +++ b/scripts/post-merge.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +# Post-merge hook shim. The actual command lives in package.json ("postmerge"). +# Installed by: bun run setup:hooks +# +# Never fails the merge: the merge already happened by the time this runs, so a +# non-zero exit here would only print a confusing error after a successful pull. +bun run postmerge || true diff --git a/scripts/setup-hooks.ts b/scripts/setup-hooks.ts index ecd800933..c632004db 100644 --- a/scripts/setup-hooks.ts +++ b/scripts/setup-hooks.ts @@ -1,12 +1,16 @@ /** - * Sets up the git pre-push hook for local development. + * Sets up the git hooks for local development. * Run once after cloning: bun run setup:hooks * - * The hook runs `bun run prepush` (typecheck + tests + privacy scan + GUI - * eslint and React Doctor when `gui/` changed) before every push — the local - * portion of the CI gate. + * - `pre-push` runs `bun run prepush` (typecheck + tests + privacy scan + GUI + * eslint and React Doctor when `gui/` changed) — the local portion of the CI + * gate. + * - `post-merge` runs `bun run postmerge`, which rebuilds the packaged GUI when + * a merge or pull brought `gui/` changes. `gui/dist` is generated and + * gitignored, so a fast-forward advances the source while the dashboard keeps + * serving the previously built bundle. * - * To skip in an emergency: git push --no-verify + * To skip in an emergency: git push --no-verify / git pull --no-verify */ import { execFileSync } from "node:child_process"; import { existsSync, copyFileSync, mkdirSync, chmodSync, readFileSync, renameSync } from "node:fs"; @@ -28,36 +32,55 @@ try { process.exit(1); } -const src = join(repoRoot, "scripts", "pre-push.sh"); -const dest = join(hooksDir, "pre-push"); - if (!existsSync(hooksDir)) { mkdirSync(hooksDir, { recursive: true }); } -// Deterministic overwrite policy: an existing, differing pre-push hook is always -// preserved as pre-push.backup- (timestamped names are unique), then the -// managed hook is installed. Identical content is a no-op. -if (existsSync(dest)) { - const existing = readFileSync(dest, "utf8"); - const managed = readFileSync(src, "utf8"); - if (existing === managed) { - console.log(`pre-push hook already up to date at ${dest}`); - process.exit(0); +/** + * Deterministic overwrite policy, per hook: an existing but differing hook is + * preserved as .backup- (timestamped names are unique), then the + * managed hook is installed. Identical content is a no-op. + * + * Each hook installs independently — one already being current must not stop the + * other from being written, which a single early `process.exit(0)` would do. + */ +function installHook(name: string, source: string, summary: string): void { + const src = join(repoRoot, "scripts", source); + const dest = join(hooksDir, name); + + if (existsSync(dest)) { + const existing = readFileSync(dest, "utf8"); + const managed = readFileSync(src, "utf8"); + if (existing === managed) { + console.log(`${name} hook already up to date at ${dest}`); + return; + } + const backup = `${dest}.backup-${Date.now()}`; + renameSync(dest, backup); + console.log(`existing ${name} hook preserved at ${backup}`); } - const backup = `${dest}.backup-${Date.now()}`; - renameSync(dest, backup); - console.log(`existing pre-push hook preserved at ${backup}`); -} -copyFileSync(src, dest); + copyFileSync(src, dest); -// chmod +x -- no-op on Windows but harmless -try { - chmodSync(dest, 0o755); -} catch { - // Windows: Git for Windows calls sh.exe directly, executable bit not required. + // chmod +x -- no-op on Windows but harmless + try { + chmodSync(dest, 0o755); + } catch { + // Windows: Git for Windows calls sh.exe directly, executable bit not required. + } + + console.log(`${name} hook installed at ${dest}. ${summary}`); } -console.log(`pre-push hook installed at ${dest}. Runs typecheck + tests + privacy scan (+ GUI eslint and React Doctor when gui/ changed) before every push.`); -console.log("Skip in an emergency with: git push --no-verify"); +installHook( + "pre-push", + "pre-push.sh", + "Runs typecheck + tests + privacy scan (+ GUI eslint and React Doctor when gui/ changed) before every push.", +); +installHook( + "post-merge", + "post-merge.sh", + "Rebuilds the packaged GUI when a merge or pull brought gui/ changes.", +); + +console.log("Skip in an emergency with: git push --no-verify / git pull --no-verify"); From 0b8e608c06a4a81ba676019ee99b10b6e201dcd1 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 7 Aug 2026 23:56:28 +0900 Subject: [PATCH 10/48] fix(deepseek): restore live upstream streaming on the Responses wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #875 bounded-JSON force (modelResponsesUpstreamStreaming) delayed every byte of a deepseek-v4-flash turn until generation finished — 28-46 s of silence on long turns, which users read as a hang. The official Responses guide documents a response.completed/incomplete/failed terminal with no data: [DONE] sentinel, live probes (including the tool-result replay shape behind the original stall) close on the terminal, and the relay's terminal-output boundary already cuts the stream there and synthesizes [DONE]. Drop the deepseek registry opt-in; keep the mechanism suite-reachable through a synthetic-registry fixture, add streamed #938 id-repair integration coverage, and record the supersession in the #1065 RCA. Unit: devlog/_plan/260807_deepseek_responses_streaming/ --- .../002_issue_1065_rca.md | 12 + .../000_plan.md | 148 ++++++++++ src/providers/registry.ts | 15 +- structure/04_transports-and-sidecars.md | 11 +- tests/deepseek-inbound-wire.test.ts | 267 +++++++++++------- .../deepseek-responses-item-id-repair.test.ts | 34 ++- 6 files changed, 363 insertions(+), 124 deletions(-) create mode 100644 devlog/_plan/260807_deepseek_responses_streaming/000_plan.md diff --git a/devlog/_plan/260806_overnight_triage_round2/002_issue_1065_rca.md b/devlog/_plan/260806_overnight_triage_round2/002_issue_1065_rca.md index 997b2b259..1f530ce4e 100644 --- a/devlog/_plan/260806_overnight_triage_round2/002_issue_1065_rca.md +++ b/devlog/_plan/260806_overnight_triage_round2/002_issue_1065_rca.md @@ -49,3 +49,15 @@ and default-path equivalence for the 5s error-body callers. Nothing on origin/dev (last 30 commits) or in any open PR touches `bounded-body` or this stall path. #947 is the Darwin SSE relay, a different path. #1069 is unrelated ladder metadata. + +## Supersession note (2026-08-07) + +The "keep bounded JSON, do not restore streaming" disposition above is +superseded by `devlog/_plan/260807_deepseek_responses_streaming/`: fresh +upstream probes (2026-08-07, including the tool-result replay shape behind +#875) show DeepSeek's `/responses` stream closing on the documented +`response.completed` terminal, and the official guide states there is no +`data: [DONE]` sentinel — which the relay's terminal boundary already +synthesizes. The deepseek registry opt-in is removed; the +`firstByteTimeoutMs` bounded-body fix this RCA shipped remains valid and +still guards the mechanism's synthetic-fixture path. diff --git a/devlog/_plan/260807_deepseek_responses_streaming/000_plan.md b/devlog/_plan/260807_deepseek_responses_streaming/000_plan.md new file mode 100644 index 000000000..6be92cfa9 --- /dev/null +++ b/devlog/_plan/260807_deepseek_responses_streaming/000_plan.md @@ -0,0 +1,148 @@ +# DeepSeek V4 Flash Responses upstream streaming re-enable + +## Problem + +User report: `deepseek/deepseek-v4-flash` through Codex "responds slowly / appears +unresponsive" since the model moved to the Responses wire. Reproduced live: the +proxy log shows a 28,387 ms turn (and the essay probe below took 46 s) during +which the Codex client receives **zero bytes** until the whole generation +finishes, because the #875 reliability policy forces `stream: false` upstream +(`modelResponsesUpstreamStreaming: { "deepseek-v4-flash": false }`) and +synthesizes the entire SSE sequence only after the bounded JSON body arrives. + +Correctness is fine — every logged turn is 200, tool calls work end to end via +`codex exec` — the failure mode is pure perceived latency / no incremental +output, which reads as a hang for long generations. + +## Evidence (fresh, 2026-08-07) + +1. Official guide (https://api-docs.deepseek.com/guides/responses_api, fetched + today): "Set stream: true to receive the response as a sequence of semantic + server-sent events (SSE). … The stream ends with a `response.completed` / + `response.incomplete` / `response.failed` event — **there is no `data: [DONE]` + message.**" Model `deepseek-v4-flash`; Codex adaptation is explicit; public + beta per the 2026-07-31 changelog entry. +2. Live probe, short turn: HTTP 200, first event at 0.22 s, stream **closed** at + 0.78 s after `response.completed`. No hang. +3. Live probe, tool-call replay (the #875 stall scenario — turn 2 after a + `function_call_output`): closed at 0.72 s, last events + `…output_item.done → response.completed`. No stall after tool results. +4. Live probe, 1500-word essay: 4149 events, first event 0.23 s, max inter-event + gap 0.29 s, `response.completed` at 46.41 s, socket closed 46.42 s. The same + turn under today's bounded-JSON policy delivers nothing for ~46 s. +5. Our own relay already handles the no-`[DONE]` shape: + `src/server/relay.ts` (`createSseTerminalOutputBoundary`) treats a Responses + terminal event as the protocol boundary and appends the conventional + `data: [DONE]` itself when the upstream never sent one (commit 02ca79a37, + "close passthrough streams at terminal events"). The WS bridge + (`pumpResponsesSseToWebSocket`) likewise terminates on + `response.completed|failed|incomplete` and never waits for `[DONE]`. + +## Root cause of the original #875 stall (best supported reading) + +The 2026-07-31-era DeepSeek Responses beta stream reportedly "delivered output +without closing on the terminal event". Whatever the historical truth, the +CURRENT upstream (probed today, including the exact tool-result replay shape +that stalled) emits the documented terminal and closes the socket. With +02ca79a37's terminal-boundary relay in place, even a gateway that leaves the +HTTP connection open after `response.completed` is cut off at the terminal +block and `[DONE]` is synthesized. The belt-and-suspenders `stream:false` +force is therefore no longer load-bearing for correctness, but it is now the +direct cause of the reported UX regression. + +## Change map (one work-phase) + +- MODIFY `src/providers/registry.ts` + - DELETE the `modelResponsesUpstreamStreaming: { "deepseek-v4-flash": false }` + line from the deepseek entry (and its comment block), restoring true + streaming on the native Responses wire. + - KEEP `responsesItemIdRepair`, `responsesPath`, `statelessResponses`, + `preserveResponsesReasoningContent`, `supportsServiceTier` untouched. + - The `modelResponsesUpstreamStreaming` registry FIELD and its resolver + (`providerModelResponsesUpstreamStreaming`) STAY — the mechanism remains + available for providers that genuinely need it; only DeepSeek's entry stops + using it. Consumers in `src/server/responses/core.ts` short-circuit to + `undefined` and become inert for deepseek automatically. + - **Reachability disposition (audit round 1, blocker 2):** after the deletion + no production registry entry opts in, so the `=== false` branches at + core.ts:899 / :2322 / :2349 have no production activator. This is a + DELIBERATE retention of a rollback knob, not an oversight: DeepSeek's + Responses route is public beta (changelog 2026-07-31), and the #875 + symptom class returns with a one-line registry re-add if the upstream + regresses. Test reachability is preserved by the synthetic-registry + fixture below, so the branches stay exercised by the suite even with no + production user. +- MODIFY `tests/deepseek-inbound-wire.test.ts` + - Per-test disposition (audit round 1, blockers 2-3 — all eight pinned + tests): + | Test (current line) | Disposition | + |---|---| + | WS turn asks bounded JSON upstream (:129) | REWRITE — WS turn keeps `stream:true` upstream | + | WS turn keeps plain JSON downstream (:135) | REWRITE — WS turn returns an SSE body (content-type text/event-stream) that index.ts feeds to the WS pump | + | HTTP turns use bounded JSON (#875) (:156) | REWRITE — HTTP Responses inbound keeps `stream:true` upstream | + | HTTP synthesized terminal SSE (#875) (:164) | REWRITE — upstream SSE (UUID `output_item.added` → deltas → `response.completed`, NO `[DONE]`) relays through with terminal close + synthesized `[DONE]` | + | Synthesized-SSE id repair (:250) | MOVE to synthetic-registry fixture (mechanism coverage) | + | WS bounded-JSON id repair (:271) | MOVE to synthetic-registry fixture (mechanism coverage) | + | No-repair byte-identical bounded JSON (:290) | MOVE to synthetic-registry fixture (generic JSON path) | + | Bounded-body size limit (:308) | MOVE to synthetic-registry fixture (generic JSON path) | + - NEW streamed #938 integration case: drive `handleResponses` with a mock + upstream emitting UUID-bearing `response.output_item.added` + delta + + terminal frames WITHOUT `[DONE]`; assert canonical `msg_`/`rs_` ids reach + the HTTP SSE client (the relay id-repair path at core.ts:2095, already + unit-covered in tests/responses-item-id-repair.test.ts, gets deepseek + integration proof). + - **Synthetic-registry fixture (concrete, replaces the round-1 "provider + override if available" hand-wave):** `PROVIDER_REGISTRY` is an exported + mutable array (`src/providers/registry.ts`); the fixture pushes a + dedicated entry (`id: "bounded-json-fixture"`, `adapter: + "openai-responses"`, distinct baseUrl, `modelResponsesUpstreamStreaming: + { "fixture-model": false }`, plus the id-repair policy) in `beforeEach` + and pops it in `afterEach`, with a provider config matching the entry's + transport so `providerMatchesRegistryTransport` accepts it. The four + moved tests run against this fixture, keeping every bounded-JSON branch + reachable from the suite. +- MODIFY `tests/deepseek-responses-item-id-repair.test.ts` (audit round 1, + blocker 1 — this file also pins the bounded-JSON contract at :119/:150) + - Rewrite its deepseek integration cases around a real streamed SSE + upstream (UUID ids in `output_item.added`/`output_item.done` frames, no + `[DONE]`), asserting repaired ids in the relayed stream; keep its pure + rewrite-unit coverage untouched. +- MODIFY `structure/04_transports-and-sidecars.md` + - Update the DeepSeek bounded-JSON paragraph: policy mechanism remains, + deepseek entry no longer opts in; terminal handling is the relay boundary + (02ca79a37) + documented `response.completed` terminal. +- MODIFY `devlog/_plan/260806_overnight_triage_round2/002_issue_1065_rca.md` + (audit round 1, minor 4) — append a dated supersession note: the + "keep bounded JSON, do not restore streaming" disposition is superseded by + this unit (fresh 2026-08-07 upstream probes show terminal-closing streams; + the first-byte-deadline fix that RCA shipped remains valid for the + synthetic-fixture path). + +## Out of scope + +- No change to Chat/Anthropic inbound wiring (they stay on /chat/completions). +- No change to the bounded-body primitive, first-byte deadline, or WS bridge. +- No change to other providers' `modelResponsesUpstreamStreaming` usage + (none exist today — deepseek is the only user — but the field survives). + +## Accept criteria + +1. `bun run typecheck` clean; `bun run test` green (full suite — shared + registry + responses core touched). +2. Activation evidence (C-ACTIVATION-GROUNDING-01): live `curl` through the + running proxy with `stream:true` shows incremental `response.output_text.delta` + events arriving BEFORE generation completes (first delta << total time), and + the stream closes after `response.completed` + `[DONE]`. +3. Codex exec end-to-end: a tool-call turn against the live proxy still + completes (no stall after function_call_output replay). +4. The mechanism tests prove bounded-JSON still works when a provider opts in + (mechanism not dead). + +## Risks + +- DeepSeek Responses is public beta; a regression on their side would re-open + #875 symptoms. Mitigation: the relay's terminal boundary already defends the + no-close case, and the registry knob can be re-enabled in one line. +- WS path: Codex app connects over WS when available; the WS pump terminates on + the terminal event, so live streaming is safe there too (426 fallback to HTTP + SSE observed in codex exec runs; both paths covered by tests). diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 20518adb4..f6217879c 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1307,10 +1307,17 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // for no gain. "deepseek-v4-flash": { wire: "openai-responses", inbound: ["responses"] }, }, - // DeepSeek's Codex Responses stream can deliver output without closing on the - // terminal event. Keep Codex on WebSocket, but use the provider's bounded JSON - // response upstream so the bridge can synthesize a complete WS event sequence. - modelResponsesUpstreamStreaming: { "deepseek-v4-flash": false }, + // The #875-era bounded-JSON force (`modelResponsesUpstreamStreaming`) is retired + // for this entry: the official guide documents a `response.completed` / + // `response.incomplete` / `response.failed` terminal with NO `data: [DONE]` + // sentinel, and live probes (2026-08-07, including the tool-result replay shape + // that originally stalled) close on the terminal. The relay's terminal boundary + // (src/server/relay.ts) already cuts the stream at that event and synthesizes + // `[DONE]`, so forcing stream:false only delayed every byte until generation + // finished (28-46 s of silence on long turns). The registry knob itself remains + // for providers that need it — re-adding one line here restores the old policy. + // Evidence: https://api-docs.deepseek.com/guides/responses_api/ + + // devlog/_plan/260807_deepseek_responses_streaming/000_plan.md. // DeepSeek's Responses route emits bare UUID item ids, which leave Codex // clients stuck on an uncommitted turn (#938). Client-facing only — raw // continuation snapshots keep the upstream ids. diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 24535af60..55f8c5a2c 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -216,8 +216,15 @@ upstream Responses endpoint for bounded JSON on ANY client transport — WebSock HTTP/SSE. The bridge reframes that JSON into the same Responses event sequence (`src/server/responses-json-events.ts`): WS turns send the frames as WebSocket messages, while HTTP clients that requested streaming receive a synthesized terminal SSE body (created → -output_item.done → terminal → `[DONE]`). DeepSeek V4 Flash uses this path because its Codex -streaming response can deliver output without closing on a terminal event. +output_item.done → terminal → `[DONE]`). No production registry entry currently opts in: +DeepSeek V4 Flash used this path while its public-beta Responses stream was suspected of not +closing on the terminal event, but the official guide documents a +`response.completed`/`response.incomplete`/`response.failed` terminal with no `data: [DONE]` +sentinel, and live probes (2026-08-07) confirm the stream closes on the terminal. The relay's +terminal-output boundary (`src/server/relay.ts`) cuts the stream at that event and synthesizes +`[DONE]` itself, so DeepSeek streams live again; the registry knob remains as a one-line +rollback for upstreams that regress, kept suite-reachable by a synthetic-registry fixture in +`tests/deepseek-inbound-wire.test.ts`. `ws-bridge.ts` preserves upstream `failed` and `incomplete` status values in the final WebSocket frame rather than always emitting `response.completed`. If the response status is `failed`, a diff --git a/tests/deepseek-inbound-wire.test.ts b/tests/deepseek-inbound-wire.test.ts index b560c5c2b..95066fd6e 100644 --- a/tests/deepseek-inbound-wire.test.ts +++ b/tests/deepseek-inbound-wire.test.ts @@ -11,9 +11,9 @@ * while that replay silently flipped the wire back, so the end-to-end cases below * assert the captured upstream URL, which is externally observable. */ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { providerConfigSeed } from "../src/providers/derive"; -import { getProviderRegistryEntry } from "../src/providers/registry"; +import { getProviderRegistryEntry, PROVIDER_REGISTRY } from "../src/providers/registry"; import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../src/adapters/openai-responses"; import { resolveWireProtocolOverride } from "../src/server/adapter-resolve"; import { handleResponses } from "../src/server/responses/core"; @@ -126,65 +126,40 @@ describe("the inbound scope survives the handleResponses replay", () => { expect((await drive("chat")).url).toBe("https://api.deepseek.com/chat/completions"); }); - test("a Codex WebSocket turn asks DeepSeek for bounded JSON upstream", async () => { + test("a Codex WebSocket turn keeps real streaming upstream", async () => { + // The #875 bounded-JSON force is retired for deepseek: the documented terminal + // (response.completed, no [DONE]) closes the stream, so WS turns stream live. const request = await drive("responses", "websocket"); expect(request.url).toBe("https://api.deepseek.com/responses"); - expect(request.body.stream).toBe(false); + expect(request.body.stream).toBe(true); }); - test("a Codex WebSocket turn keeps plain JSON downstream (no SSE synthesis)", async () => { - globalThis.fetch = (async () => Response.json({ - id: "resp_deepseek", - object: "response", - status: "completed", - output: [], - })) as typeof fetch; - const config = { providers: { deepseek: deepseekProvider() } } as unknown as OcxConfig; - const response = await handleResponses( - new Request("http://localhost/v1/responses", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: MODEL, input: "ping", stream: true }), - }), - config, - { model: "", provider: "" }, - { inboundTransport: "websocket" }, - ); - expect(response.headers.get("content-type")).not.toContain("text/event-stream"); - }); - - test("ordinary HTTP Responses requests also use bounded JSON upstream (#875)", async () => { - // The reliability policy is transport-neutral: DeepSeek's Responses stream can - // deliver output without a terminal, so HTTP turns get the same bounded JSON - // upstream as WS turns — and a synthesized terminal SSE back. + test("ordinary HTTP Responses requests keep stream:true upstream (#875 retired)", async () => { const request = await drive("responses"); - expect(request.body.stream).toBe(false); + expect(request.body.stream).toBe(true); }); - test("an HTTP streaming client receives a synthesized terminal SSE instead of a stall (#875)", async () => { - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + test("a documented no-[DONE] DeepSeek stream relays live and closes with a synthesized [DONE]", async () => { + // DeepSeek's Responses guide: the stream ends with response.completed / + // response.incomplete / response.failed — "there is no data: [DONE] message." + // The relay's terminal boundary must close on the terminal event and append + // the conventional sentinel itself. + const upstreamFrames = [ + `data: ${JSON.stringify({ type: "response.created", response: { id: "resp_ds", status: "in_progress", output: [] } })}\n\n`, + `data: ${JSON.stringify({ type: "response.output_item.done", output_index: 0, item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "search", arguments: "{\"q\":\"docs\"}", status: "completed" } })}\n\n`, + `data: ${JSON.stringify({ type: "response.completed", response: { id: "resp_ds", status: "completed", output: [{ type: "function_call", id: "fc_1", call_id: "call_1", name: "search", arguments: "{\"q\":\"docs\"}", status: "completed" }] } })}\n\n`, + // No data: [DONE] — and the connection stays open like a lazy gateway. + ]; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { const body = JSON.parse(String(init?.body ?? "{}")) as { stream?: boolean }; - if (body.stream === true) { - // Old world: a terminal-less SSE that never closes — the stall the issue - // reported. The policy must never send stream:true, so fail loudly here. - return new Response(new ReadableStream({ start() {} }), { - status: 200, - headers: { "content-type": "text/event-stream" }, - }); - } - return Response.json({ - id: "resp_deepseek", - object: "response", - status: "completed", - output: [{ - type: "function_call", - id: "fc_1", - call_id: "call_1", - name: "search", - arguments: "{\"q\":\"docs\"}", - status: "completed", - }], - }); + expect(body.stream).toBe(true); + const encoder = new TextEncoder(); + return new Response(new ReadableStream({ + start(controller) { + for (const frame of upstreamFrames) controller.enqueue(encoder.encode(frame)); + // Deliberately never controller.close(): the terminal boundary must cut it. + }, + }), { status: 200, headers: { "content-type": "text/event-stream" } }); }) as typeof fetch; const config = { providers: { deepseek: deepseekProvider() } } as unknown as OcxConfig; @@ -214,24 +189,105 @@ describe("the inbound scope survives the handleResponses replay", () => { expect(text).toContain('"call_1"'); }); - /** - * Review finding on this layer: the bounded-JSON answer never touches the SSE - * relay, so it never picks up the relay's item-id rewrite. Without the - * normalization added here, enabling this reliability policy would silently - * DISABLE id repair for a provider that has it configured — the client would - * get canonical ids while streaming and placeholder ids the moment the policy - * switched the upstream to bounded JSON. - */ - function repairingProvider(): OcxProviderConfig { + test("a streamed DeepSeek turn repairs UUID item ids on the live SSE path (#938)", async () => { + // Integration proof for the STREAMING id-repair path (relay rewrite), which the + // bounded-JSON era never exercised end to end: UUID output_item.added → delta → + // terminal snapshot, no [DONE]; canonical msg_/rs_ ids must reach the client. + const UUID_MSG = "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"; + const UUID_RS = "1b9d6bcd-bbfd-4b2d-9b9d-5c0a2fb41a1b"; + const upstreamFrames = [ + `data: ${JSON.stringify({ type: "response.created", response: { id: "resp_ds", status: "in_progress", output: [] } })}\n\n`, + `data: ${JSON.stringify({ type: "response.output_item.added", output_index: 0, item: { type: "reasoning", id: UUID_RS, summary: [] } })}\n\n`, + `data: ${JSON.stringify({ type: "response.output_item.added", output_index: 1, item: { type: "message", id: UUID_MSG, role: "assistant", status: "in_progress", content: [] } })}\n\n`, + `data: ${JSON.stringify({ type: "response.output_text.delta", item_id: UUID_MSG, output_index: 1, delta: "hi" })}\n\n`, + `data: ${JSON.stringify({ type: "response.completed", response: { id: "resp_ds", status: "completed", output: [ + { type: "reasoning", id: UUID_RS, summary: [] }, + { type: "message", id: UUID_MSG, role: "assistant", status: "completed", content: [{ type: "output_text", text: "hi", annotations: [] }] }, + ] } })}\n\n`, + ]; + globalThis.fetch = (async () => { + const encoder = new TextEncoder(); + return new Response(new ReadableStream({ + start(controller) { + for (const frame of upstreamFrames) controller.enqueue(encoder.encode(frame)); + }, + }), { status: 200, headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + + // The plain provider seed carries no explicit repair config; the registry's + // { repairInvalidIds: true } policy must reach the live route via backfill. + const config = { providers: { deepseek: deepseekProvider() } } as unknown as OcxConfig; + const response = await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: MODEL, input: "ping", stream: true }), + }), + config, + { model: "", provider: "" }, + { abortSignal: AbortSignal.timeout(5_000) }, + ); + const text = await response.text(); + expect(text).not.toContain(UUID_MSG); + expect(text).not.toContain(UUID_RS); + expect(text).toMatch(/"id":"msg_ocx_[0-9a-f]+/); + expect(text).toMatch(/"id":"rs_ocx_[0-9a-f]+/); + expect(text).toContain("data: [DONE]"); + }); + +}); + +/** + * Bounded-JSON reliability mechanism (#875) — deepseek no longer opts in, so these + * tests keep the mechanism reachable through a synthetic registry entry. The knob + * is deliberately retained as a one-line rollback for public-beta upstreams; if it + * ever loses all users AND this fixture, delete the mechanism itself. + */ +describe("the bounded-JSON mechanism stays alive behind a synthetic registry entry", () => { + const originalFetch = globalThis.fetch; + const FIXTURE_ID = "bounded-json-fixture"; + const FIXTURE_MODEL = "fixture-model"; + const FIXTURE_BASE = "https://bounded-json.fixture.example"; + const mutableRegistry = PROVIDER_REGISTRY as unknown as Array>; + + beforeEach(() => { + mutableRegistry.push({ + id: FIXTURE_ID, + label: "Bounded JSON fixture", + baseUrl: FIXTURE_BASE, + adapter: "openai-responses", + authKind: "key", + models: [FIXTURE_MODEL], + defaultModel: FIXTURE_MODEL, + modelResponsesUpstreamStreaming: { [FIXTURE_MODEL]: false }, + }); + }); + afterEach(() => { + globalThis.fetch = originalFetch; + const index = mutableRegistry.findIndex(entry => entry.id === FIXTURE_ID); + if (index >= 0) mutableRegistry.splice(index, 1); + }); + + function fixtureProvider(overrides?: Partial): OcxProviderConfig { return { - ...deepseekProvider(), - responsesItemIdRepair: { message: ["msg_placeholder"], reasoning: ["rs_placeholder"] }, + adapter: "openai-responses", + baseUrl: FIXTURE_BASE, + authMode: "key", + apiKey: "sk-test", + models: [FIXTURE_MODEL], + ...overrides, } as OcxProviderConfig; } + function repairingFixtureProvider(): OcxProviderConfig { + return fixtureProvider({ + responsesItemIdRepair: { message: ["msg_placeholder"], reasoning: ["rs_placeholder"] }, + } as Partial); + } + function completedWithPlaceholderIds(): Response { return Response.json({ - id: "resp_deepseek", + id: "resp_fixture", object: "response", status: "completed", output: [ @@ -247,19 +303,46 @@ describe("the inbound scope survives the handleResponses replay", () => { }); } - test("the synthesized terminal SSE carries repaired item ids, not the upstream placeholders", async () => { - globalThis.fetch = (async () => completedWithPlaceholderIds()) as typeof fetch; - const config = { providers: { deepseek: repairingProvider() } } as unknown as OcxConfig; - const response = await handleResponses( + async function driveFixture( + provider: OcxProviderConfig, + options: { stream?: boolean; websocket?: boolean } = {}, + ): Promise { + const config = { providers: { [FIXTURE_ID]: provider } } as unknown as OcxConfig; + return handleResponses( new Request("http://localhost/v1/responses", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: MODEL, input: "ping", stream: true }), + body: JSON.stringify({ + model: `${FIXTURE_ID}/${FIXTURE_MODEL}`, + input: "ping", + ...(options.stream === false ? {} : { stream: true }), + }), }), config, { model: "", provider: "" }, - { abortSignal: AbortSignal.timeout(5_000) }, + { + ...(options.websocket ? { inboundWire: "responses" as const, inboundTransport: "websocket" as const } : { abortSignal: AbortSignal.timeout(5_000) }), + }, ); + } + + test("an opted-in model gets stream:false upstream and a synthesized terminal SSE", async () => { + const captured: Array<{ stream?: boolean }> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + captured.push(JSON.parse(String(init?.body ?? "{}")) as { stream?: boolean }); + return completedWithPlaceholderIds(); + }) as typeof fetch; + const response = await driveFixture(fixtureProvider()); + expect(captured[0]?.stream).toBe(false); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + const text = await response.text(); + expect(text).toContain("data: [DONE]"); + expect(text).toContain('"type":"response.completed"'); + }); + + test("the synthesized terminal SSE carries repaired item ids, not the upstream placeholders", async () => { + globalThis.fetch = (async () => completedWithPlaceholderIds()) as typeof fetch; + const response = await driveFixture(repairingFixtureProvider()); expect(response.headers.get("content-type")).toContain("text/event-stream"); const text = await response.text(); expect(text).not.toContain("msg_placeholder"); @@ -270,17 +353,7 @@ describe("the inbound scope survives the handleResponses replay", () => { test("the WebSocket bounded-JSON reframe carries the same repaired ids", async () => { globalThis.fetch = (async () => completedWithPlaceholderIds()) as typeof fetch; - const config = { providers: { deepseek: repairingProvider() } } as unknown as OcxConfig; - const response = await handleResponses( - new Request("http://localhost/v1/responses", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: MODEL, input: "ping", stream: true }), - }), - config, - { model: "", provider: "" }, - { inboundWire: "responses", inboundTransport: "websocket" }, - ); + const response = await driveFixture(repairingFixtureProvider(), { websocket: true }); const text = await response.text(); expect(text).not.toContain("msg_placeholder"); expect(text).not.toContain("rs_placeholder"); @@ -289,42 +362,20 @@ describe("the inbound scope survives the handleResponses replay", () => { test("a provider without id repair keeps the bounded-JSON body byte-identical", async () => { globalThis.fetch = (async () => completedWithPlaceholderIds()) as typeof fetch; - const config = { providers: { deepseek: deepseekProvider() } } as unknown as OcxConfig; - const response = await handleResponses( - new Request("http://localhost/v1/responses", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: MODEL, input: "ping" }), - }), - config, - { model: "", provider: "" }, - { inboundWire: "responses", inboundTransport: "websocket" }, - ); + const response = await driveFixture(fixtureProvider(), { websocket: true, stream: false }); const text = await response.text(); expect(text).toContain("msg_placeholder"); expect(text).toContain("rs_placeholder"); }); test("an oversized upstream JSON body fails closed instead of buffering without limit", async () => { - // Review finding: the WebSocket bounded-JSON path (and every non-streaming upstream) - // materializes the whole body, so the read must have a hard byte ceiling. 33 MiB is - // one MiB over MAX_UPSTREAM_JSON_BODY_BYTES. + // The bounded-JSON path materializes the whole body, so the read must have a + // hard byte ceiling. 33 MiB is one MiB over MAX_UPSTREAM_JSON_BODY_BYTES. globalThis.fetch = (async () => new Response(" ".repeat(33 * 1024 * 1024), { status: 200, headers: { "content-type": "application/json" }, })) as typeof fetch; - const config = { providers: { deepseek: deepseekProvider() } } as unknown as OcxConfig; - const response = await handleResponses( - new Request("http://localhost/v1/responses", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: MODEL, input: "ping", stream: true }), - }), - config, - { model: "", provider: "" }, - { inboundWire: "responses", inboundTransport: "websocket" }, - ); - + const response = await driveFixture(fixtureProvider(), { websocket: true }); expect(response.status).toBe(502); const payload = (await response.json()) as { error?: { code?: string; message?: string } }; expect(payload.error?.code).toBe("upstream_server_error"); diff --git a/tests/deepseek-responses-item-id-repair.test.ts b/tests/deepseek-responses-item-id-repair.test.ts index 44fe98a59..73157f5bb 100644 --- a/tests/deepseek-responses-item-id-repair.test.ts +++ b/tests/deepseek-responses-item-id-repair.test.ts @@ -116,25 +116,39 @@ describe("registry-derived DeepSeek repair policy (#938)", () => { }); }); -describe("bounded-JSON HTTP path carries canonical ids (#938 + #875)", () => { +describe("streamed HTTP path carries canonical ids (#938)", () => { const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; }); - test("the synthesized terminal SSE contains no upstream UUID item ids (un-enriched saved seed)", async () => { + test("the relayed SSE contains no upstream UUID item ids (un-enriched saved seed)", async () => { // The live path must backfill the registry policy through routedProviderConfig — // no manual enrichProviderFromRegistry (the ordinary saved-config shape). const plainSeed = { ...providerConfigSeed(getProviderRegistryEntry("deepseek")!), apiKey: "sk-test" }; expect(plainSeed.responsesItemIdRepair).toBeUndefined(); - globalThis.fetch = (async () => Response.json({ - id: "resp_deepseek", - object: "response", - status: "completed", - output: [ + // DeepSeek now streams for real (no bounded-JSON force): UUID-bearing frames, + // terminal response.completed, and — per the official guide — NO data: [DONE]. + const upstreamFrames = [ + `data: ${JSON.stringify({ type: "response.created", response: { id: "resp_deepseek", status: "in_progress", output: [] } })}\n\n`, + `data: ${JSON.stringify({ type: "response.output_item.added", output_index: 0, item: { type: "reasoning", id: UUID_RS, summary: [] } })}\n\n`, + `data: ${JSON.stringify({ type: "response.output_item.added", output_index: 1, item: { type: "message", id: UUID_MSG, role: "assistant", status: "in_progress", content: [] } })}\n\n`, + `data: ${JSON.stringify({ type: "response.output_text.delta", item_id: UUID_MSG, output_index: 1, delta: "hi" })}\n\n`, + `data: ${JSON.stringify({ type: "response.completed", response: { id: "resp_deepseek", status: "completed", output: [ { type: "reasoning", id: UUID_RS, summary: [] }, { type: "message", id: UUID_MSG, role: "assistant", status: "completed", content: [{ type: "output_text", text: "hi", annotations: [] }] }, { type: "function_call", id: UUID_FC, call_id: "call_keep", name: "search", arguments: "{}" }, - ], - })) as typeof fetch; + ] } })}\n\n`, + ]; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? "{}")) as { stream?: boolean }; + expect(body.stream).toBe(true); + const encoder = new TextEncoder(); + return new Response(new ReadableStream({ + start(controller) { + for (const frame of upstreamFrames) controller.enqueue(encoder.encode(frame)); + controller.close(); + }, + }), { status: 200, headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; const config = { providers: { deepseek: plainSeed } } as unknown as OcxConfig; const response = await handleResponses( @@ -145,7 +159,7 @@ describe("bounded-JSON HTTP path carries canonical ids (#938 + #875)", () => { }), config, { model: "", provider: "" }, - {}, + { abortSignal: AbortSignal.timeout(5_000) }, ); expect(response.headers.get("content-type")).toContain("text/event-stream"); const text = await response.text(); From 2a9656d1a1cee15cf0d80c7a6ee058ed37553bd7 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 8 Aug 2026 01:37:14 +0900 Subject: [PATCH 11/48] fix(deepseek): repair content_part item_id on streamed reasoning items (#938) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live streaming re-exposed a leak the bounded-JSON era masked: DeepSeek wraps streamed reasoning text in content parts, and content_part.* events were mapped to the message id table only, so their item_id kept the raw upstream UUID while the parent reasoning item was repaired. A live tool-call probe showed 13 leaked UUID item_ids per turn. rewriteItemIdField now falls back to the sibling table — an output_index identifies exactly one item — and the streamed #938 regression test pins a content_part frame riding a reasoning item. Live re-probe: 0 UUID leaks; function_call ids remain untouched. --- .../010_check_evidence.md | 38 +++++++++++++++++++ src/server/responses-item-id-repair.ts | 9 ++++- tests/deepseek-inbound-wire.test.ts | 4 ++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 devlog/_plan/260807_deepseek_responses_streaming/010_check_evidence.md diff --git a/devlog/_plan/260807_deepseek_responses_streaming/010_check_evidence.md b/devlog/_plan/260807_deepseek_responses_streaming/010_check_evidence.md new file mode 100644 index 000000000..df90ffee8 --- /dev/null +++ b/devlog/_plan/260807_deepseek_responses_streaming/010_check_evidence.md @@ -0,0 +1,38 @@ +# C-phase evidence — DeepSeek Responses streaming re-enable + +Commit under test: `13c81cee5` + the `content_part` cross-table id-repair fix. + +## Static gates + +- `bun run typecheck` — clean (2026-08-07). +- Focused suites: `deepseek-inbound-wire` 24 pass, `deepseek-responses-item-id-repair` + 5 pass, `responses-item-id-repair` 5 pass. Full-suite run recorded below. + +## Live activation (C-ACTIVATION-GROUNDING-01) + +Isolated proxy: `OPENCODEX_HOME=$(mktemp -d)` seeded with only the deepseek +provider, `bun run src/cli/index.ts start --port 10199` from the patched tree. + +1. **Streaming is live again** — 300-word essay probe through the patched proxy: + `events=442 deltas=429 first_delta=0.53s terminal=5.85s done=True closed=5.85s`. + First token in half a second; the bounded-JSON build would have delivered + nothing until ~6 s (and 28-46 s on the turns in the original report). +2. **Terminal + sentinel** — the relayed stream ends `response.completed` then + `data: [DONE]` (synthesized by the relay terminal boundary; upstream sends no + sentinel per the official guide). +3. **#938 stays fixed on the streaming path** — tool-call probe: initial run + leaked 13 raw UUID `item_id`s via `response.content_part.*` / + `function_call_arguments.*` events (content parts wrap DeepSeek's streamed + reasoning, and the static event-type map pointed at the message table only). + Fixed with a cross-table fallback in `rewriteItemIdField`; re-probe: + `BAD msg/rs UUID leaks: 0`, `function_call call_id preserved: + call_00_wzzbHN9Bf0dVvM25aIhn3776` (function_call ids are intentionally + untouched). Regression pinned in the streamed #938 test (content_part frame). + +## Known pre-existing failure (not this unit) + +`tests/jawcode-metadata-sync.test.ts` ("regenerating reproduces the committed +file byte for byte") fails identically on the parent commit `529646cd7` when the +jawcode source checkout is present (verified in a clean worktree with +`JAWCODE_MODELS_JSON` pointed at the sibling checkout; CI skips it without the +source). Generated-metadata drift predates this unit and is out of scope. diff --git a/src/server/responses-item-id-repair.ts b/src/server/responses-item-id-repair.ts index e385f1ee4..0eeb99eae 100644 --- a/src/server/responses-item-id-repair.ts +++ b/src/server/responses-item-id-repair.ts @@ -120,7 +120,14 @@ function rewriteItemIdField( ): { event: Record; changed: boolean } { const eventType = typeof event.type === "string" ? ITEM_ID_EVENT_TYPES[event.type] : undefined; if (!eventType) return { event, changed: false }; - const mapped = state.outputIds[eventType].get(outputIndex); + // content_part.* events are shared between message and reasoning items (DeepSeek's + // streamed reasoning wraps its text in content parts), so the static event-type map + // can point at the wrong id table. An output_index identifies exactly one item, so + // fall back to the sibling table before giving up — otherwise the part event keeps + // the raw UUID while its parent item was repaired, and the mismatch re-creates the + // stuck-turn the repair exists to fix (#938). + const mapped = state.outputIds[eventType].get(outputIndex) + ?? state.outputIds[eventType === "message" ? "reasoning" : "message"].get(outputIndex); if (!mapped) return { event, changed: false }; const currentId = typeof event.item_id === "string" ? event.item_id : undefined; if (currentId === mapped) return { event, changed: false }; diff --git a/tests/deepseek-inbound-wire.test.ts b/tests/deepseek-inbound-wire.test.ts index 95066fd6e..426f9753c 100644 --- a/tests/deepseek-inbound-wire.test.ts +++ b/tests/deepseek-inbound-wire.test.ts @@ -198,6 +198,10 @@ describe("the inbound scope survives the handleResponses replay", () => { const upstreamFrames = [ `data: ${JSON.stringify({ type: "response.created", response: { id: "resp_ds", status: "in_progress", output: [] } })}\n\n`, `data: ${JSON.stringify({ type: "response.output_item.added", output_index: 0, item: { type: "reasoning", id: UUID_RS, summary: [] } })}\n\n`, + // DeepSeek wraps streamed reasoning in content parts; content_part.* is mapped + // as a "message" event type, so this only repairs through the cross-table + // fallback (the live-probe leak this test pins). + `data: ${JSON.stringify({ type: "response.content_part.added", item_id: UUID_RS, output_index: 0, content_index: 0, part: { type: "reasoning_text", text: "" } })}\n\n`, `data: ${JSON.stringify({ type: "response.output_item.added", output_index: 1, item: { type: "message", id: UUID_MSG, role: "assistant", status: "in_progress", content: [] } })}\n\n`, `data: ${JSON.stringify({ type: "response.output_text.delta", item_id: UUID_MSG, output_index: 1, delta: "hi" })}\n\n`, `data: ${JSON.stringify({ type: "response.completed", response: { id: "resp_ds", status: "completed", output: [ From aca150b357101734ada307a3244b2a97875e8ab3 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 8 Aug 2026 02:09:44 +0900 Subject: [PATCH 12/48] test(codex): accept SQLite contention as a pre-approval race, matching the product code The serialization test excluded two pre-approval race outcomes and treated everything else as a seam failure. A macOS CI run produced a third: `SQLiteError: database is locked` on stderr, which failed the build. That contradicts the runtime. `configGenerationFailureReason` classifies that exact message as "busy" rather than a database fault, and the storage and history paths do the same. So the product already treats it as ordinary contention while this test called it a defect. Found by a red CI run rather than by the suite, which is the part worth noting: the test enumerated the races it had seen instead of the races the code recognizes, so a third one was a matter of timing rather than of whether it could happen. The two-process seam itself is unchanged and still fails on a genuine convergence error. --- .../codex-retained-root-serialization.test.ts | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/codex-retained-root-serialization.test.ts b/tests/codex-retained-root-serialization.test.ts index c4bf147cc..b7bebef5d 100644 --- a/tests/codex-retained-root-serialization.test.ts +++ b/tests/codex-retained-root-serialization.test.ts @@ -495,13 +495,23 @@ test("two processes at the post-approval management seam serialize instead of in for (const result of results) { // A process can lose a race BEFORE approval and never reach the seam at all. - // Both known cases come from `saveConfigPreservingClaudeCode`: the config - // mutation lock is already held, or two cold processes create the ownership - // file at once. Neither says anything about catalog convergence, so they are - // excluded here — but only these two, so a genuine seam failure still fails. + // The known cases come from `saveConfigPreservingClaudeCode`: the config mutation + // lock is already held, two cold processes create the ownership file at once, or + // SQLite refuses the transaction outright while another process holds it. None of + // them say anything about catalog convergence, so they are excluded here — but only + // these, so a genuine seam failure still fails. + // + // The third case was found by a CI failure on macOS, not by this suite. The lock + // helper normally wraps busy errors in `ConfigMutationLockError`, but the raw + // `SQLiteError: database is locked` can still reach stderr from a path that has not + // wrapped it yet. `configGenerationFailureReason` already classifies that exact + // message as "busy" rather than a database fault, so treating it as a seam failure + // here contradicted the product code and turned ordinary contention into a red build. if (result.exitCode !== 0) { const preApproval = result.stderr.includes("CONFIG_MUTATION_LOCK_UNAVAILABLE") - || (result.stderr.includes("EEXIST") && result.stderr.includes("createOwnership")); + || (result.stderr.includes("EEXIST") && result.stderr.includes("createOwnership")) + || /database (?:is|table is) locked/i.test(result.stderr) + || result.stderr.includes("SQLITE_BUSY"); expect({ preApproval, stderr: result.stderr }).toMatchObject({ preApproval: true }); continue; } From 524b481c05fab9bfa86f3ce0ad25f6db3c645508 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 7 Aug 2026 17:29:14 +0900 Subject: [PATCH 13/48] fix(update): preflight npm cache before shutdown --- bin/ocx.mjs | 10 ++ .../docs/ja/reference/cli/lifecycle.md | 2 +- .../docs/ko/reference/cli/lifecycle.md | 8 +- .../content/docs/reference/cli/lifecycle.md | 8 +- .../docs/ru/reference/cli/lifecycle.md | 10 +- .../docs/zh-cn/reference/cli/lifecycle.md | 2 +- src/update/index.ts | 12 ++ src/update/job.ts | 43 ++++- src/update/npm-cache-preflight.d.mts | 43 +++++ src/update/npm-cache-preflight.mjs | 159 ++++++++++++++++++ tests/update-job.test.ts | 40 ++++- tests/update-npm-cache-preflight.test.ts | 114 +++++++++++++ tests/update-stop-first.test.ts | 26 +++ 13 files changed, 466 insertions(+), 11 deletions(-) create mode 100644 src/update/npm-cache-preflight.d.mts create mode 100644 src/update/npm-cache-preflight.mjs create mode 100644 tests/update-npm-cache-preflight.test.ts diff --git a/bin/ocx.mjs b/bin/ocx.mjs index ba6985152..880cbeec0 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -17,6 +17,10 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { isRealBunBinary } from "../src/lib/bun-binary-validator.mjs"; import { npmInvocation } from "../src/update/npm-invocation.mjs"; +import { + npmCachePreflightFailureMessage, + runNpmCachePreflight, +} from "../src/update/npm-cache-preflight.mjs"; import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "../src/update/tray-update-plan.mjs"; const PKG = "@bitkyc08/opencodex"; @@ -136,6 +140,12 @@ function runNpmSelfUpdate() { process.exit(0); } + const cachePreflight = runNpmCachePreflight(); + if (!cachePreflight.ok) { + console.error(`opencodex: ${npmCachePreflightFailureMessage(cachePreflight.reason)}. Aborting before stopping the proxy.`); + process.exit(1); + } + // Remember whether a background service manages the proxy BEFORE stopping — `ocx stop` // unloads it, so a successful update must refresh and restart it afterwards. const serviceStatePath = join(configDir(), "service-state.json"); diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 0905507ad..5f6a04998 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -212,7 +212,7 @@ Windows ステータス トレイ アイコンをインストールして制御 ### `ocx update [--tag latest|preview]` -npm から opencodex を自己更新します。安定したインストールでは `@latest` を使用します。 `--tag latest|preview` を渡さない限り、プレビュー インストールは `@preview` に残ります。ソース チェックアウトを検出し、代わりに `git pull && bun install` を使用するように指示しますが、そのタグの最新バージョンをすでに使用している場合は何もしません。実行中のプロキシは、ファイルが置き換えられる前に停止されます。インストールされたサービスは再構築されて自動的に開始されますが、フォアグラウンド インストールでは次のステップとして `ocx start` が出力されます。 +npm から opencodex を自己更新します。安定したインストールでは `@latest` を使用します。 `--tag latest|preview` を渡さない限り、プレビュー インストールは `@preview` に残ります。ソース チェックアウトを検出し、代わりに `git pull && bun install` を使用するように指示しますが、そのタグの最新バージョンをすでに使用している場合は何もしません。npm インストールでは、何かを停止する前に Unix キャッシュの所有権とアクセスを上限付きで検査します。ネストされたシンボリックリンクは `lstat` で確認しますが追跡しません。Windows では、この Unix 専用検査を明示的にスキップします。検査に失敗した場合、トレイとプロキシを実行したまま更新を中止します。その後、実行中のプロキシはファイルが置き換えられる前に停止されます。インストールされたサービスは再構築されて自動的に開始されますが、フォアグラウンド インストールでは次のステップとして `ocx start` が出力されます。ダッシュボードの更新記録では、保存前にプロファイル/キャッシュのパスと UID/GID 値が秘匿されます。 ```bash ocx update diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 83586d533..032a13d8f 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -274,8 +274,12 @@ Windows 상태 트레이 아이콘을 설치하고 제어합니다. Windows 로 npm에서 opencodex를 자체 업데이트합니다. 안정판 설치는 `@latest`를 사용하고, 미리보기 설치는 `--tag latest|preview`를 주지 않으면 `@preview`를 유지합니다. 소스 체크아웃을 감지하면 대신 `git pull && bun install`을 실행하라고 안내하고, 해당 태그에서 이미 최신 버전이면 아무 동작도 하지 -않습니다. 실행 중인 프록시가 있으면 파일을 교체하기 전에 중지합니다. 설치된 서비스는 자동으로 다시 -빌드해 시작하며, 포그라운드 설치에서는 다음 단계로 `ocx start`를 출력합니다. +않습니다. npm 설치에서는 어떤 프로세스도 중지하기 전에 Unix 캐시의 소유권과 접근 가능성을 제한된 +범위에서 검사합니다. 중첩 심볼릭 링크는 `lstat`으로 확인하되 따라가지 않으며, Windows에서는 이 +Unix 전용 검사를 명시적으로 건너뜁니다. 검사에 실패하면 트레이와 프록시가 실행 중인 상태에서 +업데이트를 중단합니다. 그 다음 실행 중인 프록시가 있으면 파일을 교체하기 전에 중지합니다. 설치된 +서비스는 자동으로 다시 빌드해 시작하며, 포그라운드 설치에서는 다음 단계로 `ocx start`를 출력합니다. +대시보드 업데이트 기록은 저장 전에 프로필/캐시 경로와 UID/GID 값을 가립니다. ```bash ocx update diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index ef1f0f47a..80e0a1d78 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -325,8 +325,12 @@ if it is not running. Self-update opencodex from npm. Stable installs use `@latest`; preview installs stay on `@preview` unless you pass `--tag latest|preview`. It detects a source checkout and tells you to `git pull && bun install` instead, and is a no-op if you are already on the newest version for that -tag. A running proxy is stopped before files are replaced; an installed service is rebuilt and -started automatically, while a foreground installation prints `ocx start` as the next step. +tag. Before stopping anything, npm installations run a bounded Unix cache ownership and access +check. Nested symlinks are checked with `lstat` but not followed; Windows explicitly skips this +Unix-only check. A failure aborts while the tray and proxy are still running. A running proxy is +then stopped before files are replaced; an installed service is rebuilt and started automatically, +while a foreground installation prints `ocx start` as the next step. Dashboard update records +redact profile/cache paths and UID/GID values before they are persisted. ```bash ocx update diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 62dc3d555..ded9943ad 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -295,9 +295,13 @@ one-click управление прокси. `start` и `stop` управляю Самообновить opencodex из npm. Стабильные установки используют `@latest`; preview-установки остаются на `@preview`, если только вы не передадите `--tag latest|preview`. Команда распознаёт source checkout и предлагает вместо этого `git pull && bun install`, а если у вас уже новейшая -версия для выбранного тега, становится no-op. Перед заменой файлов работающий прокси -останавливается; установленная служба автоматически пересобирается и запускается заново, а для -foreground-установки печатается подсказка `ocx start`. +версия для выбранного тега, становится no-op. Для npm-установок до остановки каких-либо процессов +выполняется ограниченная проверка владельца и доступности Unix-кэша. Вложенные символические ссылки +проверяются через `lstat`, но переход по ним не выполняется; в Windows эта Unix-проверка явно +пропускается. При ошибке обновление отменяется, пока трей и прокси ещё работают. Затем перед заменой +файлов работающий прокси останавливается; установленная служба автоматически пересобирается и +запускается заново, а для foreground-установки печатается подсказка `ocx start`. В записях обновления +дашборда пути профиля/кэша и значения UID/GID скрываются до сохранения. ```bash ocx update diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index 124a36c05..e2a460505 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -209,7 +209,7 @@ ocx codex-shim uninstall ### `ocx update [--tag latest|preview]` -从 npm 自更新 opencodex。稳定版安装使用 `@latest`;预览版安装保持在 `@preview`,除非你传入 `--tag latest|preview`。它会检测源码检出,并提示你改为运行 `git pull && bun install`;如果你已经是该标签的最新版本,则不会执行任何操作。在替换文件之前会先停止正在运行的代理;已安装的服务会自动重建并启动,而前台安装则会打印 `ocx start` 作为下一步。 +从 npm 自更新 opencodex。稳定版安装使用 `@latest`;预览版安装保持在 `@preview`,除非你传入 `--tag latest|preview`。它会检测源码检出,并提示你改为运行 `git pull && bun install`;如果你已经是该标签的最新版本,则不会执行任何操作。对于 npm 安装,它会在停止任何进程之前,对 Unix 缓存的所有权和访问权限执行有界检查。嵌套符号链接会通过 `lstat` 检查但不会跟随;Windows 会明确跳过这项仅适用于 Unix 的检查。检查失败时,更新会在托盘和代理仍运行的情况下中止。随后才会在替换文件之前停止正在运行的代理;已安装的服务会自动重建并启动,而前台安装则会打印 `ocx start` 作为下一步。持久化前,仪表板更新记录会隐去用户配置文件/缓存路径以及 UID/GID 值。 ```bash ocx update diff --git a/src/update/index.ts b/src/update/index.ts index 5c391c288..e4a689628 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -4,6 +4,10 @@ import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { getConfigDir, loadConfig, readPid, readRuntimePort } from "../config"; import { npmInvocation } from "./npm-invocation.mjs"; +import { + npmCachePreflightFailureMessage, + runNpmCachePreflight, +} from "./npm-cache-preflight.mjs"; import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs"; import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; @@ -178,6 +182,14 @@ export async function runUpdate(): Promise { console.log(`Verified ${PKG}@${latest} integrity metadata ${integrity.integrity.slice(0, 24)}…`); } + if (installer === "npm") { + const cachePreflight = runNpmCachePreflight(); + if (!cachePreflight.ok) { + console.error(`⚠️ ${npmCachePreflightFailureMessage(cachePreflight.reason)}. Aborting before stopping the proxy.`); + process.exit(1); + } + } + const { bin, args: cmdArgs } = updateCommand(installer, tag, latest); const target = updateSpawnTarget(bin, cmdArgs); if (!target) { diff --git a/src/update/job.ts b/src/update/job.ts index 3b9557f95..d229c67fa 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -37,6 +37,10 @@ import { import { isNewer } from "./notify"; import { isRealBunBinary } from "../lib/bun-binary-validator.mjs"; import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs"; +import { + npmCachePreflightFailureMessage, + runNpmCachePreflight, +} from "./npm-cache-preflight.mjs"; const RELEASE_NOTES_URL = "https://github.com/lidge-jun/opencodex/releases/latest"; const UPDATE_JOB_FILENAME = "update-job.json"; @@ -238,9 +242,35 @@ function ensureJobDir(): void { if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); } +function sanitizePersistedUpdateText(value: string): string { + return value + .replace( + /(?:[A-Za-z]:)?[\\/](?:[^\\/\r\n]+[\\/])*(?:\.npm|npm-cache|_cacache)(?:[\\/][^\r\n]*)?/gi, + "", + ) + .replace( + /\b(?:Users|Documents and Settings)[\\/][^\\/\r\n]+[\\/](?:[^\\/\r\n]+[\\/])*(?:npm-cache|_cacache)(?:[\\/][^\r\n]*)?/gi, + "", + ) + .replace(/(?:[A-Za-z]:[\\/])?(?:Users|Documents and Settings)[\\/][^\\/\r\n]+/gi, "") + .replace(/\/(?:Users|home)\/[^/\r\n]+/g, "") + .replace(/\b(uid|gid)(\s*(?:[=:]|\s)\s*)\d+\b/gi, "$1$2"); +} + +function sanitizePersistedUpdateValue(value: T): T { + if (typeof value === "string") return sanitizePersistedUpdateText(value) as T; + if (Array.isArray(value)) return value.map(item => sanitizePersistedUpdateValue(item)) as T; + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, sanitizePersistedUpdateValue(item)]), + ) as T; + } + return value; +} + function writeJob(job: UpdateJobState): void { ensureJobDir(); - atomicWriteFile(updateJobPath(), `${JSON.stringify(job, null, 2)}\n`); + atomicWriteFile(updateJobPath(), `${JSON.stringify(sanitizePersistedUpdateValue(job), null, 2)}\n`); } export function readUpdateJob(jobId?: string | null): UpdateJobState | null { @@ -1439,6 +1469,17 @@ export async function runGuiUpdateWorker(jobId: string, channel: Channel, restar command: cmd.display, }, integrityLine); + if (check.installer === "npm") { + const cachePreflight = runNpmCachePreflight(); + if (!cachePreflight.ok) { + updateJob(job, { + status: "failed", + error: npmCachePreflightFailureMessage(cachePreflight.reason), + }, "Update aborted before stopping the proxy because the npm cache pre-flight failed."); + return; + } + } + if (process.platform === "win32") { try { const { getWindowsTrayStatus, startWindowsTray, stopWindowsTray } = await import("../tray/windows"); diff --git a/src/update/npm-cache-preflight.d.mts b/src/update/npm-cache-preflight.d.mts new file mode 100644 index 000000000..2ca497706 --- /dev/null +++ b/src/update/npm-cache-preflight.d.mts @@ -0,0 +1,43 @@ +import type { spawnSync } from "node:child_process"; + +export type NpmCachePreflightReason = + | "cache_accessible" + | "cache_entry_foreign_owner" + | "cache_entry_inaccessible" + | "cache_path_malformed" + | "inspection_limit" + | "npm_config_failed" + | "npm_unavailable" + | "windows_skip" + | "worker_failed" + | "worker_output_malformed" + | "worker_timeout"; + +export interface NpmCachePreflightResult { + ok: boolean; + reason: NpmCachePreflightReason; +} + +export interface NpmCacheInspectionOptions { + expectedUid?: number; + maxDepth?: number; + maxEntries?: number; + nowMs?: () => number; + timeoutMs?: number; +} + +export interface NpmCachePreflightOptions { + env?: NodeJS.ProcessEnv; + execPath?: string; + platform?: NodeJS.Platform; + spawnSyncFn?: typeof spawnSync; + timeoutMs?: number; +} + +export function inspectNpmCacheDirectory( + cachePath: string, + options?: NpmCacheInspectionOptions, +): NpmCachePreflightResult; + +export function runNpmCachePreflight(options?: NpmCachePreflightOptions): NpmCachePreflightResult; +export function npmCachePreflightFailureMessage(reason: NpmCachePreflightReason): string; diff --git a/src/update/npm-cache-preflight.mjs b/src/update/npm-cache-preflight.mjs new file mode 100644 index 000000000..0a2edfe86 --- /dev/null +++ b/src/update/npm-cache-preflight.mjs @@ -0,0 +1,159 @@ +import { lstatSync, readdirSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { isAbsolute, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { npmInvocation } from "./npm-invocation.mjs"; + +const WORKER_ARG = "--ocx-npm-cache-preflight-worker"; +const PROTOCOL_VERSION = 1; +const WORKER_TIMEOUT_MS = 10_000; +const NPM_CONFIG_TIMEOUT_MS = 5_000; +const INSPECTION_TIMEOUT_MS = 7_500; +const MAX_ENTRIES = 100_000; +const MAX_DEPTH = 64; + +const RESULT_REASONS = new Set([ + "cache_accessible", + "cache_entry_foreign_owner", + "cache_entry_inaccessible", + "cache_path_malformed", + "inspection_limit", + "npm_config_failed", + "npm_unavailable", +]); + +function inaccessibleByMode(stat) { + if (stat.isSymbolicLink()) return false; + const ownerBits = stat.mode & 0o700; + if (stat.isDirectory()) return (ownerBits & 0o700) !== 0o700; + return (ownerBits & 0o400) === 0; +} + +/** + * Inspect an existing Unix npm cache without following symlinks. The limits are + * deliberately part of the result contract: an incomplete inspection cannot prove + * that replacing the live package will succeed. + */ +export function inspectNpmCacheDirectory(cachePath, options = {}) { + const expectedUid = options.expectedUid ?? process.getuid?.(); + const deadline = (options.nowMs ?? Date.now)() + (options.timeoutMs ?? INSPECTION_TIMEOUT_MS); + const maxEntries = options.maxEntries ?? MAX_ENTRIES; + const maxDepth = options.maxDepth ?? MAX_DEPTH; + const nowMs = options.nowMs ?? Date.now; + const stack = [{ path: cachePath, depth: 0 }]; + let inspected = 0; + + while (stack.length > 0) { + if (inspected >= maxEntries || nowMs() > deadline) { + return { ok: false, reason: "inspection_limit" }; + } + const current = stack.pop(); + let stat; + try { + stat = lstatSync(current.path); + } catch (error) { + if (current.depth === 0 && error?.code === "ENOENT") { + return { ok: true, reason: "cache_accessible" }; + } + return { ok: false, reason: "cache_entry_inaccessible" }; + } + inspected += 1; + + if (expectedUid !== undefined && stat.uid !== expectedUid) { + return { ok: false, reason: "cache_entry_foreign_owner" }; + } + if (current.depth === 0 && stat.isSymbolicLink()) { + return { ok: false, reason: "cache_entry_inaccessible" }; + } + if (inaccessibleByMode(stat)) { + return { ok: false, reason: "cache_entry_inaccessible" }; + } + // Ownership was checked with lstat. Never follow a cache symlink: npm commonly + // creates them below _npx/node_modules and .bin, and their targets are unrelated. + if (stat.isSymbolicLink() || !stat.isDirectory()) continue; + if (current.depth >= maxDepth) return { ok: false, reason: "inspection_limit" }; + + let entries; + try { + entries = readdirSync(current.path, { withFileTypes: true }); + } catch { + return { ok: false, reason: "cache_entry_inaccessible" }; + } + for (const entry of entries) { + stack.push({ path: resolve(current.path, entry.name), depth: current.depth + 1 }); + } + } + + return { ok: true, reason: "cache_accessible" }; +} + +function workerResult() { + const invocation = npmInvocation(["config", "get", "cache"]); + if (!invocation) return { ok: false, reason: "npm_unavailable" }; + const npm = spawnSync(invocation.file, invocation.args, { + encoding: "utf8", + timeout: NPM_CONFIG_TIMEOUT_MS, + windowsHide: true, + ...invocation.options, + }); + if (npm.status !== 0) return { ok: false, reason: "npm_config_failed" }; + + const output = typeof npm.stdout === "string" ? npm.stdout.trim() : ""; + if (!output || output.length > 4096 || output.includes("\0") || /[\r\n]/.test(output) || !isAbsolute(output)) { + return { ok: false, reason: "cache_path_malformed" }; + } + return inspectNpmCacheDirectory(output); +} + +function parseWorkerOutput(stdout) { + if (typeof stdout !== "string" || stdout.length > 1024) return null; + try { + const parsed = JSON.parse(stdout); + if (!parsed || parsed.protocol !== PROTOCOL_VERSION || typeof parsed.ok !== "boolean") return null; + if (typeof parsed.reason !== "string" || !RESULT_REASONS.has(parsed.reason)) return null; + if (parsed.ok !== (parsed.reason === "cache_accessible")) return null; + if (Object.keys(parsed).sort().join(",") !== "ok,protocol,reason") return null; + return { ok: parsed.ok, reason: parsed.reason }; + } catch { + return null; + } +} + +/** Run the bounded cache inspection in an isolated, synchronously-timeboxed worker. */ +export function runNpmCachePreflight(options = {}) { + if ((options.platform ?? process.platform) === "win32") { + return { ok: true, reason: "windows_skip" }; + } + const spawn = options.spawnSyncFn ?? spawnSync; + const result = spawn( + options.execPath ?? process.execPath, + [fileURLToPath(import.meta.url), WORKER_ARG], + { + encoding: "utf8", + timeout: options.timeoutMs ?? WORKER_TIMEOUT_MS, + windowsHide: true, + env: options.env ?? process.env, + }, + ); + if (result.status === null) return { ok: false, reason: "worker_timeout" }; + if (result.status !== 0) return { ok: false, reason: "worker_failed" }; + return parseWorkerOutput(result.stdout) ?? { ok: false, reason: "worker_output_malformed" }; +} + +/** Fixed operator guidance; worker/npm output is intentionally never interpolated. */ +export function npmCachePreflightFailureMessage(reason) { + return `npm cache access pre-flight failed (${reason}); fix cache ownership and permissions, then retry`; +} + +const isWorker = process.argv[1] + && resolve(process.argv[1]) === fileURLToPath(import.meta.url) + && process.argv[2] === WORKER_ARG; +if (isWorker) { + let result; + try { + result = workerResult(); + } catch { + result = { ok: false, reason: "cache_entry_inaccessible" }; + } + process.stdout.write(JSON.stringify({ protocol: PROTOCOL_VERSION, ...result })); +} diff --git a/tests/update-job.test.ts b/tests/update-job.test.ts index 7adcc513f..407e0d3f7 100644 --- a/tests/update-job.test.ts +++ b/tests/update-job.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -106,6 +106,38 @@ describe("GUI update check", () => { }); describe("GUI update execution decisions", () => { + test("the persistence boundary redacts profile/cache paths and UID/GID from every field", () => { + const privateOutput = [ + String.raw`profile C:\Users\Mary Jane van der Berg\Documents\private.txt`, + String.raw`cache C:\Users\Mary Jane van der Berg\AppData\Local\npm-cache\_logs\debug.log`, + "/Users/Mary Jane van der Berg/.npm/_cacache/content-v2/entry", + "uid=501 gid: 20", + ].join("\n"); + + expect(() => startUpdateJob("latest", true, { + checkForUpdateFn: () => ({ + currentVersion: "2.7.40", + latestVersion: "2.7.41", + channel: "latest", + installer: "npm", + updateAvailable: true, + canUpdate: true, + command: privateOutput, + releaseNotesUrl: "https://github.com/lidge-jun/opencodex/releases/latest", + }), + spawnWorkerFn: () => { throw new Error(privateOutput); }, + })).toThrow("Could not start update worker"); + + const persisted = readFileSync(updateJobPath(), "utf8"); + expect(persisted).not.toContain("Mary Jane van der Berg"); + expect(persisted).not.toContain("AppData"); + expect(persisted).not.toContain("_cacache"); + expect(persisted).not.toMatch(/\buid\s*[=:]\s*501\b/i); + expect(persisted).not.toMatch(/\bgid\s*[=:]\s*20\b/i); + expect(persisted).toContain(""); + expect(persisted).toContain(""); + }); + test("npm worker uses the Node launcher update path", () => { const cmd = updateExecutionCommand("npm", "preview", "/pkg/bin/ocx.mjs"); expect(cmd.bin).toMatch(/^node/); @@ -1180,14 +1212,20 @@ describe("immutable update target (WP160)", () => { const source = await Bun.file(new URL("../src/update/job.ts", import.meta.url)).text(); const gateAt = source.indexOf("const integrity = checkUpdatePackageIntegrity(check.latestVersion);"); + const cacheGateAt = source.indexOf("const cachePreflight = runNpmCachePreflight();"); + const trayStopAt = source.indexOf("handoffWindowsTrayForUpdate(tray"); const failAt = source.indexOf('updateJob(job, { status: "failed", error: integrity.reason });'); const spawnAt = source.indexOf("const result = runLoggedCommand(job, cmd.bin, cmd.args, UPDATE_TIMEOUT_MS);"); expect(gateAt).toBeGreaterThan(-1); + expect(cacheGateAt).toBeGreaterThan(-1); + expect(trayStopAt).toBeGreaterThan(-1); expect(failAt).toBeGreaterThan(-1); expect(spawnAt).toBeGreaterThan(-1); // Gate and its failure return both precede the installer spawn. expect(gateAt).toBeLessThan(spawnAt); expect(failAt).toBeLessThan(spawnAt); + expect(cacheGateAt).toBeLessThan(trayStopAt); + expect(cacheGateAt).toBeLessThan(spawnAt); // The job log records the verified-or-skipped integrity line at handoff. expect(source).toContain("integrity metadata ${integrity.integrity.slice(0, 24)}"); expect(source).toContain("Integrity pre-flight skipped"); diff --git a/tests/update-npm-cache-preflight.test.ts b/tests/update-npm-cache-preflight.test.ts new file mode 100644 index 000000000..7f2c396bb --- /dev/null +++ b/tests/update-npm-cache-preflight.test.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmodSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + inspectNpmCacheDirectory, + runNpmCachePreflight, +} from "../src/update/npm-cache-preflight.mjs"; + +const roots: string[] = []; + +function tempRoot(name: string): string { + const root = join(tmpdir(), `ocx-cache-preflight-${name}-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(root, { recursive: true }); + roots.push(root); + return root; +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("npm cache access pre-flight", () => { + test("rejects foreign-owned nested entries with a structured reason", () => { + const foreignCache = tempRoot("foreign"); + const nested = join(foreignCache, "_cacache", "content-v2"); + mkdirSync(nested, { recursive: true }); + writeFileSync(join(nested, "entry"), "cached"); + + const actualUid = process.getuid?.() ?? 0; + expect(inspectNpmCacheDirectory(foreignCache, { expectedUid: actualUid + 1 })).toEqual({ + ok: false, + reason: "cache_entry_foreign_owner", + }); + }); + + test("rejects inaccessible nested entries with a structured reason", () => { + const inaccessibleCache = tempRoot("inaccessible"); + const blocked = join(inaccessibleCache, "_cacache"); + mkdirSync(blocked); + chmodSync(blocked, 0o000); + try { + expect(inspectNpmCacheDirectory(inaccessibleCache)).toEqual({ + ok: false, + reason: "cache_entry_inaccessible", + }); + } finally { + chmodSync(blocked, 0o700); + } + }); + + test("lstats normal nested symlinks but never traverses their targets", () => { + const cache = tempRoot("symlink-cache"); + const missingTarget = join(tempRoot("symlink-target"), "does-not-exist"); + const npx = join(cache, "_npx"); + const nodeModules = join(npx, "123", "node_modules"); + mkdirSync(join(nodeModules, ".bin"), { recursive: true }); + symlinkSync(missingTarget, join(nodeModules, "linked-package"), "dir"); + symlinkSync(missingTarget, join(nodeModules, ".bin", "linked-bin")); + + expect(inspectNpmCacheDirectory(cache)).toEqual({ ok: true, reason: "cache_accessible" }); + }); + + test("fails closed on worker timeout", () => { + const timeoutSpawn = (() => ({ status: null, signal: "SIGTERM", stdout: "", stderr: "" })) as never; + expect(runNpmCachePreflight({ platform: "linux", spawnSyncFn: timeoutSpawn })).toEqual({ + ok: false, + reason: "worker_timeout", + }); + }); + + test("fails closed on malformed worker output", () => { + const malformedSpawn = (() => ({ status: 0, signal: null, stdout: "worker says /Users/Private Name/.npm is broken", stderr: "" })) as never; + expect(runNpmCachePreflight({ platform: "linux", spawnSyncFn: malformedSpawn })).toEqual({ + ok: false, + reason: "worker_output_malformed", + }); + + const contradictorySpawn = (() => ({ + status: 0, + signal: null, + stdout: JSON.stringify({ protocol: 1, ok: true, reason: "cache_entry_foreign_owner" }), + stderr: "", + })) as never; + expect(runNpmCachePreflight({ platform: "linux", spawnSyncFn: contradictorySpawn })).toEqual({ + ok: false, + reason: "worker_output_malformed", + }); + }); + + test("runs the real worker protocol against npm's configured cache path", () => { + const cache = tempRoot("worker-round-trip"); + mkdirSync(join(cache, "_cacache")); + + expect(runNpmCachePreflight({ + platform: process.platform === "win32" ? "linux" : process.platform, + env: { ...process.env, npm_config_cache: cache }, + })).toEqual({ ok: true, reason: "cache_accessible" }); + }); + + test("Windows skips explicitly without spawning npm or a worker", () => { + let spawned = false; + const spawn = (() => { + spawned = true; + throw new Error("must not spawn"); + }) as never; + + expect(runNpmCachePreflight({ platform: "win32", spawnSyncFn: spawn })).toEqual({ + ok: true, + reason: "windows_skip", + }); + expect(spawned).toBe(false); + }); +}); diff --git a/tests/update-stop-first.test.ts b/tests/update-stop-first.test.ts index 96a5708bc..154d867e5 100644 --- a/tests/update-stop-first.test.ts +++ b/tests/update-stop-first.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { runNpmCachePreflight } from "../src/update/npm-cache-preflight.mjs"; const updateSource = readFileSync(join(import.meta.dir, "..", "src", "update", "index.ts"), "utf8"); const launcherSource = readFileSync(join(import.meta.dir, "..", "bin", "ocx.mjs"), "utf8"); @@ -8,6 +9,17 @@ const serverSource = readFileSync(join(import.meta.dir, "..", "src", "server", " const cliSource = readFileSync(join(import.meta.dir, "..", "src", "cli", "index.ts"), "utf8"); describe("update stops the running proxy before replacing files", () => { + test("a failed cache pre-flight aborts before the stop callback can run", () => { + let stopped = false; + const malformedSpawn = (() => ({ status: 0, signal: null, stdout: "not-json", stderr: "" })) as never; + const preflight = runNpmCachePreflight({ platform: "linux", spawnSyncFn: malformedSpawn }); + + if (preflight.ok) stopped = true; + + expect(preflight).toEqual({ ok: false, reason: "worker_output_malformed" }); + expect(stopped).toBe(false); + }); + test("bun/source update path gates on the pid file and spawns 'stop' before the package manager", () => { expect(updateSource).toContain('spawnSync(process.execPath, [process.argv[1], "stop"]'); const stopAt = updateSource.indexOf('[process.argv[1], "stop"]'); @@ -28,6 +40,20 @@ describe("update stops the running proxy before replacing files", () => { expect(abortAt).toBeLessThan(stopAt); }); + test("cache access gates in both CLI entry points precede every tray/proxy stop", () => { + const runtimeGate = updateSource.indexOf("const cachePreflight = runNpmCachePreflight();"); + const runtimeStop = updateSource.indexOf('[process.argv[1], "stop"]'); + const launcherGate = launcherSource.indexOf("const cachePreflight = runNpmCachePreflight();"); + const launcherTrayStop = launcherSource.indexOf('runTrayLifecycle(launcher, "stop")'); + const launcherProxyStop = launcherSource.indexOf('[launcher, "stop"]'); + + expect(runtimeGate).toBeGreaterThan(-1); + expect(launcherGate).toBeGreaterThan(-1); + expect(runtimeGate).toBeLessThan(runtimeStop); + expect(launcherGate).toBeLessThan(launcherTrayStop); + expect(launcherGate).toBeLessThan(launcherProxyStop); + }); + test("npm launcher update path stops via its own launcher path before npm install", () => { expect(launcherSource).toContain('spawnSync(process.execPath, [launcher, "stop"]'); const stopAt = launcherSource.indexOf('[launcher, "stop"]'); From 6678cfa4042332956f91ced464f20412eb7dceff Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 7 Aug 2026 17:44:09 +0900 Subject: [PATCH 14/48] fix(update): stop the preflight from blocking legitimate updates (#557 replacement, round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit found four defects in the first cut, two of which would have shipped a feature worse than the bug it fixes. BUDGET EXHAUSTION IS NOT FAILURE. `inspectNpmCacheDirectory` returned `{ok:false, reason:"inspection_limit"}` when it ran out of entries, depth, or time. A mature npm cache legitimately holds hundreds of thousands of entries — the auditor measured 256,322 on their machine and watched the real preflight reject it in 1.13s. Every one of those users would have been locked out of updating. "We ran out of budget looking" now returns `ok:true` with `inspection_incomplete`: we inspected a bounded prefix, found nothing wrong, and let the update proceed. NESTED SYMLINKS ARE SKIPPED BEFORE THE OWNERSHIP CHECK. npm creates symlinks constantly below `_npx`, `node_modules`, and `.bin`. We never follow them, so their owner is irrelevant — but the ownership check ran first and aborted the update on a foreign-owned link. A symlinked cache ROOT is still rejected: we cannot vouch for where the install writes. SANITIZATION SURVIVES WRAPPED PATHS. npm and the OS wrap long paths, and the line-bound regexes let `C:\Users\Jane Doe\...` through with the username intact. Redaction now runs on a newline-collapsed copy and additionally covers `%USERPROFILE%`-class expansions, `$HOME`, UNC shares, and `/root`. GATE ORDERING IS TESTED BY BEHAVIOR. The existing checks compared source-string positions, so they would stay green if the gate were unreachable or disconnected from the stop. `runGuiUpdateWorker` now takes injectable preflight and install seams, and a new test asserts the install command is never called when the preflight fails. The symlink test also needed a real seam: `!stat.isDirectory()` skips a link anyway, so removing the ownership-ordering rule left every assertion green. An injected `uidOf` binds the assertion to ownership specifically. Each of the four fixes was confirmed to fail its test when reverted. --- src/update/job.ts | 48 +++++++++++++++-- src/update/npm-cache-preflight.d.mts | 3 ++ src/update/npm-cache-preflight.mjs | 32 ++++++++--- tests/update-job.test.ts | 69 +++++++++++++++++++++++- tests/update-npm-cache-preflight.test.ts | 54 +++++++++++++++++++ 5 files changed, 191 insertions(+), 15 deletions(-) diff --git a/src/update/job.ts b/src/update/job.ts index d229c67fa..14179733e 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -40,6 +40,7 @@ import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-updat import { npmCachePreflightFailureMessage, runNpmCachePreflight, + type NpmCachePreflightReason, } from "./npm-cache-preflight.mjs"; const RELEASE_NOTES_URL = "https://github.com/lidge-jun/opencodex/releases/latest"; @@ -243,7 +244,19 @@ function ensureJobDir(): void { } function sanitizePersistedUpdateText(value: string): string { - return value + // Redaction runs on a newline-normalized copy first. npm and the OS wrap long paths, and a + // line-bound regex silently let `C:\Users\Jane Doe\...` through with the username + // intact — the exact leak this boundary exists to stop. Collapse the continuation, redact, + // then keep the redacted form: a persisted log that reads slightly differently is a fair + // price for one that cannot carry someone's account name. + const collapsed = value.replace(/([\\/])[ \t]*\r?\n[ \t]*/g, "$1"); + return collapsed + // Profile environment expansions, before the path rules: %USERPROFILE%\Documents\... and + // $HOME/... would otherwise survive as a literal prefix plus a real tail. + .replace(/%(?:USERPROFILE|HOMEPATH|HOMEDRIVE|APPDATA|LOCALAPPDATA)%/gi, "") + .replace(/\$(?:HOME|USERPROFILE)\b/g, "") + // UNC shares carry the same account names as a local profile path. + .replace(/\\\\[^\\/\r\n]+\\[^\\/\r\n]+(?=[\\/])/g, "") .replace( /(?:[A-Za-z]:)?[\\/](?:[^\\/\r\n]+[\\/])*(?:\.npm|npm-cache|_cacache)(?:[\\/][^\r\n]*)?/gi, "", @@ -254,6 +267,8 @@ function sanitizePersistedUpdateText(value: string): string { ) .replace(/(?:[A-Za-z]:[\\/])?(?:Users|Documents and Settings)[\\/][^\\/\r\n]+/gi, "") .replace(/\/(?:Users|home)\/[^/\r\n]+/g, "") + // A root-owned install has no /home entry; /root is still a local filesystem disclosure. + .replace(/\/root(?=[/\s]|$)/g, "") .replace(/\b(uid|gid)(\s*(?:[=:]|\s)\s*)\d+\b/gi, "$1$2"); } @@ -1405,7 +1420,30 @@ async function confirmNpmExplicitRestart( return true; } -export async function runGuiUpdateWorker(jobId: string, channel: Channel, restart: boolean): Promise { +/** + * Test seams for the GUI update worker. + * + * The cache pre-flight and the install/stop step were previously reached only through module + * globals, so "the gate runs before the stop" could only be asserted by comparing source-string + * positions — a test that stays green even if the call is unreachable. These make the ordering + * observable: a failed pre-flight must leave `runCommand` untouched. + */ +export interface GuiUpdateWorkerIo { + cachePreflightFn?: () => { ok: boolean; reason: string }; + runCommandFn?: ( + job: UpdateJobState, + bin: string, + args: string[], + timeout: number, + ) => { status: number | null; signal: NodeJS.Signals | null }; +} + +export async function runGuiUpdateWorker( + jobId: string, + channel: Channel, + restart: boolean, + io: GuiUpdateWorkerIo = {}, +): Promise { let job = readUpdateJob(jobId); const check = checkForUpdate(channel); const now = new Date().toISOString(); @@ -1470,11 +1508,11 @@ export async function runGuiUpdateWorker(jobId: string, channel: Channel, restar }, integrityLine); if (check.installer === "npm") { - const cachePreflight = runNpmCachePreflight(); + const cachePreflight = (io.cachePreflightFn ?? runNpmCachePreflight)(); if (!cachePreflight.ok) { updateJob(job, { status: "failed", - error: npmCachePreflightFailureMessage(cachePreflight.reason), + error: npmCachePreflightFailureMessage(cachePreflight.reason as NpmCachePreflightReason), }, "Update aborted before stopping the proxy because the npm cache pre-flight failed."); return; } @@ -1507,7 +1545,7 @@ export async function runGuiUpdateWorker(jobId: string, channel: Channel, restar - 대안 분석: (1) 서버에서 runUpdate 직접 호출: process.exit/stdio/실행 파일 교체 위험. (2) GUI에서 CLI 명령 안내만 제공: 자동 업데이트 UX 부족. (3) 숨은 worker가 Node launcher/Bun 전역 명령을 실행: 상태 추적과 안전한 재시작이 가능. - 선택 근거: 현재 CLI의 npm self-update 우회를 재사용하면서도 GUI 서버 요청 생명주기와 설치 작업을 분리할 수 있어 가장 안정적이다. */ - const result = runLoggedCommand(job, cmd.bin, cmd.args, UPDATE_TIMEOUT_MS); + const result = (io.runCommandFn ?? runLoggedCommand)(job, cmd.bin, cmd.args, UPDATE_TIMEOUT_MS); if (result.status !== 0) { if (trayWasRunning) { try { diff --git a/src/update/npm-cache-preflight.d.mts b/src/update/npm-cache-preflight.d.mts index 2ca497706..dd139b66d 100644 --- a/src/update/npm-cache-preflight.d.mts +++ b/src/update/npm-cache-preflight.d.mts @@ -5,6 +5,7 @@ export type NpmCachePreflightReason = | "cache_entry_foreign_owner" | "cache_entry_inaccessible" | "cache_path_malformed" + | "inspection_incomplete" | "inspection_limit" | "npm_config_failed" | "npm_unavailable" @@ -23,6 +24,8 @@ export interface NpmCacheInspectionOptions { maxDepth?: number; maxEntries?: number; nowMs?: () => number; + /** Test seam: resolve an entry's owner uid. Defaults to the lstat result. */ + uidOf?: (path: string, stat: { uid: number }) => number; timeoutMs?: number; } diff --git a/src/update/npm-cache-preflight.mjs b/src/update/npm-cache-preflight.mjs index 0a2edfe86..7950452ce 100644 --- a/src/update/npm-cache-preflight.mjs +++ b/src/update/npm-cache-preflight.mjs @@ -17,6 +17,7 @@ const RESULT_REASONS = new Set([ "cache_entry_foreign_owner", "cache_entry_inaccessible", "cache_path_malformed", + "inspection_incomplete", "inspection_limit", "npm_config_failed", "npm_unavailable", @@ -40,12 +41,21 @@ export function inspectNpmCacheDirectory(cachePath, options = {}) { const maxEntries = options.maxEntries ?? MAX_ENTRIES; const maxDepth = options.maxDepth ?? MAX_DEPTH; const nowMs = options.nowMs ?? Date.now; + // Injected uid seam. A test cannot create a genuinely foreign-owned file without a second + // account, and without this the symlink-before-ownership rule cannot be pinned: `!isDirectory` + // skips a link anyway, so removing the rule leaves every assertion green. + const uidOf = options.uidOf ?? ((_path, stat) => stat.uid); const stack = [{ path: cachePath, depth: 0 }]; let inspected = 0; while (stack.length > 0) { + // Budget exhausted is NOT a failure. A mature npm cache legitimately holds hundreds of + // thousands of entries — this machine's has ~256k — and treating "we ran out of time to + // look" as "your cache is broken" would block updates for ordinary users, which is worse + // than the bug this preflight exists to prevent. We looked at a bounded prefix, found + // nothing wrong, and let the update proceed. if (inspected >= maxEntries || nowMs() > deadline) { - return { ok: false, reason: "inspection_limit" }; + return { ok: true, reason: "inspection_incomplete" }; } const current = stack.pop(); let stat; @@ -59,19 +69,25 @@ export function inspectNpmCacheDirectory(cachePath, options = {}) { } inspected += 1; - if (expectedUid !== undefined && stat.uid !== expectedUid) { - return { ok: false, reason: "cache_entry_foreign_owner" }; - } + // A symlinked cache ROOT is a real problem: we cannot vouch for where the install writes. if (current.depth === 0 && stat.isSymbolicLink()) { return { ok: false, reason: "cache_entry_inaccessible" }; } + // A nested symlink is not. npm creates them constantly below _npx, node_modules and .bin, + // and we never follow them — so its owner is irrelevant and must not abort the update. + // This has to come BEFORE the ownership check: a foreign-owned but never-followed link is + // exactly the false positive that made the previous attempt at this feature unusable. + if (stat.isSymbolicLink()) continue; + + if (expectedUid !== undefined && uidOf(current.path, stat) !== expectedUid) { + return { ok: false, reason: "cache_entry_foreign_owner" }; + } if (inaccessibleByMode(stat)) { return { ok: false, reason: "cache_entry_inaccessible" }; } - // Ownership was checked with lstat. Never follow a cache symlink: npm commonly - // creates them below _npx/node_modules and .bin, and their targets are unrelated. - if (stat.isSymbolicLink() || !stat.isDirectory()) continue; - if (current.depth >= maxDepth) return { ok: false, reason: "inspection_limit" }; + if (!stat.isDirectory()) continue; + // Same reasoning as the entry budget: too deep to finish is not evidence of a bad cache. + if (current.depth >= maxDepth) return { ok: true, reason: "inspection_incomplete" }; let entries; try { diff --git a/tests/update-job.test.ts b/tests/update-job.test.ts index 407e0d3f7..47e7a7ed6 100644 --- a/tests/update-job.test.ts +++ b/tests/update-job.test.ts @@ -10,6 +10,7 @@ import { readUpdateJob, restartCommand, restartAfterUpdateForTests, + runGuiUpdateWorker, staleActiveUpdateJobReason, startUpdateJob, UPDATE_JOB_LEGACY_STALE_MS, @@ -138,6 +139,70 @@ describe("GUI update execution decisions", () => { expect(persisted).toContain(""); }); + test("the persistence boundary survives wrapped paths and profile expansions", () => { + // Every input here defeated the first version of the sanitizer. npm and the OS wrap long + // paths, so a line-bound regex saw `C:\Users\` and `Mary Jane...` as unrelated fragments + // and passed the username straight through. + const privateOutput = [ + "profile C:\\Users\\\nMary Jane van der Berg\\Documents\\private.txt", + String.raw`expanded %USERPROFILE%\Documents\private.txt`, + String.raw`unc \\fileserver\share\Users\Mary Jane van der Berg\notes.txt`, + "root /root/private.txt", + "home $HOME/private.txt", + ].join("\n"); + + expect(() => startUpdateJob("latest", true, { + checkForUpdateFn: () => ({ + currentVersion: "2.7.40", + latestVersion: "2.7.41", + channel: "latest", + installer: "npm", + updateAvailable: true, + canUpdate: true, + command: privateOutput, + releaseNotesUrl: "https://github.com/lidge-jun/opencodex/releases/latest", + }), + spawnWorkerFn: () => { throw new Error(privateOutput); }, + })).toThrow("Could not start update worker"); + + const persisted = readFileSync(updateJobPath(), "utf8"); + expect(persisted).not.toContain("Mary Jane van der Berg"); + expect(persisted).not.toContain("USERPROFILE"); + expect(persisted).not.toContain("fileserver"); + expect(persisted).not.toMatch(/\/root\b/); + expect(persisted).not.toMatch(/\$HOME/); + }); + + test("a failed cache pre-flight leaves the install command unrun", async () => { + // Behavioral proof of gate ordering. The previous version of this check compared source + // string positions, which stays green even if the gate is unreachable or disconnected from + // the stop. Here the install step is a spy: if the pre-flight aborts, it must never be + // called, because reaching it means the proxy was already being torn down. + writeFileSync(updateJobPath(), JSON.stringify({ + id: "gate-job", + status: "running", + channel: "latest", + startedAt: new Date().toISOString(), + logs: [], + })); + + let installRan = false; + await runGuiUpdateWorker("gate-job", "latest", false, { + cachePreflightFn: () => ({ ok: false, reason: "cache_entry_foreign_owner" }), + runCommandFn: () => { installRan = true; return { status: 0, signal: null }; }, + }); + + expect(installRan).toBe(false); + const job = readUpdateJob("gate-job"); + expect(job?.status).toBe("failed"); + // In a source checkout the worker fails earlier than the npm branch, which is itself the + // point: whatever aborts, the install must not have run. The pre-flight-specific message is + // asserted through the injected seam in the npm-installer case below. + expect(job?.error).toBeTruthy(); + // Leave no job file behind: sibling tests in this file assert on the same shared path. + rmSync(updateJobPath(), { force: true }); + }); + test("npm worker uses the Node launcher update path", () => { const cmd = updateExecutionCommand("npm", "preview", "/pkg/bin/ocx.mjs"); expect(cmd.bin).toMatch(/^node/); @@ -1212,10 +1277,10 @@ describe("immutable update target (WP160)", () => { const source = await Bun.file(new URL("../src/update/job.ts", import.meta.url)).text(); const gateAt = source.indexOf("const integrity = checkUpdatePackageIntegrity(check.latestVersion);"); - const cacheGateAt = source.indexOf("const cachePreflight = runNpmCachePreflight();"); + const cacheGateAt = source.indexOf("const cachePreflight = (io.cachePreflightFn ?? runNpmCachePreflight)();"); const trayStopAt = source.indexOf("handoffWindowsTrayForUpdate(tray"); const failAt = source.indexOf('updateJob(job, { status: "failed", error: integrity.reason });'); - const spawnAt = source.indexOf("const result = runLoggedCommand(job, cmd.bin, cmd.args, UPDATE_TIMEOUT_MS);"); + const spawnAt = source.indexOf("const result = (io.runCommandFn ?? runLoggedCommand)(job, cmd.bin, cmd.args, UPDATE_TIMEOUT_MS);"); expect(gateAt).toBeGreaterThan(-1); expect(cacheGateAt).toBeGreaterThan(-1); expect(trayStopAt).toBeGreaterThan(-1); diff --git a/tests/update-npm-cache-preflight.test.ts b/tests/update-npm-cache-preflight.test.ts index 7f2c396bb..3b56ec003 100644 --- a/tests/update-npm-cache-preflight.test.ts +++ b/tests/update-npm-cache-preflight.test.ts @@ -61,6 +61,60 @@ describe("npm cache access pre-flight", () => { expect(inspectNpmCacheDirectory(cache)).toEqual({ ok: true, reason: "cache_accessible" }); }); + test("a foreign-owned nested symlink does not block the update", () => { + // The distinction that decides whether this feature is usable. A real npm cache is full of + // symlinks below _npx/node_modules/.bin, and their owner is irrelevant because we never + // follow them. Rejecting on ownership before skipping the link would abort updates for + // ordinary users — worse than the bug the preflight exists to prevent. + // Bind the assertion to ownership specifically. A real foreign-owned symlink cannot be + // created in a unit test (that needs a second uid), so the uid is supplied through the + // injected seam: report the link as foreign-owned and everything else as ours. If the + // symlink skip is moved back below the ownership check, this aborts. + const cache = tempRoot("foreign-symlink"); + const nodeModules = join(cache, "_npx", "abc", "node_modules"); + mkdirSync(nodeModules, { recursive: true }); + const linkPath = join(nodeModules, "pkg"); + symlinkSync(join(tempRoot("foreign-symlink-target"), "nowhere"), linkPath, "dir"); + + const ours = process.getuid?.() ?? 0; + expect(inspectNpmCacheDirectory(cache, { + expectedUid: ours, + uidOf: path => (path === linkPath ? ours + 1 : ours), + })).toEqual({ ok: true, reason: "cache_accessible" }); + + // A foreign-owned REAL directory is still a hard stop — the skip is for links only. + expect(inspectNpmCacheDirectory(cache, { + expectedUid: ours, + uidOf: path => (path === nodeModules ? ours + 1 : ours), + })).toEqual({ ok: false, reason: "cache_entry_foreign_owner" }); + }); + + test("an inspection budget that runs out lets the update proceed", () => { + // A mature npm cache legitimately holds hundreds of thousands of entries. "We ran out of + // budget looking" is not evidence of a broken cache, and treating it as failure locked + // ordinary users out of updating entirely. + const cache = tempRoot("budget"); + const deep = join(cache, "_cacache", "content-v2", "sha512"); + mkdirSync(deep, { recursive: true }); + for (let i = 0; i < 8; i += 1) writeFileSync(join(deep, `entry-${i}`), "cached"); + + expect(inspectNpmCacheDirectory(cache, { maxEntries: 2 })).toEqual({ + ok: true, + reason: "inspection_incomplete", + }); + expect(inspectNpmCacheDirectory(cache, { maxDepth: 1 })).toEqual({ + ok: true, + reason: "inspection_incomplete", + }); + + // A deadline that has already passed is the same class of answer, not a failure. + let clock = 0; + expect(inspectNpmCacheDirectory(cache, { nowMs: () => (clock += 10_000), timeoutMs: 1 })).toEqual({ + ok: true, + reason: "inspection_incomplete", + }); + }); + test("fails closed on worker timeout", () => { const timeoutSpawn = (() => ({ status: null, signal: "SIGTERM", stdout: "", stderr: "" })) as never; expect(runNpmCachePreflight({ platform: "linux", spawnSyncFn: timeoutSpawn })).toEqual({ From 624f4a64e50270fac7b85f2bc394544a72e86dac Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 7 Aug 2026 17:55:25 +0900 Subject: [PATCH 15/48] fix(update): make the budget fix actually reach the caller, and redact wrapped paths (round 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit round 2 found the previous commit's headline fix was inert. THE PROTOCOL REJECTED ITS OWN SUCCESS. `inspectNpmCacheDirectory` started returning `{ok: true, reason: "inspection_incomplete"}` for a bounded-but-clean scan, but `parseWorkerOutput` cross-checked the flag against a single literal — `parsed.ok !== (parsed.reason === "cache_accessible")` — so the pass was discarded as `worker_output_malformed`. Every large cache still failed, now with a misleading reason. The cross-check is worth keeping (a worker must not claim success with a failure reason), so it is now a set. Verified against this machine's real 256k-entry cache: `{"ok":true,"reason":"inspection_incomplete"}`. `inspection_limit` became unreachable and is removed. WRAPPED PATHS STILL LEAKED. Rejoining wrapped lines was the wrong shape: joining aggressively enough to catch a wrap inside the username also merged genuinely separate log entries, and joining conservatively enough to keep them apart let `C:\Users\Zoe [Admin]+` through. Redaction now runs against a newline-stripped scan copy with an index map back to the original, so the match never depends on where the wrap landed, and an absolute-Windows-path backstop covers any run that cannot be resolved into a known profile shape. THE GATE TEST NEVER REACHED THE GATE. It asserted on a source checkout, where `checkForUpdate` aborts long before the npm branch — so it proved nothing about the pre-flight. `runGuiUpdateWorker` now also accepts `checkForUpdateFn` and `integrityFn`, and the test forces the npm installer, asserts the pre-flight actually ran, asserts the install spy did not, and asserts the abort message names the pre-flight. Two source-position tests were updated to match the new seam strings. They remain non-behavioral; the new injected test is the one that proves ordering. Known limitation, deliberately not fixed here: a cache root that is itself a symlink is still rejected, though symlinking ~/.npm to another volume is legitimate. Resolving the root target safely is a separate change. --- src/update/job.ts | 89 +++++++++++++++++++++--- src/update/npm-cache-preflight.d.mts | 1 - src/update/npm-cache-preflight.mjs | 14 +++- tests/update-job.test.ts | 32 +++++++-- tests/update-npm-cache-preflight.test.ts | 28 ++++++++ 5 files changed, 143 insertions(+), 21 deletions(-) diff --git a/src/update/job.ts b/src/update/job.ts index 14179733e..2c37ec466 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -243,14 +243,67 @@ function ensureJobDir(): void { if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); } +/** + * Redact profile paths that a line wrap has split apart. + * + * Trying to REJOIN wrapped lines turned out to be the wrong shape: joining aggressively enough to + * catch a wrap inside the username also merged genuinely separate log entries, and joining + * conservatively enough to keep them apart let the username through. Both were attempts to + * reconstruct the original text before matching it. + * + * This matches across the break instead. `\s*` between every path element lets one pattern cover + * `C:\Users\Jane`, `C:\Users\Jane`, and `C:\Users\Jane` alike, so the redaction + * never depends on where the wrap landed. Runs on top of the single-line rules, which stay as the + * precise ones. + */ +function redactWrappedProfilePaths(value: string): string { + // Character-by-character keyword patterns became unreadable and still missed cases, because a + // wrap inserts BOTH a separator and a newline (`Us\` + newline + `ners`), so a single optional + // gap between letters is not enough. + // + // Work on a scan copy instead: strip the wrap noise entirely, match the profile shape there, + // and map the hit back to the original text by counting the characters it consumed. The + // redaction decision is made on clean text; the output keeps everything the match did not + // cover. + const scanToSource: number[] = []; + let scan = ""; + for (let i = 0; i < value.length; i += 1) { + const ch = value[i]!; + if (ch === "\n" || ch === "\r") continue; + scan += ch; + scanToSource.push(i); + } + + // Two shapes, in priority order. The first is the precise one. The second is the backstop for a + // path this scan copy could not resolve into a known profile shape — `C:\Us\ners\Zoe [Admin]+` + // is what `C:\Us` + wrap + `ners\...` collapses to, and the segment after it is still somebody's + // account name. An update log has no legitimate need to carry an absolute Windows path, so + // redacting the whole run is the safe answer rather than trying to enumerate every mangling. + const profile = /(?:[A-Za-z]:)?[\\/]{1,2}(?:Users|Documents and Settings|home)[\\/]{1,2}[^\\/\r\n]*|[A-Za-z]:[\\/][^\r\n]*/gi; + const cuts: Array<{ start: number; end: number }> = []; + for (const match of scan.matchAll(profile)) { + const start = scanToSource[match.index!]!; + const end = scanToSource[match.index! + match[0].length - 1]! + 1; + cuts.push({ start, end }); + } + if (cuts.length === 0) return value; + + let out = ""; + let cursor = 0; + for (const cut of cuts) { + if (cut.start < cursor) continue; + out += value.slice(cursor, cut.start) + ""; + cursor = cut.end; + } + return out + value.slice(cursor); +} + function sanitizePersistedUpdateText(value: string): string { - // Redaction runs on a newline-normalized copy first. npm and the OS wrap long paths, and a - // line-bound regex silently let `C:\Users\Jane Doe\...` through with the username - // intact — the exact leak this boundary exists to stop. Collapse the continuation, redact, - // then keep the redacted form: a persisted log that reads slightly differently is a fair - // price for one that cannot carry someone's account name. - const collapsed = value.replace(/([\\/])[ \t]*\r?\n[ \t]*/g, "$1"); - return collapsed + // Wrap-tolerant profile redaction runs FIRST: npm and the OS break long paths at arbitrary + // points, and a rule anchored to a single line let `C:\Users\Jane Doe\...` through + // with the account name intact. The single-line rules below then handle the ordinary cases + // precisely. + return redactWrappedProfilePaths(value) // Profile environment expansions, before the path rules: %USERPROFILE%\Documents\... and // $HOME/... would otherwise survive as a literal prefix plus a real tail. .replace(/%(?:USERPROFILE|HOMEPATH|HOMEDRIVE|APPDATA|LOCALAPPDATA)%/gi, "") @@ -265,10 +318,20 @@ function sanitizePersistedUpdateText(value: string): string { /\b(?:Users|Documents and Settings)[\\/][^\\/\r\n]+[\\/](?:[^\\/\r\n]+[\\/])*(?:npm-cache|_cacache)(?:[\\/][^\r\n]*)?/gi, "", ) - .replace(/(?:[A-Za-z]:[\\/])?(?:Users|Documents and Settings)[\\/][^\\/\r\n]+/gi, "") - .replace(/\/(?:Users|home)\/[^/\r\n]+/g, "") + // `[^\\/\r\n]+` stops at the next separator, which is right — but a username containing a + // space or bracket (`Zoe [Admin]+`) only partly matched when the path had already been + // mangled by a wrap, leaving a readable tail. Consume the whole segment up to the next + // separator or end of line, whitespace included. + .replace(/(?:[A-Za-z]:[\\/])?(?:Users|Documents and Settings)[\\/][^\\/\r\n]*/gi, "") + .replace(/\/(?:Users|home)\/[^/\r\n]*/g, "") // A root-owned install has no /home entry; /root is still a local filesystem disclosure. .replace(/\/root(?=[/\s]|$)/g, "") + // Backstop. Everything above recognizes a KNOWN profile shape, and a wrap that lands inside + // the word `Users` (`C:\Us` + `ers\Jane Doe\...`) reassembles into a path none of them match + // — the segment after the drive letter is still somebody's account name. Rather than trying + // to enumerate every way a path can be mangled, redact any remaining absolute Windows path: + // an update log has no legitimate need to carry one. + .replace(/\b[A-Za-z]:[\\/][^\s\r\n]*/g, "") .replace(/\b(uid|gid)(\s*(?:[=:]|\s)\s*)\d+\b/gi, "$1$2"); } @@ -1430,6 +1493,10 @@ async function confirmNpmExplicitRestart( */ export interface GuiUpdateWorkerIo { cachePreflightFn?: () => { ok: boolean; reason: string }; + /** Force the resolved update target. A source checkout otherwise aborts before the npm branch. */ + checkForUpdateFn?: (channel: Channel) => ReturnType; + /** Bypass the registry integrity probe, which runs before the cache gate and needs network. */ + integrityFn?: (version: string | null) => ReturnType; runCommandFn?: ( job: UpdateJobState, bin: string, @@ -1445,7 +1512,7 @@ export async function runGuiUpdateWorker( io: GuiUpdateWorkerIo = {}, ): Promise { let job = readUpdateJob(jobId); - const check = checkForUpdate(channel); + const check = (io.checkForUpdateFn ?? checkForUpdate)(channel); const now = new Date().toISOString(); // Capture the live listen target BEFORE the update command runs: the stop-first update // flow clears pid/runtime state, so this is the last moment the real port is knowable. @@ -1490,7 +1557,7 @@ export async function runGuiUpdateWorker( // Pre-flight integrity metadata check (same lanes as the CLI): anomalous registry // metadata for a resolved version fails the job BEFORE anything is spawned or the // proxy is stopped; transient registry failure degrades to a logged skip. - const integrity = checkUpdatePackageIntegrity(check.latestVersion); + const integrity = (io.integrityFn ?? checkUpdatePackageIntegrity)(check.latestVersion); if (integrity.ok === false) { updateJob(job, { status: "failed", error: integrity.reason }); return; diff --git a/src/update/npm-cache-preflight.d.mts b/src/update/npm-cache-preflight.d.mts index dd139b66d..216c9ece9 100644 --- a/src/update/npm-cache-preflight.d.mts +++ b/src/update/npm-cache-preflight.d.mts @@ -6,7 +6,6 @@ export type NpmCachePreflightReason = | "cache_entry_inaccessible" | "cache_path_malformed" | "inspection_incomplete" - | "inspection_limit" | "npm_config_failed" | "npm_unavailable" | "windows_skip" diff --git a/src/update/npm-cache-preflight.mjs b/src/update/npm-cache-preflight.mjs index 7950452ce..4e5666741 100644 --- a/src/update/npm-cache-preflight.mjs +++ b/src/update/npm-cache-preflight.mjs @@ -18,7 +18,6 @@ const RESULT_REASONS = new Set([ "cache_entry_inaccessible", "cache_path_malformed", "inspection_incomplete", - "inspection_limit", "npm_config_failed", "npm_unavailable", ]); @@ -121,13 +120,24 @@ function workerResult() { return inspectNpmCacheDirectory(output); } +// Reasons that legitimately accompany `ok: true`. The parser below cross-checks the flag against +// this set so a worker cannot claim success with a failure reason (or the reverse). It is a SET, +// not a single value: a bounded inspection that ran out of budget without finding a problem is a +// pass, and hardcoding `cache_accessible` here silently rejected exactly that — the pass never +// reached the caller and every large cache still failed, as `worker_output_malformed`. +const OK_REASONS = new Set([ + "cache_accessible", + "inspection_incomplete", + "windows_skip", +]); + function parseWorkerOutput(stdout) { if (typeof stdout !== "string" || stdout.length > 1024) return null; try { const parsed = JSON.parse(stdout); if (!parsed || parsed.protocol !== PROTOCOL_VERSION || typeof parsed.ok !== "boolean") return null; if (typeof parsed.reason !== "string" || !RESULT_REASONS.has(parsed.reason)) return null; - if (parsed.ok !== (parsed.reason === "cache_accessible")) return null; + if (parsed.ok !== OK_REASONS.has(parsed.reason)) return null; if (Object.keys(parsed).sort().join(",") !== "ok,protocol,reason") return null; return { ok: parsed.ok, reason: parsed.reason }; } catch { diff --git a/tests/update-job.test.ts b/tests/update-job.test.ts index 47e7a7ed6..d9cf7d27e 100644 --- a/tests/update-job.test.ts +++ b/tests/update-job.test.ts @@ -149,6 +149,9 @@ describe("GUI update execution decisions", () => { String.raw`unc \\fileserver\share\Users\Mary Jane van der Berg\notes.txt`, "root /root/private.txt", "home $HOME/private.txt", + // Wraps that do NOT land on a separator — these defeated the first collapse. + "midsegment C:\\Us\\\nners\\Zoe [Admin]+\\Documents\\private.txt", + "midname C:\\Users\\Zo\\\ne Admin\\Documents\\private.txt", ].join("\n"); expect(() => startUpdateJob("latest", true, { @@ -171,6 +174,8 @@ describe("GUI update execution decisions", () => { expect(persisted).not.toContain("fileserver"); expect(persisted).not.toMatch(/\/root\b/); expect(persisted).not.toMatch(/\$HOME/); + expect(persisted).not.toContain("Zoe [Admin]+"); + expect(persisted).not.toContain("e Admin"); }); test("a failed cache pre-flight leaves the install command unrun", async () => { @@ -183,22 +188,35 @@ describe("GUI update execution decisions", () => { status: "running", channel: "latest", startedAt: new Date().toISOString(), - logs: [], + log: [], })); let installRan = false; + let preflightRan = false; await runGuiUpdateWorker("gate-job", "latest", false, { - cachePreflightFn: () => ({ ok: false, reason: "cache_entry_foreign_owner" }), + // Force the npm installer: this worktree is a source checkout, so the real + // checkForUpdate aborts before the npm branch and the gate would never be reached. + checkForUpdateFn: () => ({ + currentVersion: "2.7.40", + latestVersion: "2.7.41", + channel: "latest", + installer: "npm", + updateAvailable: true, + canUpdate: true, + command: "npm i -g opencodex@latest", + releaseNotesUrl: "https://github.com/lidge-jun/opencodex/releases/latest", + }), + integrityFn: () => ({ ok: true as const, integrity: "sha512-testfixturevalue000000000" }), + cachePreflightFn: () => { preflightRan = true; return { ok: false, reason: "cache_entry_foreign_owner" }; }, runCommandFn: () => { installRan = true; return { status: 0, signal: null }; }, }); + expect(preflightRan).toBe(true); expect(installRan).toBe(false); const job = readUpdateJob("gate-job"); expect(job?.status).toBe("failed"); - // In a source checkout the worker fails earlier than the npm branch, which is itself the - // point: whatever aborts, the install must not have run. The pre-flight-specific message is - // asserted through the injected seam in the npm-installer case below. - expect(job?.error).toBeTruthy(); + expect(job?.error ?? "").toMatch(/cache/i); + expect(JSON.stringify(job?.log ?? [])).toContain("before stopping the proxy"); // Leave no job file behind: sibling tests in this file assert on the same shared path. rmSync(updateJobPath(), { force: true }); }); @@ -1276,7 +1294,7 @@ describe("immutable update target (WP160)", () => { test("GUI worker gates integrity before spawning and fails the job on anomalous metadata", async () => { const source = await Bun.file(new URL("../src/update/job.ts", import.meta.url)).text(); - const gateAt = source.indexOf("const integrity = checkUpdatePackageIntegrity(check.latestVersion);"); + const gateAt = source.indexOf("const integrity = (io.integrityFn ?? checkUpdatePackageIntegrity)(check.latestVersion);"); const cacheGateAt = source.indexOf("const cachePreflight = (io.cachePreflightFn ?? runNpmCachePreflight)();"); const trayStopAt = source.indexOf("handoffWindowsTrayForUpdate(tray"); const failAt = source.indexOf('updateJob(job, { status: "failed", error: integrity.reason });'); diff --git a/tests/update-npm-cache-preflight.test.ts b/tests/update-npm-cache-preflight.test.ts index 3b56ec003..ea38027f5 100644 --- a/tests/update-npm-cache-preflight.test.ts +++ b/tests/update-npm-cache-preflight.test.ts @@ -115,6 +115,34 @@ describe("npm cache access pre-flight", () => { }); }); + test("the worker protocol accepts an incomplete-but-clean inspection", () => { + // The gap that made the budget fix inert: `inspectNpmCacheDirectory` returned ok:true with + // `inspection_incomplete`, and the protocol parser then rejected it because it only accepted + // `cache_accessible` alongside ok:true. Every large cache still failed — as + // `worker_output_malformed`, which hid the real cause. Assert the wire contract directly. + const emit = (payload: Record) => (() => ({ + status: 0, + signal: null, + stdout: JSON.stringify(payload), + stderr: "", + })) as never; + + expect(runNpmCachePreflight({ + platform: "linux", + spawnSyncFn: emit({ protocol: 1, ok: true, reason: "inspection_incomplete" }), + })).toEqual({ ok: true, reason: "inspection_incomplete" }); + + // The cross-check still holds in both directions: a reason cannot lie about its flag. + expect(runNpmCachePreflight({ + platform: "linux", + spawnSyncFn: emit({ protocol: 1, ok: false, reason: "inspection_incomplete" }), + })).toEqual({ ok: false, reason: "worker_output_malformed" }); + expect(runNpmCachePreflight({ + platform: "linux", + spawnSyncFn: emit({ protocol: 1, ok: true, reason: "cache_entry_foreign_owner" }), + })).toEqual({ ok: false, reason: "worker_output_malformed" }); + }); + test("fails closed on worker timeout", () => { const timeoutSpawn = (() => ({ status: null, signal: "SIGTERM", stdout: "", stderr: "" })) as never; expect(runNpmCachePreflight({ platform: "linux", spawnSyncFn: timeoutSpawn })).toEqual({ From 327e3eed3b5cdd01ea0f07ebf9801460d187d879 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 7 Aug 2026 17:59:42 +0900 Subject: [PATCH 16/48] fix(update): drop wrap indentation before redacting, and accept a symlinked cache root (round 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit INDENTED CONTINUATIONS STILL LEAKED. The scan copy stripped CR/LF but kept the whitespace that follows a wrap, so `Us` + newline + two spaces + `ers` never reformed into the keyword and the profile rules did not fire. Three real leaks went through the persistence boundary with the account name intact, including a non-ASCII one. The scan now consumes the break and its indentation, and the match set gains a UNC backstop alongside the absolute-Windows-path one. Regression inputs are the auditor's exact cases: a wrap inside `Users` behind a UNC share, a wrap inside `Documents and Settings`, and a wrapped POSIX path with a Korean username. A SYMLINKED CACHE ROOT IS NO LONGER REJECTED. Pointing ~/.npm at another volume is ordinary npm configuration, and refusing it was the same class of false positive as failing on a large cache — it blocks an update for a user whose setup is fine, which this change's own rule says is worse than the defect. The root is now resolved once via realpath and the target inspected; nested symlinks are still never followed, and an unresolvable root remains a hard stop. Both fixes confirmed to fail their tests when reverted. --- src/update/job.ts | 24 ++++++++++++++++++++---- src/update/npm-cache-preflight.d.mts | 2 ++ src/update/npm-cache-preflight.mjs | 22 +++++++++++++++++++--- tests/update-job.test.ts | 8 ++++++++ tests/update-npm-cache-preflight.test.ts | 20 ++++++++++++++++++++ 5 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/update/job.ts b/src/update/job.ts index 2c37ec466..7b814cc0c 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -269,9 +269,16 @@ function redactWrappedProfilePaths(value: string): string { let scan = ""; for (let i = 0; i < value.length; i += 1) { const ch = value[i]!; - if (ch === "\n" || ch === "\r") continue; - scan += ch; - scanToSource.push(i); + if (ch !== "\n" && ch !== "\r") { + scan += ch; + scanToSource.push(i); + continue; + } + // Drop the break AND the indentation that continues it. A wrapped log line is usually + // indented, and keeping those spaces left `Us` + ` ers` unreconstructable — the keyword + // never reformed, so the profile rules did not fire and the account name survived. + while (i + 1 < value.length && (value[i + 1] === "\n" || value[i + 1] === "\r")) i += 1; + while (i + 1 < value.length && (value[i + 1] === " " || value[i + 1] === "\t")) i += 1; } // Two shapes, in priority order. The first is the precise one. The second is the backstop for a @@ -279,7 +286,16 @@ function redactWrappedProfilePaths(value: string): string { // is what `C:\Us` + wrap + `ners\...` collapses to, and the segment after it is still somebody's // account name. An update log has no legitimate need to carry an absolute Windows path, so // redacting the whole run is the safe answer rather than trying to enumerate every mangling. - const profile = /(?:[A-Za-z]:)?[\\/]{1,2}(?:Users|Documents and Settings|home)[\\/]{1,2}[^\\/\r\n]*|[A-Za-z]:[\\/][^\r\n]*/gi; + const profile = new RegExp([ + // Precise: a profile keyword followed by the account segment. Covers drive-letter paths, + // UNC shares, and POSIX roots, since the scan copy has already healed the wrap. + String.raw`(?:[A-Za-z]:)?[\\/]{1,2}(?:Users|Documents and Settings|home)[\\/]{1,2}[^\\/\r\n]*`, + // Backstop 1: any absolute Windows path a wrap mangled past recognition. + String.raw`[A-Za-z]:[\\/][^\r\n]*`, + // Backstop 2: a UNC share. `\\server\share\...` carries the same account names, and a wrap + // inside the keyword can leave a shape the precise rule no longer matches. + String.raw`\\\\[^\\/\r\n]+\\[^\r\n]*`, + ].join("|"), "gi"); const cuts: Array<{ start: number; end: number }> = []; for (const match of scan.matchAll(profile)) { const start = scanToSource[match.index!]!; diff --git a/src/update/npm-cache-preflight.d.mts b/src/update/npm-cache-preflight.d.mts index 216c9ece9..09a64c57a 100644 --- a/src/update/npm-cache-preflight.d.mts +++ b/src/update/npm-cache-preflight.d.mts @@ -23,6 +23,8 @@ export interface NpmCacheInspectionOptions { maxDepth?: number; maxEntries?: number; nowMs?: () => number; + /** Test seam: resolve a symlinked cache root. Defaults to realpathSync. */ + realpathFn?: (path: string) => string; /** Test seam: resolve an entry's owner uid. Defaults to the lstat result. */ uidOf?: (path: string, stat: { uid: number }) => number; timeoutMs?: number; diff --git a/src/update/npm-cache-preflight.mjs b/src/update/npm-cache-preflight.mjs index 4e5666741..2ff015f5a 100644 --- a/src/update/npm-cache-preflight.mjs +++ b/src/update/npm-cache-preflight.mjs @@ -1,4 +1,4 @@ -import { lstatSync, readdirSync } from "node:fs"; +import { lstatSync, readdirSync, realpathSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { isAbsolute, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -46,6 +46,7 @@ export function inspectNpmCacheDirectory(cachePath, options = {}) { const uidOf = options.uidOf ?? ((_path, stat) => stat.uid); const stack = [{ path: cachePath, depth: 0 }]; let inspected = 0; + let rootResolved = false; while (stack.length > 0) { // Budget exhausted is NOT a failure. A mature npm cache legitimately holds hundreds of @@ -68,9 +69,24 @@ export function inspectNpmCacheDirectory(cachePath, options = {}) { } inspected += 1; - // A symlinked cache ROOT is a real problem: we cannot vouch for where the install writes. + // A symlinked cache ROOT used to be rejected outright, but pointing ~/.npm at another volume + // is ordinary npm configuration, and blocking those users would be the same false-positive + // failure this preflight exists to avoid. Resolve the root once and inspect the target; + // only an unresolvable root is a real problem. Nested links are still never followed. if (current.depth === 0 && stat.isSymbolicLink()) { - return { ok: false, reason: "cache_entry_inaccessible" }; + // Resolve exactly once. realpath already collapses a chain, so a second pass would only + // happen if the target is itself reported as a link — treat that as unresolvable rather + // than looping. + if (rootResolved) return { ok: false, reason: "cache_entry_inaccessible" }; + rootResolved = true; + let resolved; + try { + resolved = (options.realpathFn ?? realpathSync)(current.path); + } catch { + return { ok: false, reason: "cache_entry_inaccessible" }; + } + stack.push({ path: resolved, depth: 0 }); + continue; } // A nested symlink is not. npm creates them constantly below _npx, node_modules and .bin, // and we never follow them — so its owner is irrelevant and must not abort the update. diff --git a/tests/update-job.test.ts b/tests/update-job.test.ts index d9cf7d27e..bd7f7c7dd 100644 --- a/tests/update-job.test.ts +++ b/tests/update-job.test.ts @@ -152,6 +152,11 @@ describe("GUI update execution decisions", () => { // Wraps that do NOT land on a separator — these defeated the first collapse. "midsegment C:\\Us\\\nners\\Zoe [Admin]+\\Documents\\private.txt", "midname C:\\Users\\Zo\\\ne Admin\\Documents\\private.txt", + // Indented continuations: the wrap leaves leading whitespace, which blocked keyword + // reconstruction until the scan copy learned to drop it too. + "unc-wrap \\\\fileserver\\share\\Us\n ers\\Zoe [Admin]+\\notes.txt", + "docs-wrap \\\\fileserver\\share\\Documents and Set\n\ttings\\A+B (Ops)\\notes.txt", + "posix-wrap /Us\n ers/\ud64d \uae38\ub3d9/private.txt", ].join("\n"); expect(() => startUpdateJob("latest", true, { @@ -176,6 +181,9 @@ describe("GUI update execution decisions", () => { expect(persisted).not.toMatch(/\$HOME/); expect(persisted).not.toContain("Zoe [Admin]+"); expect(persisted).not.toContain("e Admin"); + expect(persisted).not.toContain("Zoe [Admin]+"); + expect(persisted).not.toContain("A+B (Ops)"); + expect(persisted).not.toContain("\ud64d \uae38\ub3d9"); }); test("a failed cache pre-flight leaves the install command unrun", async () => { diff --git a/tests/update-npm-cache-preflight.test.ts b/tests/update-npm-cache-preflight.test.ts index ea38027f5..69c16b216 100644 --- a/tests/update-npm-cache-preflight.test.ts +++ b/tests/update-npm-cache-preflight.test.ts @@ -143,6 +143,26 @@ describe("npm cache access pre-flight", () => { })).toEqual({ ok: false, reason: "worker_output_malformed" }); }); + test("a cache root symlinked to another volume is inspected, not rejected", () => { + // Pointing ~/.npm at another volume is ordinary npm configuration. Rejecting it outright was + // the same class of false positive as failing on a large cache: it blocks updates for users + // whose setup is fine. The root is resolved once; nested links are still never followed. + const realCache = tempRoot("symlinked-root-target"); + mkdirSync(join(realCache, "_cacache", "content-v2"), { recursive: true }); + writeFileSync(join(realCache, "_cacache", "content-v2", "entry"), "cached"); + + const linkHome = tempRoot("symlinked-root-home"); + const linkedRoot = join(linkHome, ".npm"); + symlinkSync(realCache, linkedRoot, "dir"); + + expect(inspectNpmCacheDirectory(linkedRoot)).toEqual({ ok: true, reason: "cache_accessible" }); + + // An unresolvable root is still a hard stop. + expect(inspectNpmCacheDirectory(linkedRoot, { + realpathFn: () => { throw new Error("ELOOP"); }, + })).toEqual({ ok: false, reason: "cache_entry_inaccessible" }); + }); + test("fails closed on worker timeout", () => { const timeoutSpawn = (() => ({ status: null, signal: "SIGTERM", stdout: "", stderr: "" })) as never; expect(runNpmCachePreflight({ platform: "linux", spawnSyncFn: timeoutSpawn })).toEqual({ From 47e7cb770fbf32053b6385910502a270195a5ae9 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 7 Aug 2026 18:03:18 +0900 Subject: [PATCH 17/48] fix(update): stop a redacted path from swallowing the log lines after it (round 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scan copy removed every line boundary, so the `[^\r\n]*` backstops ran to the end of the text: one redacted path consumed every following log entry. Privacy was intact; the diagnostics were destroyed. The persisted log is what a user reads when an update fails, so eating it is its own kind of damage. Boundary sentinels are now inserted, but only where the next line starts a new log entry rather than continuing a path. Marking every boundary would have been equally wrong — it blocks the reconstruction that catches a username split across a wrap. The test is structural: a continuation carries a separator (or follows one), a new entry is a label with none. That distinction is what lets `Mary Jane van der Berg\Documents\...` still reconstruct while `KEEP diagnostic code E42` survives untouched. Regression asserts both halves: the username is gone AND the following diagnostic line is still present. --- src/update/job.ts | 23 ++++++++++++++++++++--- tests/update-job.test.ts | 6 ++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/update/job.ts b/src/update/job.ts index 7b814cc0c..1b7a24686 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -279,6 +279,23 @@ function redactWrappedProfilePaths(value: string): string { // never reformed, so the profile rules did not fire and the account name survived. while (i + 1 < value.length && (value[i + 1] === "\n" || value[i + 1] === "\r")) i += 1; while (i + 1 < value.length && (value[i + 1] === " " || value[i + 1] === "\t")) i += 1; + // Mark the boundary ONLY when the next line starts a new log entry rather than continuing a + // path. Without a marker the scan copy is one long line, so the backstops below run to the + // end and swallow every following entry — redaction stayed correct but the diagnostics were + // destroyed. Marking every boundary is equally wrong: it blocks the reconstruction that + // catches a username split across the break. A continuation has no space in its head; a new + // entry looks like `label something`. + // A continuation carries path structure — it contains a separator, or the line before it + // ended on one. A new entry is a label with no separator at all (`KEEP diagnostic code E42`). + // Keying on "contains a separator" rather than "contains a space" is what lets a username + // with spaces (`Mary Jane van der Berg\Documents\...`) still reconstruct. + const nextLine = value.slice(i + 1).split(/\r?\n/, 1)[0] ?? ""; + const previousEndsOnSeparator = /[\\/]$/.test(scan); + const startsNewEntry = !previousEndsOnSeparator && !/[\\/]/.test(nextLine); + if (startsNewEntry) { + scan += "\u0000"; + scanToSource.push(i + 1); + } } // Two shapes, in priority order. The first is the precise one. The second is the backstop for a @@ -289,12 +306,12 @@ function redactWrappedProfilePaths(value: string): string { const profile = new RegExp([ // Precise: a profile keyword followed by the account segment. Covers drive-letter paths, // UNC shares, and POSIX roots, since the scan copy has already healed the wrap. - String.raw`(?:[A-Za-z]:)?[\\/]{1,2}(?:Users|Documents and Settings|home)[\\/]{1,2}[^\\/\r\n]*`, + String.raw`(?:[A-Za-z]:)?[\\/]{1,2}(?:Users|Documents and Settings|home)[\\/]{1,2}[^\\/\r\n\u0000]*`, // Backstop 1: any absolute Windows path a wrap mangled past recognition. - String.raw`[A-Za-z]:[\\/][^\r\n]*`, + String.raw`[A-Za-z]:[\\/][^\r\n\u0000]*`, // Backstop 2: a UNC share. `\\server\share\...` carries the same account names, and a wrap // inside the keyword can leave a shape the precise rule no longer matches. - String.raw`\\\\[^\\/\r\n]+\\[^\r\n]*`, + String.raw`\\\\[^\\/\r\n\u0000]+\\[^\r\n\u0000]*`, ].join("|"), "gi"); const cuts: Array<{ start: number; end: number }> = []; for (const match of scan.matchAll(profile)) { diff --git a/tests/update-job.test.ts b/tests/update-job.test.ts index bd7f7c7dd..66576f69f 100644 --- a/tests/update-job.test.ts +++ b/tests/update-job.test.ts @@ -157,6 +157,10 @@ describe("GUI update execution decisions", () => { "unc-wrap \\\\fileserver\\share\\Us\n ers\\Zoe [Admin]+\\notes.txt", "docs-wrap \\\\fileserver\\share\\Documents and Set\n\ttings\\A+B (Ops)\\notes.txt", "posix-wrap /Us\n ers/\ud64d \uae38\ub3d9/private.txt", + // A redacted path must not swallow the lines after it: the persisted log is what a user + // reads when an update fails, and eating the diagnostics is its own kind of damage. + "unc \\\\server\\share\\Us\n ers\\Jane\\x", + "KEEP diagnostic code E42", ].join("\n"); expect(() => startUpdateJob("latest", true, { @@ -184,6 +188,8 @@ describe("GUI update execution decisions", () => { expect(persisted).not.toContain("Zoe [Admin]+"); expect(persisted).not.toContain("A+B (Ops)"); expect(persisted).not.toContain("\ud64d \uae38\ub3d9"); + expect(persisted).not.toContain("Jane"); + expect(persisted).toContain("KEEP diagnostic code E42"); }); test("a failed cache pre-flight leaves the install command unrun", async () => { From 4c70fc10c54e1039ac622ae967cc35afdbc7a349 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 7 Aug 2026 18:06:38 +0900 Subject: [PATCH 18/48] fix(update): redact wrapped profile paths line-aware, not by guessing continuations (round 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The separator heuristic could not work, and the audit proved it with two inputs that fail in opposite directions: C:\Users\Z / " oë [Admin]+" continuation with NO separator -> leaked ...\Users\Jane\x / "npm ERR! /usr/…" new record WITH a separator -> swallowed Nothing in the text distinguishes those two cases, so any rule keyed on separators trades one failure for the other. Four shapes were tried before this one: rejoin-aggressively (merged unrelated entries), rejoin-conservatively (leaked the username), strip-all-boundaries (swallowed the diagnostics), and sentinel-on-heuristic (both of the above, depending on the input). The redaction is now line-aware with one carry bit. A line is redacted normally; if it ENDS on an incomplete profile prefix — an unclosed account segment, or a split keyword like `...\Documents and Set` — the next line is treated as that account name's continuation and redacted whole. This is deliberately asymmetric. It can redact a following line that was actually unrelated, costing one line of diagnostics. The alternative costs somebody's account name, and this boundary exists precisely so that never happens. The keyword-prefix set is generated from the keywords rather than hand-written, so a wrap at any offset inside `Documents and Settings` is covered without enumerating them. Regressions now assert both directions: the username is gone, and an unrelated following record — with a separator in it — survives. --- src/update/job.ts | 118 ++++++++++++++------------------------- tests/update-job.test.ts | 7 +++ 2 files changed, 48 insertions(+), 77 deletions(-) diff --git a/src/update/job.ts b/src/update/job.ts index 1b7a24686..5cc1f7268 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -244,91 +244,55 @@ function ensureJobDir(): void { } /** - * Redact profile paths that a line wrap has split apart. + * Redact profile paths, including ones a line wrap has split apart. * - * Trying to REJOIN wrapped lines turned out to be the wrong shape: joining aggressively enough to - * catch a wrap inside the username also merged genuinely separate log entries, and joining - * conservatively enough to keep them apart let the username through. Both were attempts to - * reconstruct the original text before matching it. + * Three earlier shapes failed, and the reason is worth recording because it is not a tuning + * problem. Rejoining wrapped lines either merged unrelated log entries or let the username + * through. Stripping every boundary let one redacted path swallow the diagnostics after it. + * Deciding "is this line a continuation or a new record?" from separators cannot work either: + * `oë [Admin]+` is a continuation with no separator, and `npm ERR! /usr/local/lib` is a new + * record with one. Nothing in the text distinguishes them. * - * This matches across the break instead. `\s*` between every path element lets one pattern cover - * `C:\Users\Jane`, `C:\Users\Jane`, and `C:\Users\Jane` alike, so the redaction - * never depends on where the wrap landed. Runs on top of the single-line rules, which stay as the - * precise ones. + * So this stops trying. It works line by line, and when a line ends on an INCOMPLETE profile + * prefix — a profile keyword whose account segment has not been closed by a separator — the + * NEXT line is treated as the continuation of that account name and redacted whole. That is + * conservative: it can redact a following line that was actually unrelated, which costs one line + * of diagnostics. Getting it wrong the other way costs someone's account name, and the whole + * point of this boundary is that it never does. */ function redactWrappedProfilePaths(value: string): string { - // Character-by-character keyword patterns became unreadable and still missed cases, because a - // wrap inserts BOTH a separator and a newline (`Us\` + newline + `ners`), so a single optional - // gap between letters is not enough. - // - // Work on a scan copy instead: strip the wrap noise entirely, match the profile shape there, - // and map the hit back to the original text by counting the characters it consumed. The - // redaction decision is made on clean text; the output keeps everything the match did not - // cover. - const scanToSource: number[] = []; - let scan = ""; - for (let i = 0; i < value.length; i += 1) { - const ch = value[i]!; - if (ch !== "\n" && ch !== "\r") { - scan += ch; - scanToSource.push(i); + const PROFILE_ANYWHERE = /(?:[A-Za-z]:)?[\\/]{1,2}(?:Users|Documents and Settings|home)[\\/]{1,2}[^\\/\r\n]*/gi; + // The line ends mid-account-name: keyword, separator, then a segment never closed. + const OPEN_PROFILE_TAIL = /(?:[A-Za-z]:)?[\\/]{1,2}(?:Users|Documents and Settings|home)[\\/]{1,2}[^\\/\r\n]*$/i; + // A wrap can also split the keyword itself, leaving a dangling head. Any prefix of the three + // keywords counts — `...\Us`, `...\Documents and Set`, `...\hom`, or a bare trailing separator. + const KEYWORDS = ["Users", "Documents and Settings", "home"]; + const keywordPrefixes = KEYWORDS + .flatMap(word => Array.from({ length: word.length }, (_, n) => word.slice(0, n + 1))) + .sort((a, b) => b.length - a.length) + .map(prefix => prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); + const OPEN_KEYWORD_TAIL = new RegExp(String.raw`[\\/](?:${keywordPrefixes.join("|")})?$`, "i"); + + const lines = value.split(/(\r?\n)/); + let carry = false; + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]!; + if (line === "\n" || line === "\r\n") continue; + + if (carry) { + // Continuation of an open account name: redact the whole line, keeping its indentation so + // the log still reads as wrapped output. + const indent = /^[ \t]*/.exec(line)?.[0] ?? ""; + lines[i] = line.trim().length > 0 ? `${indent}` : line; + carry = false; continue; } - // Drop the break AND the indentation that continues it. A wrapped log line is usually - // indented, and keeping those spaces left `Us` + ` ers` unreconstructable — the keyword - // never reformed, so the profile rules did not fire and the account name survived. - while (i + 1 < value.length && (value[i + 1] === "\n" || value[i + 1] === "\r")) i += 1; - while (i + 1 < value.length && (value[i + 1] === " " || value[i + 1] === "\t")) i += 1; - // Mark the boundary ONLY when the next line starts a new log entry rather than continuing a - // path. Without a marker the scan copy is one long line, so the backstops below run to the - // end and swallow every following entry — redaction stayed correct but the diagnostics were - // destroyed. Marking every boundary is equally wrong: it blocks the reconstruction that - // catches a username split across the break. A continuation has no space in its head; a new - // entry looks like `label something`. - // A continuation carries path structure — it contains a separator, or the line before it - // ended on one. A new entry is a label with no separator at all (`KEEP diagnostic code E42`). - // Keying on "contains a separator" rather than "contains a space" is what lets a username - // with spaces (`Mary Jane van der Berg\Documents\...`) still reconstruct. - const nextLine = value.slice(i + 1).split(/\r?\n/, 1)[0] ?? ""; - const previousEndsOnSeparator = /[\\/]$/.test(scan); - const startsNewEntry = !previousEndsOnSeparator && !/[\\/]/.test(nextLine); - if (startsNewEntry) { - scan += "\u0000"; - scanToSource.push(i + 1); - } - } - // Two shapes, in priority order. The first is the precise one. The second is the backstop for a - // path this scan copy could not resolve into a known profile shape — `C:\Us\ners\Zoe [Admin]+` - // is what `C:\Us` + wrap + `ners\...` collapses to, and the segment after it is still somebody's - // account name. An update log has no legitimate need to carry an absolute Windows path, so - // redacting the whole run is the safe answer rather than trying to enumerate every mangling. - const profile = new RegExp([ - // Precise: a profile keyword followed by the account segment. Covers drive-letter paths, - // UNC shares, and POSIX roots, since the scan copy has already healed the wrap. - String.raw`(?:[A-Za-z]:)?[\\/]{1,2}(?:Users|Documents and Settings|home)[\\/]{1,2}[^\\/\r\n\u0000]*`, - // Backstop 1: any absolute Windows path a wrap mangled past recognition. - String.raw`[A-Za-z]:[\\/][^\r\n\u0000]*`, - // Backstop 2: a UNC share. `\\server\share\...` carries the same account names, and a wrap - // inside the keyword can leave a shape the precise rule no longer matches. - String.raw`\\\\[^\\/\r\n\u0000]+\\[^\r\n\u0000]*`, - ].join("|"), "gi"); - const cuts: Array<{ start: number; end: number }> = []; - for (const match of scan.matchAll(profile)) { - const start = scanToSource[match.index!]!; - const end = scanToSource[match.index! + match[0].length - 1]! + 1; - cuts.push({ start, end }); - } - if (cuts.length === 0) return value; - - let out = ""; - let cursor = 0; - for (const cut of cuts) { - if (cut.start < cursor) continue; - out += value.slice(cursor, cut.start) + ""; - cursor = cut.end; + const redacted = line.replace(PROFILE_ANYWHERE, ""); + lines[i] = redacted; + carry = OPEN_PROFILE_TAIL.test(line) || OPEN_KEYWORD_TAIL.test(line); } - return out + value.slice(cursor); + return lines.join(""); } function sanitizePersistedUpdateText(value: string): string { diff --git a/tests/update-job.test.ts b/tests/update-job.test.ts index 66576f69f..70c78565e 100644 --- a/tests/update-job.test.ts +++ b/tests/update-job.test.ts @@ -161,6 +161,11 @@ describe("GUI update execution decisions", () => { // reads when an update fails, and eating the diagnostics is its own kind of damage. "unc \\\\server\\share\\Us\n ers\\Jane\\x", "KEEP diagnostic code E42", + // Ends INSIDE the account name with no separator on the continuation. + "terminal C:\\Users\\Z\n oe [Admin]+", + // A genuinely new record that contains a separator must survive. + "unc2 \\\\server\\share\\Users\\Jane\\x", + "UNC FOLLOW /usr/local/lib/node_modules", ].join("\n"); expect(() => startUpdateJob("latest", true, { @@ -190,6 +195,8 @@ describe("GUI update execution decisions", () => { expect(persisted).not.toContain("\ud64d \uae38\ub3d9"); expect(persisted).not.toContain("Jane"); expect(persisted).toContain("KEEP diagnostic code E42"); + expect(persisted).not.toContain("oe [Admin]+"); + expect(persisted).toContain("UNC FOLLOW /usr/local/lib/node_modules"); }); test("a failed cache pre-flight leaves the install command unrun", async () => { From 00582311ba282daf08b8d1c842facea3290b053e Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 7 Aug 2026 18:12:24 +0900 Subject: [PATCH 19/48] fix(update): stop persisting free-form vendor output (round 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six rounds of redaction, six new leaks. A wrap inside the keyword, inside the account name, an indented continuation, three consecutive wraps, an empty continuation line — each fix surfaced the next case, and the last two attempts started breaking cases they had previously fixed. That is not a tuning problem. The leak surface is whatever npm chooses to print and however the terminal wraps it, and no redactor gets to see the original line structure. Taking the auditor's second recommendation: - `runLoggedCommand` no longer persists stdout/stderr. It records exit status or signal, any recognized npm error codes (a fixed vocabulary, not user text), and a withheld-byte count. Detailed output stays ephemeral. - The persistence boundary replaces any multi-line value wholesale with a line count and a note. Single-line structured fields keep the precise redaction, which is what makes `command` and `error` still readable. The cost is real and worth naming: a user reading a failed update job now sees which step failed, how it exited, and any npm error code, but not the installer's own message. That is a genuine diagnostic loss. It buys a boundary that cannot leak an account name regardless of what npm prints, which the previous six versions could not promise. The auditor's five leaking inputs are kept as regressions. They now pass structurally rather than by pattern-matching. --- src/update/job.ts | 79 ++++++++++++++++++++++++++++++++++++++-- tests/update-job.test.ts | 13 +++++-- 2 files changed, 84 insertions(+), 8 deletions(-) diff --git a/src/update/job.ts b/src/update/job.ts index 5cc1f7268..dd4372aef 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -283,8 +283,18 @@ function redactWrappedProfilePaths(value: string): string { // Continuation of an open account name: redact the whole line, keeping its indentation so // the log still reads as wrapped output. const indent = /^[ \t]*/.exec(line)?.[0] ?? ""; - lines[i] = line.trim().length > 0 ? `${indent}` : line; - carry = false; + if (line.trim().length === 0) { + // A blank continuation does not end the wrap — npm can emit one — so hold the carry + // rather than spending it here and letting the real continuation through. + continue; + } + lines[i] = `${indent}`; + // A wrap can span several lines (`...\Us` / `ers\Ja` / `ne [Admin]+\...`), and every rule + // for "has this one ended?" that was tried here broke a case it had previously fixed — + // the text simply does not say. Keep carrying while the line still looks like path + // fragments (no spaces around separators, no sentence-like content) and stop at the first + // line that reads as ordinary prose. Over-redacting a fragment line is the safe error. + carry = /[\\/]/.test(line) ? !/[\\/]\s|\s[\\/]/.test(line) && line.trim().split(/\s+/).length <= 3 : true; continue; } @@ -296,10 +306,25 @@ function redactWrappedProfilePaths(value: string): string { } function sanitizePersistedUpdateText(value: string): string { + // Free-form vendor text cannot be made safe by redaction, and six rounds of trying is the + // evidence: a wrap inside the keyword, inside the account name, an indented continuation, + // three consecutive wraps, an empty continuation — each fix surfaced the next leak, because + // the leak surface is whatever npm chooses to print and however the terminal wraps it. + // + // So multi-line text does not cross this boundary at all. A value that still contains a line + // break after the single-line rules below is replaced wholesale; single-line values keep the + // precise redaction, which is enough for the structured fields (command, error, log entries) + // that legitimately need to stay readable. // Wrap-tolerant profile redaction runs FIRST: npm and the OS break long paths at arbitrary // points, and a rule anchored to a single line let `C:\Users\Jane Doe\...` through // with the account name intact. The single-line rules below then handle the ordinary cases // precisely. + // Multi-line values are vendor output, not a structured field. Reduce them to a shape and size + // note rather than trying to redact text whose wrapping we do not control. + if (/\r?\n/.test(value)) { + const lines = value.split(/\r?\n/).length; + return `<${lines} lines of output withheld (may contain local paths)>`; + } return redactWrappedProfilePaths(value) // Profile environment expansions, before the path rules: %USERPROFILE%\Documents\... and // $HOME/... would otherwise survive as a literal prefix plus a real tail. @@ -622,6 +647,20 @@ export function startUpdateJob( return startedJob; } +/** + * Run an update step and record WHAT HAPPENED, not what the tool printed. + * + * Raw installer output used to be persisted verbatim, which put local paths and account names + * into a stored file. Six rounds of trying to sanitize it after the fact each produced a new + * leak — a wrap inside the keyword, a wrap inside the account name, an indented continuation, + * three consecutive wraps, an empty continuation line. Every fix was an attempt to reconstruct + * arbitrary multi-line text well enough to match it, and that is not a problem a redactor can + * win: the leak surface is whatever npm decides to print. + * + * So the raw stream is no longer persisted at all. The job keeps the command, its exit status, + * and a bounded, structured summary — enough to tell a user which step failed and how, with no + * free-form vendor text passing through the boundary. Detailed output stays ephemeral. + */ function runLoggedCommand(job: UpdateJobState, bin: string, args: string[], timeout: number): { status: number | null; signal: NodeJS.Signals | null } { job = updateJob(job, {}, `$ ${formatCommand(bin, args)}`); const result = spawnSync(bin, args, { @@ -631,11 +670,43 @@ function runLoggedCommand(job: UpdateJobState, bin: string, args: string[], time }); const stdout = typeof result.stdout === "string" ? result.stdout.trim() : ""; const stderr = typeof result.stderr === "string" ? result.stderr.trim() : ""; - if (stdout) job = updateJob(job, {}, stdout.slice(-4000)); - if (stderr) updateJob(job, {}, stderr.slice(-4000)); + const summary = summarizeCommandOutput(stdout, stderr, result.status, result.signal); + if (summary) updateJob(job, {}, summary); return { status: result.status, signal: result.signal }; } +/** npm error codes are safe to surface: they are a fixed vocabulary, not user text. */ +const NPM_ERROR_CODE = /\b(E[A-Z]{3,}|ERR_[A-Z_]+)\b/g; + +/** + * Build a structured, path-free summary of a command's result. + * + * Only three things cross the boundary: how the process ended, how much it printed, and any + * recognized error codes. None of those can carry a filesystem path or an account name. + */ +function summarizeCommandOutput( + stdout: string, + stderr: string, + status: number | null, + signal: NodeJS.Signals | null, +): string | null { + if (!stdout && !stderr && status === 0) return null; + + const parts: string[] = []; + parts.push(signal ? `terminated by ${signal}` : `exit ${status ?? "null"}`); + + const codes = [...new Set([ + ...stderr.matchAll(NPM_ERROR_CODE), + ...stdout.matchAll(NPM_ERROR_CODE), + ].map(match => match[0]))].slice(0, 5); + if (codes.length > 0) parts.push(`codes: ${codes.join(", ")}`); + + const bytes = stdout.length + stderr.length; + if (bytes > 0) parts.push(`${bytes} bytes of output withheld (may contain local paths)`); + + return parts.join(" · "); +} + /** * Tear down anything that would make `ocx start` exit 1 with "already running" * (service wrapper respawn, stale pidfile + live /healthz) before a pinned spawn. diff --git a/tests/update-job.test.ts b/tests/update-job.test.ts index 70c78565e..f14aa61fb 100644 --- a/tests/update-job.test.ts +++ b/tests/update-job.test.ts @@ -135,8 +135,9 @@ describe("GUI update execution decisions", () => { expect(persisted).not.toContain("_cacache"); expect(persisted).not.toMatch(/\buid\s*[=:]\s*501\b/i); expect(persisted).not.toMatch(/\bgid\s*[=:]\s*20\b/i); - expect(persisted).toContain(""); - expect(persisted).toContain(""); + // Multi-line vendor output no longer crosses the boundary at all — it is replaced by a + // shape note. The secrets are what matter here, and none of them survive. + expect(persisted).toContain("lines of output withheld"); }); test("the persistence boundary survives wrapped paths and profile expansions", () => { @@ -166,6 +167,10 @@ describe("GUI update execution decisions", () => { // A genuinely new record that contains a separator must survive. "unc2 \\\\server\\share\\Users\\Jane\\x", "UNC FOLLOW /usr/local/lib/node_modules", + // Three consecutive wraps, and an empty continuation line — a single carry bit could not + // cover either. These are why raw output is no longer persisted at all. + "three C:\\Us\n ers\\Ja\n ne [Admin]+\\Documents\\x", + "empty C:\\Users\\Z\n\n oe (Blank)+", ].join("\n"); expect(() => startUpdateJob("latest", true, { @@ -194,9 +199,9 @@ describe("GUI update execution decisions", () => { expect(persisted).not.toContain("A+B (Ops)"); expect(persisted).not.toContain("\ud64d \uae38\ub3d9"); expect(persisted).not.toContain("Jane"); - expect(persisted).toContain("KEEP diagnostic code E42"); expect(persisted).not.toContain("oe [Admin]+"); - expect(persisted).toContain("UNC FOLLOW /usr/local/lib/node_modules"); + expect(persisted).not.toContain("ne [Admin]+"); + expect(persisted).not.toContain("oe (Blank)+"); }); test("a failed cache pre-flight leaves the install command unrun", async () => { From 313dac3633c7e563881e44611b93c98439abe700 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 7 Aug 2026 18:17:15 +0900 Subject: [PATCH 20/48] fix(update): make the code allowlist an actual allowlist, and close single-line path leaks (round 8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more findings, all real. THE "FIXED VOCABULARY" WAS A SHAPE PATTERN. `E[A-Z]{3,}` matches `ERROR`, so `npm ERR! path C:\Users\ERROR\.npm` re-emitted the username as a "code" — the summary leaking exactly what withholding the output was meant to protect. It is now an explicit Set of recognized npm/libc codes, extracted only from npm's canonical `code ` position rather than scanned out of free text. SINGLE-LINE PATHS STILL LEAKED. The multi-line path is withheld wholesale, but single-line values keep precise redaction, and three rules there stopped at the first space — so `\\server\home$\Jane Doe\...` and `D:\Profiles\Mary Jane\...` kept the surname. Path segments legitimately contain spaces; those runs now continue across them and stop at a delimiter that cannot appear mid-path. The UNC rule also consumed only `server\share`, leaving the account segment behind for later rules that could no longer recognize it. BYTE COUNT WAS A CODE-UNIT COUNT. `Buffer.byteLength(..., "utf8")` now, which matters for the non-ASCII output this feature exists around. --- src/update/job.ts | 45 +++++++++++++++++++++++++++++++++------- tests/update-job.test.ts | 24 +++++++++++++++++++++ 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/src/update/job.ts b/src/update/job.ts index dd4372aef..35fda19ed 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -331,7 +331,10 @@ function sanitizePersistedUpdateText(value: string): string { .replace(/%(?:USERPROFILE|HOMEPATH|HOMEDRIVE|APPDATA|LOCALAPPDATA)%/gi, "") .replace(/\$(?:HOME|USERPROFILE)\b/g, "") // UNC shares carry the same account names as a local profile path. - .replace(/\\\\[^\\/\r\n]+\\[^\\/\r\n]+(?=[\\/])/g, "") + // Consume the whole UNC run, not just the server\share prefix. Stopping at the share left + // the segment after it — which on a home share IS the account name — for later rules that + // could no longer recognize the mangled remainder. + .replace(/\\\\[^\\/\r\n]+\\[^;,"'\r\n]*/g, "") .replace( /(?:[A-Za-z]:)?[\\/](?:[^\\/\r\n]+[\\/])*(?:\.npm|npm-cache|_cacache)(?:[\\/][^\r\n]*)?/gi, "", @@ -348,12 +351,20 @@ function sanitizePersistedUpdateText(value: string): string { .replace(/\/(?:Users|home)\/[^/\r\n]*/g, "") // A root-owned install has no /home entry; /root is still a local filesystem disclosure. .replace(/\/root(?=[/\s]|$)/g, "") + // Any remaining UNC share, including administrative and home shares (`\\server\home$\Jane + // Doe\...`). Path segments may contain spaces, so the run continues across them and stops at + // a delimiter that cannot appear mid-path. + .replace(/\\\\[^\\/\s]+\\[^;,"'\r\n]*/g, "") + // Any remaining absolute POSIX path under a directory that commonly holds per-user data. + .replace(/\/(?:export\/home|var\/home|Volumes)\/[^;,"'\r\n]*/gi, "") // Backstop. Everything above recognizes a KNOWN profile shape, and a wrap that lands inside // the word `Users` (`C:\Us` + `ers\Jane Doe\...`) reassembles into a path none of them match // — the segment after the drive letter is still somebody's account name. Rather than trying // to enumerate every way a path can be mangled, redact any remaining absolute Windows path: // an update log has no legitimate need to carry one. - .replace(/\b[A-Za-z]:[\\/][^\s\r\n]*/g, "") + // Windows path segments legitimately contain spaces (`D:\Profiles\Mary Jane\...`), so this + // run continues past them and stops only at a delimiter that cannot appear inside a path. + .replace(/\b[A-Za-z]:[\\/][^;,"'\r\n]*/g, "") .replace(/\b(uid|gid)(\s*(?:[=:]|\s)\s*)\d+\b/gi, "$1$2"); } @@ -675,8 +686,26 @@ function runLoggedCommand(job: UpdateJobState, bin: string, args: string[], time return { status: result.status, signal: result.signal }; } -/** npm error codes are safe to surface: they are a fixed vocabulary, not user text. */ -const NPM_ERROR_CODE = /\b(E[A-Z]{3,}|ERR_[A-Z_]+)\b/g; +/** + * Recognized npm/libc error codes, as an explicit set. + * + * A shape pattern like `E[A-Z]{3,}` is NOT a vocabulary: `C:\Users\ERROR\.npm` matches it, and + * the summary then re-emits the username the withheld output was protecting. Only codes on this + * list are surfaced, and only when they appear in npm's canonical `code ` position. + */ +const NPM_ERROR_CODES = new Set([ + "EACCES", "EPERM", "ENOENT", "EEXIST", "ENOTDIR", "EISDIR", "EMFILE", "ENFILE", + "ENOSPC", "EROFS", "EXDEV", "ELOOP", "ENAMETOOLONG", "ENOTEMPTY", "EBUSY", + "EAGAIN", "ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN", + "EPROTO", "ECONNABORTED", "EHOSTUNREACH", "ENETUNREACH", "EPIPE", + "E401", "E403", "E404", "E409", "E429", "E500", "E503", + "EINTEGRITY", "ERESOLVE", "ETARGET", "EPUBLISHCONFLICT", "ENEEDAUTH", + "EUSAGE", "EJSONPARSE", "EOTP", "EINVALIDTYPE", "ELIFECYCLE", + "ERR_SOCKET_TIMEOUT", "ERR_INVALID_ARG_TYPE", "ERR_MODULE_NOT_FOUND", +]); + +/** npm prints `npm ERR! code EACCES`; anchor on that position rather than scanning free text. */ +const NPM_CODE_RECORD = /(?:^|\s)code\s+([A-Z][A-Z0-9_]{2,})\b/g; /** * Build a structured, path-free summary of a command's result. @@ -696,12 +725,12 @@ function summarizeCommandOutput( parts.push(signal ? `terminated by ${signal}` : `exit ${status ?? "null"}`); const codes = [...new Set([ - ...stderr.matchAll(NPM_ERROR_CODE), - ...stdout.matchAll(NPM_ERROR_CODE), - ].map(match => match[0]))].slice(0, 5); + ...stderr.matchAll(NPM_CODE_RECORD), + ...stdout.matchAll(NPM_CODE_RECORD), + ].map(match => match[1]!).filter(code => NPM_ERROR_CODES.has(code)))].slice(0, 5); if (codes.length > 0) parts.push(`codes: ${codes.join(", ")}`); - const bytes = stdout.length + stderr.length; + const bytes = Buffer.byteLength(stdout, "utf8") + Buffer.byteLength(stderr, "utf8"); if (bytes > 0) parts.push(`${bytes} bytes of output withheld (may contain local paths)`); return parts.join(" · "); diff --git a/tests/update-job.test.ts b/tests/update-job.test.ts index f14aa61fb..7d0e09b64 100644 --- a/tests/update-job.test.ts +++ b/tests/update-job.test.ts @@ -247,6 +247,30 @@ describe("GUI update execution decisions", () => { rmSync(updateJobPath(), { force: true }); }); + test("single-line UNC and custom profile roots do not leak account names", () => { + // A shape-based code pattern let `C:\\Users\\ERROR\\.npm` echo back as a "code", and the + // single-line path still carried `\\\\server\\home$\\Jane Doe` and `D:\\Profiles\\Mary Jane`. + const oneLine = String.raw`unc \\server\home$\Jane Doe\private.txt; custom D:\Profiles\Mary Jane\private.txt`; + + expect(() => startUpdateJob("latest", true, { + checkForUpdateFn: () => ({ + currentVersion: "2.7.40", + latestVersion: "2.7.41", + channel: "latest", + installer: "npm", + updateAvailable: true, + canUpdate: true, + command: oneLine, + releaseNotesUrl: "https://github.com/lidge-jun/opencodex/releases/latest", + }), + spawnWorkerFn: () => { throw new Error(oneLine); }, + })).toThrow("Could not start update worker"); + + const persisted = readFileSync(updateJobPath(), "utf8"); + expect(persisted).not.toContain("Jane Doe"); + expect(persisted).not.toContain("Mary Jane"); + }); + test("npm worker uses the Node launcher update path", () => { const cmd = updateExecutionCommand("npm", "preview", "/pkg/bin/ocx.mjs"); expect(cmd.bin).toMatch(/^node/); From 6e41fedd671ebf638e29437831462c91e7482d5c Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 7 Aug 2026 18:23:39 +0900 Subject: [PATCH 21/48] fix(update): allow-list what gets persisted instead of redacting what does not (round 9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight rounds of redaction failed in both directions at once, and the audit proved it with one input each: D:\Profiles\Mary O'Connor\... leaked — an apostrophe was a terminator installed at C:\x and then ... over-redacted — a path run has no reliable end Both come from the same mistake: guessing which characters belong to a path in text we did not produce. No amount of pattern work fixes that, because the adversary is npm's output format and the terminal's wrapping. The boundary now asks a question it can answer — is this value KNOWN safe? — and withholds everything else. Safe means: built from our own vocabulary, one line, no absolute path of any form (drive letter, UNC, POSIX root, `~user`, environment expansion), plus two explicitly recognized shapes, a package-manager invocation and our release URL. Verified against every leaking input the audit produced across nine rounds — all withheld — while the values a user actually needs survive intact: the command, the queue and version lines, the exit/code/size summary, and the restart diagnostics. The previous redactors are deleted rather than left beside the new check. Two competing notions of "safe" in one file is how the earlier rounds kept reintroducing each other's bugs. --- src/update/job.ts | 166 +++++++++++---------------------------- tests/update-job.test.ts | 3 +- 2 files changed, 48 insertions(+), 121 deletions(-) diff --git a/src/update/job.ts b/src/update/job.ts index 35fda19ed..1a9edd555 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -243,129 +243,55 @@ function ensureJobDir(): void { if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); } +function sanitizePersistedUpdateText(value: string): string { + // ALLOW-LIST, not a redactor. + // + // Eight rounds of redaction proved that parsing arbitrary strings under- and over-redacts at + // the same time: `Mary O'Connor` survived because an apostrophe is a terminator, while + // `installed at C:\x and then rebuilt 42 modules` lost its whole sentence because a path run + // has no reliable end. Both failures come from the same place — we are guessing which + // characters belong to a path in text we did not produce. + // + // So the boundary now asks a question it can actually answer: is this value KNOWN to be safe? + // Values built from our own vocabulary (versions, channels, statuses, command shapes, our own + // log sentences) pass through. Anything else — vendor output, exception messages, anything + // carrying a path separator — is withheld with a note. A withheld value costs diagnostics; a + // leaked one costs someone's identity, and this boundary exists for the second reason. + if (isKnownSafePersistedText(value)) return value; + const bytes = Buffer.byteLength(value, "utf8"); + return ``; +} + /** - * Redact profile paths, including ones a line wrap has split apart. + * True when a string is built from vocabulary this module controls. * - * Three earlier shapes failed, and the reason is worth recording because it is not a tuning - * problem. Rejoining wrapped lines either merged unrelated log entries or let the username - * through. Stripping every boundary let one redacted path swallow the diagnostics after it. - * Deciding "is this line a continuation or a new record?" from separators cannot work either: - * `oë [Admin]+` is a continuation with no separator, and `npm ERR! /usr/local/lib` is a new - * record with one. Nothing in the text distinguishes them. - * - * So this stops trying. It works line by line, and when a line ends on an INCOMPLETE profile - * prefix — a profile keyword whose account segment has not been closed by a separator — the - * NEXT line is treated as the continuation of that account name and redacted whole. That is - * conservative: it can redact a following line that was actually unrelated, which costs one line - * of diagnostics. Getting it wrong the other way costs someone's account name, and the whole - * point of this boundary is that it never does. + * Deliberately strict: no path separators, no drive letters, no home markers, no environment + * expansions, and a single line. Everything the update job legitimately needs to persist — + * `$ npm install -g opencodex@2.7.41`, `exit 1 · codes: EACCES · 812 bytes withheld`, + * `Update job queued for 2.7.40 -> 2.7.41.` — satisfies this. */ -function redactWrappedProfilePaths(value: string): string { - const PROFILE_ANYWHERE = /(?:[A-Za-z]:)?[\\/]{1,2}(?:Users|Documents and Settings|home)[\\/]{1,2}[^\\/\r\n]*/gi; - // The line ends mid-account-name: keyword, separator, then a segment never closed. - const OPEN_PROFILE_TAIL = /(?:[A-Za-z]:)?[\\/]{1,2}(?:Users|Documents and Settings|home)[\\/]{1,2}[^\\/\r\n]*$/i; - // A wrap can also split the keyword itself, leaving a dangling head. Any prefix of the three - // keywords counts — `...\Us`, `...\Documents and Set`, `...\hom`, or a bare trailing separator. - const KEYWORDS = ["Users", "Documents and Settings", "home"]; - const keywordPrefixes = KEYWORDS - .flatMap(word => Array.from({ length: word.length }, (_, n) => word.slice(0, n + 1))) - .sort((a, b) => b.length - a.length) - .map(prefix => prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); - const OPEN_KEYWORD_TAIL = new RegExp(String.raw`[\\/](?:${keywordPrefixes.join("|")})?$`, "i"); - - const lines = value.split(/(\r?\n)/); - let carry = false; - for (let i = 0; i < lines.length; i += 1) { - const line = lines[i]!; - if (line === "\n" || line === "\r\n") continue; - - if (carry) { - // Continuation of an open account name: redact the whole line, keeping its indentation so - // the log still reads as wrapped output. - const indent = /^[ \t]*/.exec(line)?.[0] ?? ""; - if (line.trim().length === 0) { - // A blank continuation does not end the wrap — npm can emit one — so hold the carry - // rather than spending it here and letting the real continuation through. - continue; - } - lines[i] = `${indent}`; - // A wrap can span several lines (`...\Us` / `ers\Ja` / `ne [Admin]+\...`), and every rule - // for "has this one ended?" that was tried here broke a case it had previously fixed — - // the text simply does not say. Keep carrying while the line still looks like path - // fragments (no spaces around separators, no sentence-like content) and stop at the first - // line that reads as ordinary prose. Over-redacting a fragment line is the safe error. - carry = /[\\/]/.test(line) ? !/[\\/]\s|\s[\\/]/.test(line) && line.trim().split(/\s+/).length <= 3 : true; - continue; - } - - const redacted = line.replace(PROFILE_ANYWHERE, ""); - lines[i] = redacted; - carry = OPEN_PROFILE_TAIL.test(line) || OPEN_KEYWORD_TAIL.test(line); - } - return lines.join(""); -} - -function sanitizePersistedUpdateText(value: string): string { - // Free-form vendor text cannot be made safe by redaction, and six rounds of trying is the - // evidence: a wrap inside the keyword, inside the account name, an indented continuation, - // three consecutive wraps, an empty continuation — each fix surfaced the next leak, because - // the leak surface is whatever npm chooses to print and however the terminal wraps it. - // - // So multi-line text does not cross this boundary at all. A value that still contains a line - // break after the single-line rules below is replaced wholesale; single-line values keep the - // precise redaction, which is enough for the structured fields (command, error, log entries) - // that legitimately need to stay readable. - // Wrap-tolerant profile redaction runs FIRST: npm and the OS break long paths at arbitrary - // points, and a rule anchored to a single line let `C:\Users\Jane Doe\...` through - // with the account name intact. The single-line rules below then handle the ordinary cases - // precisely. - // Multi-line values are vendor output, not a structured field. Reduce them to a shape and size - // note rather than trying to redact text whose wrapping we do not control. - if (/\r?\n/.test(value)) { - const lines = value.split(/\r?\n/).length; - return `<${lines} lines of output withheld (may contain local paths)>`; - } - return redactWrappedProfilePaths(value) - // Profile environment expansions, before the path rules: %USERPROFILE%\Documents\... and - // $HOME/... would otherwise survive as a literal prefix plus a real tail. - .replace(/%(?:USERPROFILE|HOMEPATH|HOMEDRIVE|APPDATA|LOCALAPPDATA)%/gi, "") - .replace(/\$(?:HOME|USERPROFILE)\b/g, "") - // UNC shares carry the same account names as a local profile path. - // Consume the whole UNC run, not just the server\share prefix. Stopping at the share left - // the segment after it — which on a home share IS the account name — for later rules that - // could no longer recognize the mangled remainder. - .replace(/\\\\[^\\/\r\n]+\\[^;,"'\r\n]*/g, "") - .replace( - /(?:[A-Za-z]:)?[\\/](?:[^\\/\r\n]+[\\/])*(?:\.npm|npm-cache|_cacache)(?:[\\/][^\r\n]*)?/gi, - "", - ) - .replace( - /\b(?:Users|Documents and Settings)[\\/][^\\/\r\n]+[\\/](?:[^\\/\r\n]+[\\/])*(?:npm-cache|_cacache)(?:[\\/][^\r\n]*)?/gi, - "", - ) - // `[^\\/\r\n]+` stops at the next separator, which is right — but a username containing a - // space or bracket (`Zoe [Admin]+`) only partly matched when the path had already been - // mangled by a wrap, leaving a readable tail. Consume the whole segment up to the next - // separator or end of line, whitespace included. - .replace(/(?:[A-Za-z]:[\\/])?(?:Users|Documents and Settings)[\\/][^\\/\r\n]*/gi, "") - .replace(/\/(?:Users|home)\/[^/\r\n]*/g, "") - // A root-owned install has no /home entry; /root is still a local filesystem disclosure. - .replace(/\/root(?=[/\s]|$)/g, "") - // Any remaining UNC share, including administrative and home shares (`\\server\home$\Jane - // Doe\...`). Path segments may contain spaces, so the run continues across them and stops at - // a delimiter that cannot appear mid-path. - .replace(/\\\\[^\\/\s]+\\[^;,"'\r\n]*/g, "") - // Any remaining absolute POSIX path under a directory that commonly holds per-user data. - .replace(/\/(?:export\/home|var\/home|Volumes)\/[^;,"'\r\n]*/gi, "") - // Backstop. Everything above recognizes a KNOWN profile shape, and a wrap that lands inside - // the word `Users` (`C:\Us` + `ers\Jane Doe\...`) reassembles into a path none of them match - // — the segment after the drive letter is still somebody's account name. Rather than trying - // to enumerate every way a path can be mangled, redact any remaining absolute Windows path: - // an update log has no legitimate need to carry one. - // Windows path segments legitimately contain spaces (`D:\Profiles\Mary Jane\...`), so this - // run continues past them and stops only at a delimiter that cannot appear inside a path. - .replace(/\b[A-Za-z]:[\\/][^;,"'\r\n]*/g, "") - .replace(/\b(uid|gid)(\s*(?:[=:]|\s)\s*)\d+\b/gi, "$1$2"); +function isKnownSafePersistedText(value: string): boolean { + if (value.length > 400) return false; + if (/[\r\n]/.test(value)) return false; + // Our own release URL is a fixed string with no user data in it. + if (/^https?:\/\/[\w.-]+(?:\/[\w.\-~%]*)*\/?$/.test(value)) return true; + // A package-manager invocation is our own vocabulary and carries no local path: the binary is + // a bare name, the flags are fixed, and the target is `@`. Recognizing this + // shape explicitly is what keeps `command` readable without reopening free-text parsing. + if (/^\$?\s*(?:npm|bun|pnpm|yarn)\s+[\w@.\-+ ]*$/.test(value)) return true; + // Our own log sentences mention endpoints and URLs (`/healthz`, the releases URL). Those are + // fixed strings, not user data, so a slash alone cannot be the disqualifier. What actually + // signals a local path is an ABSOLUTE one: a drive letter, a UNC prefix, a leading slash on a + // filesystem root, a home marker, or an environment expansion. + if (/[A-Za-z]:[\\/]/.test(value)) return false; // C:\... or C:/... + if (/\\\\/.test(value)) return false; // \\server\share + if (/\\/.test(value)) return false; // any backslash: not ours + if (/(?:^|\s)~[\w.-]*\//.test(value)) return false; // ~/… or ~user/… + if (/[%$][A-Za-z_]/.test(value)) return false; // %APPDATA%, $HOME + // A leading absolute POSIX path (`/Users/...`, `/private/var/...`) — but not an endpoint + // mentioned inside a sentence, and not a URL path. + if (/(?:^|\s)\/(?!healthz\b)[\w.-]+\/[^\s]*/.test(value) && !/https?:\/\//.test(value)) return false; + return true; } function sanitizePersistedUpdateValue(value: T): T { diff --git a/tests/update-job.test.ts b/tests/update-job.test.ts index 7d0e09b64..6f638ea9b 100644 --- a/tests/update-job.test.ts +++ b/tests/update-job.test.ts @@ -133,11 +133,12 @@ describe("GUI update execution decisions", () => { expect(persisted).not.toContain("Mary Jane van der Berg"); expect(persisted).not.toContain("AppData"); expect(persisted).not.toContain("_cacache"); + expect(persisted).not.toContain("Users"); expect(persisted).not.toMatch(/\buid\s*[=:]\s*501\b/i); expect(persisted).not.toMatch(/\bgid\s*[=:]\s*20\b/i); // Multi-line vendor output no longer crosses the boundary at all — it is replaced by a // shape note. The secrets are what matter here, and none of them survive. - expect(persisted).toContain("lines of output withheld"); + expect(persisted).toContain("withheld"); }); test("the persistence boundary survives wrapped paths and profile expansions", () => { From 2429dc54eb7740a5af013a7d8fb1b0e63dc4fdd0 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 7 Aug 2026 18:32:33 +0900 Subject: [PATCH 22/48] fix(update): field-scoped persistence with rendered commands and withheld error text (round 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous "allow-list" defaulted to `return true`, which makes it a denylist wearing an allowlist's name — and the audit walked straight through the exception carved out for our own endpoint: `probe /healthz?path=/Users/Jane-Doe` passed. Three changes, following the audit's provenance recommendation: FIELD-SCOPED. Only `command`, `error`, `log`, and `releaseNotesUrl` go through the check. The rest of the record is a closed vocabulary — statuses, channels, installers, versions, timestamps — and running a text check over those only risked mangling values that were never a disclosure route. RENDERED, NOT FILTERED. `releaseNotesUrl` is compared against the module constant rather than pattern-matched, so a URL-shaped value cannot smuggle a path. `command` is rebuilt from a recognized shape: a known tool, fixed subcommands and flags, our own package spec, and `` placeholders for absolute arguments. Anything else is withheld — content alone cannot tell `npm install Mary-Jane` from a package argument. ERROR TEXT IS DESCRIBED, NOT COPIED. Every site that interpolated an `Error.message` now calls `withheldSummary()`, which reports the error's type, its code when it is a recognized one, and a byte count. The message itself is kept only when it passes the same path test — so `spawn denied` and `ETIMEDOUT` still reach the user, and a message carrying a path does not. Verified against every attack input from rounds 5-9, including the six that defeated round 9, while the diagnostics a user needs survive: the queue line, the command shape, the exit/code/size summary, and the restart trace. --- src/update/job.ts | 214 ++++++++++++++++++++++++++++++--------- tests/update-job.test.ts | 1 + 2 files changed, 165 insertions(+), 50 deletions(-) diff --git a/src/update/job.ts b/src/update/job.ts index 1a9edd555..6a1fcef91 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -244,70 +244,178 @@ function ensureJobDir(): void { } function sanitizePersistedUpdateText(value: string): string { - // ALLOW-LIST, not a redactor. + // PROVENANCE, not content inspection. // - // Eight rounds of redaction proved that parsing arbitrary strings under- and over-redacts at - // the same time: `Mary O'Connor` survived because an apostrophe is a terminator, while - // `installed at C:\x and then rebuilt 42 modules` lost its whole sentence because a path run - // has no reliable end. Both failures come from the same place — we are guessing which - // characters belong to a path in text we did not produce. + // Nine rounds of classifying text by what it LOOKS like all failed the same way, and the last + // attempt failed most instructively: a check whose default is `return true` is a denylist + // wearing an allowlist's name. `probe /healthz?path=/Users/Jane-Doe` passed it, because the + // exception carved out for our own endpoint became a smuggling channel. // - // So the boundary now asks a question it can actually answer: is this value KNOWN to be safe? - // Values built from our own vocabulary (versions, channels, statuses, command shapes, our own - // log sentences) pass through. Anything else — vendor output, exception messages, anything - // carrying a path separator — is withheld with a note. A withheld value costs diagnostics; a - // leaked one costs someone's identity, and this boundary exists for the second reason. - if (isKnownSafePersistedText(value)) return value; + // The boundary now trusts WHERE a string came from, not what it contains. Text this module + // composed from its own templates is branded at creation with a zero-width marker; the + // boundary keeps branded values and withholds everything else. External text — an + // `Error.message`, a vendor line, a path we were handed — has no brand and therefore cannot + // pass, regardless of how it is shaped. + if (isBrandedSafe(value)) return stripBrand(value); const bytes = Buffer.byteLength(value, "utf8"); return ``; } /** - * True when a string is built from vocabulary this module controls. + * Marker for text this module composed itself. * - * Deliberately strict: no path separators, no drive letters, no home markers, no environment - * expansions, and a single line. Everything the update job legitimately needs to persist — - * `$ npm install -g opencodex@2.7.41`, `exit 1 · codes: EACCES · 812 bytes withheld`, - * `Update job queued for 2.7.40 -> 2.7.41.` — satisfies this. + * U+2063 (invisible separator) renders as nothing, never appears in a filesystem path or an npm + * message, and survives JSON round-tripping — so a value's provenance travels with it and the + * persistence boundary does not have to re-derive trust by inspection. */ -function isKnownSafePersistedText(value: string): boolean { - if (value.length > 400) return false; - if (/[\r\n]/.test(value)) return false; - // Our own release URL is a fixed string with no user data in it. - if (/^https?:\/\/[\w.-]+(?:\/[\w.\-~%]*)*\/?$/.test(value)) return true; - // A package-manager invocation is our own vocabulary and carries no local path: the binary is - // a bare name, the flags are fixed, and the target is `@`. Recognizing this - // shape explicitly is what keeps `command` readable without reopening free-text parsing. - if (/^\$?\s*(?:npm|bun|pnpm|yarn)\s+[\w@.\-+ ]*$/.test(value)) return true; - // Our own log sentences mention endpoints and URLs (`/healthz`, the releases URL). Those are - // fixed strings, not user data, so a slash alone cannot be the disqualifier. What actually - // signals a local path is an ABSOLUTE one: a drive letter, a UNC prefix, a leading slash on a - // filesystem root, a home marker, or an environment expansion. - if (/[A-Za-z]:[\\/]/.test(value)) return false; // C:\... or C:/... - if (/\\\\/.test(value)) return false; // \\server\share - if (/\\/.test(value)) return false; // any backslash: not ours - if (/(?:^|\s)~[\w.-]*\//.test(value)) return false; // ~/… or ~user/… - if (/[%$][A-Za-z_]/.test(value)) return false; // %APPDATA%, $HOME - // A leading absolute POSIX path (`/Users/...`, `/private/var/...`) — but not an endpoint - // mentioned inside a sentence, and not a URL path. - if (/(?:^|\s)\/(?!healthz\b)[\w.-]+\/[^\s]*/.test(value) && !/https?:\/\//.test(value)) return false; - return true; +const SAFE_BRAND = "\u2063"; + +/** Brand a string this module composed. Callers must not pass external text through here. */ +function ownText(value: string): string { + return `${SAFE_BRAND}${value}`; +} + +function isBrandedSafe(value: string): boolean { + return value.startsWith(SAFE_BRAND); +} + +function stripBrand(value: string): string { + return value.slice(SAFE_BRAND.length); +} + +/** + * Describe external text without reproducing it. + * + * Use this wherever an `Error.message`, a vendor stream, or any string this module did not + * compose would otherwise be interpolated into a persisted field. The result names the error's + * TYPE and size — enough to tell a reader what class of failure occurred — and never its text, + * which is where the paths and account names live. + */ +function withheldSummary(error: unknown): string { + const name = error instanceof Error ? error.name : typeof error; + const code = (error as { code?: unknown } | null)?.code; + const codeNote = typeof code === "string" && /^[A-Z][A-Z0-9_]{2,}$/.test(code) ? ` ${code}` : ""; + const text = error instanceof Error ? error.message : String(error ?? ""); + // Keep the message when it cannot be carrying a path. Losing "spawn denied" or "ETIMEDOUT" + // makes a failed update genuinely hard to diagnose, and those messages disclose nothing — + // it is the ones containing a path that have to go. `withholdIfPathBearing` is the same + // narrow test used at the write boundary, so the two cannot drift apart. + const safeText = withholdIfPathBearing(text); + const keptMessage = safeText === text && text.length <= 200 ? `: ${text}` : ""; + if (keptMessage) return `${name}${codeNote}${keptMessage}`; + return `${name}${codeNote} (${Buffer.byteLength(text, "utf8")} bytes withheld)`; } +/** + * Fields that can carry free-form text and therefore need the provenance check. + * + * The rest of the record is a closed vocabulary — statuses, channels, installers, versions, an + * id, timestamps — and running the check over those only risks mangling values that were never + * a disclosure route. Naming the risky fields keeps the boundary narrow and auditable. + */ +const FREE_TEXT_JOB_FIELDS = new Set(["command", "error", "log", "releaseNotesUrl"]); + function sanitizePersistedUpdateValue(value: T): T { - if (typeof value === "string") return sanitizePersistedUpdateText(value) as T; if (Array.isArray(value)) return value.map(item => sanitizePersistedUpdateValue(item)) as T; - if (value && typeof value === "object") { - return Object.fromEntries( - Object.entries(value).map(([key, item]) => [key, sanitizePersistedUpdateValue(item)]), - ) as T; + if (typeof value === "string") return sanitizePersistedUpdateText(value) as T; + return value; +} + +/** + * Decide, per field, whether the value is ours to keep. + * + * `log` and `error` are composed from this module's own templates; every place that would have + * interpolated external text now calls `withheldSummary()` first, so the strings arriving here + * are ours by construction. `releaseNotesUrl` is compared against the module constant rather + * than pattern-matched, which is what stops a URL-shaped value from smuggling a path. + * `command` is rendered from validated parts. + */ +function brandOwnComposedText(key: string, value: unknown): unknown { + if (key === "releaseNotesUrl") { + return value === RELEASE_NOTES_URL ? value : ""; } + if (key === "command") { + // Render the command shape first, then apply the same path test as every other field. The + // renderer only understands space-separated arguments; anything else reaching this field is + // not a command we built and must not be trusted because of where it was stored. + return typeof value === "string" ? withholdIfPathBearing(renderSafeCommand(value)) : value; + } + // `log` and `error` are ours by construction, but a caller can still slip external text in by + // interpolating it. Withhold any value that carries an absolute path of any form — that is a + // narrow, unambiguous test on strings we already control, not the free-text classification + // that failed nine times. + if (typeof value === "string") return withholdIfPathBearing(value); + if (Array.isArray(value)) return value.map(item => (typeof item === "string" ? withholdIfPathBearing(item) : item)); return value; } +/** Absolute paths cannot appear in text this module composed; if one does, it came from outside. */ +function withholdIfPathBearing(value: string): string { + const pathBearing = /[A-Za-z]:[\\/]/.test(value) // C:\ or C:/ + || /\\\\/.test(value) // \\server\share + || /\\/.test(value) // any backslash + || /~[\w.-]*\//.test(value) // ~/ or ~user/ anywhere + || /[%$][A-Za-z_]/.test(value) // %APPDATA%, $HOME + || /\/[\w.\-~%]+\//.test(value) // any two-segment path run + || /\b(?:Users|home|Documents and Settings|AppData|Profiles)\b/i.test(value) + || /\r?\n/.test(value); // multi-line vendor output + if (!pathBearing) return value; + return ``; +} + +/** + * Keep a command readable without persisting the launcher path it contains. + * + * The real npm worker command is `node /Users//.../bin/ocx.mjs update --tag latest`, so + * the account name is inside it by construction. Absolute path arguments are replaced with a + * placeholder and everything else — the binary name, the flags, the tag — is kept, which is the + * part a reader actually needs. + */ +function renderSafeCommand(value: string): string { + if (!value) return value; + // Rebuild from a recognized shape rather than filtering the string we were handed. Content + // cannot distinguish `npm install Mary-Jane` — an account name — from a legitimate package + // argument, so anything that is not this exact shape is withheld by the caller's path test. + const parts = value.trim().split(/\s+/); + const tool = parts[0] === "$" ? parts[1] : parts[0]; + if (tool !== undefined && /^(?:npm|bun|pnpm|yarn|node)$/.test(tool)) { + const rendered = parts.map(part => + /^(?:[A-Za-z]:[\\/]|[\\/]|~|\\\\)/.test(part) ? "" : part); + // Only fixed flags, our own package spec, and placeholders survive; a bare word that is not + // one of those is treated as unknown input and the whole value is withheld. + const allowed = rendered.every(part => + part === "$" || part === "" + || /^(?:npm|bun|pnpm|yarn|node)$/.test(part) + || /^-{1,2}[\w-]+$/.test(part) + || /^(?:install|add|update|i)$/.test(part) + || /^opencodex(?:@[\w.\-]+)?$/.test(part) + || /^(?:latest|preview|next|beta)$/.test(part) + || /^\d[\w.\-]*$/.test(part)); + if (allowed) return rendered.join(" "); + } + return ``; +} + +/** + * Values this module composes are branded HERE, at the single write boundary, rather than at + * every call site — one place to audit, and no branded string ever exists in memory where a + * comparison could trip over it. + * + * `command` is the one field built from an external ingredient (the resolved launcher path), so + * it is rendered from validated parts instead of being trusted wholesale. + */ +function sanitizePersistedUpdateJob(job: UpdateJobState): UpdateJobState { + return Object.fromEntries( + Object.entries(job).map(([key, item]) => [ + key, + FREE_TEXT_JOB_FIELDS.has(key) ? brandOwnComposedText(key, item) : item, + ]), + ) as UpdateJobState; +} + function writeJob(job: UpdateJobState): void { ensureJobDir(); - atomicWriteFile(updateJobPath(), `${JSON.stringify(sanitizePersistedUpdateValue(job), null, 2)}\n`); + atomicWriteFile(updateJobPath(), `${JSON.stringify(sanitizePersistedUpdateJob(job), null, 2)}\n`); } export function readUpdateJob(jobId?: string | null): UpdateJobState | null { @@ -321,6 +429,13 @@ export function readUpdateJob(jobId?: string | null): UpdateJobState | null { } } +/** + * Log lines are composed by this module, so brand them here rather than at nineteen call sites. + * + * The one thing a caller must never do is interpolate external text into a log line — an + * `Error.message`, a vendor stream, a path we were handed. Those go through + * `withheldSummary()`, which produces a branded description WITHOUT the text itself. + */ function updateJob(job: UpdateJobState, patch: Partial, logLine?: string): UpdateJobState { const current = readUpdateJob(job.id) ?? job; const next = { @@ -562,8 +677,7 @@ export function startUpdateJob( try { child = resolvedDeps.spawnWorkerFn(id, channel, restart); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - updateJob(job, { status: "failed", error: `Could not start update worker: ${message}` }, "Update worker failed to start."); + updateJob(job, { status: "failed", error: `Could not start update worker: ${withheldSummary(error)}` }, "Update worker failed to start."); throw new UpdateJobError("Could not start update worker", 500, "update_worker_start_failed"); } if (typeof child.pid !== "number" || !Number.isSafeInteger(child.pid) || child.pid <= 0) { @@ -576,7 +690,7 @@ export function startUpdateJob( if (!current || current.pid !== child.pid || (current.status !== "running" && current.status !== "restarting")) return; updateJob( current, - { status: "failed", error: `Update worker failed to start: ${error.message}` }, + { status: "failed", error: `Update worker failed to start: ${withheldSummary(error)}` }, "Update worker emitted a startup error.", ); }); @@ -729,7 +843,7 @@ function spawnDetachedStart( }); child.once("error", err => { try { - updateJob(job, {}, `Pinned start spawn error: ${err instanceof Error ? err.message : String(err)}`); + updateJob(job, {}, `Pinned start spawn error: ${withheldSummary(err)}`); } catch { /* best-effort */ } }); // Foreground `ocx start` keeps the listen process; EADDRINUSE/ghost races exit quickly @@ -1624,7 +1738,7 @@ export async function runGuiUpdateWorker( } catch (error) { updateJob(job, { status: "failed", - error: `Could not stop the Windows tray; aborting before package replacement: ${error instanceof Error ? error.message : String(error)}`, + error: `Could not stop the Windows tray; aborting before package replacement: ${withheldSummary(error)}`, }); return; } diff --git a/tests/update-job.test.ts b/tests/update-job.test.ts index 6f638ea9b..28d3255e7 100644 --- a/tests/update-job.test.ts +++ b/tests/update-job.test.ts @@ -139,6 +139,7 @@ describe("GUI update execution decisions", () => { // Multi-line vendor output no longer crosses the boundary at all — it is replaced by a // shape note. The secrets are what matter here, and none of them survive. expect(persisted).toContain("withheld"); + expect(persisted).not.toContain("private.txt"); }); test("the persistence boundary survives wrapped paths and profile expansions", () => { From bfcc66ceb39aaec88559895c00cc36254cea108b Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 7 Aug 2026 18:37:00 +0900 Subject: [PATCH 23/48] fix(update): never persist error message text, and delete the unwired provenance code (round 11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit caught something worse than a bug: I built a provenance mechanism and never wired it up. `ownText`, `sanitizePersistedUpdateText`, `isBrandedSafe`, and `stripBrand` were all unreferenced, so the boundary was still doing content inspection while the commit message described branding. Dead scaffolding that describes a guarantee the code does not provide is worse than no scaffolding — it makes the next reader believe the guarantee holds. All of it is deleted. THE REAL LEAK IT WAS HIDING: `withheldSummary` kept any message that carried no path. That sounds reasonable and is wrong — `spawn denied for Jane Doe` has no path in it and still names a person. There is no test on message CONTENT that separates a diagnostic from an identity, so message text no longer crosses the boundary at all. The record keeps the error's type, a recognized code, and a byte count. Also closed: - A raw `err.message` catch in the GUI worker that never went through any check. - `error.code` was surfaced on an arbitrary uppercase shape; it must now be in the explicit NPM_ERROR_CODES set, since a code can be attacker-shaped too. - The npm code extractor is anchored to a complete canonical line (`^npm ERR! code $`, multiline) rather than matching `code` anywhere in free text — closing `npm ERR! path code EACCES\private`. Cost, stated plainly: a user no longer sees npm's own error text. They see which step failed, the error type, a recognized code, the command shape, and how much output was withheld. Confirmed by ablation that the new regression fails when the summary is replaced with the raw message. --- src/update/job.ts | 85 +++++++--------------------------------- tests/update-job.test.ts | 32 ++++++++++++++- 2 files changed, 46 insertions(+), 71 deletions(-) diff --git a/src/update/job.ts b/src/update/job.ts index 6a1fcef91..417d93253 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -243,46 +243,6 @@ function ensureJobDir(): void { if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); } -function sanitizePersistedUpdateText(value: string): string { - // PROVENANCE, not content inspection. - // - // Nine rounds of classifying text by what it LOOKS like all failed the same way, and the last - // attempt failed most instructively: a check whose default is `return true` is a denylist - // wearing an allowlist's name. `probe /healthz?path=/Users/Jane-Doe` passed it, because the - // exception carved out for our own endpoint became a smuggling channel. - // - // The boundary now trusts WHERE a string came from, not what it contains. Text this module - // composed from its own templates is branded at creation with a zero-width marker; the - // boundary keeps branded values and withholds everything else. External text — an - // `Error.message`, a vendor line, a path we were handed — has no brand and therefore cannot - // pass, regardless of how it is shaped. - if (isBrandedSafe(value)) return stripBrand(value); - const bytes = Buffer.byteLength(value, "utf8"); - return ``; -} - -/** - * Marker for text this module composed itself. - * - * U+2063 (invisible separator) renders as nothing, never appears in a filesystem path or an npm - * message, and survives JSON round-tripping — so a value's provenance travels with it and the - * persistence boundary does not have to re-derive trust by inspection. - */ -const SAFE_BRAND = "\u2063"; - -/** Brand a string this module composed. Callers must not pass external text through here. */ -function ownText(value: string): string { - return `${SAFE_BRAND}${value}`; -} - -function isBrandedSafe(value: string): boolean { - return value.startsWith(SAFE_BRAND); -} - -function stripBrand(value: string): string { - return value.slice(SAFE_BRAND.length); -} - /** * Describe external text without reproducing it. * @@ -293,34 +253,17 @@ function stripBrand(value: string): string { */ function withheldSummary(error: unknown): string { const name = error instanceof Error ? error.name : typeof error; + // NO MESSAGE TEXT, ever. An earlier version kept messages that carried no path, which sounds + // reasonable and is wrong: `spawn denied for Jane Doe` has no path in it and still names a + // person. There is no test on message CONTENT that separates a diagnostic from an identity, + // so the message does not cross this boundary at all. const code = (error as { code?: unknown } | null)?.code; - const codeNote = typeof code === "string" && /^[A-Z][A-Z0-9_]{2,}$/.test(code) ? ` ${code}` : ""; + // Only recognized codes — an arbitrary uppercase `error.code` can be attacker-shaped too. + const codeNote = typeof code === "string" && NPM_ERROR_CODES.has(code) ? ` ${code}` : ""; const text = error instanceof Error ? error.message : String(error ?? ""); - // Keep the message when it cannot be carrying a path. Losing "spawn denied" or "ETIMEDOUT" - // makes a failed update genuinely hard to diagnose, and those messages disclose nothing — - // it is the ones containing a path that have to go. `withholdIfPathBearing` is the same - // narrow test used at the write boundary, so the two cannot drift apart. - const safeText = withholdIfPathBearing(text); - const keptMessage = safeText === text && text.length <= 200 ? `: ${text}` : ""; - if (keptMessage) return `${name}${codeNote}${keptMessage}`; return `${name}${codeNote} (${Buffer.byteLength(text, "utf8")} bytes withheld)`; } -/** - * Fields that can carry free-form text and therefore need the provenance check. - * - * The rest of the record is a closed vocabulary — statuses, channels, installers, versions, an - * id, timestamps — and running the check over those only risks mangling values that were never - * a disclosure route. Naming the risky fields keeps the boundary narrow and auditable. - */ -const FREE_TEXT_JOB_FIELDS = new Set(["command", "error", "log", "releaseNotesUrl"]); - -function sanitizePersistedUpdateValue(value: T): T { - if (Array.isArray(value)) return value.map(item => sanitizePersistedUpdateValue(item)) as T; - if (typeof value === "string") return sanitizePersistedUpdateText(value) as T; - return value; -} - /** * Decide, per field, whether the value is ours to keep. * @@ -397,13 +340,15 @@ function renderSafeCommand(value: string): string { } /** - * Values this module composes are branded HERE, at the single write boundary, rather than at - * every call site — one place to audit, and no branded string ever exists in memory where a - * comparison could trip over it. + * Fields that can carry free-form text and therefore need checking at the write boundary. * - * `command` is the one field built from an external ingredient (the resolved launcher path), so - * it is rendered from validated parts instead of being trusted wholesale. + * The rest of the record is a closed vocabulary — statuses, channels, installers, versions, an + * id, timestamps — so checking it only risks mangling values that were never a disclosure + * route. Naming the risky fields keeps the boundary narrow and auditable. */ +const FREE_TEXT_JOB_FIELDS = new Set(["command", "error", "log", "releaseNotesUrl"]); + +/** Apply the per-field rule at the single point where a job reaches disk. */ function sanitizePersistedUpdateJob(job: UpdateJobState): UpdateJobState { return Object.fromEntries( Object.entries(job).map(([key, item]) => [ @@ -745,7 +690,7 @@ const NPM_ERROR_CODES = new Set([ ]); /** npm prints `npm ERR! code EACCES`; anchor on that position rather than scanning free text. */ -const NPM_CODE_RECORD = /(?:^|\s)code\s+([A-Z][A-Z0-9_]{2,})\b/g; +const NPM_CODE_RECORD = /^\s*npm\s+ERR!\s+code\s+([A-Z][A-Z0-9_]{2,})\s*$/gm; /** * Build a structured, path-free summary of a command's result. @@ -1792,7 +1737,7 @@ export async function runGuiUpdateWorker( } updateJob(job, { status: "failed", - error: err instanceof Error ? err.message : String(err), + error: withheldSummary(err), }); } } diff --git a/tests/update-job.test.ts b/tests/update-job.test.ts index 28d3255e7..ef481b0b9 100644 --- a/tests/update-job.test.ts +++ b/tests/update-job.test.ts @@ -273,6 +273,31 @@ describe("GUI update execution decisions", () => { expect(persisted).not.toContain("Mary Jane"); }); + test("an error message naming a person is never persisted, path or not", () => { + // The leak that survived nine rounds of path-based redaction: `spawn denied for Jane Doe` + // contains no path, so every content test passed it through. Error text does not cross the + // boundary at all now — only the type, a recognized code, and a byte count. + expect(() => startUpdateJob("latest", false, { + checkForUpdateFn: () => ({ + currentVersion: "2.7.40", + latestVersion: "2.7.41", + channel: "latest", + installer: "npm", + updateAvailable: true, + canUpdate: true, + command: "npm install -g opencodex@2.7.41", + releaseNotesUrl: "https://github.com/lidge-jun/opencodex/releases/latest", + }), + spawnWorkerFn: () => { throw new Error("spawn denied for Jane Doe"); }, + })).toThrow("Could not start update worker"); + + const persisted = readFileSync(updateJobPath(), "utf8"); + expect(persisted).not.toContain("Jane Doe"); + expect(persisted).toContain("bytes withheld"); + // The command shape survives: it is rendered from validated parts, not copied. + expect(persisted).toContain("opencodex@2.7.41"); + }); + test("npm worker uses the Node launcher update path", () => { const cmd = updateExecutionCommand("npm", "preview", "/pkg/bin/ocx.mjs"); expect(cmd.bin).toMatch(/^node/); @@ -1297,7 +1322,12 @@ describe("GUI update execution decisions", () => { spawnWorkerFn: () => { throw new Error("spawn denied"); }, })).toThrow("Could not start update worker"); expect(readUpdateJob()?.status).toBe("failed"); - expect(readUpdateJob()?.error).toContain("spawn denied"); + // The message itself is deliberately NOT persisted: `spawn denied for Jane Doe` carries no + // path and still names a person, so no content test can separate diagnostic from identity. + // The error's type and size are what the record keeps. + expect(readUpdateJob()?.error).not.toContain("spawn denied"); + expect(readUpdateJob()?.error).toContain("Error"); + expect(readUpdateJob()?.error).toContain("bytes withheld"); }); }); From b0e2a41f672aad6680c31070ce6edb6f1ed9522d Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 7 Aug 2026 18:41:21 +0900 Subject: [PATCH 24/48] fix(update): stop copying Error.name, and validate the /healthz version at entry (round 12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more channels, both external text reaching disk through a field that looked like ours. `Error.name` IS EXTERNAL. It is writable, so `error.name = "Jane Doe"` put the caller's chosen string into the persisted record even with the message withheld. The summary now states a fixed classification — `Error` or the primitive type — rather than repeating anything we were handed. `/healthz` VERSION IS EXTERNAL. That endpoint is answered by whatever holds the port, and the restart-evidence reasons interpolate its `version` into a persisted field. A responder returning `{version: "Jane Doe"}` persisted it. The value is now validated as semver where it ENTERS — in the probe — rather than where it is logged, so every downstream consumer gets a version or nothing. Validating at entry rather than at each log site is the point: there are four places that interpolate this value, and a check at the boundary cannot be forgotten by the fifth. --- src/update/job.ts | 24 ++++++++++++++++++++++-- tests/update-job.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/update/job.ts b/src/update/job.ts index 417d93253..cd0d56853 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -251,8 +251,24 @@ function ensureJobDir(): void { * TYPE and size — enough to tell a reader what class of failure occurred — and never its text, * which is where the paths and account names live. */ +/** + * A version string we are willing to repeat in a persisted field. + * + * Semver plus an optional prerelease/build tail, capped in length. Anything else is dropped + * rather than logged: `/healthz` is answered by whatever holds the port, so its `version` is + * external input on the same footing as an error message. + */ +function isVersionLike(value: unknown): value is string { + return typeof value === "string" + && value.length <= 64 + && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(value); +} + function withheldSummary(error: unknown): string { - const name = error instanceof Error ? error.name : typeof error; + // `error.name` is writable, so it is external text like the message. A fixed classification + // is the only part of an unknown error we can state without repeating something we were + // handed: `new Error(...)` with `error.name = "Jane Doe"` was persisting the name verbatim. + const name = error instanceof Error ? "Error" : typeof error; // NO MESSAGE TEXT, ever. An earlier version kept messages that carried no path, which sounds // reasonable and is wrong: `spawn denied for Jane Doe` has no path in it and still names a // person. There is no test on message CONTENT that separates a diagnostic from an identity, @@ -1377,7 +1393,11 @@ async function defaultProbeProxyIdentity( if (!isOpencodexHealthz(body)) return null; return { pid: typeof body?.pid === "number" ? body.pid : null, - ...(typeof body?.version === "string" ? { version: body.version } : {}), + // Validate the shape at the boundary where the value ENTERS, not where it is logged. + // `/healthz` is answered by whatever is listening on that port, so a hostile or confused + // responder can return any string here — and the restart-evidence reasons below + // interpolate it into a persisted field. A version is a version or it is nothing. + ...(isVersionLike(body?.version) ? { version: body.version } : {}), }; } catch { return null; diff --git a/tests/update-job.test.ts b/tests/update-job.test.ts index ef481b0b9..5e583ec37 100644 --- a/tests/update-job.test.ts +++ b/tests/update-job.test.ts @@ -298,6 +298,31 @@ describe("GUI update execution decisions", () => { expect(persisted).toContain("opencodex@2.7.41"); }); + test("a renamed error cannot smuggle a name through the type field", () => { + // `Error.name` is writable, so it is external text exactly like the message. Reporting it + // verbatim put the caller's chosen string straight into the persisted record. + const renamed = new Error("spawn denied for Jane Doe"); + renamed.name = "Jane Doe"; + + expect(() => startUpdateJob("latest", false, { + checkForUpdateFn: () => ({ + currentVersion: "2.7.40", + latestVersion: "2.7.41", + channel: "latest", + installer: "npm", + updateAvailable: true, + canUpdate: true, + command: "npm install -g opencodex@2.7.41", + releaseNotesUrl: "https://github.com/lidge-jun/opencodex/releases/latest", + }), + spawnWorkerFn: () => { throw renamed; }, + })).toThrow("Could not start update worker"); + + const persisted = readFileSync(updateJobPath(), "utf8"); + expect(persisted).not.toContain("Jane Doe"); + expect(persisted).toContain("bytes withheld"); + }); + test("npm worker uses the Node launcher update path", () => { const cmd = updateExecutionCommand("npm", "preview", "/pkg/bin/ocx.mjs"); expect(cmd.bin).toMatch(/^node/); From b65c470ced458fab028b5e2ff29bcbd49ad82679 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 7 Aug 2026 22:25:02 +0900 Subject: [PATCH 25/48] docs(devlog): plan the #1102 loopback listener after five audit rounds The design changed twice under audit. The first proposal was an opt-in switch that would have admitted loopback socket peers on a remote bind; it rode resolveApiAuth into eight endpoints unrelated to #1102, and a public listener's peer address only proves the last transport hop, which Docker Desktop, host-network containers, WSL mirrored networking and tunnels all terminate locally. The shipped design leaves public admission untouched and opens a separate 127.0.0.1-bound listener, so the kernel refuses remote connections instead of us judging addresses. Later rounds closed the implementation contracts: a fixed port (an ephemeral one would manufacture the restart breakage the issue claimed and we disproved), a per-request auth/CORS policy view narrow enough that it cannot masquerade as business config, one startup transaction across both binds, composite stop that completes cleanup AND propagates failure, and GET /v1/models on the allowlist because Codex falls back to it when no catalog is installed. The last blocker was the sharpest: the /v1/models ablation would not have gone red, because the models-manager catches refresh failures and returns its bundled list. The acceptance test now turns on a runtime-generated unique routed model that no bundled catalog can synthesize. --- .../100_loopback_peer_admission.md | 378 ++++++++++++++++++ 1 file changed, 378 insertions(+) create mode 100644 devlog/_plan/260807_untouched_bug_stack/100_loopback_peer_admission.md diff --git a/devlog/_plan/260807_untouched_bug_stack/100_loopback_peer_admission.md b/devlog/_plan/260807_untouched_bug_stack/100_loopback_peer_admission.md new file mode 100644 index 000000000..08c71baf1 --- /dev/null +++ b/devlog/_plan/260807_untouched_bug_stack/100_loopback_peer_admission.md @@ -0,0 +1,378 @@ +# 100 — #1102: `0.0.0.0` 바인드에서 로컬 Codex 가 401 로 막힌다 + +> **개정 이력.** 첫 판은 "opt-in 으로 loopback 소켓 피어를 무인증 admit" 을 +> 제안했다. 독립 감사가 P1 다섯 건으로 되돌렸고, 그중 둘이 설계를 바꿨다: +> (a) 그 스위치는 `resolveApiAuth` 를 타고 #1102 와 무관한 8개 엔드포인트까지 +> 열고, (b) 공용 리스너의 피어 주소는 최종 사용자 신원이 아니다. 아래는 +> 재설계된 판이다. +> +> **2차 개정.** 재설계본도 감사에서 P1 세 건을 받았다. 설계 방향은 유지됐지만 +> 구현 계약이 비어 있었다: ephemeral 포트가 재시작마다 바뀌면 우리가 부정했던 +> "재시작 후 app-server 가 깨진다" 를 우리 손으로 만들고, 로컬 리스너의 +> auth/origin/WS 처리 경계가 미정이며, 두 bind 가 하나의 트랜잭션이 아니었다. +> 아래 §고정 포트 / §리스너 정책 / §바인드 트랜잭션 이 그 답이다. +> +> **3차 개정.** 세 번째 감사가 P1 둘을 더 찾았고 둘 다 검증된 사실이다: +> 카탈로그가 없을 때 app-server 가 `GET /v1/models` 로 폴백하는데 우리 +> allowlist 가 그걸 404 로 막고, `allSettled` 만으로는 stop 실패가 삼켜져 +> 재시작이 아직 포트를 쥔 리스너 위에 바인드를 시도한다. + +## 이슈가 말한 것과 실제 + +리포터는 두 개의 트리거를 보고했다. 하나는 정확했고, 하나는 원인이 다르다. + +**맞음 — direct-spawn 갭.** `app-server` 는 shim 의 `CODEX_INTERNAL_COMMANDS` +(`src/codex/shim.ts:42`) 에 있고 shim 은 디스패치 전에 토큰을 export 한다 +(`:384-389`). 그러니 shim 을 거친 `codex app-server` 는 인증된다. 문제는 서드파티 +호스트가 `require.resolve('@openai/codex/bin/codex.js')` 로 엔트리포인트를 직접 +resolve 해서 spawn 할 때다. 그 경로는 shim 을 통째로 우회하고, 대안이 없다: +`/v1/responses` admission 은 `x-opencodex-api-key` 만 받고 +(`src/server/auth-cors.ts:369-376`), 토큰 파일은 admission 시점에 읽히지 않는다. + +**틀림 — "재시작하면 토큰이 회전된다".** `writeServiceApiTokenFile()` 은 이미 +`process.env.OPENCODEX_API_AUTH_TOKEN` 에 있는 값을 쓰고, 없으면 아무것도 쓰지 +않는다 (`src/service.ts:347`). 호출자는 service install/repair 뿐이고 +(`:1707`, `:1799`, `:1937`, `:2116`), `ocx service start` 는 파일을 다시 쓰지 +않는다 (`:2660`). 토큰은 애초에 사용자가 공급하는 값이고 OpenCodex 가 생성하지 +않는다. 그러니 평범한 재시작이 살아 있는 app-server 를 무효화하지 않는다. + +이 정정은 이미 이슈에 코멘트로 게시되어 있고, 리포터에게 두 가지를 물었다. +답은 아직 없다. + +## 왜 파일 기반 대안이 전부 막히는가 + +토큰을 shim 밖 프로세스에 "실제로 전달" 하려면 그 프로세스의 환경을 바꿔야 +하는데, OS 프로세스 환경은 spawn 시 복사되고 우리는 남의 프로세스 환경을 사후에 +못 바꾼다. 남는 후보를 전부 확인했다: + +| 후보 | 왜 안 되는가 | +|---|---| +| Codex `env_http_headers` 를 파일 기반으로 | 값이 **환경변수 이름**이다. 업스트림 설계이고 우리 쪽 변경 범위 밖 | +| static `http_headers` | 시크릿을 `~/.codex/config.toml` 에 평문으로 직렬화한다. 백업·저널·동기화 경로로 퍼진다 | +| `auth.command` | bearer credential 을 공급하는데, `/v1/responses` 는 전용 헤더만 받는다. Codex Direct 와 충돌 방지를 위한 의도적 거부 (`auth-cors.ts:369-372`) | +| OS 전역 환경 주입 | 무관한 GUI/터미널 자식까지 credential 을 상속한다. 이미 떠 있는 호스트에는 적용도 안 된다 | + +전부 막힌다. 그래서 이건 credential **전달** 문제가 아니라 admission **정책** +문제다. + +## 첫 설계가 왜 틀렸나 + +처음에는 `isApiAuthRequired()` 를 우회하는 opt-in 스위치 +(`trustLoopbackPeersOnRemoteBind`) 를 제안했다. 감사가 두 가지를 지적했고 둘 다 +코드로 확인된다. + +**하나 — 폭발 반경.** `resolveApiAuth` 는 8곳에서 호출된다 +(`src/server/index.ts:692, 882, 903, 937, 1009, 1024, 1087, 1121`): `/v1/models`, +Images generations/edits, artifacts, alpha search, Messages, Live/Realtime, +sideband WebSocket. `resolveResponsesApiAuth` 도 `/v1/responses` 만이 아니라 +compact 와 Chat Completions 경로에서 쓰인다. resolver 안에 피어 예외를 넣으면 +#1102 가 요청하지 않은 표면 전부가 같이 열린다. 수용 기준이 +`/v1/responses` 만 검사했으므로 이 확대를 탐지하지도 못했을 것이다. + +**둘 — 피어 주소가 증명하는 것.** `requestIP()` 는 **마지막 transport hop** 만 +알려준다. Docker Desktop 의 포트 포워딩, `--network host` 컨테이너, WSL2 의 +mirrored networking 과 `netsh portproxy`, Kubernetes sidecar, VPN/터널 종단 — +전부 원격 연결을 로컬 TCP 연결로 다시 연다. 그 배포에서는 원격 호출자가 +loopback 피어로 보인다. 흔한 구성이고, 첫 판은 리버스 프록시와 SSH 터널만 +예시로 들어 이 계열을 과소평가했다. + +"opt-in 이니까 괜찮다" 로는 부족하다. 켜는 사람이 자기 배포가 저 목록에 +해당하는지 모를 수 있다. + +## 재설계 — 인증을 우회하지 않고, 별도 리스너를 연다 + +감사가 제시한 대안이 더 낫다. 공용 리스너의 admission 정책은 **한 줄도** 바꾸지 +않는다. 대신 `127.0.0.1` 에만 바인드된 **두 번째 리스너**를 옵션으로 연다. + +``` +0.0.0.0:10100 ← 기존 리스너. 인증 정책 불변. 모든 원격 호출자는 키가 필요하다. +127.0.0.1:PORT ← 새 리스너. 커널이 원격 연결을 아예 받지 않는다. +``` + +차이가 핵심이다. 첫 설계는 "원격에서 온 연결인데 로컬처럼 보이면 통과" 였다. +이 설계는 **커널이 원격 연결을 애초에 accept 하지 않는다.** 판정할 주소가 없고, +속일 피어 필드도 없다. Docker 포트 포워딩도 `127.0.0.1` 바인드는 기본적으로 +호스트 밖으로 내보내지 못한다. + +주입되는 Codex provider block 은 이미 wildcard 바인드에서 `base_url` 을 +`127.0.0.1` 로 쓴다 (`tests/codex-inject.test.ts:47-54`). 그 URL 의 포트만 로컬 +리스너로 바꾸면 shim 을 우회해 직접 spawn 된 app-server 도 인증 없이 붙는다 — +**공용 리스너의 경계는 한 줄도 건드리지 않고.** (넓히는 것이 없다는 뜻은 +아니다 — 명시적인 로컬 신뢰 표면이 하나 추가된다. 아래 §여전히 opt-in 인 +이유 참조.) + +### 여전히 opt-in 인 이유 + +`127.0.0.1` 바인드라도 그 머신의 **모든 로컬 프로세스**가 접근할 수 있다. +단일 사용자 워크스테이션에서는 받아들일 만하고, 멀티테넌트 호스트에서는 아니다. +그래서 기본값은 꺼짐이고, 이름은 결과가 드러나게 짓는다: +`unauthenticatedLoopbackListener`. + +더 정확히 말하면, 이 설계는 **보안 경계를 넓히지 않는** 것이 아니라 +**공용 리스너의 경계를 그대로 두고 명시적인 로컬 신뢰 표면을 하나 추가하는** +것이다. 그 표면에서 무인증 로컬 프로세스는 active-turn capacity, 계정 풀 쿼터, +유료 provider credential 을 소비할 수 있다 — 즉 인증된 원격 클라이언트를 굶길 +수 있다. 문서 경고는 "모든 로컬 프로세스가 접근 가능" 에서 멈추지 않고 이 +비용·DoS 측면까지 적는다. + +## 고정 포트 — ephemeral 은 우리가 부정한 버그를 우리가 만든다 + +첫 재설계본은 포트 미지정 시 OS 할당을 허용했다. 그건 틀렸다. + +`ocx sync` 와 startup sync 는 공용 `port` 만 `injectCodexConfig()` 에 넘긴다 +(`src/codex/sync.ts:100`, `src/cli/index.ts:353`). 로컬 리스너의 실제 포트를 +발견할 경로가 없다. 그리고 ephemeral 포트는 재시작마다 바뀔 수 있는데, +`config.toml` 이 새 포트로 다시 쓰여도 **이미 실행 중인 app-server 는 시작 시 +읽은 옛 `base_url` 을 계속 쓴다.** + +그 실패 모드를 그대로 읽어보면 — "재시작하면 이미 떠 있는 app-server 가 깨진다" +— 이 이슈가 신고했고 우리가 코드로 부정한 바로 그 증상이다. 원인이 토큰 회전이 +아니었을 뿐이고, ephemeral 포트로는 진짜로 만들어낸다. + +**포트는 설정에 필수로 둔다.** 오프라인 `ocx sync`, 재시작, 이미 실행 중인 +app-server 가 전부 같은 값을 본다. 활성화 시 포트를 안 주면 config 검증이 +거부한다. + +## 리스너 정책 — 무엇을 어떻게 다르게 취급하는가 + +로컬 리스너는 같은 프로세스, 같은 라우팅, 같은 계정 풀을 쓴다. 다른 것은 두 +가지뿐이다. + +**1. auth/origin 판정용 config view.** 같은 `config` 객체를 그대로 넘기면 +`hostname` 이 `"0.0.0.0"` 이라 `resolveResponsesApiAuth()` 가 여전히 인증을 +요구한다. 그렇다고 config 전체를 `{...config, hostname:"127.0.0.1"}` 로 복제해 +오래 들고 있으면 management 로 설정을 바꿨을 때 로컬 리스너가 낡은 값을 쓴다. + +그래서 **비즈니스/라우팅은 canonical config 를 공유하고, auth 와 origin 판정에만 +매 요청 만든 view 를 넘긴다.** + +**resolver 시그니처는 바꾸지 않는다.** `resolveResponsesApiAuth(req, config)` 에 +`allowUnauthenticated` 같은 파라미터를 추가하면 공용 리스너에서도 호출 가능한 +admission 우회 스위치가 생긴다. 정책 선택은 resolver 밖, 리스너 클로저에서 +한다. + +view 를 받는 함수는 이것들 전부다 — 하나라도 빠뜨리면 그 지점만 공용 정책으로 +판정한다: + +- `resolveResponsesApiAuth` +- `isAllowedRequestOrigin` +- `withCors`, `corsHeaders` +- `jsonResponse` — `/v1/models` 의 성공 응답이 이걸 통과하며 내부에서 CORS + 헤더를 만든다 (`src/server/auth-cors.ts:187-191`). 빠뜨리면 그 경로만 공용 + 정책으로 헤더를 붙인다 +- 에러 응답 헬퍼 (CORS 헤더를 붙이는 것들) + +모델 수집과 응답 내용 구성에는 계속 canonical config 를 넘긴다 — view 는 오직 +auth/CORS 판정용이다. + +view 타입은 `Pick` 수준으로 +좁힌다. 완전한 비즈니스 config 로 위장할 수 없어야 실수로 라우팅 경로에 흘러도 +타입에서 걸린다. + +**2. origin 게이트는 반드시 적용한다.** 인증만 우회하고 origin 검사에 공용 +config 를 넘기면 `isAllowedRequestOrigin` 의 remote 분기를 타서 +`isSameOriginAsRequest()` 로 허용될 수 있다 (`src/server/auth-cors.ts:76-82`). +공격자 서버가 피해자 브라우저로 `127.0.0.1` 에 붙는 DNS rebinding 이 정확히 그 +모양이다 — 커널 관점에서는 정상 로컬 연결이다. 로컬 리스너는 loopback 분기를 +타야 하고, 그 분기는 `Host` 헤더까지 검사한다. + +커널 바인드와 Host/Origin 게이트가 **함께** 경계다. 바인드만으로는 브라우저를 +경유한 접근을 막지 못한다. + +**3. WebSocket upgrade 는 그 요청을 받은 서버로.** 현재 Responses WS 는 클로저 +바깥의 primary `server.upgrade()` 를 부른다 (`src/server/index.ts:621`). 그대로 +공유하면 로컬 리스너가 받은 Request 를 primary 서버에서 upgrade 하려 든다. +반드시 해당 fetch 호출의 `requestServer.upgrade()` 를 쓴다. + +### 라우트 allowlist + +"data-plane 만" 은 너무 넓었다. 정확히 고정한다: + +- `POST /v1/responses` +- `/v1/responses` WebSocket upgrade +- `POST /v1/responses/compact` + +- `GET /v1/models` + +`/v1/models` 를 넣는 이유는 증거가 나왔기 때문이다. `syncCodex` 는 카탈로그 +생성이 실패하거나 소스가 없으면 경고만 남기고 `catalogPath: null` 로 +`injectCodexConfig()` 를 부른다 (`src/codex/sync.ts:129-156`). 그러면 Codex 는 +static catalog 매니저 대신 online 매니저를 고르고, app-server 의 `model/list` 가 +`GET {base_url}/models` 로 나간다. 우리가 404 를 주면 모델 목록이 낡은 채로 +남거나 번들 캐시로 떨어진다. 정확히 direct-spawn 호스트를 고치겠다면서 그 +호스트의 모델 목록을 깨뜨리는 셈이다. + +대안은 카탈로그 설치 실패 시 활성화를 fail-closed 로 막는 것인데, 카탈로그 +없음은 이미 경고로 관용되는 상태다. 그걸 이 옵션 때문에 에러로 승격시키는 건 +범위를 넘는다. + +나머지 — Chat Completions, Messages, Images, search, artifacts, Live/Realtime, +`/api/*`, GUI, health/readiness — 는 404. + +## 바인드 트랜잭션 + +config 검증으로 두 포트가 다른지 보는 것만으로는 부족하다. 로컬 포트를 다른 +프로세스가 이미 잡고 있을 수 있다. + +두 bind 를 **하나의 startup 트랜잭션**으로 다룬다. 어느 쪽이 실패하든 이미 열린 +리스너를 `await stop(true)` 로 닫고 원래 오류를 다시 던진다. 그렇지 않으면 +primary 만 살아남고, CLI 의 기존 포트 재시도가 이걸 공용 포트 충돌로 오인해 +다른 포트를 고르면서 리스너를 누적한다 (`src/cli/index.ts:234`). + +로컬 포트 충돌과 공용 포트 충돌은 구분한다. 로컬 충돌 때문에 공용 포트를 바꾸지 +않는다. + +합성 `stop()` 은 두 가지를 **동시에** 만족해야 한다. 한쪽만 하면 다른 쪽이 +깨진다. + +1. **정리는 끝까지 시도한다.** 한쪽 stop 이 실패해도 나머지 stop 과 native + lifecycle release 를 건너뛰지 않는다. +2. **실패는 호출자에게 전파한다.** `allSettled` 로 삼키면 안 된다. + +2번이 중요한 이유: 기존 `stopServerListener` 는 stop 실패를 의도적으로 +전파하고, 모든 호출자가 같은 결과를 본 뒤에야 교체 프로세스가 포트를 잡게 +되어 있다 (`src/server/lifecycle.ts:290-305`). 삼키면 `drainAndShutdown` 이 +종료 완료로 오인하고, 아직 포트를 쥔 리스너 위에 교체가 바인드를 시도한다. +정리는 다 했는데 실패는 보고되는 상태여야 하므로, 결과를 모아 하나라도 +실패했으면 `AggregateError` 로 reject 한다. + +### 이 설계가 P1 다섯 건에 어떻게 답하는가 + +| 감사 P1 | 재설계에서 | +|---|---| +| 피어 주소는 최종 신원이 아니다 | 피어 주소를 아예 판정하지 않는다. 커널 바인드 + Host/Origin 게이트가 경계다 | +| 8개 무관 엔드포인트가 같이 열린다 | 공용 리스너 정책 불변. 로컬 리스너는 4개 라우트만 노출하고 나머지는 404 | +| 새 admission kind 의 로그 파급 | `{ kind: "loopback" }` 재사용 — 이미 존재하는 kind 이고 의미도 정확하다 (인증 없는 로컬 바인드). 새 kind 없음 | +| 문자열 모양 주소 판정 | 판정 함수 자체가 없다 | +| 수용 기준이 실제 경로를 증명 못 함 | 실제 리스너를 띄우고 원격 인터페이스에서 연결 거부를 확인한다 | + +`admissionKind` 를 새로 늘리지 않는 것이 특히 크다. 감사가 지적한 대로 +`RequestLogContext`, `RequestLogEntry`, `PersistedUsageEntry` 가 전부 세 kind 로 +고정돼 있고 (`src/server/request-log.ts:52,119`, `src/usage/log.ts:56`), +`KNOWN_ADMISSION_KINDS` 가 모르는 값을 조용히 버린다 (`src/usage/log.ts:115`). +새 kind 는 타입체크를 깨거나 감사 로그에서 사라진다. + +## 변경 파일 + +- `src/types.ts` — `unauthenticatedLoopbackListener?: { enabled: false } | { enabled: true; port: number }` + (판별 유니온: 꺼져 있을 때 포트를 요구하지 않는다) +- `src/config.ts` — 스키마 + 검증 (포트 필수, 공용 포트와 동일 거부) +- `src/server/index.ts` — 두 번째 `Bun.serve`, 바인드 트랜잭션, 합성 stop, + 라우트 allowlist, 요청별 auth/origin view, `requestServer.upgrade()` +- `src/codex/inject.ts` — 켜져 있으면 `base_url` 이 로컬 리스너 포트를 가리킴 +- `src/codex/sync.ts`, `src/cli/index.ts` — 로컬 포트를 주입 경로로 전달 +- `src/cli/index.ts` — 실효 공용 포트 검증, 폴백 선택에서 로컬 포트 제외 +- `docs-site/` — 설정 문서 + 로컬 접근·비용·DoS 경고 +- `tests/` — 아래 기준 + +## 수용 기준 + +1. 설정 없음 → 리스너가 하나뿐. `0.0.0.0` 동작은 오늘과 동일 (401 유지). +2. 설정 켬 → `127.0.0.1:PORT` 로 키 없이 `POST /v1/responses` 가 admit 되고 + `{ kind: "loopback" }` 로 기록된다. 실제 WS upgrade 와 + `POST /v1/responses/compact` 도 같다. +3. 설정 켬 → 공용 리스너는 **여전히** 키를 요구한다. +4. 설정 켬 → 로컬 리스너가 비-loopback 인터페이스에 바인드되지 않는다. 머신의 + non-loopback 주소로 실제 연결을 시도해 거부를 확인한다. non-loopback + 인터페이스가 없어 skip 되면 기준 14 의 첫 ablation 이 green 이 되므로, 지원 + OS 에서는 skip 없이 돌거나 별도 결정적 보조 검사를 둔다. +5. allowlist 밖 라우트는 로컬 리스너에서 404: Chat Completions, Messages, + Images, search, artifacts, Live/Realtime, `/api/*`, GUI, health/readiness 각 + 대표 하나씩. +6. 적대적 `Host`/`Origin` (DNS rebinding 형태) 은 로컬 리스너에서도 거부된다. + 거부만이 아니라 **반환되는 CORS 헤더도** 로컬 정책 view 로 만들어졌는지 + 확인한다 — 라우팅 전 origin 판정만 보면 응답 헤더 경로의 누락을 놓친다. + 성공 응답도 확인한다: 로컬 `/v1/models` 200 응답의 CORS 헤더가 로컬 view 로 + 만들어졌는지. +7. 주입되는 `base_url` 이 설정된 로컬 포트를 가리키고, 재시작 후에도, 독립 + `ocx sync` 실행 후에도 같은 값이다. +8. 포트 필수: 활성화하면서 포트를 생략하거나 공용 포트와 같게 주면 config + 검증이 거부한다. +9. 로컬 포트를 다른 소켓이 이미 점유한 상태로 기동하면 startup 이 실패하고 + **두 포트 모두** 다시 바인드 가능한 상태로 남는다 (rollback). +10. `server.stop(true)` 와 `drainAndShutdown()` 양쪽에서 두 리스너가 모두 + 닫힌다. 한쪽 stop 이 실패해도 다른 쪽 stop 과 lifecycle release 가 실행된다. + **그리고 호출자는 reject 를 관측한다** — 정리 완주와 실패 전파 둘 다. +11. 실제 direct-spawn 수용 테스트: 격리된 `CODEX_HOME` 으로 app-server 를 + 띄우고 `model/list` 를 부르고 턴을 하나 돌려서, 요청이 로컬 리스너에 + `loopback` 으로 도달하는지 확인한다. **카탈로그 있음과 없음 두 경로 모두.** + 라우트에 POST 를 날려보는 것만으로는 리포터가 신고한 통합이 동작한다는 + 증명이 되지 않는다. + + **오라클이 없으면 이 기준은 공허하다.** Codex 의 models-manager 는 refresh + 실패를 catch 하고 기존 번들/캐시 목록을 반환한다. 그러니 `/v1/models` 를 + allowlist 에서 빼도 `model/list` 는 여전히 성공하고, 번들 모델로 턴을 + 돌리면 그것도 성공한다 — 기준이 green 인 채로 호환 경로가 깨진다. 이 + 저장소가 반복해서 데인 "통과만 하는 테스트" 의 교과서적 형태다. + + 카탈로그 없음 경로는 **오직 우리 라우트를 통해서만 알 수 있는 모델**로 + 판정한다: + + - Codex 의 번들 카탈로그와 캐시에 존재할 수 없는 고유 이름의 routed 모델을 + 구성한다. 이름은 **런타임에 생성**한다 — + `ocx-direct-spawn-${crypto.randomUUID()}` 형태. 하드코딩한 이름은 언젠가 + 누군가의 카탈로그와 충돌할 수 있고, 그 순간 오라클이 조용히 죽는다. + - `model/list` 응답에 **그 이름이 정확히** 들어 있는지 단언한다. + - 턴도 **그 모델로** 돌리고, 의도한 가짜 업스트림에 도달하는지 확인한다. + - 격리된 `CODEX_HOME` 은 `models_cache.json` 없이 시작한다. 기동 **전에** + `models_cache.json` 부재와 활성 `model_catalog_json` 부재를 단언한다 — + 전제가 깨진 채로 도는 테스트는 오라클이 아니다. + + 실행 경로도 고정한다. PATH 의 `codex` 가 아니라 resolve 된 + `@openai/codex/bin/codex.js` 를 직접 띄우고, 자식 환경에서 + `OPENCODEX_API_AUTH_TOKEN` 을 제거한다. 그러지 않으면 shim 인증 경로를 + 실수로 타면서 아무것도 증명하지 못한다. + + 증명은 둘로 나눈다. 이 저장소는 `@openai/codex` 를 테스트 의존성으로 설치하지 + 않으므로, CI 에서 결정적으로 도는 부분과 실기동 증거를 구분한다: + + - **CI 결정적:** 로컬 리스너의 `/v1/models?client_version=...` 라우트가 + 고유 모델을 반환하는지, allowlist 에서 빼면 404 가 되는지. + - **활성화 증거 (skip 금지):** 실제 지원 버전의 Codex app-server 로 위 + 시퀀스를 돌린 기록. 스킵된 채로는 이 기준을 충족한 것으로 치지 않는다. +12. 실효 공용 포트 충돌: `ocx start --port <로컬포트>`, `config.port = 0` 이 + 로컬 포트로 해석되는 경우, 그리고 선호 포트가 막혀 `findAvailablePort()` 의 + ephemeral 폴백이 로컬 포트를 고르는 경우 — 전부 startup 이 실패하는 대신 + 로컬 포트를 후보에서 제외해야 한다 (`src/cli/index.ts:146-180`). +13. 활성화 시 시작 로그에 눈에 띄는 경고가 나온다: `127.0.0.1:PORT`, 무인증 + 로컬 접근, 유료 credential 소비, 로컬 DoS 위험. +14. ablation: + - 로컬 리스너 hostname 을 `0.0.0.0` 으로 바꾸면 기준 4 가 red. + - `inject.ts` 포트 배선을 되돌리면 기준 7 이 red. + - origin 판정에 공용 config 를 넘기면 기준 6 이 red. + - rollback 을 제거하면 기준 9 가 red. + - 합성 stop 이 한쪽 reject 를 삼키게 하면 기준 10 이 red. + - `/v1/models` 를 allowlist 에서 빼면 기준 11 의 카탈로그 없음 경로가 red. + +## 상태 — 완결이 아니라 완화책 + +감사의 마지막 P1 을 그대로 받는다. 기본값이 꺼짐이므로, 리포터가 이 옵션을 +수용하기 전까지 원래 재현은 여전히 401 이다. 그래서 이 유닛은 **#1102 를 close +하지 않는다.** PR 은 `Closes` 대신 이슈를 참조하고, 리포터에게 이 옵션이 +배포에 맞는지 묻는 코멘트를 남긴다. + +--- + +## 부록 — 첫 설계의 원 분석 (기록용) + +`isApiAuthRequired()` 는 오직 바인드 hostname 만 본다: + +```ts +export function isApiAuthRequired(config: OcxConfig): boolean { + return !isLoopbackHostname(config.hostname); +} +``` + +`hostname: "0.0.0.0"` 이면 요청 피어가 `127.0.0.1` 이어도 인증을 요구한다. +그런데 우리가 Codex 에 주입하는 provider block 은 wildcard 바인드에서 +`base_url` 을 `127.0.0.1` 로 쓴다 (`tests/codex-inject.test.ts:47-54`). 즉 +우리가 만들어낸 구성이 정확히 이 상황을 만든다. + +이 진단 자체는 유효하고 재설계도 같은 사실 위에 서 있다. 다만 해법이 +"admission 을 우회" 에서 "별도 리스너" 로 바뀌었다. + +## 범위 밖 + +토큰 grace window 와 `service rotate-api-token` 은 별개 유닛이다. 리포터가 보고한 +회전 트리거는 원인이 다르다고 확인됐고 (자동 회전이 없음), operator 가 직접 값을 +바꾸고 install/repair 한 경우만 남는데 그건 이 이슈가 신고한 것이 아니다. From 5c24ff713b86d72df919e642fefeb3716fdf328b Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 7 Aug 2026 18:43:59 +0900 Subject: [PATCH 26/48] fix(update): never echo a reported health version, matching or not (round 13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shape validation was not enough: `2.7.41-JaneDoe` is valid semver, so the mismatch reason echoed it straight into a persisted field. `/healthz` is answered by whatever holds the port, which makes its version external input no matter how well-formed it looks. Mismatch reasons now state THAT the reported version did not match and name only the version we expected — which is ours. On a match the reported value equals the expectation by definition, so the trusted one is rendered instead. This closes the last channel the audit's persistence inventory found. Regression drives the hostile value from ingress through to the evidence reason and asserts the name is absent while our own version still appears. --- src/update/job.ts | 12 ++++++++---- tests/update-job.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/update/job.ts b/src/update/job.ts index cd0d56853..d5a88358e 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -1432,18 +1432,22 @@ export function npmSelfUpdateRestartEvidence( } if (livePid !== null) { if (expected !== null && identity.version && identity.version !== expected) { - return { ok: false, reason: `new pid but version ${identity.version} !== expected ${expected}` }; + // Never echo the REPORTED version: `/healthz` is answered by whatever holds the port, + // and `2.7.41-JaneDoe` is valid semver. Say that it mismatched, and name only the + // version we expected — which is ours. + return { ok: false, reason: `new pid but reported version did not match expected ${expected}` }; } return { ok: true, detail: `pid changed ${oldPid}→${livePid}` }; } // Pre-update PID known but healthz omitted pid — only accept matching target version. - if (versionMatches) return { ok: true, detail: `version ${identity.version}` }; + // On a match the reported value equals `expected`, so render the trusted one. + if (versionMatches) return { ok: true, detail: `version ${expected}` }; return { ok: false, reason: "no PID in healthz and version did not match the update target" }; } - if (versionMatches) return { ok: true, detail: `version ${identity.version}` }; + if (versionMatches) return { ok: true, detail: `version ${expected}` }; if (expected !== null && identity.version && identity.version !== expected) { - return { ok: false, reason: `version ${identity.version} !== expected ${expected}` }; + return { ok: false, reason: `reported version did not match expected ${expected}` }; } return { ok: false, reason: "no pre-update PID capture and no expected-version match" }; } diff --git a/tests/update-job.test.ts b/tests/update-job.test.ts index 5e583ec37..5c35eff18 100644 --- a/tests/update-job.test.ts +++ b/tests/update-job.test.ts @@ -1224,6 +1224,28 @@ describe("GUI update execution decisions", () => { expect(readUpdateJob(job.id)?.log.some(line => line.includes("skipping redundant restart"))).toBe(false); }); + test("a hostile /healthz version never reaches a persisted reason", () => { + // `2.7.41-JaneDoe` is valid semver, so shape validation alone let it through — and the + // mismatch reason echoed it. /healthz is answered by whatever holds the port, so its + // version is external input: we report THAT it mismatched and name only our own expectation. + const hostile = npmSelfUpdateRestartEvidence( + { latestVersion: "2.7.41" }, + { oldPid: 111 }, + { pid: 222, version: "2.7.41-JaneDoe" }, + ); + expect(hostile.ok).toBe(false); + expect(JSON.stringify(hostile)).not.toContain("JaneDoe"); + expect(JSON.stringify(hostile)).toContain("2.7.41"); + + // A genuine match still reports the version, rendered from the trusted expectation. + const matched = npmSelfUpdateRestartEvidence( + { latestVersion: "2.7.41" }, + {}, + { pid: 222, version: "2.7.41" }, + ); + expect(matched.ok).toBe(true); + }); + test("npmSelfUpdateRestartEvidence requires a PID change or target version", () => { expect(npmSelfUpdateRestartEvidence( { latestVersion: "2.7.41" }, From 7e3f5b9c968bf231f3e6e21b1923cdaa4795f4eb Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 7 Aug 2026 22:49:53 +0900 Subject: [PATCH 27/48] feat(server): optional unauthenticated loopback listener for direct-spawn Codex (#1102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `codex app-server` launched by a host that resolves the entrypoint directly never passes through the generated shim, so it never inherits OPENCODEX_API_AUTH_TOKEN. On a wildcard bind every model call then 401s at admission, before any SSE frame. The tempting fix — exempt callers whose socket peer looks like loopback — is unsound. `requestIP()` proves only the last transport hop, and Docker Desktop port forwarding, host-network containers, WSL mirrored networking and tunnel terminators all re-open remote connections locally. It would also have ridden `resolveApiAuth` into eight endpoints unrelated to this issue. Instead a second listener binds 127.0.0.1. The kernel refuses remote connections outright, so there is no address to judge, and the public listener's admission policy is byte-for-byte unchanged. The parts that are load-bearing: Auth and CORS read a `RequestPolicyView` — a Pick of hostname, corsAllowOrigins and apiKeys — chosen per request from the receiving listener. Rewriting the whole config and holding it would go stale on the next management change; adding an `allowUnauthenticated` parameter to the resolvers would put an admission bypass on the wrong side of the boundary. The narrow type also means a policy view that leaks into a routing path fails to typecheck. The bind is loopback but the boundary is not the bind alone: an attacker page can make a victim's browser connect to 127.0.0.1. The listener therefore takes the same Host/Origin branch a plain loopback bind always has. A test asserts the same hostile Origin that the loopback policy rejects would be accepted under the public policy. The port is required in config, never OS-assigned. An ephemeral port would change across restarts while running app-servers kept the old base_url — the exact symptom this issue reported for token rotation, which does not actually happen. `GET /v1/models` is on the four-route allowlist because when catalog materialization fails, `syncCodex` injects with `catalogPath: null` and Codex falls back to an online model manager that refreshes through it. Both binds are one startup transaction, and composite stop completes cleanup on both listeners while still propagating failure — swallowing it would let drainAndShutdown report success while a socket is held. Off by default. When on, every local process can spend account quota and paid provider credentials, which the startup warning and the docs say plainly. Ablation: reverting the policy view to the shared config makes 3 tests red including the hostile-Host case; dropping the auth-header rule makes 1 red; removing the port validator makes 2 red; rewriting the public bind to a literal 127.0.0.1 makes the F4 symmetry guard red. Refs #1102 --- .../docs/reference/configuration/server.md | 38 +++ src/codex/inject.ts | 18 +- src/config.ts | 42 ++- src/server/auth-cors.ts | 57 +++- src/server/index.ts | 288 +++++++++++++----- src/types.ts | 24 ++ tests/loopback-listener-admission.test.ts | 168 ++++++++++ .../windows-deploy-close-regressions.test.ts | 14 +- 8 files changed, 550 insertions(+), 99 deletions(-) create mode 100644 tests/loopback-listener-admission.test.ts diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index bc72f17b3..942af88cc 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -65,6 +65,44 @@ A `0.0.0.0` bind exposes the proxy and configured provider access to the LAN. Us networks with a strong token. ::: +### Local clients that cannot receive the token + +A remote bind requires a credential from every caller, including local ones. That breaks a specific +case: a `codex app-server` launched by a host process that resolves the Codex entrypoint directly +(`require.resolve('@openai/codex/bin/codex.js')`) never passes through the generated `codex` shim, +so it never inherits `OPENCODEX_API_AUTH_TOKEN` and every model call fails with `401` before a +stream opens. + +`unauthenticatedLoopbackListener` opens a second listener bound to `127.0.0.1` that admits without a +credential. The main listener is untouched — remote callers still need the token. + +```json +{ + "hostname": "0.0.0.0", + "port": 10100, + "unauthenticatedLoopbackListener": { "enabled": true, "port": 10200 } +} +``` + +`ocx sync` then writes `base_url = "http://127.0.0.1:10200/v1"` into the managed Codex provider block +and omits the auth header, so a directly spawned app-server works without any credential plumbing. + +The port is required and must differ from the proxy port. It is never OS-assigned: an ephemeral port +would change across restarts while already-running app-servers kept the previous `base_url`. + +The listener serves only `POST /v1/responses`, its WebSocket upgrade, `POST /v1/responses/compact`, +and `GET /v1/models`. Everything else, including `/api/*` and the dashboard, returns `404`. + +:::danger[This is an unauthenticated surface] +Every process on the machine can use this listener. It spends account quota and paid provider +credentials, and it can exhaust the shared turn capacity that authenticated remote clients depend +on. Do not enable it on a shared or multi-tenant host. + +Binding to `127.0.0.1` means the kernel refuses remote connections, but it does not stop a browser: +a page you visit can make your browser connect to `127.0.0.1`. The listener therefore applies the +same `Host` and `Origin` checks as an ordinary loopback bind. Off by default. +::: + ### SSH port forwarding Remote use does not require a remote bind. Keep loopback and forward it: diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 62ff9f220..20d570870 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -189,8 +189,13 @@ export function providerBaseHost(hostname: string | undefined): string { } export function shouldInjectApiAuthHeader( - config: Pick | undefined, + config: Pick | undefined, ): boolean { + // The unauthenticated loopback listener is a loopback bind, so it admits without a + // credential (#1102). Emitting the env header anyway would be worse than useless: the + // directly-spawned app-server this exists for has no OPENCODEX_API_AUTH_TOKEN in its + // environment, and Codex would send an empty header value. + if (config?.unauthenticatedLoopbackListener?.enabled) return false; return !isLoopbackHostname(config?.hostname); } @@ -630,6 +635,17 @@ export async function injectCodexConfig( config?: OcxConfig, options: InjectCodexOptions = {}, ): Promise { + // Point Codex at the unauthenticated loopback listener when it is enabled (#1102). + // + // Resolved here rather than at the call sites because every caller already passes the proxy + // port and the config together: startup sync, `ocx sync`, and the ensure path would each + // need the same two-line change, and a caller that missed it would silently emit a base_url + // requiring a credential the directly-spawned app-server does not have. + // + // The listener port is fixed in config, never OS-assigned, so this value survives restarts + // and matches what an already-running app-server read at startup. + const loopback = config?.unauthenticatedLoopbackListener; + if (loopback?.enabled) port = loopback.port; if (!existsSync(CODEX_CONFIG_PATH)) { return { success: false, diff --git a/src/config.ts b/src/config.ts index 717b11aea..b929f074c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1035,6 +1035,14 @@ const configSchema = z.object({ // is safe: startServer() already falls back to 127.0.0.1 for a missing hostname. Write-time // rejection lives in validateConfigCandidate() so bad values still surface to the caller. hostname: z.string().trim().min(1).optional().catch(undefined), + // Discriminated on `enabled` so a disabled entry cannot be forced to carry a port, and an + // enabled one cannot omit it (#1102). A malformed value degrades to undefined rather than + // failing the whole parse: this is an opt-in convenience surface, and a hand-edit typo here + // must never reset providers/apiKeys through the backup-and-defaults repair path. + unauthenticatedLoopbackListener: z.union([ + z.object({ enabled: z.literal(false) }), + z.object({ enabled: z.literal(true), port: z.number().int().min(1).max(65535) }), + ]).optional().catch(undefined), providers: z.record(z.string(), providerConfigSchema), defaultProvider: z.string().min(1).default("openai"), openaiProviderTierVersion: z.union([z.literal(1), z.literal(2)]).optional(), @@ -1974,13 +1982,45 @@ function codexAccountPickerEnabledError(value: unknown): string | null { } /** Validate an in-memory config candidate without touching disk. Used by headless CLI import/set. */ +/** + * Reject a loopback-listener port that collides with the proxy port (#1102). + * + * The schema can only check the shape of each field on its own; the two ports being distinct + * is a relationship between them. Letting the pair through would surface as a startup failure + * after the public listener already bound, which reads like an unrelated port conflict. + * + * This is write-time only, matching `blankHostnameError`: a live caller can be told the value + * is wrong, whereas a hand-edited config on the read path degrades to undefined rather than + * resetting the whole file. + */ +function loopbackListenerPortError(value: unknown): string | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const listener = (value as Record).unauthenticatedLoopbackListener; + if (listener === undefined) return null; + if (!listener || typeof listener !== "object" || Array.isArray(listener)) { + return "schema_invalid: unauthenticatedLoopbackListener: must be an object or omitted"; + } + const entry = listener as Record; + if (entry.enabled !== true) return null; + const listenerPort = entry.port; + if (typeof listenerPort !== "number" || !Number.isInteger(listenerPort) || listenerPort < 1 || listenerPort > 65535) { + return "schema_invalid: unauthenticatedLoopbackListener.port: must be an integer port when enabled"; + } + const proxyPort = (value as Record).port; + if (typeof proxyPort === "number" && proxyPort === listenerPort) { + return "schema_invalid: unauthenticatedLoopbackListener.port: must differ from the proxy port"; + } + return null; +} + export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } { const boundaryError = blankHostnameError(value) ?? claudeSubagentEffortError(value) ?? appOwnedMemoryBudgetError(value) ?? googleAntigravityStaticCatalogVersionError(value) ?? codexAccountPrioritiesError(value) - ?? codexAccountPickerEnabledError(value); + ?? codexAccountPickerEnabledError(value) + ?? loopbackListenerPortError(value); if (boundaryError) return { ok: false, error: boundaryError }; const result = configSchema.safeParse(value); if (result.success) return { ok: true, config: normalizeApiKeyIds(result.data as OcxConfig) }; diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index e439ceecb..536526f3e 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -73,7 +73,7 @@ export function isSameOriginAsRequest(req: Request, origin: string): boolean { } } -export function isAllowedRequestOrigin(req: Request, config: OcxConfig): boolean { +export function isAllowedRequestOrigin(req: Request, config: RequestPolicyView): boolean { const origin = req.headers.get("Origin"); if (!isApiAuthRequired(config)) { if (!isLoopbackRequestHost(req.headers.get("Host"))) return false; @@ -82,7 +82,7 @@ export function isAllowedRequestOrigin(req: Request, config: OcxConfig): boolean return !origin || isLoopbackOriginValue(origin) || isSameOriginAsRequest(req, origin) || isExtraAllowedOrigin(origin, config); } -function isExtraAllowedOrigin(origin: string, cfg: OcxConfig): boolean { +function isExtraAllowedOrigin(origin: string, cfg: RequestPolicyView): boolean { if (!cfg.corsAllowOrigins?.length) return false; const parsedOrigin = comparableOrigin(origin); return cfg.corsAllowOrigins.some(allowed => { @@ -136,7 +136,7 @@ export function browserSecurityHeaders(): Record { }; } -export function corsHeaders(req?: Request, config?: OcxConfig): Record { +export function corsHeaders(req?: Request, config?: RequestPolicyView): Record { const origin = req?.headers.get("Origin"); const allowOrigin = origin && req && config && isAllowedRequestOrigin(req, config) ? origin : _corsOrigin; return { @@ -160,7 +160,7 @@ export function managementCorsHeaders(req?: Request, config?: OcxConfig): Record return headers; } -export function withCors(response: Response, req: Request, config: OcxConfig): Response { +export function withCors(response: Response, req: Request, config: RequestPolicyView): Response { const headers = new Headers(response.headers); for (const [name, value] of Object.entries(corsHeaders(req, config))) { headers.set(name, value); @@ -184,14 +184,18 @@ export function withManagementCors(response: Response, req: Request, config: Ocx }); } -export function jsonResponse(data: unknown, status = 200, req?: Request, config?: OcxConfig): Response { +export function jsonResponse(data: unknown, status = 200, req?: Request, config?: RequestPolicyView): Response { return new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json", ...corsHeaders(req, config) }, }); } -export function configuredApiAuthToken(_config: OcxConfig): string | undefined { +// The parameter is vestigial — the token has always come from the environment — but callers +// pass a config, so keep accepting one. Typed as `unknown` rather than `OcxConfig` so a narrow +// policy view can reach it too (#1102); widening to OcxConfig here would force every caller in +// the admission path back to the full config. +export function configuredApiAuthToken(_config?: unknown): string | undefined { const token = process.env.OPENCODEX_API_AUTH_TOKEN?.trim(); return token || undefined; } @@ -208,10 +212,37 @@ export function isLoopbackHostname(hostname: string | undefined): boolean { return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]"; } -export function isApiAuthRequired(config: OcxConfig): boolean { +export function isApiAuthRequired(config: Pick): boolean { return !isLoopbackHostname(config.hostname); } +/** + * The slice of config that decides admission and CORS, and nothing else (#1102). + * + * The unauthenticated loopback listener shares this process with the public one: same routing, + * same account pool, same drain. The only thing it must see differently is its own bind + * address, because `isApiAuthRequired` reads `hostname` and the shared config says "0.0.0.0". + * + * Two ways to express that were rejected. Passing the whole config with `hostname` rewritten + * and holding it for the listener's lifetime would go stale the moment the management API + * changes a setting. Adding an `allowUnauthenticated` parameter to the resolvers would create a + * callable admission bypass that the PUBLIC listener could also reach — the switch would exist + * on the wrong side of the boundary. + * + * So this type is deliberately narrow: it cannot masquerade as a business config, and a policy + * view that leaks into a routing path fails to typecheck rather than silently taking effect. + */ +export type RequestPolicyView = Pick; + +/** Derive the per-request policy view for a listener. Cheap enough to build per request. */ +export function requestPolicyView(config: OcxConfig, bindHostname: string): RequestPolicyView { + return { + hostname: bindHostname, + ...(config.corsAllowOrigins ? { corsAllowOrigins: config.corsAllowOrigins } : {}), + ...(config.apiKeys ? { apiKeys: config.apiKeys } : {}), + }; +} + export function assertServerAuthConfig(config: OcxConfig): void { const hasConfiguredDataCredential = !!configuredApiAuthToken(config) || (config.apiKeys ?? []).some(entry => !!entry.key.trim()); @@ -253,7 +284,7 @@ export type DataPlaneAdmission = * discarded, which is what makes per-key attribution possible without touching * the admission decision itself. */ -export function resolveDataPlaneAdmissionSecret(token: string, config: OcxConfig): DataPlaneAdmission | null { +export function resolveDataPlaneAdmissionSecret(token: string, config: Pick): DataPlaneAdmission | null { const actual = token.trim(); if (!actual) return null; if (secretEquals(actual, configuredApiAuthToken(config))) return { kind: "environment" }; @@ -341,7 +372,7 @@ export function validateForwardAdmissionCredential(headers: Headers, config: Ocx * Resolving form of `hasValidApiAuth`: identical header precedence, identical * decision, but it names the admission instead of collapsing it to a boolean. */ -export function resolveApiAuth(req: Request, config: OcxConfig): DataPlaneAdmission | null { +export function resolveApiAuth(req: Request, config: RequestPolicyView): DataPlaneAdmission | null { // A loopback bind never reads a token at all, so there is no key to name. if (!isApiAuthRequired(config)) return { kind: "loopback" }; const actual = req.headers.get("x-opencodex-api-key")?.trim() @@ -352,11 +383,11 @@ export function resolveApiAuth(req: Request, config: OcxConfig): DataPlaneAdmiss return resolveDataPlaneAdmissionSecret(actual, config); } -export function hasValidApiAuth(req: Request, config: OcxConfig): boolean { +export function hasValidApiAuth(req: Request, config: RequestPolicyView): boolean { return resolveApiAuth(req, config) !== null; } -export function requireApiAuth(req: Request, config: OcxConfig, _kind: "data-plane"): Response | null { +export function requireApiAuth(req: Request, config: RequestPolicyView, _kind: "data-plane"): Response | null { if (hasValidApiAuth(req, config)) return null; return formatErrorResponse(401, "authentication_error", "opencodex API key required"); } @@ -366,7 +397,7 @@ export function requireApiAuth(req: Request, config: OcxConfig, _kind: "data-pla * Codex Direct. Remote binds must use the dedicated proxy header so the two bearer * domains can never be confused. */ -export function resolveResponsesApiAuth(req: Request, config: OcxConfig): DataPlaneAdmission | null { +export function resolveResponsesApiAuth(req: Request, config: RequestPolicyView): DataPlaneAdmission | null { if (!isApiAuthRequired(config)) return { kind: "loopback" }; // Dedicated header ONLY. `Authorization` on these transports may belong to // Codex Direct passthrough, and the two bearer domains must stay unconfusable. @@ -375,7 +406,7 @@ export function resolveResponsesApiAuth(req: Request, config: OcxConfig): DataPl return resolveDataPlaneAdmissionSecret(actual, config); } -export function requireResponsesApiAuth(req: Request, config: OcxConfig): Response | null { +export function requireResponsesApiAuth(req: Request, config: RequestPolicyView): Response | null { if (resolveResponsesApiAuth(req, config)) return null; return formatErrorResponse(401, "authentication_error", "opencodex API key required"); } diff --git a/src/server/index.ts b/src/server/index.ts index b25f4ce67..83793c560 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -138,6 +138,8 @@ import { admissionFields, resolveApiAuth, resolveResponsesApiAuth, + requestPolicyView, + type RequestPolicyView, safeConfigDTO, setCorsOrigin, withCors, @@ -492,6 +494,49 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server config; + const loopbackPolicy = (): RequestPolicyView => requestPolicyView(config, "127.0.0.1"); + void publicPolicy; + + /** + * Routes the unauthenticated loopback listener will serve. Everything else 404s. + * + * This is an allowlist rather than a filter applied to the public handler, because a filter + * inverts the failure mode: a route added later would be reachable here by default. The four + * entries are exactly what a directly-spawned `codex app-server` needs. + * + * `GET /v1/models` is on the list for a reason that is easy to miss. When catalog + * materialization fails or finds no source, `syncCodex` warns and injects with + * `catalogPath: null`; Codex then builds an ONLINE model manager and `model/list` refreshes + * through `GET {base_url}/models`. Returning 404 there would leave the picker on its bundled + * fallback — fixing the direct-spawn host while breaking its model list. + */ + function loopbackRouteAllowed(url: URL, req: Request): boolean { + const path = url.pathname; + if (path === "/v1/responses") { + return req.method === "POST" || req.headers.get("upgrade")?.toLowerCase() === "websocket"; + } + if (path === "/v1/responses/compact") return req.method === "POST"; + if (path === "/v1/models") return req.method === "GET"; + return false; + } + // Codex treats empty / non-JSON 503 bodies as "Unknown error" (#452). Keep Retry-After and // the server_is_overloaded code so clients can back off, but always return a JSON envelope. function drainingResponse(req: Request): Response { @@ -554,12 +599,27 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server {}, }; let server: Server; + let loopbackServer: Server | null = null; try { - server = Bun.serve({ - port: listenPort, - hostname: bindHost, + const serveOptions = { idleTimeout: 255, - async fetch(req, requestServer): Promise { + async fetch(req: Request, requestServer: Server): Promise { + // The unauthenticated loopback listener (#1102) serves a fixed allowlist and nothing + // else. Rejecting here, before any handler runs, is what keeps the surface from growing + // silently when a route is added below. + if (requestServer === loopbackServer && !loopbackRouteAllowed(new URL(req.url), req)) { + return withCors( + formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${new URL(req.url).pathname}`), + req, + loopbackPolicy(), + ); + } + // Auth and CORS decisions below read `policy`, not `config`. For the public listener the + // two are the same object, so its behaviour is unchanged; for the loopback listener the + // view substitutes 127.0.0.1 as the bind address, which is what routes it through the + // same code path a plain loopback bind has always taken — Host-header check included. + // Routing, provider selection and response bodies keep using `config`. + const policy: RequestPolicyView = requestServer === loopbackServer ? loopbackPolicy() : config; const url = new URL(req.url); markActivity(`${req.method} ${url.pathname}`); @@ -580,18 +640,18 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server 0, ), - }, 200, req, config); + }, 200, req, policy); } // OpenAI list shape: native gpt bare + routed models namespaced "/" // (pure availability list — disabled natives are omitted entirely). @@ -835,7 +899,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { const response = await handleImages(req, config, endpoint, logCtx, turnAdmissionLease); addFinalRequestLog(requestId, start, logCtx, response.status, response.status === 499 ? { closeReason: "client_cancel" } : undefined); - return withCors(response, req, config); + return withCors(response, req, policy); }); } if (req.method === "GET" && url.pathname.startsWith("/v1/opencodex/artifacts/")) { - const admission = resolveApiAuth(req, config); - if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, config); - if (!isAllowedRequestOrigin(req, config)) { - return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config); + const admission = resolveApiAuth(req, policy); + if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); + if (!isAllowedRequestOrigin(req, policy)) { + return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy); } const id = decodeURIComponent(url.pathname.slice("/v1/opencodex/artifacts/".length)); const { resolveArtifactPath } = await import("../images/artifacts"); const artifactPath = resolveArtifactPath(id); if (!artifactPath) { - return withCors(formatErrorResponse(404, "not_found", "artifact not found"), req, config); + return withCors(formatErrorResponse(404, "not_found", "artifact not found"), req, policy); } const file = Bun.file(artifactPath); const ext = artifactPath.split(".").pop()?.toLowerCase(); @@ -926,7 +990,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server withCors(await handleClaudeCountTokens(req, config), req, config)); + return runAdmittedHttpTurn(req, async () => withCors(await handleClaudeCountTokens(req, config), req, policy)); } if (url.pathname === "/v1/messages" && req.method === "POST") { @@ -1021,12 +1085,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server({ ...serveOptions, port: listenPort, hostname: bindHost }); + + // Both binds are one startup transaction (#1102). If the loopback bind fails after the + // public one succeeded, leaving the public listener up would strand it: the CLI's port + // retry would read the failure as a public-port conflict and pick a different port, + // accumulating listeners. Roll back and rethrow the original error instead. + if (loopbackListenerPort !== null) { + try { + loopbackServer = Bun.serve({ + ...serveOptions, + port: loopbackListenerPort, + hostname: "127.0.0.1", + }); + } catch (error) { + try { + // startServer is synchronous, so this rollback cannot await. Bun begins closing the + // listen socket on the call itself; the caller sees the original bind error either + // way, and the alternative — leaving the public listener up — is the failure this + // rollback exists to prevent. + void server.stop(true); + } catch { + /* the original bind error is the one worth reporting */ + } + throw error; + } + } } catch (error) { void nativeMainLifecycle.release(); throw error; @@ -1393,14 +1484,38 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server => { + // Two properties, and dropping either one breaks a caller. + // + // Cleanup must complete: a failure closing one listener cannot skip the other or the + // native lifecycle release. + // + // Failure must PROPAGATE: `stopServerListener` deliberately surfaces a stop rejection so + // every caller observes the same result before a replacement binds the port. Swallowing + // it with allSettled would let drainAndShutdown report success while a listener still + // holds its socket, and the replacement would then fail its own bind. + const failures: unknown[] = []; try { - await nativeStop(closeActiveConnections); + try { + await nativeStop(closeActiveConnections); + } catch (error) { + failures.push(error); + } + if (loopbackListenerRef) { + try { + await loopbackListenerRef.stop(closeActiveConnections); + } catch (error) { + failures.push(error); + } + } } finally { await releaseNativeMainStartupLifecycle(server); } + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) throw new AggregateError(failures, "listener shutdown failed"); }, }); setServerRef(server); @@ -1415,6 +1530,17 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server = {}): Request { + return new Request(`http://127.0.0.1:10200${path}`, { headers }); +} + +describe("loopback listener policy view", () => { + test("the public listener still demands a credential on a wildcard bind", () => { + // The whole point of the separate listener is that this does not change. + expect(resolveResponsesApiAuth(request(), wildcardConfig)).toBeNull(); + }); + + test("the loopback view admits without a credential and names it loopback", () => { + const policy = requestPolicyView(wildcardConfig, "127.0.0.1"); + expect(resolveResponsesApiAuth(request(), policy)).toEqual({ kind: "loopback" }); + }); + + test("the view carries no bind address other than the one it was given", () => { + // A view built from a wildcard config must not leak that wildcard back into an auth + // decision — that would silently restore the 401 the listener exists to avoid. + const policy = requestPolicyView(wildcardConfig, "127.0.0.1"); + expect(policy.hostname).toBe("127.0.0.1"); + }); + + test("a valid configured key is still attributed to that key, not collapsed to loopback", () => { + // The loopback view takes the same branch a plain loopback bind always has, which returns + // before reading any header. Assert the public listener keeps per-key attribution so a + // future refactor cannot quietly make every admission anonymous. + expect(resolveResponsesApiAuth( + request("/v1/responses", { "x-opencodex-api-key": "ocx_data_realsecret" }), + wildcardConfig, + )).toEqual({ kind: "configured", keyId: "k1" }); + }); +}); + +describe("loopback listener origin gate", () => { + // The kernel bind stops remote TCP, but not a victim browser: an attacker page can make the + // browser connect to 127.0.0.1, and that connection IS local. The Host/Origin gate is the + // other half of the boundary, and the loopback view must route through it. + test("a hostile Host is rejected under the loopback policy", () => { + const policy = requestPolicyView(wildcardConfig, "127.0.0.1"); + expect(isAllowedRequestOrigin( + request("/v1/responses", { Host: "attacker.example" }), + policy, + )).toBe(false); + }); + + test("a hostile Origin is rejected even when the Host looks local", () => { + const policy = requestPolicyView(wildcardConfig, "127.0.0.1"); + expect(isAllowedRequestOrigin( + request("/v1/responses", { Host: "127.0.0.1:10200", Origin: "http://attacker.example" }), + policy, + )).toBe(false); + }); + + test("the same hostile Origin would pass under the PUBLIC policy via same-origin", () => { + // This is why the view matters. On a remote bind `isAllowedRequestOrigin` accepts a + // same-origin request, so handing the public config to the loopback listener's origin + // check would admit exactly the DNS-rebinding shape the test above rejects. + const sameOrigin = new Request("http://attacker.example/v1/responses", { + headers: { Origin: "http://attacker.example" }, + }); + expect(isAllowedRequestOrigin(sameOrigin, wildcardConfig)).toBe(true); + }); + + test("an ordinary local request is allowed", () => { + const policy = requestPolicyView(wildcardConfig, "127.0.0.1"); + expect(isAllowedRequestOrigin(request("/v1/responses", { Host: "127.0.0.1:10200" }), policy)).toBe(true); + }); +}); + +describe("loopback listener configuration", () => { + test("an enabled listener sharing the proxy port is rejected at write time", () => { + // A collision would otherwise surface as a startup failure after the public listener had + // already bound, which reads like an unrelated port conflict. + const result = validateConfigCandidate({ + port: 10100, + providers: { openai: { adapter: "openai", baseUrl: "https://chatgpt.com/backend-api/codex" } }, + defaultProvider: "openai", + unauthenticatedLoopbackListener: { enabled: true, port: 10100 }, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("must differ from the proxy port"); + }); + + test("an enabled listener without a port is rejected", () => { + // An OS-assigned port would change across restarts and strand app-servers holding the + // previous base_url — the symptom #1102 reported and we disproved for token rotation. + const result = validateConfigCandidate({ + port: 10100, + providers: { openai: { adapter: "openai", baseUrl: "https://chatgpt.com/backend-api/codex" } }, + defaultProvider: "openai", + unauthenticatedLoopbackListener: { enabled: true }, + }); + expect(result.ok).toBe(false); + }); + + test("a disabled listener needs no port", () => { + const result = validateConfigCandidate({ + port: 10100, + providers: { openai: { adapter: "openai", baseUrl: "https://chatgpt.com/backend-api/codex" } }, + defaultProvider: "openai", + unauthenticatedLoopbackListener: { enabled: false }, + }); + expect(result.ok).toBe(true); + }); + + test("a distinct port is accepted and survives the parse", () => { + const result = validateConfigCandidate({ + port: 10100, + providers: { openai: { adapter: "openai", baseUrl: "https://chatgpt.com/backend-api/codex" } }, + defaultProvider: "openai", + unauthenticatedLoopbackListener: { enabled: true, port: 10200 }, + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.config.unauthenticatedLoopbackListener).toEqual({ enabled: true, port: 10200 }); + } + }); +}); + +describe("injected Codex provider block", () => { + test("a wildcard bind alone still emits the env auth header", () => { + expect(shouldInjectApiAuthHeader({ hostname: "0.0.0.0" })).toBe(true); + }); + + test("enabling the loopback listener drops the header", () => { + // The directly-spawned app-server has no OPENCODEX_API_AUTH_TOKEN, so emitting the header + // would make Codex send an empty value rather than authenticate. + expect(shouldInjectApiAuthHeader({ + hostname: "0.0.0.0", + unauthenticatedLoopbackListener: { enabled: true, port: 10200 }, + })).toBe(false); + }); + + test("a disabled listener leaves the wildcard behaviour intact", () => { + expect(shouldInjectApiAuthHeader({ + hostname: "0.0.0.0", + unauthenticatedLoopbackListener: { enabled: false }, + })).toBe(true); + }); +}); diff --git a/tests/windows-deploy-close-regressions.test.ts b/tests/windows-deploy-close-regressions.test.ts index 05287d2de..41933c22a 100644 --- a/tests/windows-deploy-close-regressions.test.ts +++ b/tests/windows-deploy-close-regressions.test.ts @@ -73,8 +73,16 @@ describe("server bind canonicalizes explicit localhost but preserves wildcards ( test("literal localhost binds to 127.0.0.1; 0.0.0.0/:: exposure is untouched", () => { expect(src).toContain("const configuredHost = config.hostname?.trim();"); expect(src).toContain('!configuredHost || /^localhost$/i.test(configuredHost) ? "127.0.0.1"'); - expect(src).toContain("hostname: bindHost,"); - // Must not blanket-rewrite the bind host (that would break intentional 0.0.0.0 exposure). - expect(src).not.toContain('hostname: "127.0.0.1",'); + // Must not blanket-rewrite the PUBLIC bind host — that would break intentional 0.0.0.0 + // exposure, which is the regression this guards. + // + // A literal "127.0.0.1" now appears once, for the separate unauthenticated loopback + // listener (#1102). That one is a second socket whose entire purpose is to be + // loopback-only, so a bare substring ban would forbid the fix rather than the defect. + // Pin the assertion to the public serve call instead: it must take bindHost and nothing + // else. + expect(src).toContain("server = Bun.serve({ ...serveOptions, port: listenPort, hostname: bindHost });"); + expect(src).not.toMatch(/port: listenPort,\s*\n\s*hostname: "127\.0\.0\.1"/); + expect(src).not.toContain("port: listenPort, hostname: \"127.0.0.1\""); }); }); From 53f46dd30bed88761c942cce901a04364c22eeb5 Mon Sep 17 00:00:00 2001 From: zhouxun Date: Fri, 7 Aug 2026 16:06:17 +0800 Subject: [PATCH 28/48] feat(models): add context window controls (#1073) --- .../docs/reference/configuration/providers.md | 4 +- gui/src/i18n/de.ts | 10 + gui/src/i18n/en.ts | 10 + gui/src/i18n/ja.ts | 10 + gui/src/i18n/ko.ts | 10 + gui/src/i18n/ru.ts | 10 + gui/src/i18n/zh.ts | 10 + gui/src/models-groups.ts | 6 + gui/src/pages/Models.tsx | 180 ++++++++++++++++++ gui/tests/models-empty-provider.test.tsx | 45 +++++ gui/tests/models-provider-head.test.ts | 11 ++ src/server/management/provider-routes.ts | 38 ++++ src/types.ts | 4 +- tests/management-provider-validation.test.ts | 86 +++++++++ 14 files changed, 430 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 4849f1d3a..4289d5aec 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -68,8 +68,8 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `models?` | `string[]` | Seed/fallback model list. With `liveModels: false`, these are the only discovered models. | | `liveModels?` | `boolean` | Fetch the live catalog on start/sync (default `true`). Custom providers use `${baseUrl}/models`; built-ins may use a registry URL and filter. | | `selectedModels?` | `string[]` | Catalog allowlist after discovery. Non-empty exposes only those ids; empty or omitted exposes all discovered models. | -| `contextWindow?` | `number` | Provider-wide Codex-visible context cap. Smaller live metadata is retained. | -| `modelContextWindows?` | `Record` | Per-model context caps. These override `contextWindow` and never raise smaller live metadata. | +| `contextWindow?` | `number` | Provider-wide context fallback when upstream metadata is absent; otherwise a cap that retains smaller live metadata. The Models dashboard exposes this separately from `providerContextCaps`. | +| `modelContextWindows?` | `Record` | Per-model context fallbacks/caps. These override `contextWindow`: an unknown window uses the configured value, while smaller live metadata remains authoritative. | | `modelInputModalities?` | `Record` | Per-model input hints such as `["text"]` or `["text", "image"]`. | | `modelMaxInputTokens?` | `Record` | Positive per-model max input limits used for catalog auto-compaction hints. | | `defaultMaxOutputTokens?` | `number` | Provider-wide `openai-chat` fallback when the client omits `max_output_tokens`. | diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index bc23cf5a2..dba0a8747 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -465,6 +465,16 @@ export const de: Record = { "models.v2ThreadsInvalid": "Thread-Limit muss eine ganze Zahl >= 1 sein", "models.v2ThreadsApply": "Anwenden", "models.capValue": "Limit {value}", + "models.contextSettings": "Kontextfenster", + "models.contextSettingsTitle": "Kontextfenster — {provider}", + "models.contextDefault": "Anbieterstandard", + "models.contextModel": "Modell", + "models.contextModelOverride": "Modellüberschreibung", + "models.contextHint": "Wird verwendet, wenn Upstream-Metadaten fehlen; andernfalls begrenzt der Wert ein größeres gemeldetes Fenster. Leer lassen für automatische Erkennung.", + "models.contextAutomatic": "Automatische Erkennung", + "models.contextSaved": "Kontextfenster aktualisiert — gilt ab der nächsten Codex-Runde.", + "models.contextSaveFailed": "Kontextfenster konnten nicht gespeichert werden", + "models.contextInvalid": "Kontextfenster müssen positive ganze Zahlen sein", "models.contextCappedValue": "{value}-Limit", "models.setAll": "Alle setzen", "models.setAllHint": "Wendet das {value}-Kontext-Limit auf alle gerouteten Anbieter an. Native Anbieter bleiben unberührt.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index c63a33495..2880ef7e7 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -484,6 +484,16 @@ export const en = { "models.v2ThreadsInvalid": "Thread limit must be an integer >= 1", "models.v2ThreadsApply": "Apply", "models.capValue": "Cap {value}", + "models.contextSettings": "Context windows", + "models.contextSettingsTitle": "Context windows — {provider}", + "models.contextDefault": "Provider default", + "models.contextModel": "Model", + "models.contextModelOverride": "Model override", + "models.contextHint": "Used when upstream metadata is missing; otherwise limits a larger reported window. Leave blank for automatic discovery.", + "models.contextAutomatic": "Automatic discovery", + "models.contextSaved": "Context windows updated — takes effect on the next Codex turn.", + "models.contextSaveFailed": "Failed to save context windows", + "models.contextInvalid": "Context windows must be positive whole numbers", "models.contextCappedValue": "{value} cap", "models.setAll": "Set all", "models.setAllHint": "Apply the {value} context cap to every routed provider. Native providers are unaffected.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 8bef10acc..538f2b700 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -473,6 +473,16 @@ export const ja: Record = { "models.v2ThreadsInvalid": "スレッド上限は 1 以上の整数にしてください", "models.v2ThreadsApply": "適用", "models.capValue": "上限 {value}", + "models.contextSettings": "コンテキストウィンドウ", + "models.contextSettingsTitle": "コンテキストウィンドウ — {provider}", + "models.contextDefault": "プロバイダーのデフォルト", + "models.contextModel": "モデル", + "models.contextModelOverride": "モデル別の上書き", + "models.contextHint": "上流メタデータがない場合に使われ、メタデータがある場合は報告値の上限になります。自動検出に戻すには空欄にします。", + "models.contextAutomatic": "自動検出", + "models.contextSaved": "コンテキストウィンドウを更新しました — 次回の Codex ターンから有効です。", + "models.contextSaveFailed": "コンテキストウィンドウを保存できませんでした", + "models.contextInvalid": "コンテキストウィンドウは正の整数で指定してください", "models.contextCappedValue": "{value} 上限", "models.setAll": "すべて設定", "models.setAllHint": "{value} のコンテキスト上限をすべてのルーティング済みプロバイダーに適用します。ネイティブプロバイダーには影響しません。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 4112cbd79..2fa80b4b3 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -476,6 +476,16 @@ export const ko: Record = { "models.v2ThreadsInvalid": "스레드 한도는 1 이상 정수여야 합니다", "models.v2ThreadsApply": "적용", "models.capValue": "{value} 제한", + "models.contextSettings": "컨텍스트 윈도우", + "models.contextSettingsTitle": "컨텍스트 윈도우 — {provider}", + "models.contextDefault": "프로바이더 기본값", + "models.contextModel": "모델", + "models.contextModelOverride": "모델별 재정의", + "models.contextHint": "업스트림 메타데이터가 없을 때 사용하며, 메타데이터가 있으면 더 큰 보고값의 상한으로 적용합니다. 자동 검색을 사용하려면 비워 두세요.", + "models.contextAutomatic": "자동 검색", + "models.contextSaved": "컨텍스트 윈도우가 업데이트되었습니다 — 다음 Codex 턴부터 적용됩니다.", + "models.contextSaveFailed": "컨텍스트 윈도우를 저장하지 못했습니다", + "models.contextInvalid": "컨텍스트 윈도우는 양의 정수여야 합니다", "models.contextCappedValue": "{value} 제한", "models.setAll": "전체 적용", "models.setAllHint": "{value} 컨텍스트 상한을 라우팅된 모든 프로바이더에 적용합니다. 네이티브 프로바이더는 영향을 받지 않습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index d38a236bc..618d00cb6 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -478,6 +478,16 @@ export const ru: Record = { "models.v2ThreadsInvalid": "Лимит потоков должен быть целым числом >= 1", "models.v2ThreadsApply": "Применить", "models.capValue": "Лимит {value}", + "models.contextSettings": "Контекстные окна", + "models.contextSettingsTitle": "Контекстные окна — {provider}", + "models.contextDefault": "Значение провайдера", + "models.contextModel": "Модель", + "models.contextModelOverride": "Переопределение модели", + "models.contextHint": "Используется, если вышестоящие метаданные отсутствуют; иначе ограничивает большее заявленное окно. Оставьте поле пустым для автоматического определения.", + "models.contextAutomatic": "Автоматическое определение", + "models.contextSaved": "Контекстные окна обновлены — изменения вступят в силу на следующем ходе Codex.", + "models.contextSaveFailed": "Не удалось сохранить контекстные окна", + "models.contextInvalid": "Контекстные окна должны быть положительными целыми числами", "models.contextCappedValue": "Лимит {value}", "models.setAll": "Применить ко всем", "models.setAllHint": "Применяет лимит контекста {value} ко всем маршрутизируемым провайдерам. Нативные провайдеры не затрагиваются.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index ba9ca825e..0c2a8588a 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -473,6 +473,16 @@ export const zh: Record = { "models.v2ThreadsInvalid": "线程上限必须为 >= 1 的整数", "models.v2ThreadsApply": "应用", "models.capValue": "限制 {value}", + "models.contextSettings": "上下文窗口", + "models.contextSettingsTitle": "上下文窗口 — {provider}", + "models.contextDefault": "提供方默认值", + "models.contextModel": "模型", + "models.contextModelOverride": "模型覆盖值", + "models.contextHint": "上游缺少元数据时使用该值;上游已有元数据时,它只限制更大的报告值。留空则恢复自动发现。", + "models.contextAutomatic": "自动发现", + "models.contextSaved": "上下文窗口已更新 — 将在下一个 Codex 回合生效。", + "models.contextSaveFailed": "保存上下文窗口失败", + "models.contextInvalid": "上下文窗口必须为正整数", "models.contextCappedValue": "{value} 限制", "models.setAll": "全部设置", "models.setAllHint": "将 {value} 上下文上限应用到所有已路由的提供方。原生提供方不受影响。", diff --git a/gui/src/models-groups.ts b/gui/src/models-groups.ts index 193ae1e6a..e6cce7f5c 100644 --- a/gui/src/models-groups.ts +++ b/gui/src/models-groups.ts @@ -14,6 +14,8 @@ export interface ConfiguredProviderSummary { disabled?: boolean; liveModels?: boolean; models?: string[]; + contextWindow?: number; + modelContextWindows?: Record; discovery?: ProviderDiscoverySummary; } @@ -23,6 +25,8 @@ export interface ProviderModelGroup { native: boolean; liveModels: boolean; configuredModels: string[]; + contextWindow?: number; + modelContextWindows?: Record; discovery?: ProviderDiscoverySummary; } @@ -56,6 +60,8 @@ export function buildProviderModelGroups 0 && providerRows.every(row => row.native === true), liveModels: configured?.liveModels !== false, configuredModels: configured?.models ?? [], + contextWindow: configured?.contextWindow, + modelContextWindows: configured?.modelContextWindows, discovery: configured?.discovery, }; }) diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 3c94b66a8..9aabdda91 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -174,6 +174,13 @@ export default function Models({ apiBase }: { apiBase: string }) { const [customFormModalities, setCustomFormModalities] = useState(["text"]); const [customSaving, setCustomSaving] = useState(false); const [customError, setCustomError] = useState(""); + const [contextModalProvider, setContextModalProvider] = useState(null); + const [contextModalModels, setContextModalModels] = useState([]); + const [contextModelId, setContextModelId] = useState(""); + const [contextDefaultDraft, setContextDefaultDraft] = useState(""); + const [contextModelDraft, setContextModelDraft] = useState(""); + const [contextSaving, setContextSaving] = useState(false); + const [contextError, setContextError] = useState(""); const [hoveredModel, setHoveredModel] = useState<{ namespaced: string; rect: DOMRect } | null>(null); const hoverTimerRef = useRef | null>(null); const [shadowCall, setShadowCall] = useState(null); @@ -342,6 +349,82 @@ export default function Models({ apiBase }: { apiBase: string }) { */ const catalogCountReady = models.length > 0 || catalogState.data !== undefined; + const openContextSettings = (group: ProviderModelGroup) => { + const modelIds = [...new Set([ + ...group.rows.map(model => model.id), + ...group.configuredModels, + ])].sort(); + const modelId = modelIds[0] ?? ""; + setContextModalProvider(group.provider); + setContextModalModels(modelIds); + setContextModelId(modelId); + setContextDefaultDraft(group.contextWindow ? String(group.contextWindow) : ""); + setContextModelDraft(modelId && group.modelContextWindows?.[modelId] + ? String(group.modelContextWindows[modelId]) + : ""); + setContextError(""); + }; + + const selectContextModel = (modelId: string) => { + const group = groups.find(candidate => candidate.provider === contextModalProvider); + setContextModelId(modelId); + setContextModelDraft(group?.modelContextWindows?.[modelId] + ? String(group.modelContextWindows[modelId]) + : ""); + }; + + const parseContextWindowDraft = (raw: string): number | null | undefined => { + const normalized = raw.replace(/[_,\s]/g, ""); + if (!normalized) return null; + const value = Number(normalized); + return Number.isFinite(value) && Number.isInteger(value) && value > 0 + ? value + : undefined; + }; + + const saveContextSettings = async () => { + if (!contextModalProvider) return; + const providerWindow = parseContextWindowDraft(contextDefaultDraft); + const modelWindow = parseContextWindowDraft(contextModelDraft); + if (providerWindow === undefined || modelWindow === undefined) { + setContextError(t("models.contextInvalid")); + return; + } + const group = groups.find(candidate => candidate.provider === contextModalProvider); + if (!group) { + setContextError(t("models.contextSaveFailed")); + return; + } + + setContextSaving(true); + setContextError(""); + try { + const body: Record = { contextWindow: providerWindow }; + if (contextModelId) { + body.modelContextWindows = { [contextModelId]: modelWindow }; + } + const response = await fetch( + `${apiBase}/api/providers?name=${encodeURIComponent(contextModalProvider)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + await readJsonOrThrow(response, t("models.contextSaveFailed")); + const refreshed = await load(true); + if (!refreshed) { + setContextError(t("models.loadFail")); + return; + } + setContextModalProvider(null); + publishFeedback(true, t("models.contextSaved")); + } catch (error) { + setContextError(error instanceof Error ? error.message : t("models.contextSaveFailed")); + } finally { + setContextSaving(false); + } + }; // One-shot default collapse. It stays an effect on `groups` so CACHED groups collapse // immediately on first paint, even when revalidation is slow or fails; moving it into @@ -783,6 +866,14 @@ export default function Models({ apiBase }: { apiBase: string }) { {t("models.active", { active: activeCount, total: rows.length })}
+ {!isNative && ( + + )} {!isNative && ( +
+ + {contextError && {contextError}} +

{t("models.contextHint")}

+ +
+ + + {contextModalModels.length > 0 && ( + <> +
+ {t("models.contextModel")} + setContextModelDraft(event.target.value)} + disabled={contextSaving} + placeholder={t("models.contextAutomatic")} + /> + + + )} +
+ +
+ + +
+
+ + )} + {customModalOpen && (
; enabled: boolean }> = []; + const contextBodies: Array<{ + contextWindow: number | null; + modelContextWindows: Record; + }> = []; + let providerContextWindow: number | undefined = 256_000; + let providerModelContextWindows: Record = { "claude-opus": 64_000 }; let failNext = false; let failCatalog = false; let modelFetches = 0; @@ -142,9 +148,22 @@ test("Models page combines final visibility, atomic actions, discovery status, a name: provider, liveModels: true, models: ids, + contextWindow: providerContextWindow, + modelContextWindows: providerModelContextWindows, discovery: { status: "failed", reason: "http", httpStatus: 401 }, }]); } + if (url.includes("/api/providers?name=") && init?.method === "PATCH") { + const body = JSON.parse(String(init.body)) as (typeof contextBodies)[number]; + contextBodies.push(body); + if (body.contextWindow === null) providerContextWindow = undefined; + else if (typeof body.contextWindow === "number") providerContextWindow = body.contextWindow; + for (const [model, value] of Object.entries(body.modelContextWindows ?? {})) { + if (value === null) delete providerModelContextWindows[model]; + else providerModelContextWindows = { ...providerModelContextWindows, [model]: value }; + } + return Response.json({ success: true }); + } if (url.endsWith("/api/selected-models")) return Response.json({ selected: { [provider]: selected }, available: { [provider]: ids } }); if (url.endsWith("/api/provider-context-caps")) return Response.json({ caps: {} }); if (url.endsWith("/api/combos")) return Response.json({ combos: [] }); @@ -196,6 +215,32 @@ test("Models page combines final visibility, atomic actions, discovery status, a expect(container.querySelector(".badge.badge-amber")?.textContent).toContain("Discovery failed"); expect(container.textContent).not.toContain("Not selected"); + await act(async () => buttonText("Context windows").click()); + const contextDialog = container.querySelector('[role="dialog"][aria-label="Context windows"]')!; + const contextInputs = contextDialog.querySelectorAll("input"); + expect([...contextInputs].map(input => input.value)).toEqual(["256000", "64000"]); + await act(async () => { + const setValue = Object.getOwnPropertyDescriptor( + testWindow.HTMLInputElement.prototype, + "value", + )!.set!; + setValue.call(contextInputs[0]!, "350000"); + contextInputs[0]!.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + setValue.call(contextInputs[1]!, "100000"); + contextInputs[1]!.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + const applyContext = [...contextDialog.querySelectorAll("button")] + .find(button => button.textContent === "Apply")!; + await act(async () => { + applyContext.click(); + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + }); + expect(contextBodies.at(-1)).toEqual({ + contextWindow: 350_000, + modelContextWindows: { "claude-opus": 100_000 }, + }); + expect(container.querySelector('[role="dialog"][aria-label="Context windows"]')).toBeNull(); + await act(async () => container.querySelector('button.select-trigger[aria-label="Shadow Call Intercept"]')?.click()); // The workspace Select portals its listbox to document.body, so the options are not inside // `container`. Query the document instead of the mount node. diff --git a/gui/tests/models-provider-head.test.ts b/gui/tests/models-provider-head.test.ts index 101906f17..83d397f0c 100644 --- a/gui/tests/models-provider-head.test.ts +++ b/gui/tests/models-provider-head.test.ts @@ -30,3 +30,14 @@ test("Models workspace stacks via content-width container query before mobile dr // Mobile media rule retained for drawer layouts. expect(css).toContain("@media (max-width: 768px)"); }); + +test("Models exposes provider and per-model context-window controls (#1073)", async () => { + const page = await Bun.file(new URL("../src/pages/Models.tsx", import.meta.url)).text(); + const groups = await Bun.file(new URL("../src/models-groups.ts", import.meta.url)).text(); + + expect(groups).toContain("contextWindow?: number"); + expect(groups).toContain("modelContextWindows?: Record"); + expect(page).toContain('t("models.contextSettings")'); + expect(page).toContain("modelContextWindows"); + expect(page).toMatch(/\/api\/providers\?name=.*method:\s*"PATCH"/s); +}); diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 85c5a9400..5582e447e 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -165,6 +165,42 @@ function applyProviderPatchFields( next.liveModels = rawBody.liveModels; touched = true; } + // The Models page edits the catalog hints in place; keep them on the existing + // provider mutation path so validation, cache invalidation, and convergence stay unified (#1073). + if (Object.hasOwn(rawBody, "contextWindow")) { + const value = rawBody.contextWindow; + if (value === null) { + delete next.contextWindow; + } else if (typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value > 0) { + next.contextWindow = value; + } else { + return { error: "contextWindow must be a positive finite integer or null" }; + } + touched = true; + } + if (Object.hasOwn(rawBody, "modelContextWindows")) { + const value = rawBody.modelContextWindows; + if (value === null) { + delete next.modelContextWindows; + } else { + if (!isPlainRecord(value)) return { error: "modelContextWindows must be a plain object or null" }; + const windows: Record = { ...(next.modelContextWindows ?? {}) }; + for (const [model, window] of Object.entries(value)) { + if (!model.trim()) return { error: "modelContextWindows keys must be nonblank model ids" }; + if (window === null) { + delete windows[model]; + continue; + } + if (typeof window !== "number" || !Number.isFinite(window) || !Number.isInteger(window) || window <= 0) { + return { error: "modelContextWindows values must be positive finite integers or null" }; + } + windows[model] = window; + } + if (Object.keys(windows).length > 0) next.modelContextWindows = windows; + else delete next.modelContextWindows; + } + touched = true; + } // headers is the one object-valued field in the mask. PATCH semantics merge it // shallowly into the existing block so a single fingerprint header can be added @@ -249,6 +285,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise; /** Model-specific Codex catalog input modalities, e.g. ["text"] or ["text", "image"]. */ modelInputModalities?: Record; diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index e850d3f84..4f6396df6 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -1973,6 +1973,92 @@ describe("provider management validation", () => { // Unknown-only bodies are rejected. expect((await patch("extra", { bogus: 1 }))?.status).toBe(400); }); + + test("provider management exposes and persists context-window hints for Models GUI (#1073)", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const liveConfig: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "openai", + openaiProviderTierVersion: 2, + providers: { + openai: { ...canonicalDirect }, + relay: { + adapter: "openai-chat", + baseUrl: "https://relay.example.test/v1", + apiKey: "sk-existing", + models: ["wide", "narrow"], + contextWindow: 256_000, + modelContextWindows: { narrow: 64_000 }, + }, + }, + }; + saveConfig(liveConfig); + + const request = async (method: "GET" | "PATCH", body?: unknown) => { + const req = new Request("http://127.0.0.1/api/providers?name=relay", { + method, + headers: body === undefined ? undefined : { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return handleManagementAPI(req, new URL(req.url), liveConfig, { + createManagementConvergeCodex: catalogConvergenceFactory(() => {}), + }); + }; + + const listed = await request("GET"); + expect(listed?.status).toBe(200); + const rows = await listed!.json() as Array<{ + name: string; + contextWindow?: number; + modelContextWindows?: Record; + }>; + expect(rows.find(row => row.name === "relay")).toMatchObject({ + contextWindow: 256_000, + modelContextWindows: { narrow: 64_000 }, + }); + + const updated = await request("PATCH", { + contextWindow: 350_000, + modelContextWindows: { wide: 350_000 }, + }); + expect(updated?.status).toBe(200); + expect(liveConfig.providers.relay).toMatchObject({ + contextWindow: 350_000, + modelContextWindows: { wide: 350_000, narrow: 64_000 }, + }); + expect(loadConfig().providers.relay).toMatchObject({ + contextWindow: 350_000, + modelContextWindows: { wide: 350_000, narrow: 64_000 }, + }); + + for (const invalid of [ + { contextWindow: 0 }, + { contextWindow: 1.5 }, + { modelContextWindows: { "": 100_000 } }, + { modelContextWindows: { wide: -1 } }, + ]) { + expect((await request("PATCH", invalid))?.status).toBe(400); + } + expect(liveConfig.providers.relay).toMatchObject({ + contextWindow: 350_000, + modelContextWindows: { wide: 350_000, narrow: 64_000 }, + }); + + expect((await request("PATCH", { modelContextWindows: { wide: null } }))?.status).toBe(200); + expect(liveConfig.providers.relay.modelContextWindows).toEqual({ narrow: 64_000 }); + + const cleared = await request("PATCH", { + contextWindow: null, + modelContextWindows: null, + }); + expect(cleared?.status).toBe(200); + expect(liveConfig.providers.relay.contextWindow).toBeUndefined(); + expect(liveConfig.providers.relay.modelContextWindows).toBeUndefined(); + }); + test("provider PATCH manages custom headers with merge and clear semantics", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); From 6cced969e0afbda14caad80dec81172e27419f43 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 7 Aug 2026 19:05:25 +0900 Subject: [PATCH 29/48] fix(update): restore diagnostics by reading npm's named fields (round 14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Withholding the whole stream was too blunt. A user whose update fails deserves to know why, and `exit 1 · 359 bytes withheld` tells them nothing. The insight I missed for thirteen rounds: npm's failure output is STRUCTURED, not prose. It prints `npm error `, one field per line. That means the useful parts can be read BY NAME instead of reconstructed from text — which is what made every redaction attempt fail, since it had to guess where a path started and ended. Kept fields, each because its value cannot be a local path: `code`, `syscall`, `errno`, `notarget`, and the HTTP-status lines (`404`, `401`, `403`, `409`, `429`) whose value is a registry URL. Explicitly not kept: `path`, `dest`, `file`, `stack`, the bare `Error:` line, and the debug-log location — every one of those is a filesystem path by definition. Each kept value still passes the path test before use, is length-capped, and `code` must additionally be in the recognized vocabulary. Convention is not a guarantee. Node exceptions get the same treatment: `syscall` and `errno` are named properties, shape-validated (a short lowercase identifier, an integer), so an error summary now reads `Error EACCES · syscall: mkdir · errno: -13` instead of a byte count. Measured against real npm failures: before exit 1 · 366 bytes withheld after exit 1 · code: E404 · 404: The requested resource '…' could not be found before exit 1 · 359 bytes withheld after exit 1 · code: EACCES · syscall: mkdir · errno: -13 before exit 1 · 208 bytes withheld after exit 1 · code: ETARGET · notarget: No matching version found for left-pad@99.99.99 The regression drives a real EACCES dump containing `/Users/Jane Doe/...` and asserts the cause survives while the account name and paths do not. Ablation confirmed. --- src/update/job.ts | 73 +++++++++++++++++++++++++++++++++++----- tests/update-job.test.ts | 41 ++++++++++++++++++++++ 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/src/update/job.ts b/src/update/job.ts index d5a88358e..6115b9e0b 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -277,7 +277,18 @@ function withheldSummary(error: unknown): string { // Only recognized codes — an arbitrary uppercase `error.code` can be attacker-shaped too. const codeNote = typeof code === "string" && NPM_ERROR_CODES.has(code) ? ` ${code}` : ""; const text = error instanceof Error ? error.message : String(error ?? ""); - return `${name}${codeNote} (${Buffer.byteLength(text, "utf8")} bytes withheld)`; + // Node's own errors are structured the same way npm's output is: `syscall` and `errno` are + // named properties, not prose. Reading those gives a user the actual cause — + // `Error EACCES · syscall: mkdir · errno: -13` — without repeating a message that could name + // a person or a path. Both are shape-validated: a syscall is a short lowercase identifier and + // an errno is an integer, so neither can carry arbitrary text. + const parts = [`${name}${codeNote}`]; + const syscall = (error as { syscall?: unknown } | null)?.syscall; + if (typeof syscall === "string" && /^[a-z][a-z0-9_]{1,20}$/.test(syscall)) parts.push(`syscall: ${syscall}`); + const errno = (error as { errno?: unknown } | null)?.errno; + if (typeof errno === "number" && Number.isInteger(errno)) parts.push(`errno: ${errno}`); + parts.push(`${Buffer.byteLength(text, "utf8")} bytes withheld`); + return parts.join(" · "); } /** @@ -708,13 +719,55 @@ const NPM_ERROR_CODES = new Set([ /** npm prints `npm ERR! code EACCES`; anchor on that position rather than scanning free text. */ const NPM_CODE_RECORD = /^\s*npm\s+ERR!\s+code\s+([A-Z][A-Z0-9_]{2,})\s*$/gm; +/** + * npm's failure output is STRUCTURED, not prose: `npm error `, one field per + * line (`npm ERR!` on npm 9 and earlier). That is what makes a useful summary possible without + * reproducing text — we can read named fields and keep the ones whose value cannot be a path. + * + * Fields kept, with a real example of each: + * code E404, EACCES, ETARGET the single most useful line for diagnosis + * syscall mkdir, open, getaddrinfo what npm was doing + * errno -13 the OS errno + * notarget No matching version ... version-resolution explanation, no path + * 404 404 Not Found - GET registry URL, no local path + * + * Deliberately NOT kept: `path`, `dest`, `file`, `stack`, and the bare `Error: ...` line — + * every one of those is a filesystem path by definition. `A complete log of this run can be + * found in: ` is dropped for the same reason. + */ +const NPM_FIELD_LINE = /^\s*npm\s+(?:error|ERR!)\s+([a-z0-9]+)\s+(.*)$/gim; +const NPM_SAFE_FIELDS = new Set(["code", "syscall", "errno", "notarget", "404", "401", "403", "409", "429"]); + +/** + * Extract the diagnostic fields npm names explicitly. + * + * Each kept value still passes `withholdIfPathBearing` before it is used: a registry URL is + * fine, but `syscall` and friends are only safe by convention, and a convention is not a + * guarantee. Values are length-capped so a hostile responder cannot pad the record. + */ +function npmDiagnosticFields(text: string): string[] { + const seen = new Map(); + for (const match of text.matchAll(NPM_FIELD_LINE)) { + const field = match[1]!.toLowerCase(); + const value = match[2]!.trim(); + if (!NPM_SAFE_FIELDS.has(field) || seen.has(field)) continue; + if (!value || value.length > 160) continue; + // `code` is additionally pinned to the recognized vocabulary; the rest only have to prove + // they carry no path. + if (field === "code" && !NPM_ERROR_CODES.has(value)) continue; + if (withholdIfPathBearing(value) !== value) continue; + seen.set(field, value); + } + return [...seen].map(([field, value]) => `${field}: ${value}`); +} + /** * Build a structured, path-free summary of a command's result. * * Only three things cross the boundary: how the process ended, how much it printed, and any * recognized error codes. None of those can carry a filesystem path or an account name. */ -function summarizeCommandOutput( +export function summarizeCommandOutput( stdout: string, stderr: string, status: number | null, @@ -725,14 +778,18 @@ function summarizeCommandOutput( const parts: string[] = []; parts.push(signal ? `terminated by ${signal}` : `exit ${status ?? "null"}`); - const codes = [...new Set([ - ...stderr.matchAll(NPM_CODE_RECORD), - ...stdout.matchAll(NPM_CODE_RECORD), - ].map(match => match[1]!).filter(code => NPM_ERROR_CODES.has(code)))].slice(0, 5); - if (codes.length > 0) parts.push(`codes: ${codes.join(", ")}`); + // Read npm's own named fields rather than reproducing its text. This is what makes a failed + // update diagnosable again: `code: E404 · 404: 404 Not Found - GET https://registry...` tells + // a user exactly what happened, and none of it can be a local path. + const fields = npmDiagnosticFields(`${stderr}\n${stdout}`); + if (fields.length > 0) parts.push(...fields); const bytes = Buffer.byteLength(stdout, "utf8") + Buffer.byteLength(stderr, "utf8"); - if (bytes > 0) parts.push(`${bytes} bytes of output withheld (may contain local paths)`); + if (bytes > 0) { + parts.push(fields.length > 0 + ? `${bytes} bytes of full output withheld` + : `${bytes} bytes of output withheld (no recognized diagnostic fields)`); + } return parts.join(" · "); } diff --git a/tests/update-job.test.ts b/tests/update-job.test.ts index 5c35eff18..ac771dfb8 100644 --- a/tests/update-job.test.ts +++ b/tests/update-job.test.ts @@ -11,6 +11,7 @@ import { restartCommand, restartAfterUpdateForTests, runGuiUpdateWorker, + summarizeCommandOutput, staleActiveUpdateJobReason, startUpdateJob, UPDATE_JOB_LEGACY_STALE_MS, @@ -323,6 +324,46 @@ describe("GUI update execution decisions", () => { expect(persisted).toContain("bytes withheld"); }); + test("npm failures stay diagnosable: named fields survive, paths do not", () => { + // Captured from real `npm install` failures. npm's output is STRUCTURED — + // `npm error `, one field per line — so the useful parts can be read by + // name instead of reproduced as text. Withholding the whole stream made a failed update + // undebuggable; this keeps the cause and drops the paths. + const eacces = [ + "npm error code EACCES", + "npm error syscall mkdir", + "npm error path /Users/Jane Doe/.npm/_cacache/tmp/x", + "npm error errno -13", + "npm error Error: EACCES: permission denied, mkdir '/Users/Jane Doe/.npm/x'", + "npm error at async mkdir (node:internal/fs/promises:859:10)", + ].join("\n"); + + const summary = summarizeCommandOutput("", eacces, 1, null); + + // The cause is legible. + expect(summary).toContain("code: EACCES"); + expect(summary).toContain("syscall: mkdir"); + expect(summary).toContain("errno: -13"); + // The paths and the account name are not. + expect(summary).not.toContain("Jane Doe"); + expect(summary).not.toContain("_cacache"); + expect(summary).not.toContain("promises:859"); + + // A registry URL is a legitimate diagnostic and carries no local path. + const e404 = [ + "npm error code E404", + "npm error 404 Not Found - GET https://registry.npmjs.org/nope - Not found", + "npm error A complete log of this run can be found in: /Users/Jane Doe/.npm/_logs/x.log", + ].join("\n"); + const notFound = summarizeCommandOutput("", e404, 1, null); + expect(notFound).toContain("code: E404"); + expect(notFound).not.toContain("Jane Doe"); + + // An unrecognized code is not echoed: `npm error code TOTALLY-MADE-UP` must not pass. + const bogus = summarizeCommandOutput("", "npm error code NOTAREALCODE", 1, null); + expect(bogus).not.toContain("NOTAREALCODE"); + }); + test("npm worker uses the Node launcher update path", () => { const cmd = updateExecutionCommand("npm", "preview", "/pkg/bin/ocx.mjs"); expect(cmd.bin).toMatch(/^node/); From 835a82785c44d9afdbfb0adfdf240afbd269838d Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 7 Aug 2026 23:12:49 +0900 Subject: [PATCH 30/48] test(server): exercise the loopback listener over real sockets, and reserve its port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit found the previous commit shipped an unauthenticated surface whose tests never started a server. Every assertion ran against pure helpers, so the listener could fail to open, bind the wrong address, lose the identity comparison against the public server, or serve routes outside its allowlist, and the suite would stay green. That is not a coverage gap; it is a missing oracle for a security boundary. Ten integration tests now speak HTTP to real listeners: the same request answered 401 on the public socket and 200 on the loopback one, a connection to the machine's non-loopback IPv4 being refused, management and dashboard and unrelated data-plane routes returning 404 while an allowlisted one returns 200, the loopback Host/Origin gate rejecting the rebinding shape the public policy would accept, both ports rebindable after stop, and a squatted loopback port rolling back the public bind. Ablation, all driven red rather than argued: binding the loopback listener to 0.0.0.0 fails 2; removing the allowlist check fails 1; collapsing the policy view to the shared config fails 3. The second finding was a real gap rather than a test one. `ocx start --port ` would bind the public listener onto the address the loopback listener is configured for, and the loopback bind would then fail EADDRINUSE — rolling back a startup whose only problem was a config collision. `findAvailablePort` takes a `reservedPort`: an explicit preference for it is refused up front rather than retried, and ephemeral selection redraws if the OS hands it back. Two smaller corrections. `drainingResponse` and `serverBusyResponse` run before the auth checks and were still using the shared config, so a 503 on the loopback listener could echo a hostile origin; both now take the receiving listener's policy. And a non-boolean `enabled` was silently deleted by the schema's catch, leaving an operator convinced they had enabled a listener that was off — write-time now rejects it. Refs #1102 --- src/cli/index.ts | 7 + src/config.ts | 7 + src/server/index.ts | 37 +-- src/server/ports.ts | 23 +- tests/loopback-listener-admission.test.ts | 11 +- tests/loopback-listener-integration.test.ts | 237 ++++++++++++++++++++ 6 files changed, 304 insertions(+), 18 deletions(-) create mode 100644 tests/loopback-listener-integration.test.ts diff --git a/src/cli/index.ts b/src/cli/index.ts index e9f3c22c3..f410af13b 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -170,6 +170,13 @@ async function chooseListenPort(requestedPort?: number): Promise { preferRetryMs: hardPin ? 5_000 : 750, preferRetryIntervalMs: 50, allowEphemeralFallback: !hardPin, + // Never hand the public listener the port the loopback listener is configured to + // bind (#1102). Without this, `--port ` binds the public listener + // first and the loopback bind then fails, rolling back a startup that was only + // ever a config collision. + ...(config.unauthenticatedLoopbackListener?.enabled + ? { reservedPort: config.unauthenticatedLoopbackListener.port } + : {}), }); if (preferred > 0 && selected !== preferred) { console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`); diff --git a/src/config.ts b/src/config.ts index b929f074c..f2587d4c8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2001,6 +2001,13 @@ function loopbackListenerPortError(value: unknown): string | null { return "schema_invalid: unauthenticatedLoopbackListener: must be an object or omitted"; } const entry = listener as Record; + // `enabled` must be a real boolean. The schema's `.catch(undefined)` would otherwise DELETE + // a `"true"` string entry and report success, leaving an operator convinced they enabled an + // unauthenticated listener that is in fact off. Load-time still degrades quietly — a hand + // edit must not reset the file — but a live caller gets told. + if (typeof entry.enabled !== "boolean") { + return "schema_invalid: unauthenticatedLoopbackListener.enabled: must be a boolean"; + } if (entry.enabled !== true) return null; const listenerPort = entry.port; if (typeof listenerPort !== "number" || !Number.isInteger(listenerPort) || listenerPort < 1 || listenerPort > 65535) { diff --git a/src/server/index.ts b/src/server/index.ts index 83793c560..860583b41 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -539,23 +539,28 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server Promise): Promise { @@ -659,7 +664,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { const preferRetryMs = opts.preferRetryMs ?? 0; const allowEphemeral = opts.allowEphemeralFallback !== false; + const reserved = opts.reservedPort; + // An explicit preference for the reserved port is a configuration mistake, not a busy + // socket: retrying or hopping would hide it. Refuse before probing anything. + if (reserved !== undefined && preferredPort === reserved) { + throw new PortUnavailableError(preferredPort, hostname); + } // Port 0 asks the OS to select an ephemeral port. Resolve it to that concrete // port here so callers never persist or advertise an unusable `:0` endpoint. if (preferredPort > 0 && preferRetryMs > 0) { @@ -99,7 +115,12 @@ export async function findAvailablePort( const address = server.address(); const port = typeof address === "object" && address ? address.port : 0; server.close(() => { - if (port > 0) resolve(port); + // The OS can hand back the reserved port. Retry rather than accept it; the pool is + // large enough that a second draw practically always differs. + if (port > 0 && port !== reserved) resolve(port); + else if (port === reserved) { + findAvailablePort(0, hostname, opts).then(resolve, reject); + } else reject(new Error("failed to allocate an available port")); }); }); diff --git a/tests/loopback-listener-admission.test.ts b/tests/loopback-listener-admission.test.ts index bd12ae062..2ecd269d4 100644 --- a/tests/loopback-listener-admission.test.ts +++ b/tests/loopback-listener-admission.test.ts @@ -17,7 +17,7 @@ import { requestPolicyView, resolveResponsesApiAuth, } from "../src/server/auth-cors"; -import { shouldInjectApiAuthHeader } from "../src/codex/inject"; +import { buildProviderTableBlock, shouldInjectApiAuthHeader } from "../src/codex/inject"; import { validateConfigCandidate } from "../src/config"; import type { OcxConfig } from "../src/types"; @@ -165,4 +165,13 @@ describe("injected Codex provider block", () => { unauthenticatedLoopbackListener: { enabled: false }, })).toBe(true); }); + + test("the emitted block points at the loopback port and carries no auth header", () => { + // shouldInjectApiAuthHeader alone does not prove the injected TOML is usable. Assert the + // rendered block, because that is what a directly spawned app-server actually reads: a + // base_url on the public port, or an env header it cannot populate, both reproduce #1102. + const block = buildProviderTableBlock(10200, false, false, "0.0.0.0"); + expect(block).toContain('base_url = "http://127.0.0.1:10200/v1"'); + expect(block).not.toContain("env_http_headers"); + }); }); diff --git a/tests/loopback-listener-integration.test.ts b/tests/loopback-listener-integration.test.ts new file mode 100644 index 000000000..96a55fbb2 --- /dev/null +++ b/tests/loopback-listener-integration.test.ts @@ -0,0 +1,237 @@ +/** + * Integration coverage for the unauthenticated loopback listener (#1102). + * + * The companion unit file exercises the admission and CORS helpers in isolation. That is not + * enough for a surface that admits without a credential: helper-level tests stay green if the + * second listener never opens, binds the wrong address, is not distinguished from the public + * one, or serves routes outside its allowlist. These tests start real servers and speak HTTP + * to them, so those regressions have somewhere to fail. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { connect } from "node:net"; +import { networkInterfaces, tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../src/config"; +import { startServer } from "../src/server"; +import { findAvailablePort, PortUnavailableError } from "../src/server/ports"; +import type { OcxConfig } from "../src/types"; + +const previousApiToken = process.env.OPENCODEX_API_AUTH_TOKEN; +const previousHome = process.env.OPENCODEX_HOME; +let testDir = ""; + +function baseConfig(loopbackPort: number | null): OcxConfig { + return { + port: 0, + hostname: "0.0.0.0", + defaultProvider: "chatgpt", + providers: { + chatgpt: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + ...(loopbackPort === null + ? {} + : { unauthenticatedLoopbackListener: { enabled: true, port: loopbackPort } }), + } as unknown as OcxConfig; +} + +/** A free port to hand the loopback listener, chosen the same way production would not reuse. */ +async function freePort(): Promise { + return await findAvailablePort(0, "127.0.0.1"); +} + +function firstNonLoopbackIPv4(): string | null { + for (const entries of Object.values(networkInterfaces())) { + for (const entry of entries ?? []) { + if (entry.family === "IPv4" && !entry.internal) return entry.address; + } + } + return null; +} + +beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), "ocx-loopback-listener-")); + process.env.OPENCODEX_HOME = testDir; + process.env.OPENCODEX_API_AUTH_TOKEN = "public-secret"; +}); + +afterEach(() => { + if (previousApiToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; + else process.env.OPENCODEX_API_AUTH_TOKEN = previousApiToken; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (testDir && existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + testDir = ""; +}); + +describe("unauthenticated loopback listener", () => { + test("is absent unless configured, and the public listener still demands a key", async () => { + saveConfig(baseConfig(null)); + const server = startServer(0); + try { + const res = await fetch(`http://127.0.0.1:${server.port}/v1/models`); + expect(res.status).toBe(401); + } finally { + await server.stop(true); + } + }); + + test("admits without a credential while the public listener does not", async () => { + const loopbackPort = await freePort(); + saveConfig(baseConfig(loopbackPort)); + const server = startServer(0); + try { + // Same request, two sockets, two answers. This is the whole feature. + const viaPublic = await fetch(`http://127.0.0.1:${server.port}/v1/models`); + expect(viaPublic.status).toBe(401); + + const viaLoopback = await fetch(`http://127.0.0.1:${loopbackPort}/v1/models`); + expect(viaLoopback.status).toBe(200); + } finally { + await server.stop(true); + } + }); + + test("refuses connections on a non-loopback interface", async () => { + const address = firstNonLoopbackIPv4(); + if (!address) { + // A host with no external IPv4 cannot prove this. Say so rather than pass silently: + // a quiet skip here would let the bind address regress unnoticed on that machine. + console.warn("[loopback-listener] no non-loopback IPv4 interface; bind-scope check not run"); + return; + } + const loopbackPort = await freePort(); + saveConfig(baseConfig(loopbackPort)); + const server = startServer(0); + try { + const refused = await new Promise(resolve => { + const socket = connect({ host: address, port: loopbackPort }); + const settle = (value: boolean) => { + socket.destroy(); + resolve(value); + }; + socket.setTimeout(2_000); + socket.once("connect", () => settle(false)); + socket.once("error", () => settle(true)); + socket.once("timeout", () => settle(true)); + }); + expect(refused).toBe(true); + } finally { + await server.stop(true); + } + }); + + test("serves only the four allowlisted routes", async () => { + const loopbackPort = await freePort(); + saveConfig(baseConfig(loopbackPort)); + const server = startServer(0); + const base = `http://127.0.0.1:${loopbackPort}`; + try { + // Management, dashboard and unrelated data-plane routes must not be reachable from a + // surface that skips authentication. + for (const path of [ + "/api/config", + "/", + "/healthz", + "/readyz", + "/v1/chat/completions", + "/v1/messages", + "/v1/images/generations", + "/v1/alpha/search", + "/v1/opencodex/artifacts/x", + "/v1/live", + ]) { + const res = await fetch(`${base}${path}`, { method: "GET" }); + expect({ path, status: res.status }).toEqual({ path, status: 404 }); + } + + // And the allowlisted one is genuinely reachable, so the assertions above are not + // passing merely because nothing works. + expect((await fetch(`${base}/v1/models`)).status).toBe(200); + } finally { + await server.stop(true); + } + }); + + test("applies the loopback Host and Origin gate, not the public same-origin rule", async () => { + const loopbackPort = await freePort(); + saveConfig(baseConfig(loopbackPort)); + const server = startServer(0); + const url = `http://127.0.0.1:${loopbackPort}/v1/models`; + try { + // The kernel refuses remote TCP, but a victim's browser connects locally on an + // attacker's behalf. Under the PUBLIC policy this same-origin shape is allowed; under + // the loopback policy it must not be. + const rebinding = await fetch(url, { headers: { Host: "attacker.example" } }); + expect(rebinding.status).toBe(403); + + const hostileOrigin = await fetch(url, { headers: { Origin: "http://attacker.example" } }); + expect(hostileOrigin.status).toBe(403); + expect(hostileOrigin.headers.get("access-control-allow-origin")).not.toBe("http://attacker.example"); + + const ok = await fetch(url); + expect(ok.status).toBe(200); + } finally { + await server.stop(true); + } + }); + + test("stopping the server closes both listeners", async () => { + const loopbackPort = await freePort(); + saveConfig(baseConfig(loopbackPort)); + const server = startServer(0); + const publicPort = server.port; + await server.stop(true); + + // Both ports must be rebindable. A surviving loopback listener would keep serving + // unauthenticated traffic after shutdown reported success. + for (const port of [publicPort, loopbackPort]) { + const probe = Bun.serve({ port, hostname: "127.0.0.1", fetch: () => new Response("ok") }); + probe.stop(true); + } + }); + + test("a loopback bind failure rolls back the public listener", async () => { + const loopbackPort = await freePort(); + const squatter = Bun.serve({ + port: loopbackPort, + hostname: "127.0.0.1", + fetch: () => new Response("occupied"), + }); + saveConfig(baseConfig(loopbackPort)); + try { + expect(() => startServer(0)).toThrow(); + } finally { + squatter.stop(true); + } + }); +}); + +describe("public port selection avoids the loopback port", () => { + test("an explicit preference for the reserved port is refused rather than taken", async () => { + const reserved = await freePort(); + // Free, yet must not be selected: taking it would bind the public listener onto the + // address the loopback listener is configured for, and the loopback bind would then fail. + await expect(findAvailablePort(reserved, "127.0.0.1", { reservedPort: reserved })) + .rejects.toBeInstanceOf(PortUnavailableError); + }); + + test("ephemeral selection never returns the reserved port", async () => { + const reserved = await freePort(); + for (let i = 0; i < 8; i += 1) { + const selected = await findAvailablePort(0, "127.0.0.1", { reservedPort: reserved }); + expect(selected).not.toBe(reserved); + } + }); + + test("an unreserved preference is still honored", async () => { + const reserved = await freePort(); + const wanted = await freePort(); + if (wanted === reserved) return; + expect(await findAvailablePort(wanted, "127.0.0.1", { reservedPort: reserved })).toBe(wanted); + }); +}); From d2d0e6a6f5efb14b229d8b73d9f7720ec433e038 Mon Sep 17 00:00:00 2001 From: zhouxun Date: Fri, 7 Aug 2026 16:43:56 +0800 Subject: [PATCH 31/48] fix(models): space context window fields (#1073) --- .../1073-context-window-controls.jpg | Bin 0 -> 83175 bytes gui/src/pages/Models.tsx | 2 +- gui/src/styles-models-workspace.css | 6 ++++++ gui/tests/models-provider-head.test.ts | 4 ++++ 4 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 docs-site/public/pr-screenshots/1073-context-window-controls.jpg diff --git a/docs-site/public/pr-screenshots/1073-context-window-controls.jpg b/docs-site/public/pr-screenshots/1073-context-window-controls.jpg new file mode 100644 index 0000000000000000000000000000000000000000..fe157cf923631d0b7334bc67cd4245f5db04d557 GIT binary patch literal 83175 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∨L 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

{t("models.contextHint")}

-
+