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/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/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/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..98a129b7c --- /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 (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 +- `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..28bfff351 --- /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` — another 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..99b9b8290 --- /dev/null +++ b/devlog/_plan/260807_untouched_bug_stack/020_routed_reasoning_effort.md @@ -0,0 +1,153 @@ +# 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. + +## 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. 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/devlog/_plan/260807_untouched_bug_stack/070_mimo_token_plan_preset.md b/devlog/_plan/260807_untouched_bug_stack/070_mimo_token_plan_preset.md new file mode 100644 index 000000000..bc9497e3b --- /dev/null +++ b/devlog/_plan/260807_untouched_bug_stack/070_mimo_token_plan_preset.md @@ -0,0 +1,100 @@ +# 070 — #1158: MiMo token-plan rejects Responses custom tools + +## Defect + +Xiaomi MiMo's paid token-plan endpoint (`https://token-plan-cn.xiaomimimo.com/v1`) +speaks the Responses wire for plain requests but rejects `type: "custom"` tools +with `400 responses_feature_not_supported`. Codex emits custom tools for +`apply_patch` and other freeform tools, so an agentic turn fails on this +provider while a plain chat turn succeeds. + +The reporter confirmed the same account works fully through `openai-chat`. + +Current state, verified on the tree: + +- `src/adapters/openai-responses.ts` filters only model-declared hosted tools; + arbitrary custom tools are serialized unchanged. +- `src/providers/registry.ts:2017` has `xiaomi` (Anthropic wire) and `:2020` + has `mimo-free` (free tier, own adapter). **No token-plan preset exists.** + +So a token-plan user hand-rolls the provider, and because MiMo documents +Responses support they naturally pick `openai-responses` — the one wire that +breaks. + +## Why a preset rather than tool stripping + +Two rejected alternatives, both worse: + +**Strip custom tools for this provider.** `apply_patch` IS a custom tool, so +stripping it disables the Codex agent loop. The user would get a provider that +no longer 400s and no longer edits files. Spark's specialized stripping +(`openai-responses.ts:248-353`) does exactly this and is not a model to follow +here. + +**`modelWireDefaults`.** That mechanism moves individual models between wires. +The known-good wire here is provider-wide, not per-model, so a preset states the +fact directly instead of repeating it per model. + +The Chat path already handles custom tools correctly: `src/responses/parser.ts` +lowers them to `{input: string}` functions and `src/bridge.ts` restores them as +`custom_tool_call`. Nothing needs building — the provider just has to be pointed +at the wire that works. + +## Change + +Add a registry entry beside the existing Xiaomi ones: + +``` +id: "mimo" +label: "Xiaomi MiMo (token plan)" +baseUrl: "https://token-plan-cn.xiaomimimo.com/v1" +adapter: "openai-chat" +authKind: "key" +models: mimo-v2.5-pro, mimo-v2.5 +efforts: low | medium | high per model +effortMap: xhigh/max/ultra -> high +preserveCustomDestination: true +``` + +`preserveCustomDestination` matters: someone may already have a hand-rolled +provider named `mimo`, and without it `routedProviderConfig()` would canonicalize +their base URL onto ours — silently retargeting their key at a different host. +The same hazard the `zhipu-bigmodel` comment documents at `registry.ts:1668`. + +The effort clamp is because MiMo's ladder stops at `high`; forwarding `ultra` +would send a value the provider rejects. + +## Tests + +**Preset shape** — `tests/provider-registry-parity.test.ts`, `MiMo token-plan +preset uses Chat and clamps extended efforts`. Assert the derived key-login +provider's adapter, base URL, and models, and that the effort map collapses the +three extended tiers to `high`. Add the id to `EXPECTED_KEY_PROVIDER_IDS`; the +parity test fails without that, which is the intended gate. + +**Collision preservation** — the shape test does NOT exercise the claim this +plan actually leans on. `preserveCustomDestination` is only consulted when the +configured endpoint, adapter, or auth differs +(`src/providers/registry.ts:2111-2124`), and only then does +`routedProviderConfig()` keep the user's row (`src/router.ts:254-258`). So the +regression has to route, not just inspect metadata: define a pre-existing +provider named `mimo` pointing somewhere else with a different adapter, route +through it, and assert its base URL, adapter, and key are untouched. Follow the +shape of `tests/cline-pass-provider.test.ts:163-183`. + +Without that second test the plan asserts a safety property it never checks — +and silently retargeting an existing user's key at another host is precisely the +failure the `zhipu-bigmodel` comment warns about. + +## Blast radius + +Registry-derived key login, `ocx init`, the provider picker, and catalog +metadata. `xiaomi` and `mimo-free` are untouched — different hosts, different +wires. + +## What this does not do + +It does not make the Responses wire work on this endpoint. If MiMo later accepts +custom tools there, the preset is the thing to revisit. The issue asked for +"preset or guidance"; this is the preset, and the note field carries the +guidance. diff --git a/devlog/_plan/260807_untouched_bug_stack/080_windows_acl_userdomain.md b/devlog/_plan/260807_untouched_bug_stack/080_windows_acl_userdomain.md new file mode 100644 index 000000000..ea786abea --- /dev/null +++ b/devlog/_plan/260807_untouched_bug_stack/080_windows_acl_userdomain.md @@ -0,0 +1,156 @@ +# 080 — #1149: ACL hardening trusts USERDOMAIN + +## Defect + +`currentWindowsUser()` in `src/lib/windows-secret-acl.ts` builds the icacls +principal as: + +```ts +return domain ? `${domain}\\${username}` : username; +``` + +`USERDOMAIN` is essentially always set — on a machine that is not domain-joined +it holds the COMPUTER NAME — so the `domain ? ... : username` branch never +takes the fallback the comment describes. Every machine gets +`DOMAIN\User`, and on a workgroup box that is `COMPUTERNAME\User`. + +That form is not always what the effective token accepts: a renamed computer, a +Microsoft-account login (where the local profile name and the account name +differ), or an AzureAD-joined machine can all produce a principal icacls cannot +resolve. The grant then fails, the harden fails closed, and every native request +returns 503. + +Both environment variables are also writable by the process that launched us, +which makes the principal attacker-influenceable in a permissions path. + +## Change + +Prefer the current user's SID. A SID sidesteps the naming question entirely: it +is what the token actually carries, it is identical in domain and workgroup +cases, it survives a computer rename, and icacls accepts `*S-1-5-21-...` +directly as a principal. + +**Reuse the existing resolver, do not write a new one.** +`src/codex/user-identity.ts:69-75` already resolves +`[WindowsIdentity]::GetCurrent().User.Value` and validates it against +`SID_PATTERN`. An earlier draft of this plan proposed a fresh `whoami /user` +lookup, which would have been strictly worse: an unqualified `whoami` is +resolvable through `PATH`, so a permissions path would have gained an +executable-substitution surface it does not currently have. Extract the shared +resolver rather than duplicating it, and keep the trusted-executable launch. + +Two constraints the extraction must honor: + +- **Charge the lookup against the harden deadline.** The 30s envelope from `030` + is per harden call and a spawn is not free; a lookup outside the budget could + push a call past it. +- **Cache only success.** A failed lookup must be retried, not memoized into a + permanent fallback. + +### Failure is fail-closed, not a guess + +Resolution order is deliberately short: + +1. Shared SID resolver -> `*`. +2. On failure, the existing `USERDOMAIN\USERNAME` form (current behavior). + +An earlier draft put bare `USERNAME` ahead of the qualified form. That is wrong +in a security-relevant way: a bare name resolves ambiguously when a local and a +domain account share it, which is the exact authority-confusion class this fix +exists to remove. The grant ACE is installed before inheritance is removed +(`src/lib/windows-secret-acl.ts:459-465`), so a wrong principal is not a +cosmetic error. + +On a `required: true` harden, a SID failure should fail closed rather than fall +back at all. The qualified fallback exists only for the optional read path, +where the current behavior is already the status quo. + +## What the principal resolves to + +| Case | Before | After | +|---|---|---| +| Domain-joined | `CORP\jane` | jane's SID | +| Workgroup | `DESKTOP-A1\jane` (may fail) | jane's SID | +| Microsoft account | `DESKTOP-A1\jane` (profile name may differ from account) | jane's SID | +| Renamed computer | stale `OLDNAME\jane` | jane's SID | + +## Security posture + +This must not become a way to grant to the WRONG principal. The SID comes from +the effective Windows token (`[WindowsIdentity]::GetCurrent().User.Value`), not +from environment. A malformed or unparseable result is rejected, never coerced. + +Net effect on the environment-variable exposure: the SID path does not read +`USERDOMAIN` or `USERNAME` at all, so the common case stops depending on +writable environment state. + +## Do not relocate the existing resolver — extract a neutral primitive + +`src/codex/user-identity.ts` has the right lookup but the wrong packaging for +this caller, in four specific ways: + +1. It throws `CodexUserIdentityRefusal` (`:33-44`), a domain-specific error the + ACL path has no business catching. +2. It launches an unqualified `powershell.exe` (`:46-60`) — PATH-resolvable, the + same substitution surface that disqualified the `whoami` idea. +3. It has no timeout and no `windowsHide`. +4. It is synchronous, so reusing it inside `hardenSecretPathAsync` + (`src/lib/windows-secret-acl.ts:700-748`) would block the event loop and + defeat the async path. + +So the shared piece is a neutral primitive: SID parsing/validation plus bounded +**sync and async** resolvers that launch an absolute System32 PowerShell path. +`user-identity.ts` keeps translating failures into its own refusal type; the ACL +owner applies its own required/optional policy. Cache successful values only. + +**Do not write a third System32 resolver.** +`resolveTrustedWindowsPowerShellExe()` in `src/lib/windows-elevation.ts:103-138` +already resolves and validates the executable through `GetSystemDirectoryW`. +Reuse it, or lift its trusted-path machinery into a neutral Windows +system-tools module — a fresh `SystemRoot`/PATH lookup would reintroduce exactly +the substitution surface this whole section exists to close. The SID primitive +still needs its own bounded sync/async execution and neutral error type; only +the executable resolution is shared. + +## Timeout must not poison the path memo + +A SID lookup that times out has to be classified distinctly. If it surfaces as +an ordinary `ETIMEDOUT`, `hardenEntry` records the path in `timedOutPaths` +(`src/lib/windows-secret-acl.ts:687-690`) and skips it for the rest of the +process — even though no `icacls` operation timed out and the path itself is +fine. Charge the lookup against the shared deadline, but keep its failure out of +that memo. + +## Test + +`tests/windows-secret-acl.test.ts`, using the existing seams plus an injected +SID resolver: + +- `resolves the ACL principal from the token SID, not USERDOMAIN` — resolver + returns a SID; assert the icacls invocation carries `*S-1-...` and that + `USERDOMAIN` is never consulted. +- `a malformed SID is rejected rather than passed to icacls` — resolver returns + garbage; assert no garbage principal reaches icacls. +- `a required harden fails closed when the SID cannot be resolved` — no bare + username, no guess. +- `an optional harden falls back to the qualified name` — the only place the + legacy `USERDOMAIN\USERNAME` form survives, and its boundary is explicit. +- `a SID lookup timeout does not mark the path as icacls-timed-out` — assert the + path is retryable rather than stuck in `timedOutPaths`. +- `only successful lookups are cached` — a failure must not memoize into a + permanent fallback. +- Both the sync and async harden entry points get coverage; the async one is the + reason a synchronous spawn is unacceptable. + +Note on the memo: `loadConfig` calls three harden wrappers +(`src/config.ts:1759-1764`), but SID resolution only runs on Windows for paths +that exist and are not already memoized, so "runs three times" is an upper +bound rather than the normal case. + +Env isolation follows the `previousAclTimeout` pattern already in +`beforeEach`/`afterEach`. + +## Security review gate + +Same class as `030`: this is credential-permission handling and needs explicit +security review plus `bun run privacy:scan` before the PR leaves draft. diff --git a/devlog/_plan/260807_untouched_bug_stack/081_windows_acl_userdomain_adopt_1180.md b/devlog/_plan/260807_untouched_bug_stack/081_windows_acl_userdomain_adopt_1180.md new file mode 100644 index 000000000..06f7e2cf0 --- /dev/null +++ b/devlog/_plan/260807_untouched_bug_stack/081_windows_acl_userdomain_adopt_1180.md @@ -0,0 +1,167 @@ +# 081 — #1149 재계획: 기여자 PR #1180 채택 + 개선 + +`080` 은 우리가 처음부터 구현하는 전제로 썼다. 그 사이 기여자 PR +[#1180](https://github.com/lidge-jun/opencodex/pull/1180) (`luvs01`, +`agent/fix-windows-acl-effective-sid`, head `df6989c17`) 이 같은 결함을 거의 +같은 설계로 이미 고쳐놨다. 처음부터 다시 쓰는 것은 기여자 저작을 버리는 +행위이고, 우리 계획이 요구한 제약을 그 PR 이 대부분 이미 만족한다. + +## #1180 이 080 의 제약을 어디까지 지켰나 + +| 080 제약 | #1180 | +|---|---| +| `whoami` 신규 작성 금지 | 지킴 — `[WindowsIdentity]::GetCurrent().User.Value` | +| 제3의 System32 리졸버 금지 | 지킴 — `resolveTrustedWindowsPowerShellExe()` 재사용 | +| sync/async 양쪽 | 지킴 — `resolveCurrentWindowsPrincipal{,Async}` | +| SID 타임아웃이 `timedOutPaths` 오염 금지 | 지킴 — 별도 코드 `EACLIDENTITY` | +| 성공만 캐시 | 지킴 — `principalFromResult` 통과 후에만 `cachedPrincipal` | +| harden 예산에서 차감 | 부분 — 남은 예산을 자식 timeout 으로 넘기지만, 실행 파일 리졸브와 spawn 준비는 그 timeout 이 시작되기 전에 일어난다 (아래 D) | +| `required:true` fail-closed | 지킴 | + +`user-identity.ts` 를 직접 재사용하는 대신 저수준 프리미티브를 새로 뽑은 것도 +`080` 의 "extract a neutral primitive" 와 같은 결론이다. 그쪽은 도메인 전용 +예외를 던지고, 무자격 `powershell.exe` 를 띄우며, 타임아웃도 `windowsHide` 도 +없고, 동기 전용이다. + +## 감사에서 뒤집힌 것 — 폴백 복원안 철회 + +이 문서의 첫 판은 optional read path 에 `USERDOMAIN\USERNAME` 폴백을 복원하자고 +했다. 독립 감사가 P1 으로 되돌렸고, 그 논증이 옳다. + +`DOMAIN\User` 라는 **형태**는 그 계정이 현재 토큰의 주체라는 **증거가 아니다**. +두 환경변수 모두 우리를 띄운 프로세스가 쓸 수 있다. 그리고 optional 경로도 +`required` 와 똑같은 파괴적 시퀀스를 돈다: + +``` +/grant:r :(F) ← 이 시점에 잘못된 계정이 Full Control 을 얻는다 +/inheritance:r ← 상속 ACE 를 전부 끊는다 +/remove:g ← Everyone/Users/Authenticated Users 만 지운다 +``` + +공격자가 고른 이름이 다른 실제 사용자로 해석되면 그 사용자의 ACE 가 시크릿에 +남고, 현재 사용자는 방금 끊긴 상속 접근을 잃는다. 고른 이름이 `BUILTIN\Users` +로 해석되면 3단계가 방금 만든 ACE 를 지워서 파일이 접근 불가가 된다. + +"optional 은 status quo 라서 안전하다" 는 논증은 성립하지 않는다. status quo 가 +안전했던 게 아니라, status quo 가 바로 #1149 가 신고한 결함이다. + +**따라서 optional SID 실패는 icacls 를 한 번도 실행하지 않고 끝낸다** — #1180 의 +동작 그대로다. 이름 폴백이 언젠가 필요하다면 환경변수가 아니라 토큰 SID 를 OS +의 신뢰된 API 로 이름 변환하는 별도 권위 경로여야 하고, 그건 이 유닛의 범위가 +아니다. + +## 우리가 얹는 것 + +### (A) 테스트 전용 상수가 프로덕션 파일 한가운데 있다 + +```ts +const FORCED_NON_WINDOWS_TEST_PRINCIPAL = "*S-1-5-21-1-2-3-1001"; + +function currentWindowsPrincipal(deadline: number): string { + if (platformOverride === "win32" && platform !== "win32") { + return FORCED_NON_WINDOWS_TEST_PRINCIPAL; + } + ... +``` + +**이것은 보안 결함이 아니다.** 감사가 정확히 지적한 대로, 프로덕션 Windows 에서는 +`platform !== "win32"` 가 거짓이라 이 분기에 도달할 수 없고, POSIX 에서도 +테스트 전용 setter 를 호출해야 켜진다. 위생 문제이며, 그 이상으로 포장하지 않는다. + +옮기는 진짜 이유는 (B) 다. 합성값이 프로덕션 모듈에 있는 한 실패 주입이 불가능하다. + +이 분기가 필요한 이유 자체는 실재한다. POSIX CI 는 `setPlatformForTests("win32")` +로 ACL 분기를 강제로 돌리는데, 그 호스트에는 PowerShell 도 System32 도 없다. +이미 그렇게 도는 테스트가 7개 파일 30여 곳이다. + +**해결:** 합성 SID 를 `windows-user-principal.ts` 의 테스트 seam 으로 옮기고, +`setPlatformForTests` 가 그 seam 을 켜고 끈다. `windows-secret-acl.ts` 에는 +`FORCED_NON_WINDOWS_TEST_PRINCIPAL` 상수도, 그것을 고르는 분기도 남지 않는다. + +### (B) 실패 경로 테스트가 POSIX CI 에서 통째로 스킵된다 + +```ts +test("identity lookup failure is fail-closed but never memoized as an icacls timeout", () => { + if (process.platform !== "win32") return; +``` + +`timedOutPaths` 오염 금지는 `080` 이 명시적으로 요구한 제약인데, 그것을 지키는 +유일한 테스트가 Linux/macOS 러너에서 한 줄도 실행되지 않는다. 원인은 (A) 다 — +합성 principal 이 runner 보다 먼저 반환하므로 POSIX 에서는 실패를 주입할 방법이 +없었다. + +**해결:** 어느 runner 를 쓸지 고를 때 명시적 override 가 합성값을 이기게 한다. + +``` +runner 선택: explicit override > synthetic(test) > default +성공 캐시: 선택된 경로와 무관하게 그대로 authoritative +``` + +"override 가 캐시보다 먼저" 라는 뜻이 아니다 — 성공한 조회는 여전히 캐시되고 +재사용된다. 바뀌는 것은 캐시가 비어 있을 때 **무엇을 실행하느냐** 뿐이다. +그러면 실패 주입 테스트가 세 플랫폼 전부에서 돈다. 스킵 가드를 제거한다. + +### (C) `required` 경계에서 `EACLIDENTITY` 코드가 소실된다 + +`sanitizedAclError` (`src/lib/windows-secret-acl.ts:557-566`) 는 허용 목록에 든 +코드만 재부착한다: + +```ts +if (code === "ETIMEDOUT" || code === "EICACLS" || code === "EACCES" || code === "EPERM") { + error.code = code; +} +``` + +`EACLIDENTITY` 가 없다. #1180 은 `sanitizeDiagnostics` 에는 케이스를 추가했으므로 +**메시지 문자열**에는 남지만, `required: true` 가 던지는 오류의 `error.code` 는 +`undefined` 다. 호출자가 원인을 프로그램적으로 구분할 수 없다. + +#1180 의 테스트가 이걸 가린다: `.toThrow(/EACLIDENTITY/)` 는 메시지만 본다. + +**해결:** 허용 목록에 `EACLIDENTITY` 를 추가하고, 테스트를 코드 검사로 바꾼다. + +### (D) 예산 caveat 을 문서로 정직하게 남긴다 + +`080` 은 "lookup 을 harden 예산에 차감" 을 요구했다. #1180 은 남은 예산을 자식 +프로세스 timeout 으로 넘기지만, 그 timeout 이 시작되기 전에 두 가지가 일어난다: +`resolveTrustedWindowsPowerShellExe()` 의 `GetSystemDirectoryW` FFI 호출, 그리고 +`Bun.spawn` 반환 이후에야 걸리는 async 타이머. + +통상 작지만 hard bound 는 아니다. 남는 위험은 잘못된 권한 부여가 아니라 — +두 작업 모두 ACL 이 바뀌기 전에 끝난다 — 예산을 조금 넘길 수 있는 가용성 +문제다. 강제하려면 runner 계약과 동기 실행 모델까지 손대야 해서 채택 개선과 +분리한다. 대신 `windows-user-principal.ts` 상단에 caveat 을 명시해서, 다음에 이 +예산을 조이는 사람이 착각하지 않게 한다. + +## 변경 파일 + +- `src/lib/windows-user-principal.ts` — 합성 seam 추가, override 우선순위, 예산 caveat +- `src/lib/windows-secret-acl.ts` — 합성 상수/분기 제거, `EACLIDENTITY` 허용 목록 추가 +- `tests/windows-user-principal.test.ts` — override 우선순위 케이스 +- `tests/windows-secret-acl.test.ts` — 스킵 가드 제거, sync/async × required/optional 행렬 + +## 수용 기준 + +1. `windows-secret-acl.ts` 전체에 `FORCED_NON_WINDOWS_TEST_PRINCIPAL` 문자열도, + 합성 principal 을 고르는 `platformOverride` 분기도 없다 (`rg` 로 확인 가능). +2. SID 실패 + `required: true` → 던져진 오류가 `toMatchObject({ code: "EACLIDENTITY" })` + 를 만족하고, `timedOutSecretPathCountForTests() === 0`, icacls 호출 0회. + **POSIX 러너에서 실제로 실행된다** (스킵 가드 없음). +3. SID 실패 + `required: false` → `{ ok: false, diagnostics }` 반환, icacls 호출 0회, + ACL 변경 없음. 환경변수 폴백 없음. +4. 2·3 이 sync (`hardenSecretPath`) 와 async (`hardenSecretPathAsync`) 양쪽에 + 동일하게 성립한다. +5. ablation — 각각 되돌렸을 때 red 가 되는 테스트를 명시한다: + - (A)+(B) 우선순위를 `synthetic → override` 로 되돌리면: 주입한 실패 runner 가 + 호출되지 않아 `identityCalls === 0` 이 되고, required 하든이 성공해버려 + 기준 2 가 **red**. + - (C) 허용 목록에서 `EACLIDENTITY` 를 빼면: `error.code` 가 `undefined` 가 되어 + 기준 2 의 `toMatchObject` 가 **red**. + - 철회한 환경변수 폴백을 되살리면: 기준 3 의 icacls 호출 0회 assertion 이 + **red**. 이 mutation 을 명시해 두는 이유는, 폴백 철회가 이 유닛에서 가장 + 되돌아오기 쉬운 결정이기 때문이다. + +## 커밋 구성 + +기여자 커밋 `df6989c17` 을 cherry-pick 해서 저작을 보존하고, 그 위에 개선 +커밋을 얹는다. #1180 은 대체 PR 번호를 남기고 close 한다. diff --git a/devlog/_plan/260807_untouched_bug_stack/090_deepseek_502_parked.md b/devlog/_plan/260807_untouched_bug_stack/090_deepseek_502_parked.md new file mode 100644 index 000000000..5d6fb53b6 --- /dev/null +++ b/devlog/_plan/260807_untouched_bug_stack/090_deepseek_502_parked.md @@ -0,0 +1,57 @@ +# 090 — #1176: DeepSeek V4 Flash 502 — parked, NEEDS-REPRO + +Recording why this is NOT being fixed in this stack, because "we looked and +chose not to act" is a different disposition from "nobody looked". + +## What is real + +The 502 is real and the local guard that produces it is identified: + +- `src/server/responses/core.ts` configures 180s total/first-byte and 30s + inter-chunk inactivity for the bounded JSON body read. +- `src/lib/bounded-body.ts` arms the first-byte deadline, then resets to the + 30s inter-chunk deadline after every non-empty chunk. +- A truncated bounded read becomes the reported local 502. +- `src/providers/registry.ts` deliberately routes DeepSeek Responses inbound + through bounded JSON, because native DeepSeek SSE previously omitted or + delayed terminal events (#875). + +## Why we are not fixing it + +The reporter is on v2.10.2, which already contains PR #1088 — the first-byte +deadline fix for the identical symptom in #1065. So this is not a stale package. + +Their trace shows `durationMs: 90241`. That rules out the 180s total deadline +and is consistent with a 30s inter-chunk timeout after an earlier chunk. What it +does NOT show is how many bytes arrived, when the last chunk landed, or whether +the upstream would eventually have completed. + +Three hypotheses remain live and the trace cannot separate them: + +1. A legitimate >30s inter-chunk pause from this model, which our deadline kills. +2. The bounded-JSON route is wrong for this case. +3. A genuine upstream stall that we correctly surface as 502. + +If (3), the "fix" is a regression: we would be removing a guard that is doing +its job. If (1), the fix is a model-scoped inactivity policy — not a bump to the +shared helper default, which has callers in Responses, upstream-error handling, +Kiro, Command Code, auth/quota, images, and web search. + +Raising a timeout because a timeout fired is how a real stall becomes a hang. + +## What would unpark it + +Either would settle it: + +- A reporter capture with byte counts and chunk timings, or +- A controlled direct-upstream run that waits past 30s and shows whether the + same body eventually reaches a valid EOF. + +## Cheap step that makes the next report decisive + +Independent of the fix, the failure is currently indistinguishable from other +truncations. Extending `BoundedBodyResult` with `timeoutPhase` +(`first_byte` | `inter_chunk` | `total`), `receivedBytes`, and `nonEmptyChunks`, +and using them in the 502 message, would mean the NEXT such report arrives +already diagnosed. That is a small observational change with no behavior risk, +and it belongs in its own PR rather than inside a bug-fix stack. 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 한 경우만 남는데 그건 이 이슈가 신고한 것이 아니다. diff --git a/devlog/_plan/260807_untouched_bug_stack/110_context_window_controls.md b/devlog/_plan/260807_untouched_bug_stack/110_context_window_controls.md new file mode 100644 index 000000000..4e55a8598 --- /dev/null +++ b/devlog/_plan/260807_untouched_bug_stack/110_context_window_controls.md @@ -0,0 +1,201 @@ +# 110 — #1073: metadata 없는 프로바이더의 context window 를 GUI 에서 정할 수 없다 + +## 이슈가 요청한 것 + +`/models` 가 id 만 주는 프로바이더(`{"data":[{"id":"gpt-5.6-luna"}]}`)에서 routed +catalog 가 `128000 / 128000 / 115200` 보수 fallback 을 쓴다. 사용자는 그 라우트가 +350K 를 지원한다는 걸 알지만 Models GUI 로 지정할 방법이 없다. + +## 백엔드는 이미 동작한다 + +조사에서 확인된 사실이고, 이게 이 유닛의 범위를 결정한다. + +- `OcxProviderConfig.contextWindow` 와 `modelContextWindows` 는 이미 존재한다 + (`src/types.ts:1178`), provider 스키마가 `.passthrough()` 라 보존된다. +- `configuredContextWindow()` (`src/codex/catalog/provider-fetch.ts:528`) 가 + `modelContextWindows[id] ?? contextWindow` 로 명시값을 고르고, + `applyProviderConfigHints()` (`:551-575`) 가 upstream 값이 있으면 + `Math.min` 으로 낮추고 **없으면 설정값을 그대로 `contextWindow` 로 만든다.** +- `providerContextCaps` 는 별개다. 기존 값이 있을 때만 낮추고 없는 값을 만들지 + 않는다 (`src/providers/context-cap.ts:24`). + +즉 손으로 `config.json` 을 편집하면 오늘도 된다. 이슈는 **backend bug 가 아니라 +enhancement** 다. 라벨이 맞다. + +빠진 것은 두 개다: +- `GET /api/providers` 가 두 필드를 반환하지 않는다 + (`src/server/management/provider-routes.ts:243`) +- PATCH mask 가 두 필드를 인정하지 않는다 (`:90`) + +그래서 Models UI 에는 저장할 경로 자체가 없다. + +## 기여자 PR #1203 을 채택한다 + +[#1203](https://github.com/lidge-jun/opencodex/pull/1203) (`estelledc`, +`fix/1073-context-window-gui`, head `d648818cf`, 3 커밋, +469/-4, 16 파일). +조사 시점에는 `CONFLICTING` 이었으나 저자가 리베이스해서 지금은 `MERGEABLE`. + +접근이 옳다. **catalog derivation 코드를 건드리지 않고** management/API 와 UI +계층에서만 노출한다. `providerContextCaps` 의 ceiling 의미도 그대로 둔다. +6개 로케일과 문서, 스크린샷까지 갖췄다. + +재작성할 구조적 문제는 없다. 국소적인 보정과 테스트가 필요하다. + +### 보정 A — 여러 모델 draft 중 하나만 저장된다 + +`gui/src/pages/Models.tsx` 의 `saveContextSettings()`: + +```ts +const modelWindow = parseContextWindowDraft(contextModelDrafts[contextModelId] ?? ""); +... +if (contextModelId) { + body.modelContextWindows = { [contextModelId]: modelWindow }; +} +``` + +`contextModelDrafts` 는 **모든** 모델의 편집 내용을 들고 있는데 PATCH 에는 +현재 선택된 `contextModelId` 하나만 실린다. 사용자가 모델 A 를 고쳐 입력하고 +B 로 옮겨 고친 뒤 Apply 하면 A 의 변경이 조용히 사라진다. 오류도 경고도 없다. + +PR 의 테스트가 이 동작을 정상값으로 고정하고 있어서 더 나쁘다. + +**해결:** dirty 한 draft 를 전부 보낸다. 값이 바뀌지 않은 모델은 payload 에서 +빼서 불필요한 쓰기를 피한다. + +### 보정 B — 철회 + +첫 판은 "저장 성공 후 refresh 실패가 닫힌 모달의 오류 상태를 쓴다" 고 했다. +감사가 되돌렸고 맞다: `load()` (`gui/src/pages/Models.tsx:301-315`) 는 fetch +오류를 잡아 `false` 를 반환하며 **던지지 않는다.** 그리고 기여자의 3번째 커밋이 +이미 모달을 닫고 성공 피드백을 게시한 뒤에 `load(true)` 를 부른다. +PR 테스트(`gui/tests/models-empty-provider.test.tsx:263-275`)가 refresh 실패 후 +성공 상태 유지까지 검증한다. + +예외 경계를 정리하는 것 자체는 방어적으로 유효하지만, 없는 결함을 고쳤다고 +말할 수는 없다. 기준 10 은 "PR 에서 이미 충족" 으로 기록한다. + +### 보정 B' — 편집하지 않은 모델을 되돌리지 않는다 + +보정 A 를 "현재 `groups` 와 draft 를 비교해서 다르면 dirty" 로 구현하면 새 +결함이 생긴다. 모달이 열린 동안 폴링이나 다른 관리 요청이 모델 A 를 +64K → 96K 로 갱신했는데 사용자는 B 만 고친 경우, 최신 값과 낡은 draft 를 +비교하면 A 도 dirty 로 판정되어 64K 로 되돌린다. 사용자가 건드리지도 않은 +모델을 되돌리는 셈이다. + +**해결:** 두 조건을 **모두** 만족할 때만 보낸다 — 사용자가 그 필드를 건드렸고 +(`touched`), 값이 모달을 열 때의 스냅샷과 다르다. + +둘 중 하나만으로는 부족하다. `touched` 만 보면 "입력했다가 원래 값으로 +되돌린" 경우에 낡은 값을 보내서 그 사이 바뀐 값을 덮는다. 스냅샷 비교만 보면 +사용자가 건드리지도 않은 필드가 dirty 로 잡힌다. + +**provider default 도 같다.** 이게 감사가 두 번째로 잡은 것이다. 첫 구현은 +`contextWindow` 를 항상 payload 에 실었으므로, 모달이 열린 사이 다른 요청이 +default 를 256K → 300K 로 바꿨는데 사용자가 모델만 편집했다면 낡은 256K 가 +300K 를 되돌린다. default 에도 `touched` + 스냅샷 비교를 적용한다. + +모든 편집이 되돌려져 payload 가 비면 PATCH 자체를 보내지 않는다. + +### 보정 D — 기존 override 가 모델 목록에서 사라질 수 있다 + +live discovery 에서 빠지고 `providers..models` 에도 없는 모델에 +`modelContextWindows` 항목만 남아 있으면, draft 에는 들어가지만 +`contextModalModels` 에는 없어서 사용자가 그 값을 보거나 지울 수 없다. +목록에 `Object.keys(group.modelContextWindows ?? {})` 를 합친다. + +### 보정 E — `1e100` 이 정수 검증을 통과한다 + +`Number.isFinite(1e100) && Number.isInteger(1e100)` 는 참이다. management PATCH +(`src/server/management/provider-routes.ts:174,194`) 와 GUI 파서 +(`gui/src/pages/Models.tsx:373-379`) 가 둘 다 통과시킨다. 저장된 뒤 catalog 에 +거대한 수로 직렬화되면 downstream Codex 의 정수 타입이 카탈로그를 거부할 수 +있다. `Number.isSafeInteger` 로 좁힌다. + +### 보정 F — 번역 문서가 새 의미와 모순된다 + +ko/ja/ru/zh-cn 의 provider 문서가 두 필드를 여전히 "상한" 으로만 설명한다. +metadata 가 없을 때 값을 **공급**한다는 의미가 빠져 있어서, 비영어 사용자는 +#1073 의 해법을 정반대로 읽는다. + +### 보정 C — #1073 의 정확한 재현이 테스트에 없다 + +PR 은 management 영속화와 GUI 를 테스트하지만, 이슈가 신고한 그 경로 — +`{data:[{id:"gpt-5.6-luna"}]}` + 명시 350K → catalog `350000/350000/315000` — +를 단언하지 않는다. 구성 요소가 각각 검증돼도 조립된 결과는 별개다. + +`auto_compact_token_limit` 은 `min(floor(contextWindow * 0.9), maxInputTokens)` +(`src/codex/catalog/effort.ts:112`) 이므로 350000 → 315000. + +**단, 테스트를 하나로 쓰면 안 된다.** 감사가 잡은 P1 이다. `modelContextWindows` +로만 350K 를 주면 `?? prov.contextWindow` 를 지워도 per-model 값이 그대로 +선택되어 결과가 변하지 않는다. provider-wide fallback 결함을 놓치는 통과 전용 +테스트가 된다. + +두 케이스로 나눈다: + +1. `contextWindow: 350000` **만** (per-model 없음) → 350K. + `?? prov.contextWindow` 를 지우면 red. +2. provider default 와 **다른** `modelContextWindows[id]` → per-model 우선. + `modelRecordValue(...)` 를 지우면 red. + +fixture 에 `modelMaxInputTokens` 를 두지 않는다. 있으면 +`min(315000, maxInputTokens)` 가 되어 기대값이 달라진다. + +## 변경 파일 + +PR 커밋을 cherry-pick 해서 저작을 보존하고, 그 위에 보정 커밋을 얹는다. + +- `gui/src/pages/Models.tsx` — 보정 A, B', D, E(파서) +- `gui/src/i18n/{en,ko,ja,zh,de,ru}.ts` — no-op 피드백 문구 `models.contextUnchanged` +- `src/server/management/provider-routes.ts` — 보정 E +- `gui/tests/models-empty-provider.test.tsx` — 다중 모델 저장, 중간 refresh 케이스 +- `tests/management-provider-validation.test.ts` — unsafe integer 거부 +- `tests/codex-catalog.test.ts` — 보정 C 의 두 acceptance 테스트 +- `docs-site/src/content/docs/{ko,ja,ru,zh-cn}/reference/configuration/providers.md` — 보정 F + +## 수용 기준 + +1. id-only `/models` + `contextWindow: 350000` **만** → catalog 가 + `350000 / 350000 / 315000`. (fixture 에 `modelMaxInputTokens` 없음) +2. id-only `/models` + provider default 와 다른 `modelContextWindows[id]` → + per-model 값이 이긴다. +3. 설정 없는 id-only 모델은 그대로 `128000 / 128000 / 115200`. +4. upstream 이 64K 를 주면 configured 350K 가 있어도 64K 유지 (`Math.min` 방향). +5. 모델 A 와 B 를 각각 편집한 뒤 한 번의 Apply 로 **둘 다** PATCH 에 실린다. +6. 편집하지 않은 모델은 payload 에 없다. **모달이 열린 동안 A 의 persisted + 값이 바뀌어도** 사용자가 A 를 건드리지 않았으면 여전히 없다. + provider default 도 마찬가지 — 사용자가 default 를 건드리지 않았으면 + 중간에 갱신됐어도 `contextWindow` 가 payload 에 없다. +7. 입력했다가 원래 값으로 되돌린 필드는 payload 에 없다. 모든 편집이 되돌려지면 + PATCH 를 아예 보내지 않는다. +8. `1e100` 같은 unsafe integer 는 management PATCH 가 거부하고, GUI 도 인라인 + 오류를 띄우며 PATCH 를 보내지 않는다. +9. live discovery 에 없지만 `modelContextWindows` 에 있는 모델이 선택 목록에 뜬다. +10. (PR 에서 이미 충족) PATCH 성공 후 refresh 실패해도 성공 피드백 유지. +11. ko/ja/ru/zh-cn 의 provider 문서가 두 필드를 "상한" 만이 아니라 "메타데이터가 + 없을 때 값을 공급" 하는 의미까지 설명한다. `rg` 대조로 확인한다. +12. ablation — 전부 **실제 결함 형태**로 되돌려서 red 를 확인한다. 인위적으로 + 강한 mutant(예: touched 가드까지 제거)는 통과 근거가 되지 못한다: + - `configuredContextWindow` 에서 `?? prov.contextWindow` 제거 → 1 이 red. + - `modelRecordValue(prov.modelContextWindows, id)` 제거 → 2 가 red. + - 보정 A 를 되돌려 선택된 모델만 전송 → 5 가 red. + - **touched 가드는 유지한 채** 비교 대상만 스냅샷 → 라이브 `groups` 로 교체 + → 6·7 이 red. 이걸 잡으려면 "건드렸다가 되돌린 필드 + 그 사이 서버가 값을 + 바꿈" 시나리오가 필요하다. 사용자의 값이 양쪽 모두와 다른 케이스로는 + 두 비교가 같은 답을 내므로 탐지되지 않는다. + - 값 비교를 문자열 비교로 되돌리면 → 7 의 재포맷 케이스가 red. + provider default 와 per-model 이 별개 분기이므로 양쪽 다 케이스가 있어야 한다. + - default 검증을 무조건 실행하도록 되돌리면 → 8 의 untouched-unsafe 케이스가 red. + - management 의 `Number.isSafeInteger` 를 `Number.isInteger` 로 → 8 이 red. + - GUI 파서의 `Number.isSafeInteger` 를 되돌리면 → 8 의 GUI 절반이 red. + - `Object.keys(group.modelContextWindows ?? {})` 를 목록에서 제거 → 9 가 red. + +## GUI 게이트 + +이 PR 은 **실제로 GUI 를 바꾼다.** 저장소 게이트가 요구하는 UI 스크린샷을 +본문에 포함해야 하고, `gui` 언급을 피해서 우회하면 안 된다. PR #1203 이 이미 +`docs-site/public/pr-screenshots/1073-context-window-controls.jpg` 를 갖고 있으므로 +cherry-pick 하면 따라온다. + +`bun run lint:gui` 와 `bun run build:gui` 가 필요하므로 `cd gui && bun install` +선행. 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 000000000..fe157cf92 Binary files /dev/null and b/docs-site/public/pr-screenshots/1073-context-window-controls.jpg differ 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/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index d2ac1dfc8..82b5d2cb6 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -60,8 +60,8 @@ namespace 付き combo または routing-profile alias はその namespace prefi | `models?` | `string[]` |シード/フォールバック モデルのリスト。 `liveModels: false` では、発見されたモデルはこれらのみです。 | | `liveModels?` | `boolean` |開始/同期時にライブ カタログをフェッチします (デフォルトは `true`)。カスタムプロバイダーは `${baseUrl}/models` を使用します。組み込みはレジストリ URL とフィルターを使用する場合があります。 | | `selectedModels?` | `string[]` |検出後のカタログ許可リスト。空でない場合は、それらの ID のみが公開されます。空または省略すると、検出されたすべてのモデルが公開されます。 | -| `contextWindow?` | `number` |プロバイダー全体の Codex に表示されるコンテキストの上限。より小さいライブメタデータが保持されます。 | -| `modelContextWindows?` | `Record` |モデルごとのコンテキストの上限。これらは `contextWindow` をオーバーライドし、より小さなライブ メタデータを生成することはありません。 | +| `contextWindow?` | `number` | アップストリームのメタデータが無い場合に使うプロバイダー全体のコンテキスト値。メタデータがある場合は上限として働き、より小さいライブ値をそのまま残します。Models ダッシュボードでは `providerContextCaps` とは別に設定します。 | +| `modelContextWindows?` | `Record` | モデルごとのコンテキスト値および上限。`contextWindow` より優先され、ウィンドウが不明なら設定値を使い、より小さいライブメタデータがあればそちらが優先されます。 | | `modelInputModalities?` | `Record` | `["text"]` や `["text", "image"]` などのモデルごとの入力ヒント。 | | `modelMaxInputTokens?` | `Record` |カタログの自動圧縮ヒントに使用されるモデルごとの正の最大入力制限。 | | `defaultMaxOutputTokens?` | `number` |クライアントが `max_output_tokens` を省略した場合の、プロバイダー全体の `openai-chat` フォールバック。 | 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/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 77ba9a95f..e10536e21 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -60,8 +60,8 @@ target도 selector로 재사용할 수 없습니다. raw account id와 email은 | `models?` | `string[]` | 시드/폴백 모델 목록입니다. `liveModels: false`이면 이 목록만 발견된 모델로 취급합니다. | | `liveModels?` | `boolean` | 시작 또는 동기화 시 라이브 카탈로그를 가져옵니다. 기본값은 `true`입니다. 사용자 지정 공급자는 `${baseUrl}/models`를 사용하고, 내장은 레지스트리 URL을 사용한 뒤 필터링할 수 있습니다. | | `selectedModels?` | `string[]` | 발견 후 카탈로그 허용 목록입니다. 값이 비어 있지 않으면 그 id만 노출하고, 비어 있거나 생략하면 발견된 모델을 모두 노출합니다. | -| `contextWindow?` | `number` | 공급자 전반의 Codex 표시 컨텍스트 상한입니다. 더 작은 라이브 메타데이터는 그대로 유지합니다. | -| `modelContextWindows?` | `Record` | 모델별 컨텍스트 상한입니다. 이 값은 `contextWindow`를 덮어쓰며, 더 작은 라이브 메타데이터를 절대 올리지 않습니다. | +| `contextWindow?` | `number` | 업스트림 메타데이터가 없을 때 쓰이는 공급자 전반의 컨텍스트 값입니다. 메타데이터가 있으면 상한으로 동작해 더 작은 라이브 값을 그대로 둡니다. Models 대시보드에서 `providerContextCaps`와 별도로 설정합니다. | +| `modelContextWindows?` | `Record` | 모델별 컨텍스트 값이자 상한입니다. `contextWindow`보다 우선하며, 창 크기를 알 수 없으면 설정값을 쓰고 더 작은 라이브 메타데이터가 있으면 그쪽을 따릅니다. | | `modelInputModalities?` | `Record` | `["text"]` 또는 `["text", "image"]` 같은 모델별 입력 힌트입니다. | | `modelMaxInputTokens?` | `Record` | 카탈로그 자동 압축 힌트에 쓰는 양수 모델별 최대 입력 한도입니다. | | `defaultMaxOutputTokens?` | `number` | 클라이언트가 `max_output_tokens`를 생략했을 때 쓰는 공급자 전반의 `openai-chat` 폴백입니다. | diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 970557956..dd6224d6c 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -181,8 +181,9 @@ advertised effort control on those models as proof of upstream-native reasoning - Exposes Cursor Router as `cursor/auto` plus explicit `cursor/auto-cost`, `cursor/auto-balance`, and `cursor/auto-intelligence` entries. Explicit levels are encoded in `requested_model.parameters` while the legacy `cursor/auto` entry retains the account/team default. -- Keeps `cursor/grok-4.5-fast` as a selectable model while sending Cursor's canonical `grok-4.5` - model with separate `effort` and `fast=true` parameters. +- Sends regular `cursor/grok-4.5` tiers with Cursor's exact live-discovery wire ids + (`cursor-grok-4.5-low`, `-medium`, or `-high`). Keeps `cursor/grok-4.5-fast` selectable while + sending the canonical `grok-4.5` model with separate `effort` and `fast=true` parameters. - Cursor-native local filesystem/shell/network execution is denied by default. Explicit `mcpServers` and `desktopExecutor` integrations have separate opt-ins; `nativeLocalExec: "on"` enables the broader built-in executor and bypasses Codex approval/sandbox semantics, and legacy 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/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/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/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/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 99ab4fab1..0cde31c85 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -70,8 +70,8 @@ cross-route credential fallback не существует. Строки API GPT- | `models?` | `string[]` | Seed/fallback-список моделей. При `liveModels: false` это и есть единственный список обнаруженных моделей. | | `liveModels?` | `boolean` | Получать live-каталог на start/sync (по умолчанию `true`). Custom-провайдеры используют `${baseUrl}/models`; built-in могут использовать registry URL и дополнительно фильтровать результат. | | `selectedModels?` | `string[]` | Allowlist каталога после discovery. Непустой список показывает только эти id; пустой или отсутствующий показывает всё, что было обнаружено. | -| `contextWindow?` | `number` | Provider-wide context cap, видимый Codex. Более маленькая live-metadata сохраняется. | -| `modelContextWindows?` | `Record` | Context cap'ы по отдельным моделям. Они перекрывают `contextWindow` и никогда не поднимают более маленькую live-metadata. | +| `contextWindow?` | `number` | Значение контекста для всего провайдера, применяемое когда upstream не отдаёт metadata; при наличии metadata работает как cap и сохраняет более маленькое live-значение. Панель Models настраивает его отдельно от `providerContextCaps`. | +| `modelContextWindows?` | `Record` | Значения и cap'ы контекста по отдельным моделям. Перекрывают `contextWindow`: если окно неизвестно, берётся заданное значение, а более маленькая live-metadata остаётся авторитетной. | | `modelInputModalities?` | `Record` | Подсказки modality по модели, например `["text"]` или `["text", "image"]`. | | `modelMaxInputTokens?` | `Record` | Положительные лимиты max input по моделям, используемые для подсказок auto-compaction в каталоге. | | `defaultMaxOutputTokens?` | `number` | Provider-wide fallback для `openai-chat`, когда клиент не передал `max_output_tokens`. | 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/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index becca0706..00a34e126 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -59,8 +59,8 @@ pool account id(不能是内部 `__main__`),或用 `"@main"` 表示 Codex | `models?` | `string[]` | 种子/回退模型列表。配合 `liveModels: false` 时,这些就是唯一发现到的模型。 | | `liveModels?` | `boolean` | 启动/同步时获取实时目录(默认 `true`)。自定义提供者使用 `${baseUrl}/models`;内置项可能使用注册表 URL 并进行过滤。 | | `selectedModels?` | `string[]` | 发现之后的目录允许列表。非空时只暴露这些 id;为空或省略时则暴露全部发现到的模型。 | -| `contextWindow?` | `number` | 该提供者范围内、对 Codex 可见的上下文上限。会保留更小的实时元数据。 | -| `modelContextWindows?` | `Record` | 按模型设置的上下文上限。它们会覆盖 `contextWindow`,且绝不会抬高更小的实时元数据。 | +| `contextWindow?` | `number` | 上游缺少元数据时使用的提供者级上下文数值;有元数据时作为上限,保留更小的实时数值。Models 面板中与 `providerContextCaps` 分开设置。 | +| `modelContextWindows?` | `Record` | 按模型设置的上下文数值与上限。优先于 `contextWindow`:窗口未知时采用所配置的数值,而更小的实时元数据仍然优先。 | | `modelInputModalities?` | `Record` | 按模型设置的输入提示,例如 `["text"]` 或 `["text", "image"]`。 | | `modelMaxInputTokens?` | `Record` | 正数型、按模型设置的最大输入限制,用于目录自动压缩提示。 | | `defaultMaxOutputTokens?` | `number` | 当客户端省略 `max_output_tokens` 时,`openai-chat` 的提供者级回退值。 | diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index bc23cf5a2..84bdf6eb8 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -465,6 +465,17 @@ 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.contextUnchanged": "Keine Änderungen am Kontextfenster zu speichern.", + "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..7d1c7964e 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -484,6 +484,17 @@ 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.contextUnchanged": "No context window changes to save.", + "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..b569245db 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -473,6 +473,17 @@ 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.contextUnchanged": "保存するコンテキストウィンドウの変更はありません。", + "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..92918b62e 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -476,6 +476,17 @@ 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.contextUnchanged": "저장할 컨텍스트 윈도우 변경이 없습니다.", + "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..b78d79add 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -478,6 +478,17 @@ 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.contextUnchanged": "Нет изменений контекстных окон для сохранения.", + "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..dff991dc6 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -473,6 +473,17 @@ 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.contextUnchanged": "没有需要保存的上下文窗口更改。", + "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..1872503f4 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -73,6 +73,22 @@ const SUBTITLE_TKEY: Record = { routing: "models.subtitle.routing", }; +/** + * Parse a context-window field: a number, `null` for "unset", or `undefined` when the text is + * not usable. Separators are cosmetic, so "64,000" and "64_000" and "64000" are one value. + * + * Safe-integer rather than integer: `Number.isInteger(1e100)` is true, the server rejects it, + * and accepting it here would turn a typo into a round-trip error instead of inline feedback. + * + * Module scope because it closes over nothing — rebuilding it every render is wasted work. + */ +function parseContextWindowDraft(raw: string): number | null | undefined { + const normalized = raw.replace(/[_,\s]/g, ""); + if (!normalized) return null; + const value = Number(normalized); + return Number.isSafeInteger(value) && value > 0 ? value : undefined; +} + export default function Models({ apiBase }: { apiBase: string }) { /* * Tab state. The hash is the source of truth, so refresh, bookmark, and @@ -174,6 +190,24 @@ 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 [contextModelDrafts, setContextModelDrafts] = useState>({}); + // What the modal showed when it opened. Every payload decision compares against THIS, not + // against the live `groups`, because the 10s poll can refresh a value mid-modal: diffing + // against current state would mark an untouched field dirty and revert someone else's change. + const [contextSnapshot, setContextSnapshot] = useState<{ + contextWindow: number | null; + modelContextWindows: Record; + }>({ contextWindow: null, modelContextWindows: {} }); + // Which fields the USER typed into. Touch alone is not enough to send — a value typed and + // then restored is not a change — but it is what makes an untouched field ineligible. + const [contextTouchedModels, setContextTouchedModels] = useState>(new Set()); + const [contextDefaultTouched, setContextDefaultTouched] = useState(false); + 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 +376,123 @@ 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, + // A model that vanished from live discovery can still hold an override. Without this it + // would sit in the drafts map, invisible in the picker, with no way to inspect or clear it. + ...Object.keys(group.modelContextWindows ?? {}), + ])].sort(); + const modelId = modelIds[0] ?? ""; + setContextModalProvider(group.provider); + setContextModalModels(modelIds); + setContextModelId(modelId); + const defaultDraft = group.contextWindow ? String(group.contextWindow) : ""; + const modelDrafts = Object.fromEntries( + Object.entries(group.modelContextWindows ?? {}) + .map(([model, window]) => [model, String(window)]), + ); + setContextDefaultDraft(defaultDraft); + setContextModelDrafts(modelDrafts); + // Canonical numbers, not the raw strings. "64,000" and "64_000" and "64000" are the same + // value, and comparing text would treat a reformat as an edit — then Apply would send a + // stale number over whatever changed while the modal was open. + setContextSnapshot({ + contextWindow: group.contextWindow ?? null, + modelContextWindows: Object.fromEntries( + Object.entries(group.modelContextWindows ?? {}).map(([model, window]) => [model, window]), + ), + }); + setContextTouchedModels(new Set()); + setContextDefaultTouched(false); + setContextError(""); + }; + + const selectContextModel = (modelId: string) => { + setContextModelId(modelId); + }; + + const saveContextSettings = async () => { + if (!contextModalProvider) return; + const providerWindow = parseContextWindowDraft(contextDefaultDraft); + const group = groups.find(candidate => candidate.provider === contextModalProvider); + if (!group) { + setContextError(t("models.contextSaveFailed")); + return; + } + + // A field is sent only when the user touched it AND its value actually differs from what + // the modal opened with. Both halves matter, and each one alone is wrong. + // + // Sending only the selected model — what this did before — silently dropped any model + // edited before switching the picker. No error, no warning, the value just did not save. + // + // Sending everything that differs from the LIVE state is wrong the other way: the 10s poll + // can refresh a field mid-modal, and a stale draft would then look dirty and revert a + // change the user never made. Comparing against the opening snapshot instead means a value + // typed and then restored sends nothing at all. + // Only validate the default when the user touched it. A malformed value inherited from a + // hand-edited config would otherwise block a save that never intended to touch it. + if (contextDefaultTouched && providerWindow === undefined) { + setContextError(t("models.contextInvalid")); + return; + } + const modelWindows: Record = {}; + for (const modelId of contextTouchedModels) { + const draft = contextModelDrafts[modelId] ?? ""; + const parsed = parseContextWindowDraft(draft); + if (parsed === undefined) { + setContextError(t("models.contextInvalid")); + return; + } + // Compare VALUES, not text. Retyping 64000 as "64,000" is not a change. + if (parsed === (contextSnapshot.modelContextWindows[modelId] ?? null)) continue; + modelWindows[modelId] = parsed; + } + const defaultChanged = contextDefaultTouched + && providerWindow !== contextSnapshot.contextWindow; + + // Nothing survived the comparison: every edit was reverted before Apply. Writing an + // unchanged payload would still stamp over concurrent edits. + if (!defaultChanged && Object.keys(modelWindows).length === 0) { + setContextModalProvider(null); + // Not "updated" — nothing was. Saying otherwise would be a small lie the user could + // act on, e.g. believing a value they typed and reverted had been written. + publishFeedback(true, t("models.contextUnchanged")); + return; + } + + setContextSaving(true); + setContextError(""); + try { + const body: Record = {}; + if (defaultChanged) body.contextWindow = providerWindow; + if (Object.keys(modelWindows).length > 0) body.modelContextWindows = modelWindows; + 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")); + } catch (error) { + setContextError(error instanceof Error ? error.message : t("models.contextSaveFailed")); + return; + } finally { + setContextSaving(false); + } + + // Past the write boundary: the values ARE saved. A refresh that fails afterwards is a + // display problem, and reporting it through `contextError` would set an error on a modal + // that is already closed — invisible to the user, and it contradicts the success they just + // saw. Let the ordinary load error surface handle it. + setContextModalProvider(null); + publishFeedback(true, t("models.contextSaved")); + await load(true); + }; // 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 +934,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")} + { + setContextModelDrafts(current => ({ + ...current, + [contextModelId]: event.target.value, + })); + setContextTouchedModels(current => new Set(current).add(contextModelId)); + }} + disabled={contextSaving} + placeholder={t("models.contextAutomatic")} + /> + + + )} +
+ +
+ + +
+
+ + )} + {customModalOpen && (
; enabled: boolean }> = []; + const contextBodies: Array<{ + contextWindow: number | null; + modelContextWindows: Record; + }> = []; + let providerContextWindow: number | undefined = 256_000; + // `retired-model` is deliberately NOT in `ids` and not a configured model: it only exists as + // an override. Without merging the override keys into the picker it would be invisible and + // unclearable, and an assertion using `claude-opus` alone could not tell the difference. + let providerModelContextWindows: Record = { + "claude-opus": 64_000, + "retired-model": 72_000, + }; let failNext = false; let failCatalog = false; let modelFetches = 0; @@ -142,9 +154,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 +221,248 @@ 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"]); + const setValue = Object.getOwnPropertyDescriptor( + testWindow.HTMLInputElement.prototype, + "value", + )!.set!; + await act(async () => { + 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 pickContextModel = async (modelId: string, dialog: HTMLElement = contextDialog) => { + await act(async () => { + dialog.querySelector('button.select-trigger[aria-label="Model"]')!.click(); + }); + const option = [...testWindow.document.querySelectorAll('[role="option"]')] + .find(candidate => candidate.textContent === modelId)!; + await act(async () => option.click()); + }; + await pickContextModel("claude-sonnet"); + expect(contextInputs[1]!.value).toBe(""); + await act(async () => { + setValue.call(contextInputs[1]!, "80000"); + contextInputs[1]!.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + await pickContextModel("claude-opus"); + expect(contextInputs[1]!.value).toBe("100000"); + await pickContextModel("claude-sonnet"); + expect(contextInputs[1]!.value).toBe("80000"); + // An override for a model that live discovery no longer returns must still be selectable, + // or the user can neither see nor clear it. + await pickContextModel("retired-model"); + expect(contextInputs[1]!.value).toBe("72000"); + await pickContextModel("claude-opus"); + const applyContext = [...contextDialog.querySelectorAll("button")] + .find(button => button.textContent === "Apply")!; + await act(async () => { + applyContext.click(); + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + }); + // Both edits must survive. This assertion previously named only `claude-opus`, which + // pinned the defect as correct behaviour: the user typed 80000 into claude-sonnet, moved + // the picker to claude-opus, hit Apply, and the sonnet value vanished with no error. + // + // `gemini-pro` is absent because it was never typed into. The payload follows what the + // user TOUCHED, not what differs from current state — a poll refreshing an untouched + // model mid-modal must not make Apply revert it. + expect(contextBodies.at(-1)).toEqual({ + contextWindow: 350_000, + modelContextWindows: { "claude-opus": 100_000, "claude-sonnet": 80_000 }, + }); + expect(container.querySelector('[role="dialog"][aria-label="Context windows"]')).toBeNull(); + + await act(async () => buttonText("Context windows").click()); + const refreshFailureDialog = container.querySelector('[role="dialog"][aria-label="Context windows"]')!; + failCatalog = true; + // Make an actual edit. Apply now compares against the values the modal opened with, so a + // reopened-and-untouched dialog sends nothing — which would leave this case asserting the + // refresh behaviour of a request that never happened. + const refreshFailureInput = refreshFailureDialog.querySelectorAll("input.input")[0]!; + await act(async () => { + setValue.call(refreshFailureInput, "360000"); + refreshFailureInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + await act(async () => { + [...refreshFailureDialog.querySelectorAll("button")] + .find(button => button.textContent === "Apply")! + .click(); + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + }); + expect(contextBodies).toHaveLength(2); + expect(contextBodies.at(-1)).toEqual({ contextWindow: 360_000 }); + expect(container.querySelector('[role="dialog"][aria-label="Context windows"]')).toBeNull(); + expect(container.textContent).toContain("Context windows updated"); + failCatalog = false; + + // An edit that is typed and then restored is not a change — and neither is retyping the + // same number in a different shape. Comparing raw text instead of parsed values would + // treat "64,000" as an edit and stamp a stale number over whatever else moved. + await act(async () => buttonText("Context windows").click()); + const revertDialog = container.querySelector('[role="dialog"][aria-label="Context windows"]')!; + const revertInput = revertDialog.querySelectorAll("input.input")[0]!; + const openingValue = revertInput.value; + await act(async () => { + setValue.call(revertInput, "999000"); + revertInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + setValue.call(revertInput, openingValue); + revertInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + await act(async () => { + [...revertDialog.querySelectorAll("button")] + .find(button => button.textContent === "Apply")! + .click(); + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + }); + expect(contextBodies).toHaveLength(2); + expect(container.querySelector('[role="dialog"][aria-label="Context windows"]')).toBeNull(); + + await act(async () => buttonText("Context windows").click()); + const reformatDialog = container.querySelector('[role="dialog"][aria-label="Context windows"]')!; + const reformatInput = reformatDialog.querySelectorAll("input.input")[0]!; + const commaFormatted = reformatInput.value.replace(/\B(?=(\d{3})+(?!\d))/g, ","); + await act(async () => { + setValue.call(reformatInput, commaFormatted); + reformatInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + // The per-model branch has its own comparison, so exercise it too: a raw-string mutant + // reverted only there would otherwise slip past the provider-default case above. + await pickContextModel("claude-opus", reformatDialog); + const reformatModelInput = reformatDialog.querySelectorAll("input.input")[1]!; + await act(async () => { + setValue.call( + reformatModelInput, + reformatModelInput.value.replace(/\B(?=(\d{3})+(?!\d))/g, "_"), + ); + reformatModelInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + await act(async () => { + [...reformatDialog.querySelectorAll("button")] + .find(button => button.textContent === "Apply")! + .click(); + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + }); + expect(contextBodies).toHaveLength(2); + + // A value the user never touched must not ride along, even after a real poll refreshed it. + // The poll has to actually run: mutating the mock alone leaves React's `groups` on the + // opening values, and then comparing drafts against LIVE state — the defect — would look + // identical to comparing against the snapshot. + await act(async () => buttonText("Context windows").click()); + const concurrentDialog = container.querySelector('[role="dialog"][aria-label="Context windows"]')!; + providerContextWindow = 300_000; + providerModelContextWindows = { ...providerModelContextWindows, "claude-opus": 96_000 }; + await act(async () => { poll(); await new Promise(resolve => testWindow.setTimeout(resolve, 0)); }); + // Edit ONLY claude-sonnet. The refreshed default and the refreshed claude-opus are both + // untouched, so neither may appear in the payload. + await pickContextModel("claude-sonnet", concurrentDialog); + const concurrentModelInput = concurrentDialog.querySelectorAll("input.input")[1]!; + await act(async () => { + setValue.call(concurrentModelInput, "70000"); + concurrentModelInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + await act(async () => { + [...concurrentDialog.querySelectorAll("button")] + .find(button => button.textContent === "Apply")! + .click(); + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + }); + expect(contextBodies).toHaveLength(3); + expect(contextBodies.at(-1)).toEqual({ modelContextWindows: { "claude-sonnet": 70_000 } }); + + // The precise mutant this defends: keep the `touched` guard but compare against the LIVE + // `groups` instead of the opening snapshot. The cases above cannot see that swap, because + // in each of them the user's value genuinely differs from both. This one does — the user + // touches a field and puts it back, while the server moves underneath. + await act(async () => buttonText("Context windows").click()); + const staleDialog = container.querySelector('[role="dialog"][aria-label="Context windows"]')!; + const staleDefaultInput = staleDialog.querySelectorAll("input.input")[0]!; + const staleOpeningDefault = staleDefaultInput.value; + await act(async () => { + setValue.call(staleDefaultInput, "111000"); + staleDefaultInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + setValue.call(staleDefaultInput, staleOpeningDefault); + staleDefaultInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + await pickContextModel("claude-opus", staleDialog); + const staleModelInput = staleDialog.querySelectorAll("input.input")[1]!; + const staleOpeningModel = staleModelInput.value; + await act(async () => { + setValue.call(staleModelInput, "123000"); + staleModelInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + // Restored, and also reformatted — the value is unchanged either way. + setValue.call(staleModelInput, staleOpeningModel.replace(/\B(?=(\d{3})+(?!\d))/g, "_")); + staleModelInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + // Now the server moves both fields, and the poll lands while the modal is still open. + providerContextWindow = 411_000; + providerModelContextWindows = { ...providerModelContextWindows, "claude-opus": 88_000 }; + await act(async () => { poll(); await new Promise(resolve => testWindow.setTimeout(resolve, 0)); }); + await act(async () => { + [...staleDialog.querySelectorAll("button")] + .find(button => button.textContent === "Apply")! + .click(); + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + }); + // Nothing was written: both fields are back at what the modal opened with. Comparing + // against the refreshed `groups` would have called both dirty and reverted 411K and 88K. + expect(contextBodies).toHaveLength(3); + expect(container.textContent).toContain("No context window changes to save"); + + // A default the user never touches must not block a per-model save, even when the stored + // value is one the validator would reject. Validating it unconditionally would strand + // anyone whose config was hand-edited before the safe-integer bound existed. + providerContextWindow = 1e100; + await act(async () => { poll(); await new Promise(resolve => testWindow.setTimeout(resolve, 0)); }); + await act(async () => buttonText("Context windows").click()); + const unsafeDefaultDialog = container.querySelector('[role="dialog"][aria-label="Context windows"]')!; + await pickContextModel("claude-sonnet", unsafeDefaultDialog); + const unsafeSiblingInput = unsafeDefaultDialog.querySelectorAll("input.input")[1]!; + await act(async () => { + setValue.call(unsafeSiblingInput, "55000"); + unsafeSiblingInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + await act(async () => { + [...unsafeDefaultDialog.querySelectorAll("button")] + .find(button => button.textContent === "Apply")! + .click(); + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + }); + expect(contextBodies.at(-1)).toEqual({ modelContextWindows: { "claude-sonnet": 55_000 } }); + + // `Number.isInteger(1e100)` is true, and the server rejects it. Accepting it in the form + // would turn a typo into a round-trip error instead of inline feedback. + const patchesBeforeUnsafe = contextBodies.length; + await act(async () => buttonText("Context windows").click()); + const unsafeDialog = container.querySelector('[role="dialog"][aria-label="Context windows"]')!; + const unsafeInput = unsafeDialog.querySelectorAll("input.input")[0]!; + await act(async () => { + setValue.call(unsafeInput, "1e100"); + unsafeInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + await act(async () => { + [...unsafeDialog.querySelectorAll("button")] + .find(button => button.textContent === "Apply")! + .click(); + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + }); + // Relative, not absolute: an absolute count silently re-targets whenever a case is added + // above, and the property under test is "this Apply wrote nothing". + expect(contextBodies).toHaveLength(patchesBeforeUnsafe); + expect(container.querySelector('[role="dialog"][aria-label="Context windows"]')).not.toBeNull(); + // The modal staying open is not the point — the user has to be TOLD why. Without this the + // test passes on a silent no-op that looks identical to a hang. + expect(unsafeDialog.textContent).toContain("Context windows must be positive whole numbers"); + await act(async () => { + [...unsafeDialog.querySelectorAll("button")] + .find(button => button.textContent === "Cancel")?.click(); + }); + 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..bfa33beb0 100644 --- a/gui/tests/models-provider-head.test.ts +++ b/gui/tests/models-provider-head.test.ts @@ -30,3 +30,18 @@ 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); + expect(page).toContain('className="models-context-fields"'); + + const css = await Bun.file(new URL("../src/styles-models-workspace.css", import.meta.url)).text(); + expect(css).toMatch(/\.models-context-fields\s*\{[^}]*gap:\s*var\(--space-4\)/s); +}); 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"); diff --git a/scripts/verify-loopback-direct-spawn.mjs b/scripts/verify-loopback-direct-spawn.mjs new file mode 100644 index 000000000..0d48e07e1 --- /dev/null +++ b/scripts/verify-loopback-direct-spawn.mjs @@ -0,0 +1,246 @@ +#!/usr/bin/env node +/** + * Activation evidence for the unauthenticated loopback listener (#1102). + * + * The server-level tests prove admission, the route allowlist, CORS, the bind scope and the + * injected port independently. None of them prove the thing the feature exists for: that a real + * `codex app-server`, spawned the way a third-party host spawns it, reaches the proxy without a + * credential. That seam is between two processes, so no in-process test can stand in for it. + * + * This is deliberately not a `bun test` file. The repository does not depend on `@openai/codex`, + * so a test that silently skips when it is absent would be worse than no test — it would report + * green on machines that never ran it. This script fails loudly instead, and its output is the + * evidence attached to the PR. + * + * The oracle is a routed model whose id is generated at run time. Codex caches model lists and + * falls back to a bundled catalog when a refresh fails, so asking "did model/list succeed" proves + * nothing — a broken `/v1/models` looks identical to a working one. A name no bundled catalog can + * contain can only have come through our listener. + * + * Usage: node scripts/verify-loopback-direct-spawn.mjs + */ +import { spawn, spawnSync } from "node:child_process"; +import http from "node:http"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; + +const UNIQUE_MODEL = `ocx-direct-spawn-${randomUUID()}`; +const steps = []; +function record(name, ok, detail) { + steps.push({ name, ok, detail }); + console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`); +} + +function resolveCodexEntrypoint() { + // The resolved entrypoint, never `codex` from PATH: the whole defect is that PATH may hold the + // generated shim, which exports the token and would make this pass for the wrong reason. + const probe = spawnSync(process.execPath, [ + "-e", + "process.stdout.write(require.resolve('@openai/codex/bin/codex.js'))", + ], { encoding: "utf8" }); + if (probe.status === 0 && probe.stdout.trim()) return probe.stdout.trim(); + const which = spawnSync("readlink", ["-f", spawnSync("which", ["codex"], { encoding: "utf8" }).stdout.trim()], { encoding: "utf8" }); + const path = which.stdout.trim(); + if (!path) throw new Error("cannot resolve @openai/codex/bin/codex.js"); + return path; +} + +async function freePort() { + return await new Promise((resolve, reject) => { + const probe = createServer(); + probe.once("error", reject); + probe.once("listening", () => { + const { port } = probe.address(); + probe.close(() => resolve(port)); + }); + probe.listen({ port: 0, host: "127.0.0.1" }); + }); +} + +/** A stand-in proxy: serves the loopback listener's four routes and records what Codex asked for. */ +function startFakeProxy(port, seen) { + return new Promise(resolve => { + const srv = http.createServer((req, res) => { + seen.push(`${req.method} ${req.url}`); + if (req.url.startsWith("/v1/responses")) { + res.writeHead(500, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: { message: "stub upstream" } })); + return; + } + if (req.url.startsWith("/v1/models")) { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ + object: "list", + data: [{ id: UNIQUE_MODEL, object: "model", created: 0, owned_by: "opencodex" }], + })); + return; + } + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: { message: "not found" } })); + }); + srv.listen(port, "127.0.0.1", () => resolve(srv)); + }); +} + +async function main() { + const entrypoint = resolveCodexEntrypoint(); + record("resolved the real Codex entrypoint, not PATH", true, entrypoint); + + const version = spawnSync(process.execPath, [entrypoint, "--version"], { encoding: "utf8" }); + record("entrypoint runs", version.status === 0, version.stdout.trim() || version.stderr.trim()); + + const home = mkdtempSync(join(tmpdir(), "ocx-direct-spawn-")); + const codexHome = join(home, ".codex"); + const port = await freePort(); + const seen = []; + const proxy = await startFakeProxy(port, seen); + + try { + // The provider block `ocx sync` writes when the loopback listener is enabled: loopback host, + // the listener's port, and NO env_http_headers — the app-server has no token to put in one. + // + // The catalog file matters and is easy to get wrong. `model/list` reads `model_catalog_json`; + // it does not call the provider's `/v1/models`. A first version of this script omitted the + // catalog and watched Codex return its five bundled ids while never touching the listener — + // which is exactly the false-negative shape the unique-id oracle exists to expose, just + // pointed at the harness instead of the feature. + mkdirSync(codexHome, { recursive: true }); + // Build the catalog with OUR OWN serializer rather than a hand-written object. Codex rejects + // the whole file on any schema mismatch and silently falls back to its bundled list, so a + // hand-rolled fixture drifts into a false negative the moment the schema moves. Using + // `buildCatalogEntries` also means this script exercises the same bytes `ocx sync` writes. + const catalogPath = join(codexHome, "opencodex-models.json"); + const build = spawnSync("bun", ["-e", ` + const { buildCatalogEntries } = await import("./src/codex/catalog/sync.ts"); + const entries = buildCatalogEntries(null, [], [{ + provider: "opencodex", + id: ${JSON.stringify(UNIQUE_MODEL)}, + contextWindow: 128000, + }]); + process.stdout.write(JSON.stringify({ models: entries })); + `], { cwd: process.cwd(), encoding: "utf8" }); + if (build.status !== 0 || !build.stdout.trim()) { + record("built the catalog with our own serializer", false, (build.stderr || "").slice(0, 400)); + throw new Error("catalog build failed"); + } + writeFileSync(catalogPath, build.stdout, "utf-8"); + record("built the catalog with our own serializer", true, `${JSON.parse(build.stdout).models.length} entries`); + writeFileSync(join(codexHome, "config.toml"), [ + `model = "${UNIQUE_MODEL}"`, + 'model_provider = "opencodex"', + `model_catalog_json = ${JSON.stringify(catalogPath)}`, + "", + "[model_providers.opencodex]", + 'name = "OpenCodex Proxy"', + `base_url = "http://127.0.0.1:${port}/v1"`, + 'wire_api = "responses"', + "requires_openai_auth = true", + "", + ].join("\n"), "utf-8"); + record("wrote an isolated CODEX_HOME with no models_cache.json", true, codexHome); + + const env = { ...process.env, CODEX_HOME: codexHome }; + // The credential must be absent, or this would prove nothing about the shim-less path. + delete env.OPENCODEX_API_AUTH_TOKEN; + record("stripped OPENCODEX_API_AUTH_TOKEN from the child environment", true); + + const child = spawn(process.execPath, [entrypoint, "app-server"], { + env, + stdio: ["pipe", "pipe", "pipe"], + }); + + let buffered = ""; + const responses = new Map(); + child.stdout.on("data", chunk => { + buffered += chunk.toString(); + let index; + while ((index = buffered.indexOf("\n")) >= 0) { + const line = buffered.slice(0, index).trim(); + buffered = buffered.slice(index + 1); + if (!line) continue; + try { + const message = JSON.parse(line); + if (message.id !== undefined) responses.set(message.id, message); + } catch { /* notifications and logs are not our concern */ } + } + }); + const stderr = []; + child.stderr.on("data", chunk => stderr.push(chunk.toString())); + + const send = payload => child.stdin.write(`${JSON.stringify(payload)}\n`); + const await_ = async (id, timeoutMs = 30_000) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (responses.has(id)) return responses.get(id); + await new Promise(r => setTimeout(r, 50)); + } + return null; + }; + + send({ id: 1, method: "initialize", params: { clientInfo: { name: "ocx-verify", version: "1", title: "OpenCodex verification" } } }); + const init = await await_(1); + record("app-server initialized", !!init && !init.error, init?.error ? JSON.stringify(init.error) : "ok"); + + send({ id: 2, method: "model/list", params: {} }); + const list = await await_(2, 45_000); + const models = list?.result?.items ?? list?.result?.models ?? list?.result?.data ?? []; + const ids = models.map(m => m?.id ?? m?.model ?? m?.slug).filter(Boolean); + // Routed models are namespaced `/` in the catalog, so match on the unique + // suffix rather than a bare equality that would fail for a correct result. + const sawUnique = ids.some(id => id === UNIQUE_MODEL || id.endsWith(`/${UNIQUE_MODEL}`)); + record( + "model/list contains the unique routed model that only our listener can supply", + sawUnique, + sawUnique ? UNIQUE_MODEL : `saw ${ids.length} ids, none matching (${ids.slice(0, 6).join(", ")})`, + ); + + const hitModels = seen.some(entry => entry.includes("/v1/models")); + // `model/list` reads the catalog file, so it does NOT prove a network hop. The turn does: + // it opens `/v1/responses` against the injected base_url, and reaching our listener there + // without a credential is the whole claim of #1102. + send({ + id: 3, + method: "thread/start", + params: { cwd: home, model: UNIQUE_MODEL, provider: "opencodex" }, + }); + const started = await await_(3, 30_000); + const threadId = started?.result?.threadId ?? started?.result?.thread?.id; + record("thread/start accepted", !!threadId, threadId ? String(threadId) : JSON.stringify(started?.error ?? started).slice(0, 200)); + + if (threadId) { + send({ + id: 4, + method: "turn/start", + params: { threadId, input: [{ type: "text", text: "ping" }] }, + }); + // The upstream is a stub, so the turn is expected to FAIL. What matters is that the + // request arrived at all: a 401 at admission would never reach the handler. + await await_(4, 25_000); + } + + const hitResponses = seen.some(entry => entry.includes("/v1/responses")); + record( + "the app-server reached the loopback listener without a credential", + hitModels || hitResponses, + seen.slice(0, 8).join(" | ") || "no requests observed", + ); + + child.kill(); + if (stderr.length && !sawUnique) console.log("\nchild stderr:\n" + stderr.join("").slice(0, 2000)); + } finally { + proxy.close(); + rmSync(home, { recursive: true, force: true }); + } + + const failed = steps.filter(step => !step.ok); + console.log(`\n${steps.length - failed.length}/${steps.length} checks passed`); + process.exit(failed.length === 0 ? 0 : 1); +} + +main().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/src/adapters/cursor/effort-map.ts b/src/adapters/cursor/effort-map.ts index a7c0b76e4..1e937b310 100644 --- a/src/adapters/cursor/effort-map.ts +++ b/src/adapters/cursor/effort-map.ts @@ -126,3 +126,14 @@ export function cursorWireModelIdWithEffort(baseModelId: string, effortSuffix: s } return `${baseModelId}-${effortSuffix}`; } + +/** + * Compose the exact flattened id sent by AgentService/Run. Discovery normalizes Cursor's optional + * `cursor-` prefix only for catalog matching, but regular Grok 4.5 requests require that prefix on + * the wire. Keep this separate from {@link cursorWireModelIdWithEffort} so discovery can continue + * comparing canonical, prefix-free ids. Grok Fast uses requested_model parameters instead. + */ +export function cursorRequestWireModelIdWithEffort(baseModelId: string, effortSuffix: string): string { + const flattened = cursorWireModelIdWithEffort(baseModelId, effortSuffix); + return baseModelId === "grok-4.5" ? `cursor-${flattened}` : flattened; +} diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index a28a32acb..b60f49fff 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -10,7 +10,7 @@ import type { import { isAllowedToolChoice, namespacedToolName, toolChoiceAliases, type OcxTool, type OcxToolChoice } from "../../types"; import type { CursorRequestMessage, CursorRequestedModelParameter, CursorRunRequest } from "./types"; import { cursorWireModelSelection, type CursorRoutingLevel } from "./discovery"; -import { cursorEffortSuffix, cursorWireModelIdWithEffort } from "./effort-map"; +import { cursorEffortSuffix, cursorRequestWireModelIdWithEffort } from "./effort-map"; import { cursorMcpToolEncodedSize, cursorMcpToolsEncodedSize, @@ -151,7 +151,7 @@ function normalizeCursorModelId(modelId: string, reasoning?: string): { ], }; } - return { ...selection, modelId: suffix ? cursorWireModelIdWithEffort(id, suffix) : id }; + return { ...selection, modelId: suffix ? cursorRequestWireModelIdWithEffort(id, suffix) : id }; } function contentPartToText(part: OcxContentPart | OcxAssistantContentPart): string | undefined { 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/cli/index.ts b/src/cli/index.ts index e9f3c22c3..ace0ee130 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -147,6 +147,18 @@ async function chooseListenPort(requestedPort?: number): Promise { const config = loadConfig(); const preferred = requestedPort ?? config.port ?? 10100; const hardPin = requestedPort !== undefined && requestedPort > 0; + const reservedLoopbackPort = config.unauthenticatedLoopbackListener?.enabled + ? config.unauthenticatedLoopbackListener.port + : undefined; + // Before the reclaim path, not after (#1102). Asking for the port the loopback listener is + // configured to bind is a configuration mistake, and reclaim would spend up to 60 seconds + // waiting for a socket to free before reporting "port is busy" — the wrong diagnosis for a + // collision the config can state outright. + if (reservedLoopbackPort !== undefined && preferred === reservedLoopbackPort) { + throw new Error( + `Port ${preferred} is reserved for unauthenticatedLoopbackListener; choose a different proxy port.`, + ); + } // Soft start: brief prefer-retry then ephemeral hop. // Explicit `--port` (service wrappers / update restart): wait for the pinned port // to free without killing any listener (healthy ocx / foreign). Never hop. @@ -170,6 +182,11 @@ 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. + ...(reservedLoopbackPort !== undefined ? { reservedPort: reservedLoopbackPort } : {}), }); if (preferred > 0 && selected !== preferred) { console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`); 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..f2587d4c8 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,52 @@ 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; + // `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) { + 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/lib/sse-decoder.ts b/src/lib/sse-decoder.ts index a0352157e..c76623d07 100644 --- a/src/lib/sse-decoder.ts +++ b/src/lib/sse-decoder.ts @@ -13,6 +13,47 @@ 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); + // 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); +} + +/** + * 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; + // 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; +} + /** * Decode text/event-stream records across arbitrary fetch chunk boundaries. * diff --git a/src/lib/windows-secret-acl.ts b/src/lib/windows-secret-acl.ts index f1b72bce8..0ac11927a 100644 --- a/src/lib/windows-secret-acl.ts +++ b/src/lib/windows-secret-acl.ts @@ -31,6 +31,11 @@ import { existsSync, statSync } from "node:fs"; import { env, platform } from "node:process"; +import { + resolveCurrentWindowsPrincipal, + resolveCurrentWindowsPrincipalAsync, + setSyntheticWindowsPrincipalForTests, +} from "./windows-user-principal"; const hardenedDirectories = new Map(); const hardenedPaths = new Map(); @@ -229,9 +234,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; @@ -322,9 +341,21 @@ export function setAsyncIcaclsRunnerForTests(runner: AsyncIcaclsRunner | null): asyncIcaclsRunner = runner ?? defaultAsyncIcaclsRunner; } -/** Test seam: force the platform gate (e.g. "win32") so CI on POSIX reaches the runner. */ +/** + * Test seam: force the platform gate (e.g. "win32") so CI on POSIX reaches the runner. + * + * Faking win32 on a host without System32 also has to supply a principal, or + * every forced-branch test would fail on the identity lookup instead of + * exercising icacls. The synthetic value is registered with the resolver, not + * chosen here, so a test that injects its own runner still wins. + */ +const SYNTHETIC_TEST_PRINCIPAL = "*S-1-5-21-1-2-3-1001"; + export function setPlatformForTests(value: string | null): void { platformOverride = value; + setSyntheticWindowsPrincipalForTests( + value === "win32" && platform !== "win32" ? SYNTHETIC_TEST_PRINCIPAL : null, + ); } /** Test seam: injectable clock for deadline tests (no real sleeps). */ @@ -394,17 +425,26 @@ function icaclsError(step: string, result: IcaclsResult): NodeJS.ErrnoException } /** - * Return the current Windows username from the environment. - * Falls back to USERDOMAIN\USERNAME if USERNAME alone is ambiguous. - * The value is used directly in icacls arguments, so it must be present. + * The ACL principal is the effective token SID and nothing else. + * + * There is no name-shaped fallback here, and that absence is the fix for #1149 + * rather than an omission. `USERDOMAIN\USERNAME` has the right shape but is not + * evidence of the current token's subject, and both variables are writable by + * the process that launched us. Granting Full Control to a wrong principal and + * then running `/inheritance:r` is destructive in both directions: another + * account can be left holding the secret, or the file can be left with no ACE + * the current user can use. When the SID cannot be resolved we decline. + * + * Non-Windows hosts that force this branch through `setPlatformForTests` get + * their principal from `setSyntheticWindowsPrincipalForTests`, which lives with + * the resolver so an injected runner can still take precedence over it. */ -function currentWindowsUser(): string | undefined { - const username = env["USERNAME"]; - const domain = env["USERDOMAIN"]; - if (!username) return undefined; - // USERDOMAIN is the machine/domain name; USERNAME is the account name. - // icacls accepts "DOMAIN\User" or just "User" for local accounts. - return domain ? `${domain}\\${username}` : username; +function currentWindowsPrincipal(deadline: number): string { + return resolveCurrentWindowsPrincipal(deadline - nowFn()); +} + +async function currentWindowsPrincipalAsync(deadline: number): Promise { + return resolveCurrentWindowsPrincipalAsync(deadline - nowFn()); } /** @@ -424,10 +464,7 @@ function grantAce(user: string, directory: boolean): string { } function runIcacls(targetPath: string, directory: boolean, deadline: number): void { - const user = currentWindowsUser(); - if (!user) { - throw new Error("Cannot determine current Windows user for ACL hardening"); - } + const principal = currentWindowsPrincipal(deadline); // The deadline is owned by hardenEntry (total budget incl. retry + verification). const run = (step: string, args: string[]): IcaclsResult => { @@ -444,7 +481,7 @@ function runIcacls(targetPath: string, directory: boolean, deadline: number): vo // Step 1: grant current user full control BEFORE any destructive ACL change. // If this fails, inheritance is untouched and the writer keeps inherited access. - runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(user, directory)]); + runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(principal, directory)]); // Step 2: disable inheritance and remove inherited ACEs. The explicit owner ACE // from step 1 survives this transition, so a later failure still leaves cleanup access. @@ -473,10 +510,7 @@ function runIcacls(targetPath: string, directory: boolean, deadline: number): vo /** Async counterpart of runIcacls — same step order and timeout/error classification (#612). */ async function runIcaclsAsync(targetPath: string, directory: boolean, deadline: number): Promise { - const user = currentWindowsUser(); - if (!user) { - throw new Error("Cannot determine current Windows user for ACL hardening"); - } + const principal = await currentWindowsPrincipalAsync(deadline); const run = async (step: string, args: string[]): Promise => { const remaining = deadline - nowFn(); @@ -490,7 +524,7 @@ async function runIcaclsAsync(targetPath: string, directory: boolean, deadline: if (!result.success) throw icaclsError(step, result); }; - await runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(user, directory)]); + await runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(principal, directory)]); await runOrThrow("/inheritance:r", [targetPath, "/inheritance:r"]); const removal = await run("/remove:g", [targetPath, "/remove:g", ...BROAD_SIDS]); @@ -524,6 +558,8 @@ function sanitizeDiagnostics(error: unknown): string { return `ACL hardening failed (${code}) — permission denied running icacls`; case "EICACLS": return "ACL hardening failed (EICACLS) — icacls command error; filesystem may not support per-user NTFS ACLs"; + case "EACLIDENTITY": + return "ACL hardening failed (EACLIDENTITY) — the effective Windows account SID could not be resolved"; default: return `ACL hardening failed${code ? ` (${code})` : ""} — filesystem may not support per-user NTFS ACLs`; } @@ -540,7 +576,17 @@ function sanitizedAclError(diagnostics: string, cause: unknown): NodeJS.ErrnoExc const code = cause && typeof cause === "object" && "code" in cause ? String((cause as { code?: unknown }).code) : ""; - if (code === "ETIMEDOUT" || code === "EICACLS" || code === "EACCES" || code === "EPERM") { + // EACLIDENTITY belongs here for the same reason as the rest: a caller that + // catches a required-mode failure has to tell "the SID could not be resolved" + // apart from "icacls stalled". Without it the code was dropped and only the + // message carried the cause, which no caller can branch on. + if ( + code === "ETIMEDOUT" || + code === "EICACLS" || + code === "EACCES" || + code === "EPERM" || + code === "EACLIDENTITY" + ) { error.code = code; } return error; diff --git a/src/lib/windows-user-principal.ts b/src/lib/windows-user-principal.ts new file mode 100644 index 000000000..93da3efd5 --- /dev/null +++ b/src/lib/windows-user-principal.ts @@ -0,0 +1,283 @@ +/** + * Resolve the effective Windows token to the locale-independent SID form that + * icacls accepts ("*S-1-..."). Environment values such as USERDOMAIN are not + * an authority for the current token: on workgroup machines USERDOMAIN may be + * the literal WORKGROUP even though the account belongs to the local computer. + * + * There is deliberately NO name-shaped fallback. A `DOMAIN\User` string has a + * valid shape, but shape is not evidence that the account is the current + * token's subject, and both environment variables are writable by whatever + * launched us. A wrong principal here is not cosmetic: `runIcacls` grants it + * Full Control and then removes inheritance, so a wrong grant either leaves a + * different account holding the secret or strands the file with no usable ACE. + * When the SID cannot be resolved, the caller declines to touch the ACL at all. + * + * Budget caveat: callers pass their REMAINING harden budget, which becomes the + * child process timeout. Trusted-executable resolution (a `GetSystemDirectoryW` + * FFI call) and spawn setup happen before that timeout starts, and the async + * timer only arms once `Bun.spawn` returns. Both are small in practice, but the + * lookup is not bounded by the deadline to the microsecond. Tightening that + * would mean passing an absolute deadline through the runner interface. + */ + +import { resolveTrustedWindowsPowerShellExe } from "./windows-elevation"; + +const SID_PATTERN = /^S-1-(?:\d+-)+\d+$/i; +const SID_EXPRESSION = + "[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value"; + +export interface WindowsPrincipalLookupResult { + success: boolean; + exitCode: number | null; + timedOut: boolean; + stdout: string; +} + +export type WindowsPrincipalRunner = ( + timeoutMs: number, +) => WindowsPrincipalLookupResult; + +export type AsyncWindowsPrincipalRunner = ( + timeoutMs: number, +) => Promise; + +const POWERSHELL_ARGS = [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-Command", + SID_EXPRESSION, +] as const; + +function windowsPrincipalPowerShellCommand(): string[] { + return [resolveTrustedWindowsPowerShellExe(), ...POWERSHELL_ARGS]; +} + +/** Test-only readback of the exact trusted executable and static arguments. */ +export function windowsPrincipalPowerShellCommandForTests(): string[] { + return windowsPrincipalPowerShellCommand(); +} + +function defaultWindowsPrincipalRunner(timeoutMs: number): WindowsPrincipalLookupResult { + const result = Bun.spawnSync(windowsPrincipalPowerShellCommand(), { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + timeout: Math.max(1, timeoutMs), + windowsHide: true, + }); + return { + success: result.success, + exitCode: result.exitCode, + timedOut: result.exitedDueToTimeout ?? false, + stdout: result.stdout ? result.stdout.toString() : "", + }; +} + +async function defaultAsyncWindowsPrincipalRunner( + timeoutMs: number, +): Promise { + const proc = Bun.spawn(windowsPrincipalPowerShellCommand(), { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + windowsHide: true, + }); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + try { proc.kill(); } catch { /* already exited */ } + }, Math.max(1, timeoutMs)); + let exitCode: number | null = null; + try { + exitCode = await proc.exited; + } finally { + clearTimeout(timer); + } + const stdout = proc.stdout + ? await new Response(proc.stdout).text().catch(() => "") + : ""; + return { + success: !timedOut && exitCode === 0, + exitCode: timedOut ? null : exitCode, + timedOut, + stdout, + }; +} + +let principalRunner: WindowsPrincipalRunner = defaultWindowsPrincipalRunner; +let asyncPrincipalRunner: AsyncWindowsPrincipalRunner = defaultAsyncWindowsPrincipalRunner; +let cachedPrincipal: string | null = null; +let asyncLookupInFlight: Promise | null = null; + +/** + * POSIX CI drives the Windows ACL branch through `setPlatformForTests("win32")`, + * on hosts that have neither System32 nor PowerShell. Those runs need SOME + * principal, so this seam supplies a synthetic one. + * + * It lives here rather than in `windows-secret-acl.ts` for one reason that is + * not cosmetic: an explicitly injected runner must be able to beat it. When the + * synthetic value was chosen first, in the ACL module, a test could not inject a + * lookup FAILURE on POSIX at all — so the fail-closed and memo-isolation cases + * were guarded with `if (process.platform !== "win32") return;` and never ran + * outside Windows. Resolution order below is what makes those cases executable + * on every runner. + */ +let syntheticPrincipalForTests: string | null = null; + +/** + * Test seam: supply the principal used when no runner was injected and the host + * is not really Windows. Pass null to disable. + */ +export function setSyntheticWindowsPrincipalForTests(principal: string | null): void { + syntheticPrincipalForTests = principal; + cachedPrincipal = null; +} + +/** True when an explicit runner override is installed and must take precedence. */ +function hasSyncRunnerOverride(): boolean { + return principalRunner !== defaultWindowsPrincipalRunner; +} + +function hasAsyncRunnerOverride(): boolean { + return asyncPrincipalRunner !== defaultAsyncWindowsPrincipalRunner; +} + +function identityError(reason: string): NodeJS.ErrnoException { + const error = new Error(`Windows effective-account SID lookup ${reason}`) as NodeJS.ErrnoException; + // Keep identity lookup failures distinct from icacls timeouts. In particular, + // they must not populate windows-secret-acl's destination timeout memo. + error.code = "EACLIDENTITY"; + return error; +} + +function principalFromResult(result: WindowsPrincipalLookupResult): string { + if (!result.success) { + throw identityError(result.timedOut + ? "timed out" + : `exited ${result.exitCode ?? "null"}`); + } + const sid = result.stdout.trim(); + if (!SID_PATTERN.test(sid)) { + throw identityError(sid ? "returned an invalid SID" : "returned an empty SID"); + } + return `*${sid.toUpperCase()}`; +} + +/** Resolve and process-cache the effective token SID for synchronous ACL paths. */ +export function resolveCurrentWindowsPrincipal(timeoutMs: number): string { + // Order matters: an explicitly injected runner outranks the synthetic value, + // so a test can inject a FAILURE on a POSIX host. See the seam comment above. + if (hasSyncRunnerOverride()) { + if (cachedPrincipal) return cachedPrincipal; + if (timeoutMs <= 0) throw identityError("had no remaining deadline"); + let overridden: WindowsPrincipalLookupResult; + try { + overridden = principalRunner(timeoutMs); + } catch { + throw identityError("could not start"); + } + const principal = principalFromResult(overridden); + cachedPrincipal = principal; + return principal; + } + if (cachedPrincipal) return cachedPrincipal; + if (syntheticPrincipalForTests) return syntheticPrincipalForTests; + if (timeoutMs <= 0) throw identityError("had no remaining deadline"); + let result: WindowsPrincipalLookupResult; + try { + result = principalRunner(timeoutMs); + } catch { + throw identityError("could not start"); + } + const principal = principalFromResult(result); + cachedPrincipal = principal; + return principal; +} + +async function waitForExistingLookup( + lookup: Promise, + timeoutMs: number, +): Promise { + if (timeoutMs <= 0) throw identityError("had no remaining deadline"); + let timer: ReturnType | undefined; + try { + return await Promise.race([ + lookup, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(identityError("timed out while awaiting the shared lookup")), + Math.max(1, timeoutMs), + ); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +/** + * Async counterpart. Concurrent callers share one owned child lookup; a later + * caller may exhaust its own budget without cancelling the lookup owned by the + * first caller. The first caller owns that child and its process timeout; a + * later, longer budget deliberately does not extend an already-running child. + */ +export async function resolveCurrentWindowsPrincipalAsync(timeoutMs: number): Promise { + const overridden = hasAsyncRunnerOverride(); + if (cachedPrincipal) return cachedPrincipal; + if (asyncLookupInFlight) return waitForExistingLookup(asyncLookupInFlight, timeoutMs); + // Same precedence rule as the sync path: an injected runner beats the synthetic. + if (!overridden && syntheticPrincipalForTests) return syntheticPrincipalForTests; + if (timeoutMs <= 0) throw identityError("had no remaining deadline"); + + const lookup = (async (): Promise => { + let result: WindowsPrincipalLookupResult; + try { + result = await asyncPrincipalRunner(timeoutMs); + } catch { + throw identityError("could not start"); + } + const principal = principalFromResult(result); + cachedPrincipal = principal; + return principal; + })(); + asyncLookupInFlight = lookup; + try { + return await lookup; + } finally { + if (asyncLookupInFlight === lookup) asyncLookupInFlight = null; + } +} + +/** Test seam: replace the sync resolver process and clear its successful cache. */ +export function setWindowsPrincipalRunnerForTests( + runner: WindowsPrincipalRunner | null, +): void { + if (asyncLookupInFlight) { + throw new Error("Cannot replace the Windows principal runner while a lookup is in flight."); + } + principalRunner = runner ?? defaultWindowsPrincipalRunner; + cachedPrincipal = null; +} + +/** Test seam: replace the async resolver process and clear its successful cache. */ +export function setAsyncWindowsPrincipalRunnerForTests( + runner: AsyncWindowsPrincipalRunner | null, +): void { + if (asyncLookupInFlight) { + throw new Error("Cannot replace the Windows principal runner while a lookup is in flight."); + } + asyncPrincipalRunner = runner ?? defaultAsyncWindowsPrincipalRunner; + cachedPrincipal = null; +} + +/** Test seam: clear only process-local principal state. */ +export function resetWindowsPrincipalForTests(): void { + if (asyncLookupInFlight) { + throw new Error("Cannot reset the Windows principal while a lookup is in flight."); + } + cachedPrincipal = null; + syntheticPrincipalForTests = null; +} 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 b63c9fa7d..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,6 +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; + 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/src/providers/quota.ts b/src/providers/quota.ts index 3ff9396c8..01516340a 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -64,6 +64,15 @@ export interface ProviderQuotaWindow { resetAt?: number; } +export interface ProviderQuotaCreditsUsd { + used: number; + limit: number; + remaining: number; + percent: number; + expiresAt?: number; + unlimited?: boolean; +} + export interface ProviderQuota { fiveHourPercent?: number; fiveHourResetAt?: number; @@ -72,6 +81,7 @@ export interface ProviderQuota { monthlyPercent?: number; monthlyResetAt?: number; customWindows?: ProviderQuotaWindow[]; + creditsUsd?: ProviderQuotaCreditsUsd; updatedAt: number; } @@ -203,6 +213,8 @@ function hasQuotaRows(quota: ProviderQuota | null | undefined): quota is Provide return typeof quota.fiveHourPercent === "number" || typeof quota.weeklyPercent === "number" || typeof quota.monthlyPercent === "number" + || quota.creditsUsd?.unlimited === true + || typeof quota.creditsUsd?.percent === "number" || !!quota.customWindows?.some(window => typeof window.percent === "number"); } @@ -340,6 +352,27 @@ async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Pro } const subscription = a6apiPayload(await subscriptionResponse.json().catch(() => null)); const token = a6apiPayload(await tokenResponse.json().catch(() => null)); + const unlimited = token?.unlimited_quota === true + || token?.unlimited_quota === 1 + || token?.unlimited_quota === "true"; + const normalizedExpiry = normalizeResetAt(token?.expires_at); + const expiry = normalizedExpiry && normalizedExpiry > 0 + ? { expiresAt: normalizedExpiry } + : {}; + if (unlimited) { + return report(provider, "a6api:billing", { + creditsUsd: { + used: 0, + limit: 0, + remaining: 0, + percent: 0, + unlimited: true, + ...expiry, + }, + customWindows: [{ label: "Unlimited API credits", percent: 0 }], + updatedAt: Date.now(), + }); + } const limitUsd = firstFinite(subscription, ["hard_limit_usd"]); const grantedUnits = firstFinite(token, ["total_granted"]); const usedUnits = firstFinite(token, ["total_used"]); @@ -362,6 +395,13 @@ async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Pro if (percent === undefined) return TERMINAL_QUOTA_FAILURE; const label = `API credits ($${remainingUsd.toFixed(2)} of $${limitUsd.toFixed(2)} remaining)`; return report(provider, "a6api:billing", { + creditsUsd: { + used: usedUsd, + limit: limitUsd, + remaining: remainingUsd, + percent, + ...expiry, + }, customWindows: [{ label, percent }], updatedAt: Date.now(), }); diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 20518adb4..375c4ada7 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"], @@ -1307,10 +1315,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. @@ -1340,6 +1355,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 +1669,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,11 +1706,50 @@ 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. 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 @@ -2017,6 +2073,34 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ models: ["mimo-auto"], note: "No key needed — uses Xiaomi MiMo's free public tier (limited-time offer). A JWT is bootstrapped automatically with an anonymous random client id stored locally. The endpoint contract mirrors the official MiMoCode client and is not publicly documented — Xiaomi may change or restrict it at any time. Prompts may be processed/retained by Xiaomi; do not send confidential material.", }, + // Xiaomi MiMo paid token plan. Separate host and wire from both `xiaomi` (Anthropic) and + // `mimo-free` (free tier, bespoke adapter), so it needs its own entry rather than a variant. + // + // Pinned to openai-chat deliberately (#1158). The endpoint answers the Responses wire for + // plain turns, which is why users configuring it by hand pick `openai-responses` — MiMo + // documents Responses support. But its gateway rejects `type: "custom"` tools with + // `400 responses_feature_not_supported`, and `apply_patch` is a custom tool, so every agentic + // turn fails while chat turns succeed. The Chat path lowers custom tools to `{input: string}` + // functions and restores them as `custom_tool_call`, so the capability survives intact. + // Stripping the tools instead would stop the 400 and disable the agent loop. + { + id: "mimo", + label: "Xiaomi MiMo (token plan)", + baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://xiaomimimo.com", + defaultModel: "mimo-v2.5-pro", + models: ["mimo-v2.5-pro", "mimo-v2.5"], + // The gateway validates the ladder strictly and rejects anything above `high`. + reasoningEfforts: ["low", "medium", "high"], + reasoningEffortMap: { xhigh: "high", max: "high", ultra: "high" }, + // A user may already have hand-rolled a provider under this id against a different host; + // without this, routedProviderConfig() would canonicalize their base URL onto ours and send + // their key somewhere they did not choose. + preserveCustomDestination: true, + note: "Xiaomi MiMo paid token plan. Pinned to the Chat wire: the Responses endpoint rejects freeform (custom) tools such as apply_patch with 400 responses_feature_not_supported, so agentic turns fail there while plain turns succeed. Reasoning tiers above high are clamped.", + }, { id: "cloudflare-ai-gateway", label: "Cloudflare AI Gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://dash.cloudflare.com/?to=/:account/ai/ai-gateway" }, { // Cloudflare Workers AI: OpenAI-compatible endpoint. The base URL contains {account_id} 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/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/server/index.ts b/src/server/index.ts index b25f4ce67..07a900e1b 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -71,6 +71,7 @@ import { getActiveTurnCount, isDraining, registerTurn, + runListenerShutdown, setServerRef, trackStreamLifetime, tryAdmitTurn, @@ -138,6 +139,8 @@ import { admissionFields, resolveApiAuth, resolveResponsesApiAuth, + requestPolicyView, + type RequestPolicyView, safeConfigDTO, setCorsOrigin, withCors, @@ -492,30 +495,82 @@ 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 { + // These two run BEFORE the auth/origin checks, so they need the receiving listener's policy + // explicitly (#1102). Reaching for the shared `config` here would attach public-policy CORS + // headers to a 503 on the loopback listener — no model runs and no credential is spent, but + // it is the one error path that would answer a rebinding origin with its own origin echoed + // back. + function drainingResponse(req: Request, policy: RequestPolicyView): Response { const response = formatErrorResponse(503, "server_error", "Service shutting down"); const headers = new Headers(response.headers); - for (const [name, value] of Object.entries(corsHeaders(req, config))) { + for (const [name, value] of Object.entries(corsHeaders(req, policy))) { headers.set(name, value); } headers.set("Retry-After", "5"); return new Response(response.body, { status: 503, headers }); } - function serverBusyResponse(req: Request, resource: string): Response { + function serverBusyResponse(req: Request, resource: string, policy: RequestPolicyView): Response { return withCors(new Response(JSON.stringify({ error: { type: "server_error", code: "server_busy", message: `${resource} capacity reached` }, }), { status: 503, headers: { "Content-Type": "application/json", "Retry-After": "1" }, - }), req, config); + }), req, policy); } - async function runAdmittedHttpTurn(req: Request, work: (lease: ActiveTurnLease) => Promise): Promise { + async function runAdmittedHttpTurn( + req: Request, + policy: RequestPolicyView, + work: (lease: ActiveTurnLease) => Promise, + ): Promise { const lease = tryAdmitTurn(); - if (!lease) return serverBusyResponse(req, "active turns"); + if (!lease) return serverBusyResponse(req, "active turns", policy); let response: Response; try { response = await work(lease); @@ -554,12 +609,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 +650,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 +909,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { + return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => { let response: Response; try { response = await handleResponsesCompact(req, config, logCtx, turnAdmissionLease); @@ -867,7 +941,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { + return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => { 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,18 +1000,18 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { + return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => { const response = await handleSearch(req, config, 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 (url.pathname === "/v1/responses" && req.method === "POST") { disableResponsesRequestTimeout(req, requestServer); if (isDraining()) { - return drainingResponse(req); + return drainingResponse(req, policy); } - const admission = resolveResponsesApiAuth(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 = resolveResponsesApiAuth(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 start = Date.now(); const requestId = nextRequestLogId(start); @@ -981,7 +1055,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { + return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => { const response = await handleResponses(req, config, logCtx, { turnAdmissionLease, abortSignal: req.signal, @@ -996,7 +1070,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server withCors(await handleClaudeCountTokens(req, config), req, config)); + return runAdmittedHttpTurn(req, policy, async () => withCors(await handleClaudeCountTokens(req, config), req, policy)); } if (url.pathname === "/v1/messages" && req.method === "POST") { disableResponsesRequestTimeout(req, requestServer); if (isDraining()) { - return drainingResponse(req); + return drainingResponse(req, policy); } - const admission = resolveApiAuth(req, config); + const admission = resolveApiAuth(req, policy); if (!admission) { - return withCors(anthropicErrorResponse(401, "opencodex API key required", "authentication_error"), req, config); + return withCors(anthropicErrorResponse(401, "opencodex API key required", "authentication_error"), req, policy); } - if (!isAllowedRequestOrigin(req, config)) { - return withCors(anthropicErrorResponse(403, "cross-origin data-plane request blocked", "permission_error"), req, config); + if (!isAllowedRequestOrigin(req, policy)) { + return withCors(anthropicErrorResponse(403, "cross-origin data-plane request blocked", "permission_error"), req, policy); } const start = Date.now(); const requestId = nextRequestLogId(start); @@ -1039,7 +1113,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server withCors( + return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => withCors( await handleClaudeMessages(req, config, logCtx, { requestId, start, turnAdmissionLease }), req, config, @@ -1051,12 +1125,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server withCors( + return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => withCors( await handleChatCompletions(req, config, logCtx, { requestId, start, turnAdmissionLease }), req, config, @@ -1082,12 +1156,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { + return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => { const response = await handleLive(req, config, logCtx, turnAdmissionLease); addFinalRequestLog( requestId, @@ -1105,7 +1179,7 @@ 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 +1494,21 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server => { - try { - await nativeStop(closeActiveConnections); - } finally { - await releaseNativeMainStartupLifecycle(server); - } + // The orchestration lives in `runListenerShutdown` so its two competing properties — + // cleanup completes, failure propagates — are testable without a live socket. + await runListenerShutdown( + [ + () => nativeStop(closeActiveConnections), + ...(loopbackListenerRef + ? [() => loopbackListenerRef.stop(closeActiveConnections)] + : []), + ], + () => releaseNativeMainStartupLifecycle(server), + ); }, }); setServerRef(server); @@ -1415,6 +1523,17 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server Promise>, + always: () => Promise, +): Promise { + const failures: unknown[] = []; + for (const step of steps) { + try { + await step(); + } catch (error) { + failures.push(error); + } + } + try { + await always(); + } catch (error) { + failures.push(error); + } + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) throw new AggregateError(failures, "listener shutdown failed"); +} + export function stopServerListener( server: ReturnType | undefined = _serverRef, ): Promise { diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 85c5a9400..d3f0fc704 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -165,6 +165,45 @@ 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; + // `Number.isInteger(1e100)` is true, so an integer check alone admits a value that + // serializes into the catalog as an enormous number and can make Codex reject the whole + // file. Safe-integer is the real bound for something that ends up in a JSON int field. + } else if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) { + next.contextWindow = value; + } else { + return { error: "contextWindow must be a positive safe 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.isSafeInteger(window) || window <= 0) { + return { error: "modelContextWindows values must be positive safe 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 +288,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { 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) { @@ -92,7 +108,31 @@ export async function findAvailablePort( throw new PortUnavailableError(preferredPort, hostname); } - return await new Promise((resolve, reject) => { + // Bounded, not recursive. The OS can hand back the reserved port, and a redraw practically + // always differs — but "practically always" is not a termination argument, and an unbounded + // async recursion has no way to stop if the assumption is ever wrong. + for (let attempt = 0; attempt < EPHEMERAL_REDRAW_LIMIT; attempt += 1) { + const port = await allocateEphemeralPort(hostname); + if (port !== reserved) return port; + } + throw new Error("failed to allocate an available port"); +} + +/** How many times an ephemeral draw may come back reserved before we give up. */ +const EPHEMERAL_REDRAW_LIMIT = 8; + +/** Test seam: replace the OS ephemeral allocator so the redraw path is reachable. */ +let ephemeralAllocator: ((hostname: string) => Promise) | null = null; + +export function setEphemeralPortAllocatorForTests( + allocator: ((hostname: string) => Promise) | null, +): void { + ephemeralAllocator = allocator; +} + +async function allocateEphemeralPort(hostname: string): Promise { + if (ephemeralAllocator) return ephemeralAllocator(hostname); + return await new Promise((resolve, reject) => { const server = createServer(); server.once("error", reject); server.once("listening", () => { diff --git a/src/server/responses-item-id-repair.ts b/src/server/responses-item-id-repair.ts index e385f1ee4..9187f25da 100644 --- a/src/server/responses-item-id-repair.ts +++ b/src/server/responses-item-id-repair.ts @@ -10,6 +10,8 @@ interface ResponsesItemIdRepairState { readonly repairInvalidIds: boolean; readonly placeholders: Record>; readonly outputIds: Record>; + /** JSON [outputIndex, rawId] -> canonical id, for exact item_id rewrites on part/delta events. */ + readonly rawIds: Map; readonly scope: string; readonly budget?: TranslatorBudget; } @@ -63,6 +65,7 @@ function createRepairState(config: ResponsesItemIdRepairConfig, budget?: Transla message: new Map(), reasoning: new Map(), }, + rawIds: new Map(), scope: randomUUID().replace(/-/g, ""), budget, }; @@ -97,6 +100,9 @@ function rememberMappedId( if (!mapped) return null; state.budget?.chargeRetained(new TextEncoder().encode(JSON.stringify([outputIndex, rawId, mapped])).byteLength, { kind: "item_ids" }); state.outputIds[type].set(outputIndex, mapped); + // Keyed by (index, rawId): an upstream that reuses one placeholder id across + // several items must not collapse them into the last item's canonical id. + if (rawId !== mapped) state.rawIds.set(JSON.stringify([outputIndex, rawId]), mapped); return mapped; } @@ -120,11 +126,24 @@ 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 currentId = typeof event.item_id === "string" ? event.item_id : undefined; + // 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. The rewrite is therefore exact-only when the + // event carries an item_id: it fires when (output_index, item_id) names an id the + // item stream already repaired, and otherwise leaves the event alone — an unknown + // id belongs to an item this repair never touched (function_call, already-canonical + // ids), and guessing by index could borrow a sibling item's identity. The index + // table serves only events with NO item_id, where repairMissingTerminalIds + // explicitly opts into the positional guess (the pre-existing contract). + if (currentId !== undefined) { + const mapped = state.rawIds.get(JSON.stringify([outputIndex, currentId])); + if (!mapped || currentId === mapped) return { event, changed: false }; + return { event: { ...event, item_id: mapped }, changed: true }; + } + if (!state.repairMissingTerminalIds) return { event, changed: false }; const mapped = state.outputIds[eventType].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 }; - if (currentId === undefined && !state.repairMissingTerminalIds) return { event, changed: false }; return { event: { ...event, item_id: mapped }, changed: true }; } diff --git a/src/types.ts b/src/types.ts index 7e1aff442..cf3819b8d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -728,6 +728,30 @@ export interface OcxConfig { contextCapValue?: number; /** Bind hostname. Default "127.0.0.1" (loopback only). Set "0.0.0.0" to expose on all interfaces. */ hostname?: string; + /** + * Optional second listener bound to 127.0.0.1 that admits data-plane requests without a + * credential (issue #1102). + * + * Why a separate listener rather than an exemption on the main one: when `hostname` is a + * wildcard, every caller needs `x-opencodex-api-key`, but a `codex app-server` spawned + * directly from the resolved entrypoint never goes through the generated shim and so never + * inherits the token. Exempting "loopback-looking peers" on the public listener would be + * unsound — `requestIP()` only proves the last transport hop, and Docker Desktop port + * forwarding, host-network containers, WSL mirrored networking and tunnels all terminate + * remote connections locally. Binding a second socket to 127.0.0.1 makes the kernel refuse + * remote connections outright, so there is no address to judge. + * + * The public listener's admission policy is unchanged. This adds an explicit local trust + * surface: every process on the machine can reach it, spend account quota, and consume paid + * provider credentials. Off by default; not for multi-tenant hosts. + * + * The port is required when enabled and must differ from the proxy port. An OS-assigned port + * would change across restarts, which would break already-running app-servers holding the + * previous `base_url` — the exact symptom #1102 reported and we disproved for token rotation. + */ + unauthenticatedLoopbackListener?: + | { enabled: false } + | { enabled: true; port: number }; /** * Outbound HTTP(S) proxy URL for provider requests (e.g. "http://user:pass@proxy:8080", or * "${HTTPS_PROXY}"-style env reference). Mirrored into HTTP_PROXY/HTTPS_PROXY at startup when @@ -1176,9 +1200,9 @@ export interface OcxProviderConfig { * full set so the user can pick). See devlog issue_052_provider-model-allowlist. */ selectedModels?: string[]; - /** Provider-wide Codex-visible context-window cap for routed catalog entries. */ + /** Provider-wide fallback when context metadata is absent; otherwise caps the reported window. */ contextWindow?: number; - /** Model-specific Codex-visible context-window caps. Values cap live metadata, never raise it. */ + /** Per-model fallback when context metadata is absent; otherwise caps the reported window. */ modelContextWindows?: Record; /** Model-specific Codex catalog input modalities, e.g. ["text"] or ["text", "image"]. */ modelInputModalities?: Record; 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..abea4ee12 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -37,6 +37,11 @@ import { import { isNewer } from "./notify"; import { isRealBunBinary } from "../lib/bun-binary-validator.mjs"; import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs"; +import { + npmCachePreflightFailureMessage, + runNpmCachePreflight, + type NpmCachePreflightReason, +} 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 +243,152 @@ function ensureJobDir(): void { if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); } +/** + * 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. + */ +/** + * 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 { + // `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, + // so the message does not cross this boundary at all. + const code = (error as { code?: unknown } | null)?.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 ?? ""); + // 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; + // Same explicit vocabulary as the npm field: a shape check accepts `janedoe`. + if (typeof syscall === "string" && POSIX_SYSCALLS.has(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(" · "); +} + +/** + * 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 ``; +} + +/** + * Fields that can carry free-form text and therefore need checking at the write boundary. + * + * 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]) => [ + key, + FREE_TEXT_JOB_FIELDS.has(key) ? brandOwnComposedText(key, item) : item, + ]), + ) as UpdateJobState; +} + function writeJob(job: UpdateJobState): void { ensureJobDir(); - atomicWriteFile(updateJobPath(), `${JSON.stringify(job, null, 2)}\n`); + atomicWriteFile(updateJobPath(), `${JSON.stringify(sanitizePersistedUpdateJob(job), null, 2)}\n`); } export function readUpdateJob(jobId?: string | null): UpdateJobState | null { @@ -254,6 +402,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 = { @@ -495,8 +650,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) { @@ -509,7 +663,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.", ); }); @@ -517,6 +671,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, { @@ -526,11 +694,171 @@ 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 }; } +/** + * 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*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; + +/** + * POSIX syscall names npm actually reports. An explicit vocabulary, not a shape. + * + * `^[a-z][a-z0-9_]{1,20}$` accepts `janedoe`, which is the whole problem: allowlisting the + * FIELD NAME while leaving its VALUE free-form just moves the leak one level in. + */ +const POSIX_SYSCALLS = new Set([ + "open", "openat", "close", "read", "write", "stat", "lstat", "fstat", "mkdir", "rmdir", + "unlink", "rename", "symlink", "readlink", "link", "chmod", "chown", "utimes", "access", + "scandir", "readdir", "copyfile", "realpath", "futime", "ftruncate", "fchmod", "fchown", + "connect", "getaddrinfo", "getnameinfo", "socket", "bind", "listen", "accept", "send", + "recv", "shutdown", "spawn", "spawnSync", "kill", "watch", "lchown", "lutimes", "mkdtemp", +]); + +/** Per-field value contracts. A field is only kept when its value satisfies its own rule. */ +const KNOWN_REGISTRY_HOSTS = new Set([ + "registry.npmjs.org", + "registry.yarnpkg.com", + "registry.npmmirror.com", + "npm.pkg.github.com", +]); + +const NPM_FIELD_VALIDATORS: Record string | null> = { + // A recognized code, nothing else. + code: value => (NPM_ERROR_CODES.has(value) ? value : null), + // A known syscall name, nothing else. + syscall: value => (POSIX_SYSCALLS.has(value) ? value : null), + // An integer, rendered from the parsed number so the original string never passes through. + errno: value => (/^-?\d{1,10}$/.test(value) ? String(Number(value)) : null), + // Version resolution: the FACT only. + // + // Two narrowing attempts failed here and the second is the instructive one. Extracting any + // `name@version` also matched `jane.doe@example.com`. Pinning the NAME to our own package + // still left the VERSION free: `@bitkyc08/opencodex@99.99.99-JaneDoe` is a valid-looking + // spec, and a semver prerelease identifier can encode anything — the same lesson the + // `/healthz` version taught in round 13. + // + // There is no trusted resolved version available at this call site, so the spec is not + // rendered at all. `code: ETARGET` plus this fact already tells a user their requested + // version does not exist, which is the diagnostic that matters. + notarget: () => "no matching version", +}; + +/** + * HTTP status lines carry a registry URL. Render it from parsed parts rather than echoing the + * line: a URL can embed userinfo (`https://Jane:pw@host/`) or a path, and the raw text also + * defeats the path test because `https:/` looks like a drive letter. + */ +function npmHttpStatusValue(field: string, value: string): string | null { + const url = /\bhttps?:\/\/[^\s]+/.exec(value)?.[0]; + if (!url) return `HTTP ${field}`; + let parsed: URL; + try { parsed = new URL(url); } catch { return `HTTP ${field}`; } + // Only hosts we can name in advance. A shape check (`^[\w.-]+$`) accepts + // `janedoe.example`, a numeric host, or a punycode host — an arbitrary hostname is a + // disclosure channel, not a diagnostic. Knowing it was the public registry versus "some + // other host" is the part that helps, and that fits in an allowlist. + return KNOWN_REGISTRY_HOSTS.has(parsed.hostname.toLowerCase()) && !parsed.username && !parsed.password + ? `HTTP ${field} from ${parsed.hostname.toLowerCase()}` + : `HTTP ${field}`; +} + +/** + * 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 (seen.has(field) || !value || value.length > 160) continue; + // Every kept field is RENDERED from a validated value, never echoed. Allowlisting the field + // name alone left the value free-form, so `npm error syscall janedoe` walked straight + // through — the field was recognized and the value was never checked against anything. + const validate = NPM_FIELD_VALIDATORS[field]; + const rendered = validate + ? validate(value) + : (/^(?:404|401|403|409|429)$/.test(field) ? npmHttpStatusValue(field, value) : null); + if (rendered === null) continue; + seen.set(field, rendered); + } + 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. + */ +export 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"}`); + + // 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(fields.length > 0 + ? `${bytes} bytes of full output withheld` + : `${bytes} bytes of output withheld (no recognized diagnostic fields)`); + } + + 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. @@ -598,7 +926,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 @@ -1187,7 +1515,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; @@ -1222,18 +1554,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" }; } @@ -1375,9 +1711,36 @@ 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 }; + /** 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, + 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 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. @@ -1422,7 +1785,7 @@ export async function runGuiUpdateWorker(jobId: string, channel: Channel, restar // 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; @@ -1439,6 +1802,17 @@ export async function runGuiUpdateWorker(jobId: string, channel: Channel, restar command: cmd.display, }, integrityLine); + if (check.installer === "npm") { + const cachePreflight = (io.cachePreflightFn ?? runNpmCachePreflight)(); + if (!cachePreflight.ok) { + updateJob(job, { + status: "failed", + error: npmCachePreflightFailureMessage(cachePreflight.reason as NpmCachePreflightReason), + }, "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"); @@ -1455,7 +1829,7 @@ export async function runGuiUpdateWorker(jobId: string, channel: Channel, restar } 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; } @@ -1466,7 +1840,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 { @@ -1509,7 +1883,7 @@ export async function runGuiUpdateWorker(jobId: string, channel: Channel, restar } updateJob(job, { status: "failed", - error: err instanceof Error ? err.message : String(err), + error: withheldSummary(err), }); } } diff --git a/src/update/npm-cache-preflight.d.mts b/src/update/npm-cache-preflight.d.mts new file mode 100644 index 000000000..09a64c57a --- /dev/null +++ b/src/update/npm-cache-preflight.d.mts @@ -0,0 +1,47 @@ +import type { spawnSync } from "node:child_process"; + +export type NpmCachePreflightReason = + | "cache_accessible" + | "cache_entry_foreign_owner" + | "cache_entry_inaccessible" + | "cache_path_malformed" + | "inspection_incomplete" + | "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; + /** 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; +} + +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..2ff015f5a --- /dev/null +++ b/src/update/npm-cache-preflight.mjs @@ -0,0 +1,201 @@ +import { lstatSync, readdirSync, realpathSync } 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_incomplete", + "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; + // 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; + let rootResolved = false; + + 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: true, reason: "inspection_incomplete" }; + } + 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; + + // 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()) { + // 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. + // 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" }; + } + 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 { + 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); +} + +// 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 !== 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 { + 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/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/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" } }), diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 1f6896f8a..6e262535c 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"; @@ -2387,6 +2388,199 @@ 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("a custom-named provider on a known vendor endpoint still gets the opt-in (#1100)", () => { + // 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", + 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", + 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(); @@ -2420,6 +2614,117 @@ describe("Codex catalog routed normalization", () => { expect(routed?.input_modalities).toEqual(["text", "image"]); }); + // #1073's exact reproduction: a provider whose /models returns nothing but ids. Two cases, + // deliberately not one — a single test that sets `modelContextWindows` would keep passing + // with the provider-wide `?? prov.contextWindow` fallback deleted, because the per-model + // value is chosen first. Each ablation needs its own oracle. + // + // No `modelMaxInputTokens` in either fixture: auto_compact_token_limit is + // min(floor(contextWindow * 0.9), maxInputTokens), so setting one would move the expectation. + test("an id-only /models honors the provider-wide contextWindow fallback (#1073)", async () => { + globalThis.fetch = (async () => new Response( + JSON.stringify({ data: [{ id: "gpt-5.6-luna" }] }), + { status: 200, headers: { "content-type": "application/json" } }, + )) as typeof fetch; + + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "sub2api", + providers: { + sub2api: { + adapter: "openai-chat", + baseUrl: "https://sub2api.test/v1", + apiKey: "sk-test", + contextWindow: 350_000, + }, + }, + }); + const routed = buildCatalogEntries(nativeTemplate(), [], models) + .find(e => e.slug === "sub2api/gpt-5.6-luna"); + + expect(routed?.context_window).toBe(350_000); + expect(routed?.max_context_window).toBe(350_000); + expect(routed?.auto_compact_token_limit).toBe(315_000); + }); + + test("a per-model contextWindow outranks the provider-wide one (#1073)", async () => { + globalThis.fetch = (async () => new Response( + JSON.stringify({ data: [{ id: "gpt-5.6-luna" }, { id: "other-model" }] }), + { status: 200, headers: { "content-type": "application/json" } }, + )) as typeof fetch; + + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "sub2api", + providers: { + sub2api: { + adapter: "openai-chat", + baseUrl: "https://sub2api.test/v1", + apiKey: "sk-test", + contextWindow: 256_000, + modelContextWindows: { "gpt-5.6-luna": 350_000 }, + }, + }, + }); + const entries = buildCatalogEntries(nativeTemplate(), [], models); + + expect(entries.find(e => e.slug === "sub2api/gpt-5.6-luna")?.context_window).toBe(350_000); + // The model without an override still gets the provider default, which is what makes this + // a comparison rather than a restatement of the previous test. + expect(entries.find(e => e.slug === "sub2api/other-model")?.context_window).toBe(256_000); + }); + + test("an id-only model with no configured window keeps the conservative fallback (#1073)", async () => { + globalThis.fetch = (async () => new Response( + JSON.stringify({ data: [{ id: "gpt-5.6-luna" }] }), + { status: 200, headers: { "content-type": "application/json" } }, + )) as typeof fetch; + + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "sub2api", + providers: { + sub2api: { + adapter: "openai-chat", + baseUrl: "https://sub2api.test/v1", + apiKey: "sk-test", + }, + }, + }); + const routed = buildCatalogEntries(nativeTemplate(), [], models) + .find(e => e.slug === "sub2api/gpt-5.6-luna"); + + expect(routed?.context_window).toBe(128_000); + expect(routed?.max_context_window).toBe(128_000); + expect(routed?.auto_compact_token_limit).toBe(115_200); + }); + + test("upstream metadata smaller than the configured window wins (#1073)", async () => { + globalThis.fetch = (async () => new Response( + JSON.stringify({ data: [{ id: "gpt-5.6-luna", context_length: 64_000 }] }), + { status: 200, headers: { "content-type": "application/json" } }, + )) as typeof fetch; + + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "sub2api", + providers: { + sub2api: { + adapter: "openai-chat", + baseUrl: "https://sub2api.test/v1", + apiKey: "sk-test", + contextWindow: 350_000, + }, + }, + }); + const routed = buildCatalogEntries(nativeTemplate(), [], models) + .find(e => e.slug === "sub2api/gpt-5.6-luna"); + + // The configured value supplies capacity when upstream has none; it never inflates a + // capacity upstream actually reported. + expect(routed?.context_window).toBe(64_000); + }); + test("liveModels false preserves configured catalog metadata without live fetch", async () => { let fetchCalls = 0; globalThis.fetch = (() => { 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; } diff --git a/tests/cursor-effort-suffix.test.ts b/tests/cursor-effort-suffix.test.ts index 218a06eef..2d4c627f0 100644 --- a/tests/cursor-effort-suffix.test.ts +++ b/tests/cursor-effort-suffix.test.ts @@ -3,6 +3,15 @@ import { createCursorRequest } from "../src/adapters/cursor/request-builder"; import { cursorEffortSuffix, cursorModelEffortLadder } from "../src/adapters/cursor/effort-map"; import type { OcxParsedRequest } from "../src/types"; +// Static fixture recorded from Cursor GetUsableModels on 2026-08-06. This pins the +// exact wire ids observed during the incident; live availability normalization is +// covered separately in cursor-discovery.test.ts. +const RECORDED_CURSOR_GROK_45_DISCOVERY_IDS = [ + "cursor-grok-4.5-low", + "cursor-grok-4.5-medium", + "cursor-grok-4.5-high", +] as const; + function modelIdFor(modelId: string, reasoning?: string): string { const parsed: OcxParsedRequest = { modelId, @@ -84,13 +93,13 @@ describe("Cursor per-model reasoning-effort suffix", () => { }); test("grok-4.5 uses current tiers and sends Fast as a separate model parameter", () => { - expect(modelIdFor("cursor/grok-4.5", "low")).toBe("grok-4.5-low"); - expect(modelIdFor("cursor/grok-4.5", "medium")).toBe("grok-4.5-medium"); - expect(modelIdFor("cursor/grok-4.5", "high")).toBe("grok-4.5-high"); - expect(modelIdFor("cursor/grok-4.5", "xhigh")).toBe("grok-4.5-high"); - expect(modelIdFor("cursor/grok-4.5")).toBe("grok-4.5-high"); + expect(modelIdFor("cursor/grok-4.5", "low")).toBe("cursor-grok-4.5-low"); + expect(modelIdFor("cursor/grok-4.5", "medium")).toBe("cursor-grok-4.5-medium"); + expect(modelIdFor("cursor/grok-4.5", "high")).toBe("cursor-grok-4.5-high"); + expect(modelIdFor("cursor/grok-4.5", "xhigh")).toBe("cursor-grok-4.5-high"); + expect(modelIdFor("cursor/grok-4.5")).toBe("cursor-grok-4.5-high"); expect(selectionFor("cursor/grok-4.5", "high")).toEqual({ - modelId: "grok-4.5-high", + modelId: "cursor-grok-4.5-high", parameters: undefined, }); expect(selectionFor("cursor/grok-4.5-fast", "low")).toEqual({ @@ -118,6 +127,14 @@ describe("Cursor per-model reasoning-effort suffix", () => { expect(cursorModelEffortLadder("grok-4.5-fast")).toEqual(["low", "medium", "high"]); }); + test("regular grok-4.5 request ids match the recorded discovery fixture", () => { + for (const effort of ["low", "medium", "high"] as const) { + const requestModelId = modelIdFor("cursor/grok-4.5", effort); + expect(requestModelId).toBe(`cursor-grok-4.5-${effort}`); + expect(RECORDED_CURSOR_GROK_45_DISCOVERY_IDS).toContain(requestModelId); + } + }); + test("kimi-k3 maps to its live effort-suffixed variants", () => { expect(modelIdFor("cursor/kimi-k3", "low")).toBe("kimi-k3-low"); expect(modelIdFor("cursor/kimi-k3", "medium")).toBe("kimi-k3-high"); diff --git a/tests/deepseek-inbound-wire.test.ts b/tests/deepseek-inbound-wire.test.ts index b560c5c2b..426f9753c 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,109 @@ 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`, + // 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: [ + { 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 +307,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 +357,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 +366,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..8815ca830 100644 --- a/tests/deepseek-responses-item-id-repair.test.ts +++ b/tests/deepseek-responses-item-id-repair.test.ts @@ -97,6 +97,54 @@ describe("registry-derived DeepSeek repair policy (#938)", () => { expect(rewrite(payload)).toBe(payload); }); + test("part events resolve by exact raw id, so a reused output_index cannot borrow the sibling's id", () => { + // Audit finding (final gate): with both tables holding the same output_index, an + // index-first lookup handed a reasoning content_part the MESSAGE canonical id. + // The raw-id map makes the rewrite exact; the index fallback only serves events + // without a known raw id. + const rewrite = createResponsesItemIdPayloadRewrite( + deepseekProvider().responsesItemIdRepair!, + createTestTranslatorBudget(), + ); + const frame = (payload: unknown) => JSON.parse(rewrite(JSON.stringify(payload))) as Record; + const rsAdded = frame({ type: "response.output_item.added", output_index: 0, item: { type: "reasoning", id: UUID_RS, summary: [] } }); + const msgAdded = frame({ type: "response.output_item.added", output_index: 0, item: { type: "message", id: UUID_MSG, role: "assistant", status: "in_progress", content: [] } }); + const rsId = (rsAdded.item as { id: string }).id; + const msgId = (msgAdded.item as { id: string }).id; + const part = frame({ type: "response.content_part.done", item_id: UUID_RS, output_index: 0, content_index: 0, part: { type: "reasoning_text", text: "x" } }); + expect(part.item_id).toBe(rsId); + expect(part.item_id).not.toBe(msgId); + // A function_call's part event is never rewritten even when it shares an index. + frame({ type: "response.output_item.added", output_index: 1, item: { type: "function_call", id: UUID_FC, call_id: "call_abc", name: "search", arguments: "{}" } }); + const fcPart = frame({ type: "response.content_part.added", item_id: UUID_FC, output_index: 1, content_index: 0, part: { type: "output_text", text: "" } }); + expect(fcPart.item_id).toBe(UUID_FC); + // …including when the function_call REUSES an index the message table holds + // (final-audit reproduction: the index fallback borrowed the message id). + const fcPartReused = frame({ type: "response.content_part.added", item_id: UUID_FC, output_index: 0, content_index: 0, part: { type: "output_text", text: "" } }); + expect(fcPartReused.item_id).toBe(UUID_FC); + // An already-canonical part id at a repaired index is never double-rewritten. + const canonicalPart = frame({ type: "response.content_part.done", item_id: rsId, output_index: 0, content_index: 0, part: { type: "reasoning_text", text: "y" } }); + expect(canonicalPart.item_id).toBe(rsId); + }); + + test("one placeholder id reused across items maps each index to its own canonical id", () => { + // Final-audit reproduction: a flat raw-id map collapsed two items sharing the + // placeholder "shared" into the LAST item's canonical id. The (index, rawId) + // key keeps them distinct. + const rewrite = createResponsesItemIdPayloadRewrite( + { message: ["shared"], reasoning: [] }, + createTestTranslatorBudget(), + ); + const frame = (payload: unknown) => JSON.parse(rewrite(JSON.stringify(payload))) as Record; + const first = frame({ type: "response.output_item.added", output_index: 0, item: { type: "message", id: "shared", role: "assistant", status: "in_progress", content: [] } }); + const second = frame({ type: "response.output_item.added", output_index: 1, item: { type: "message", id: "shared", role: "assistant", status: "in_progress", content: [] } }); + const firstId = (first.item as { id: string }).id; + const secondId = (second.item as { id: string }).id; + expect(firstId).not.toBe(secondId); + const lateDelta = frame({ type: "response.output_text.delta", item_id: "shared", output_index: 0, delta: "hi" }); + expect(lateDelta.item_id).toBe(firstId); + }); + test("repairResponsesJsonItemIds normalizes a whole bounded-JSON response", () => { const repaired = repairResponsesJsonItemIds( { @@ -116,25 +164,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 +207,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(); diff --git a/tests/loopback-listener-admission.test.ts b/tests/loopback-listener-admission.test.ts new file mode 100644 index 000000000..2ecd269d4 --- /dev/null +++ b/tests/loopback-listener-admission.test.ts @@ -0,0 +1,177 @@ +/** + * Tests for the unauthenticated loopback listener (#1102). + * + * The defect: with `hostname: "0.0.0.0"`, every caller needs `x-opencodex-api-key`, but a + * `codex app-server` spawned from the resolved entrypoint never goes through the generated + * shim and so never inherits the token. Every model call 401s at admission. + * + * The fix is deliberately NOT an exemption on the public listener. `requestIP()` only proves + * the last transport hop, and Docker port forwarding, host-network containers, WSL mirrored + * networking and tunnels all terminate remote connections locally — a peer that "looks + * loopback" is not evidence of a local caller. Instead a second socket binds 127.0.0.1, so the + * kernel refuses remote connections and there is no address to judge. + */ +import { describe, expect, test } from "bun:test"; +import { + isAllowedRequestOrigin, + requestPolicyView, + resolveResponsesApiAuth, +} from "../src/server/auth-cors"; +import { buildProviderTableBlock, shouldInjectApiAuthHeader } from "../src/codex/inject"; +import { validateConfigCandidate } from "../src/config"; +import type { OcxConfig } from "../src/types"; + +const wildcardConfig = { + hostname: "0.0.0.0", + apiKeys: [{ id: "k1", key: "ocx_data_realsecret", name: "test" }], +} as unknown as OcxConfig; + +function request(path = "/v1/responses", headers: Record = {}): 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); + }); + + 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..25419c593 --- /dev/null +++ b/tests/loopback-listener-integration.test.ts @@ -0,0 +1,483 @@ +/** + * 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, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +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 { runListenerShutdown } from "../src/server/lifecycle"; +import { + findAvailablePort, + PortUnavailableError, + setEphemeralPortAllocatorForTests, +} 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; +} + +/** One-shot settle with a cleared timer, so a late timeout cannot fire into the next test. */ +function handshake(url: string): Promise { + return new Promise(resolve => { + const ws = new WebSocket(url); + let settled = false; + let timer: ReturnType | undefined; + const settle = (value: boolean) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + try { ws.close(); } catch { /* already closing */ } + resolve(value); + }; + ws.addEventListener("open", () => settle(true)); + ws.addEventListener("error", () => settle(false)); + timer = setTimeout(() => settle(false), 3_000); + }); +} + +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, using each route's real method", async () => { + const loopbackPort = await freePort(); + saveConfig(baseConfig(loopbackPort)); + const server = startServer(0); + const base = `http://127.0.0.1:${loopbackPort}`; + try { + // Each entry uses the METHOD its handler actually accepts. Probing a POST route with GET + // would 404 on method mismatch inside the handler, so the assertion would hold even if + // the allowlist were widened to admit that route — the test would be watching nothing. + const denied: Array<{ method: string; path: string; body?: string }> = [ + { method: "GET", path: "/api/config" }, + { method: "GET", path: "/" }, + { method: "GET", path: "/healthz" }, + { method: "GET", path: "/readyz" }, + { method: "POST", path: "/v1/chat/completions", body: '{"model":"x","messages":[]}' }, + { method: "POST", path: "/v1/messages", body: '{"model":"x","messages":[]}' }, + { method: "POST", path: "/v1/images/generations", body: '{"prompt":"x"}' }, + { method: "POST", path: "/v1/alpha/search", body: '{"query":"x"}' }, + { method: "GET", path: "/v1/opencodex/artifacts/x" }, + { method: "POST", path: "/v1/live", body: "{}" }, + { method: "POST", path: "/v1/realtime/calls", body: "{}" }, + // Allowlisted paths still reject the methods they do not serve. + { method: "DELETE", path: "/v1/responses" }, + { method: "POST", path: "/v1/models" }, + ]; + for (const { method, path, body } of denied) { + const res = await fetch(`${base}${path}`, { + method, + ...(body ? { body, headers: { "content-type": "application/json" } } : {}), + }); + expect({ method, path, status: res.status }).toEqual({ method, path, status: 404 }); + } + + // And an allowlisted route is genuinely reachable, so the rejections above are not + // passing merely because nothing works on this listener. + expect((await fetch(`${base}/v1/models`)).status).toBe(200); + } finally { + await server.stop(true); + } + }); + + test("admits POST /v1/responses and its compact sibling without a credential", async () => { + const loopbackPort = await freePort(); + saveConfig(baseConfig(loopbackPort)); + const server = startServer(0); + const base = `http://127.0.0.1:${loopbackPort}`; + const publicBase = `http://127.0.0.1:${server.port}`; + try { + // These are the routes the reported defect actually fails on. `/v1/models` passing does + // not prove they admit: they use a different resolver. + // + // The request is deliberately malformed, so it fails INSIDE the handler rather than at + // admission. Any status other than 401 proves admission let it through, which is the + // only thing under test here — no upstream is involved. + for (const path of ["/v1/responses", "/v1/responses/compact"]) { + const viaPublic = await fetch(`${publicBase}${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + expect({ path, status: viaPublic.status }).toEqual({ path, status: 401 }); + + const viaLoopback = await fetch(`${base}${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + // Not `not.toBe(401)`: a 404 would satisfy that too, so removing the route from the + // allowlist would keep the assertion green. The route must be admitted AND reachable, + // which means neither 401 nor 404. + expect({ path, status: viaLoopback.status }).not.toEqual({ path, status: 401 }); + expect({ path, status: viaLoopback.status }).not.toEqual({ path, status: 404 }); + } + } finally { + await server.stop(true); + } + }); + + test("upgrades a Responses WebSocket on the listener that received it", async () => { + const loopbackPort = await freePort(); + saveConfig({ ...baseConfig(loopbackPort), websockets: true } as unknown as OcxConfig); + const server = startServer(0); + try { + // What this proves: the loopback listener completes a Responses WebSocket handshake + // without a credential, and the public one does not. + // + // What it does NOT prove: that the upgrade goes through `requestServer` rather than the + // captured `server`. That ablation was run and stayed green — this Bun version accepts + // an upgrade issued from a sibling Bun.serve in the same process. `requestServer` is + // still correct (it is the server that received the request, and nothing documents the + // cross-listener behaviour as supported), but the assertion below cannot defend it. + // Saying so beats implying coverage that does not exist. + expect(await handshake(`ws://127.0.0.1:${loopbackPort}/v1/responses`)).toBe(true); + expect(await handshake(`ws://127.0.0.1:${server.port}/v1/responses`)).toBe(false); + } 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 rather than stranding it", async () => { + const loopbackPort = await freePort(); + // A FIXED public port, not 0. Throwing is not the property under test — a startup that + // throws while leaving the public listener bound is exactly the failure the rollback + // exists to prevent, and only a rebind attempt can tell the two apart. + // Reserve the loopback port during this draw: two back-to-back freePort() calls can hand + // back the same port, which would make the test squat its own public port. + const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: loopbackPort }); + const squatter = Bun.serve({ + port: loopbackPort, + hostname: "127.0.0.1", + fetch: () => new Response("occupied"), + }); + saveConfig(baseConfig(loopbackPort)); + try { + expect(() => startServer(publicPort)).toThrow(); + + const rebound = Bun.serve({ + port: publicPort, + hostname: "127.0.0.1", + fetch: () => new Response("ok"), + }); + expect(rebound.port).toBe(publicPort); + rebound.stop(true); + } finally { + squatter.stop(true); + } + }); + + // Not covered here: composite stop's failure PROPAGATION when one listener's stop rejects. + // The composite captures the underlying stop at construction, so a test cannot inject a + // rejection from outside without a seam that does not exist yet. Its sibling property — + // cleanup completing across both listeners — is covered by the test above. Writing a case + // that asserts something weaker and calls it propagation coverage would be worse than the + // gap, because the next reader would believe the branch was defended. +}); + +describe("composite listener shutdown", () => { + // Both listeners share one `stop`, and the two properties it must hold pull against each + // other: keep cleaning up after a failure, yet still report that failure. A test against a + // live server cannot inject the rejection, so the orchestration was extracted. + test("a failing step does not stop the others, and still reaches the caller", async () => { + const ran: string[] = []; + const failure = new Error("primary stop failed"); + await expect(runListenerShutdown( + [ + async () => { ran.push("primary"); throw failure; }, + async () => { ran.push("loopback"); }, + ], + async () => { ran.push("lifecycle"); }, + )).rejects.toBe(failure); + // The whole point: a rejected primary stop must not strand the loopback socket or skip + // the native lifecycle release. + expect(ran).toEqual(["primary", "loopback", "lifecycle"]); + }); + + test("two failures are reported together rather than one hiding the other", async () => { + const ran: string[] = []; + let caught: unknown; + try { + await runListenerShutdown( + [ + async () => { ran.push("primary"); throw new Error("a"); }, + async () => { ran.push("loopback"); throw new Error("b"); }, + ], + async () => { ran.push("lifecycle"); }, + ); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(AggregateError); + expect((caught as AggregateError).errors).toHaveLength(2); + expect(ran).toEqual(["primary", "loopback", "lifecycle"]); + }); + + test("a failing lifecycle release is reported too", async () => { + const failure = new Error("release failed"); + await expect(runListenerShutdown( + [async () => {}], + async () => { throw failure; }, + )).rejects.toBe(failure); + }); + + test("an all-clear shutdown resolves", async () => { + await expect(runListenerShutdown([async () => {}, async () => {}], async () => {})) + .resolves.toBeUndefined(); + }); +}); + +describe("seams the runtime cannot defend", () => { + // Two properties have no runtime oracle on this Bun version, and both would regress + // silently. A source assertion is a weak instrument, but a weak instrument aimed at a known + // blind spot beats none — the alternative is a comment nobody runs. + const serverSource = readFileSync(join(process.cwd(), "src", "server", "index.ts"), "utf-8"); + + test("the WebSocket upgrade uses the receiving server, never the captured binding", () => { + // Swapping in `server.upgrade` stays green at runtime here: this Bun accepts an upgrade + // issued from a sibling Bun.serve in the same process. Another version or platform is not + // promised to, and the loopback listener would then fail to upgrade at all. + expect(serverSource).not.toMatch(/\bif \(server\.upgrade\(req,/); + expect(serverSource.match(/requestServer\.upgrade\(req,/g)?.length).toBe(2); + }); + + test("the loopback listener binds 127.0.0.1 explicitly", () => { + // The connection-refused test above is the real oracle, but it degrades to a warning on a + // host with no external IPv4 — and on that host the 0.0.0.0 ablation would pass. This + // holds everywhere. + expect(serverSource).toMatch(/port: loopbackListenerPort,\s*\n\s*hostname: "127\.0\.0\.1",/); + }); +}); + +describe("public port selection avoids the loopback port", () => { + afterEach(() => setEphemeralPortAllocatorForTests(null)); + + 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 redraws when the OS hands back the reserved port", async () => { + // Without a seam this branch is unreachable: the OS practically never returns the one + // reserved port, so a loop over real draws would pass with the redraw code deleted. + const reserved = 45_001; + const draws = [reserved, reserved, 45_002]; + let index = 0; + setEphemeralPortAllocatorForTests(async () => draws[index++] ?? 45_003); + expect(await findAvailablePort(0, "127.0.0.1", { reservedPort: reserved })).toBe(45_002); + expect(index).toBe(3); + }); + + test("redrawing is bounded rather than recursing forever", async () => { + const reserved = 45_001; + let draws = 0; + setEphemeralPortAllocatorForTests(async () => { + draws += 1; + return reserved; + }); + await expect(findAvailablePort(0, "127.0.0.1", { reservedPort: reserved })).rejects.toThrow(); + expect(draws).toBe(8); + }); + + 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); + }); +}); + +describe("Codex injection targets the loopback listener", () => { + test("the written config points at the loopback port with no auth header", () => { + // A subprocess, because CODEX_CONFIG_PATH is resolved at module load: setting CODEX_HOME + // in-process would write to whatever path this test file already imported. + // + // The child passes the PUBLIC port to injectCodexConfig, which is what every real caller + // does. The loopback substitution happens inside the injector, so handing the loopback + // port straight to the block builder would keep passing with that wiring deleted. + const root = mkdtempSync(join(tmpdir(), "ocx-loopback-inject-")); + const codexHome = join(root, ".codex"); + const ocxHome = join(root, ".opencodex"); + mkdirSync(codexHome, { recursive: true }); + mkdirSync(ocxHome, { recursive: true }); + writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5"\n', "utf-8"); + const config = baseConfig(10_200) as Record; + config.port = 10_100; + writeFileSync(join(ocxHome, "config.json"), JSON.stringify(config), "utf-8"); + try { + const child = spawnSync(process.execPath, [ + join(process.cwd(), "tests", "helpers", "codex-inject-race-child.ts"), + ], { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + CODEX_HOME: codexHome, + OPENCODEX_HOME: ocxHome, + OCX_INJECT_RACE_PAYLOAD: JSON.stringify({ port: 10_100 }), + }, + }); + const line = (child.stdout ?? "").trim().split("\n").filter(Boolean).pop() ?? "{}"; + expect(JSON.parse(line)).toMatchObject({ success: true }); + + const written = readFileSync(join(codexHome, "config.toml"), "utf-8"); + expect(written).toContain("http://127.0.0.1:10200/v1"); + expect(written).not.toContain("http://127.0.0.1:10100/v1"); + expect(written).not.toContain("env_http_headers"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index e850d3f84..884a955bc 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -1973,6 +1973,97 @@ 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 }, + // `Number.isInteger(1e100)` is true, so an integer check alone lets this through. It + // would then serialize into the catalog as an enormous number and can make Codex reject + // the whole file — the failure surfaces far from the PATCH that caused it. + { contextWindow: 1e100 }, + { modelContextWindows: { wide: 1e100 } }, + { 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 }); diff --git a/tests/mimo-token-plan-provider.test.ts b/tests/mimo-token-plan-provider.test.ts new file mode 100644 index 000000000..17314d6e7 --- /dev/null +++ b/tests/mimo-token-plan-provider.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test"; +import { KEY_LOGIN_PROVIDERS } from "../src/oauth/key-providers"; +import { PROVIDER_REGISTRY } from "../src/providers/registry"; +import { routeModel } from "../src/router"; +import type { OcxConfig } from "../src/types"; + +describe("Xiaomi MiMo token plan (#1158)", () => { + const entry = PROVIDER_REGISTRY.find(row => row.id === "mimo"); + + test("is pinned to the Chat wire, which is the one that accepts custom tools", () => { + // The endpoint answers Responses for plain turns, so a hand-configured provider naturally + // picks `openai-responses` — and then every agentic turn 400s, because the gateway rejects + // `type: "custom"` tools and `apply_patch` is one. The preset exists to make the working + // wire the default. + expect(entry?.adapter).toBe("openai-chat"); + expect(entry?.baseUrl).toBe("https://token-plan-cn.xiaomimimo.com/v1"); + expect(entry?.models).toEqual(["mimo-v2.5-pro", "mimo-v2.5"]); + + const derived = KEY_LOGIN_PROVIDERS["mimo"]; + expect(derived?.adapter).toBe("openai-chat"); + expect(derived?.baseUrl).toBe("https://token-plan-cn.xiaomimimo.com/v1"); + }); + + test("clamps reasoning tiers above the ladder the gateway validates", () => { + expect(entry?.reasoningEfforts).toEqual(["low", "medium", "high"]); + for (const tier of ["xhigh", "max", "ultra"]) { + expect(entry?.reasoningEffortMap?.[tier]).toBe("high"); + } + }); + + test("a same-named custom provider keeps its own destination and credential", () => { + // The claim the preset actually leans on, and the one the shape assertions above do NOT + // exercise. Someone may already have a hand-rolled provider called `mimo` pointing + // elsewhere; without `preserveCustomDestination`, routing would canonicalize their base URL + // onto the registry's and send their key to a host they never chose. + const config: OcxConfig = { + port: 10100, + defaultProvider: "mimo", + providers: { + mimo: { + adapter: "openai-responses", + baseUrl: "https://private.example/v1", + apiKey: "user-key", + authMode: "key", + }, + }, + }; + + const route = routeModel(config, "mimo/custom-model"); + expect(route.provider.baseUrl).toBe("https://private.example/v1"); + expect(route.provider.apiKey).toBe("user-key"); + expect(route.provider.adapter).toBe("openai-responses"); + // The registry's effort clamp must not be applied to a row we do not own either. + expect(route.provider.reasoningEfforts).toBeUndefined(); + }); +}); diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index 873a7af54..4a0ed3b1c 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -282,6 +282,13 @@ describe("fetchProviderQuotaReports", () => { label: "API credits ($15.00 of $20.00 remaining)", percent: 25, }]); + expect(result.reports[0]?.quota.creditsUsd).toEqual({ + used: 5, + limit: 20, + remaining: 15, + percent: 25, + expiresAt: Date.parse("2026-08-01T00:00:00Z"), + }); expect(seen.map(row => row.url).sort()).toEqual([ "https://api.a6api.com/api/usage/token/", "https://api.a6api.com/dashboard/billing/subscription", @@ -290,6 +297,36 @@ describe("fetchProviderQuotaReports", () => { expect(seen.every(row => row.redirect === "error")).toBe(true); }); + test("A6API unlimited keys remain visible even when all finite credit totals are zero", async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => new Response(JSON.stringify( + String(input).includes("subscription") + ? { data: { hard_limit_usd: 100_000_000 } } + : { data: { + total_granted: 0, + total_used: 0, + total_available: 0, + unlimited_quota: true, + expires_at: "2027-01-01T00:00:00Z", + } }, + ), { status: 200 })) as typeof fetch; + + const result = await fetchProviderQuotaReports(a6apiOnlyConfig(), true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.quota.creditsUsd).toEqual({ + used: 0, + limit: 0, + remaining: 0, + percent: 0, + unlimited: true, + expiresAt: Date.parse("2027-01-01T00:00:00Z"), + }); + expect(result.reports[0]?.quota.customWindows).toEqual([{ + label: "Unlimited API credits", + percent: 0, + }]); + }); + test("A6API quota never sends API keys to a non-canonical base URL", async () => { const seen: string[] = []; globalThis.fetch = (async (input: RequestInfo | URL) => { diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index 37bc5467a..131ae199e 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -32,10 +32,10 @@ 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", + "opencode-free", "xiaomi", "kilo", "mimo-free", "mimo", "cloudflare-ai-gateway", "cloudflare-workers-ai", "gitlab-duo", ]; describe("provider registry parity", () => { @@ -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"); diff --git a/tests/sse-unspaced-data-fields.test.ts b/tests/sse-unspaced-data-fields.test.ts new file mode 100644 index 000000000..3df1db33f --- /dev/null +++ b/tests/sse-unspaced-data-fields.test.ts @@ -0,0 +1,192 @@ +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"; + +// #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" }; + +const sseEncoder = new TextEncoder(); + +function streamOf(body: string): ReadableStream { + 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); + 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 or a comment", () => { + expect(sseFieldValue("event: x", "data")).toBeNull(); + expect(sseFieldValue("database: x", "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(""); + }); + + 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("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", "data", "database: x"]) { + 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); + }); +}); + +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)); + }); +}); diff --git a/tests/update-job.test.ts b/tests/update-job.test.ts index 7adcc513f..c8ae64de4 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 { @@ -10,6 +10,8 @@ import { readUpdateJob, restartCommand, restartAfterUpdateForTests, + runGuiUpdateWorker, + summarizeCommandOutput, staleActiveUpdateJobReason, startUpdateJob, UPDATE_JOB_LEGACY_STALE_MS, @@ -106,6 +108,327 @@ 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.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("withheld"); + expect(persisted).not.toContain("private.txt"); + }); + + 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", + // 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", + // 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", + // 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", + // 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, { + 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/); + 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"); + expect(persisted).not.toContain("Jane"); + expect(persisted).not.toContain("oe [Admin]+"); + expect(persisted).not.toContain("ne [Admin]+"); + expect(persisted).not.toContain("oe (Blank)+"); + }); + + 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(), + log: [], + })); + + let installRan = false; + let preflightRan = false; + await runGuiUpdateWorker("gate-job", "latest", false, { + // 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"); + 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 }); + }); + + 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("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("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 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"); + + // The registry host survives — that is the diagnostic — but never the URL path, which can + // name a private scope, and never userinfo, which is a credential. + expect(notFound).toContain("registry.npmjs.org"); + const scoped = summarizeCommandOutput("", "npm error 404 Not Found - GET https://registry.npmjs.org/@janedoe-private/pkg", 1, null); + expect(scoped).not.toContain("janedoe-private"); + // Userinfo in a registry URL is a credential. Assembled rather than written literally so + // the privacy scanner does not read the fixture itself as an embedded secret. + const userinfoUrl = `https://Jane:secret${"@"}registry.npmjs.org/x`; + const credentialed = summarizeCommandOutput("", `npm error 404 GET ${userinfoUrl}`, 1, null); + expect(credentialed).not.toContain("Jane"); + expect(credentialed).not.toContain("secret"); + }); + + test("an allowlisted field name does not make its value safe", () => { + // The gap after the first attempt: field NAMES were allowlisted while VALUES stayed + // free-form, so `npm error syscall janedoe` walked straight through a recognized field. + // Every field is now rendered from a validated value, never echoed. + const forged = [ + "npm error syscall janedoe", + "npm error errno JaneDoe", + "npm error notarget No matching version found for Jane Doe", + "NpM ErRoR SyScAlL JaneDoe", + ].join("\n"); + + const summary = summarizeCommandOutput("", forged, 1, null); + expect(summary).not.toContain("janedoe"); + expect(summary).not.toContain("JaneDoe"); + expect(summary).not.toContain("Jane Doe"); + // The one field that still reports does so as a fixed phrase with no borrowed text. + expect(summary).toContain("no matching version"); + + // No package spec is echoed at all. `name@version` matches an email address; pinning the + // name to our own package still left the VERSION free, and a semver prerelease identifier + // can encode anything (`@bitkyc08/opencodex@99.99.99-JaneDoe`). `code: ETARGET` plus the + // bare fact is the diagnostic that matters. + for (const line of [ + "npm error notarget No matching version found for jane.doe@example.com", + "npm error notarget No matching version found for @bitkyc08/opencodex@99.99.99-JaneDoe", + ]) { + const out = summarizeCommandOutput("", line, 1, null); + expect(out).toContain("no matching version"); + expect(out).not.toContain("JaneDoe"); + expect(out).not.toContain("jane.doe"); + } + + // Registry hosts are an allowlist, not a shape: an arbitrary hostname is a disclosure + // channel even when it parses cleanly. + const foreign = summarizeCommandOutput("", "npm error 404 GET https://janedoe.example/private", 1, null); + expect(foreign).not.toContain("janedoe"); + expect(foreign).toContain("HTTP 404"); + + // Node exceptions use the same vocabulary rather than a shape check. + const hostile = Object.assign(new Error("boom"), { syscall: "janedoe", errno: "JaneDoe" }); + 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 hostile; }, + })).toThrow("Could not start update worker"); + const persisted = readFileSync(updateJobPath(), "utf8"); + expect(persisted).not.toContain("janedoe"); + expect(persisted).not.toContain("JaneDoe"); + }); + test("npm worker uses the Node launcher update path", () => { const cmd = updateExecutionCommand("npm", "preview", "/pkg/bin/ocx.mjs"); expect(cmd.bin).toMatch(/^node/); @@ -1007,6 +1330,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" }, @@ -1130,7 +1475,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"); }); }); @@ -1179,15 +1529,21 @@ 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 });'); - 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); 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..69c16b216 --- /dev/null +++ b/tests/update-npm-cache-preflight.test.ts @@ -0,0 +1,216 @@ +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("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("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("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({ + 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"]'); 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\""); }); }); diff --git a/tests/windows-secret-acl.test.ts b/tests/windows-secret-acl.test.ts index acf3cb343..77580d239 100644 --- a/tests/windows-secret-acl.test.ts +++ b/tests/windows-secret-acl.test.ts @@ -36,14 +36,29 @@ import { atomicWriteFile } from "../src/config"; import { hardenStableLockFile } from "../src/codex/native-main-lock-file"; import { nativeMainClaimPath, withNativeMainSharedClaim } from "../src/codex/native-main-claim"; import { NATIVE_MAIN_OWNER_DB, retainNativeMainOwner } from "../src/codex/native-main-owner"; +import { + resetWindowsPrincipalForTests, + setAsyncWindowsPrincipalRunnerForTests, + setWindowsPrincipalRunnerForTests, +} from "../src/lib/windows-user-principal"; 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 = ""; }); @@ -116,6 +131,181 @@ describe("hardenSecretPath – required mode (required: true)", () => { }); }); +describe("effective Windows principal integration", () => { + test("the owner grant uses a numeric SID even when USERDOMAIN says WORKGROUP", () => { + const filePath = join(testDir, "workgroup-secret.json"); + writeFileSync(filePath, "data", "utf-8"); + const oldDomain = process.env.USERDOMAIN; + const oldUser = process.env.USERNAME; + process.env.USERDOMAIN = "WORKGROUP"; + process.env.USERNAME = "not-authoritative"; + resetHardenedStateForTests(); + setPlatformForTests("win32"); + const seen: string[][] = []; + setIcaclsRunnerForTests(args => { + seen.push(args); + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + try { + expect(hardenSecretPath(filePath, { required: true })).toEqual({ ok: true }); + const grant = seen.find(args => args.includes("/grant:r")); + expect(grant).toBeDefined(); + expect(grant![2]).toMatch(/^\*S-1-(?:\d+-)+\d+:\(F\)$/i); + expect(grant![2]).not.toContain("WORKGROUP"); + } finally { + setIcaclsRunnerForTests(null); + setPlatformForTests(null); + resetHardenedStateForTests(); + if (oldDomain === undefined) delete process.env.USERDOMAIN; + else process.env.USERDOMAIN = oldDomain; + if (oldUser === undefined) delete process.env.USERNAME; + else process.env.USERNAME = oldUser; + } + }); + + // These ran only on Windows until the resolver learned to let an injected + // runner outrank the synthetic POSIX principal. A case that silently returns + // on two of three CI platforms is not coverage of a fail-closed boundary, and + // the timedOutPaths isolation it asserts is the whole reason the identity + // failure carries its own error code. + const IDENTITY_DIAGNOSTIC = + "ACL hardening failed (EACLIDENTITY) — the effective Windows account SID could not be resolved"; + + test("a failed identity lookup fails closed, runs no icacls, and never enters the timeout memo", () => { + const requiredPath = join(testDir, "identity-required.json"); + const optionalPath = join(testDir, "identity-optional.json"); + writeFileSync(requiredPath, "required", "utf-8"); + writeFileSync(optionalPath, "optional", "utf-8"); + let identityCalls = 0; + let icaclsCalls = 0; + setWindowsPrincipalRunnerForTests(() => { + identityCalls += 1; + return { success: false, exitCode: null, timedOut: true, stdout: "" }; + }); + setIcaclsRunnerForTests(() => { + icaclsCalls += 1; + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + resetHardenedStateForTests(); + setPlatformForTests("win32"); + try { + // required: throws, and the CODE survives sanitization so a caller can + // branch on the cause rather than parse the message. + let thrown: NodeJS.ErrnoException | undefined; + try { + hardenSecretPath(requiredPath, { required: true }); + } catch (error) { + thrown = error as NodeJS.ErrnoException; + } + expect(thrown).toBeDefined(); + expect(thrown).toMatchObject({ code: "EACLIDENTITY" }); + + // optional: soft-fails WITHOUT touching the ACL. No name-shaped fallback. + expect(hardenSecretPath(optionalPath, { required: false })).toEqual({ + ok: false, + diagnostics: IDENTITY_DIAGNOSTIC, + }); + + // The injected runner actually ran — this is what regressed to 0 when the + // synthetic principal was chosen first. + expect(identityCalls).toBe(2); + expect(icaclsCalls).toBe(0); + // An identity failure is not an icacls timeout: the path stays retryable. + expect(timedOutSecretPathCountForTests()).toBe(0); + } finally { + setIcaclsRunnerForTests(null); + setPlatformForTests(null); + resetHardenedStateForTests(); + setWindowsPrincipalRunnerForTests(null); + resetWindowsPrincipalForTests(); + } + }); + + test("the async harden path applies the same identity policy", async () => { + const requiredPath = join(testDir, "identity-required-async.json"); + const optionalPath = join(testDir, "identity-optional-async.json"); + writeFileSync(requiredPath, "required", "utf-8"); + writeFileSync(optionalPath, "optional", "utf-8"); + let identityCalls = 0; + let icaclsCalls = 0; + setAsyncWindowsPrincipalRunnerForTests(async () => { + identityCalls += 1; + return { success: false, exitCode: null, timedOut: true, stdout: "" }; + }); + setAsyncIcaclsRunnerForTests(async () => { + icaclsCalls += 1; + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + resetHardenedStateForTests(); + setPlatformForTests("win32"); + try { + let thrown: NodeJS.ErrnoException | undefined; + try { + await hardenSecretPathAsync(requiredPath, { required: true }); + } catch (error) { + thrown = error as NodeJS.ErrnoException; + } + expect(thrown).toBeDefined(); + expect(thrown).toMatchObject({ code: "EACLIDENTITY" }); + + expect(await hardenSecretPathAsync(optionalPath, { required: false })).toEqual({ + ok: false, + diagnostics: IDENTITY_DIAGNOSTIC, + }); + + expect(identityCalls).toBe(2); + expect(icaclsCalls).toBe(0); + expect(timedOutSecretPathCountForTests()).toBe(0); + } finally { + setAsyncIcaclsRunnerForTests(null); + setPlatformForTests(null); + resetHardenedStateForTests(); + setAsyncWindowsPrincipalRunnerForTests(null); + resetWindowsPrincipalForTests(); + } + }); + + test("no name-shaped principal reaches icacls when the environment names a plausible account", () => { + const filePath = join(testDir, "no-name-fallback.json"); + writeFileSync(filePath, "data", "utf-8"); + const oldDomain = process.env.USERDOMAIN; + const oldUser = process.env.USERNAME; + // A shape a reader would accept at a glance. It is still not the token. + process.env.USERDOMAIN = "CORP"; + process.env.USERNAME = "administrator"; + const seen: string[][] = []; + setWindowsPrincipalRunnerForTests(() => ({ + success: false, + exitCode: 1, + timedOut: false, + stdout: "", + })); + setIcaclsRunnerForTests(args => { + seen.push(args); + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + resetHardenedStateForTests(); + setPlatformForTests("win32"); + try { + expect(hardenSecretPath(filePath, { required: false })).toEqual({ + ok: false, + diagnostics: IDENTITY_DIAGNOSTIC, + }); + expect(seen).toEqual([]); + } finally { + setIcaclsRunnerForTests(null); + setPlatformForTests(null); + resetHardenedStateForTests(); + setWindowsPrincipalRunnerForTests(null); + resetWindowsPrincipalForTests(); + if (oldDomain === undefined) delete process.env.USERDOMAIN; + else process.env.USERDOMAIN = oldDomain; + if (oldUser === undefined) delete process.env.USERNAME; + else process.env.USERNAME = oldUser; + } + }); +}); + describe("ephemeral harden success memo lifecycle", () => { test("forgetHardenedSecretPath releases only the actual temp and a second temp hardens again", () => { // Earlier cases in this file harden real paths under the win32 override and @@ -354,6 +544,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 +567,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 +677,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 +825,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 +859,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; diff --git a/tests/windows-user-principal.test.ts b/tests/windows-user-principal.test.ts new file mode 100644 index 000000000..c0d01cc57 --- /dev/null +++ b/tests/windows-user-principal.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import { + resetWindowsPrincipalForTests, + resolveCurrentWindowsPrincipal, + resolveCurrentWindowsPrincipalAsync, + setAsyncWindowsPrincipalRunnerForTests, + setWindowsPrincipalRunnerForTests, + windowsPrincipalPowerShellCommandForTests, +} from "../src/lib/windows-user-principal"; +import { setTrustedWindowsElevationExecutablesForTests } from "../src/lib/windows-elevation"; + +const ok = (stdout = "S-1-5-21-111-222-333-1001\r\n") => ({ + success: true, + exitCode: 0, + timedOut: false, + stdout, +}); + +afterEach(() => { + setWindowsPrincipalRunnerForTests(null); + setAsyncWindowsPrincipalRunnerForTests(null); + setTrustedWindowsElevationExecutablesForTests(null); + resetWindowsPrincipalForTests(); +}); + +describe("Windows effective ACL principal", () => { + test("builds a hidden non-interactive command from the trusted PowerShell path", () => { + const trusted = "C:\\trusted-system32\\WindowsPowerShell\\v1.0\\powershell.exe"; + setTrustedWindowsElevationExecutablesForTests({ powershell: trusted }); + expect(windowsPrincipalPowerShellCommandForTests()).toEqual([ + trusted, + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-Command", + "[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value", + ]); + }); + + test("the default trusted runner resolves the real token on Windows", () => { + if (process.platform !== "win32") return; + expect(resolveCurrentWindowsPrincipal(5_000)).toMatch(/^\*S-1-(?:\d+-)+\d+$/i); + }); + + test("the default trusted async runner settles and resolves the real token on Windows", async () => { + if (process.platform !== "win32") return; + expect(await resolveCurrentWindowsPrincipalAsync(5_000)) + .toMatch(/^\*S-1-(?:\d+-)+\d+$/i); + }); + + test("uses the token SID and normalizes it for icacls, independent of WORKGROUP env", () => { + const oldDomain = process.env.USERDOMAIN; + const oldUser = process.env.USERNAME; + process.env.USERDOMAIN = "WORKGROUP"; + process.env.USERNAME = "not-the-token-authority"; + setWindowsPrincipalRunnerForTests(() => ok()); + try { + expect(resolveCurrentWindowsPrincipal(1_000)).toBe("*S-1-5-21-111-222-333-1001"); + } finally { + if (oldDomain === undefined) delete process.env.USERDOMAIN; + else process.env.USERDOMAIN = oldDomain; + if (oldUser === undefined) delete process.env.USERNAME; + else process.env.USERNAME = oldUser; + } + }); + + test("caches only a successful lookup", () => { + let calls = 0; + setWindowsPrincipalRunnerForTests(() => { + calls += 1; + return ok(); + }); + expect(resolveCurrentWindowsPrincipal(1_000)).toMatch(/^\*S-1-/); + expect(resolveCurrentWindowsPrincipal(1_000)).toMatch(/^\*S-1-/); + expect(calls).toBe(1); + }); + + test("invalid output fails closed and is retried rather than cached", () => { + let calls = 0; + setWindowsPrincipalRunnerForTests(() => { + calls += 1; + return ok("WORKGROUP\\user\n"); + }); + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + resolveCurrentWindowsPrincipal(1_000); + throw new Error("expected identity refusal"); + } catch (error) { + expect((error as NodeJS.ErrnoException).code).toBe("EACLIDENTITY"); + } + } + expect(calls).toBe(2); + }); + + test("a resolver timeout stays EACLIDENTITY rather than entering the icacls timeout class", () => { + setWindowsPrincipalRunnerForTests(() => ({ + success: false, + exitCode: null, + timedOut: true, + stdout: "", + })); + try { + resolveCurrentWindowsPrincipal(1_000); + throw new Error("expected identity refusal"); + } catch (error) { + expect((error as NodeJS.ErrnoException).code).toBe("EACLIDENTITY"); + } + }); + + test("concurrent async callers share one owned lookup", async () => { + let calls = 0; + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + setAsyncWindowsPrincipalRunnerForTests(async () => { + calls += 1; + await gate; + return ok(); + }); + + const first = resolveCurrentWindowsPrincipalAsync(2_000); + const second = resolveCurrentWindowsPrincipalAsync(2_000); + await Bun.sleep(0); + expect(calls).toBe(1); + release(); + await expect(first).resolves.toBe("*S-1-5-21-111-222-333-1001"); + await expect(second).resolves.toBe("*S-1-5-21-111-222-333-1001"); + }); +});