diff --git a/devlog/_plan/260906_a_runtime_stack/000_plan.md b/devlog/_plan/260906_a_runtime_stack/000_plan.md new file mode 100644 index 0000000000..521e47c38a --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/000_plan.md @@ -0,0 +1,42 @@ +# A runtime integration roadmap + +## Loop specification + +- Archetype: spec-satisfaction repair; C3 runtime, C4 proxy credential/recovery boundaries. +- Trigger: owner assigned A (#3672, #3679, #3568, #3581, #3671), authorized inherited parallel subagents, contributor-preserving stacked PRs, no-verify pushes, dev integration and immediate resolved-work closure. +- Goal: preserve transport termination, configured WS egress, native subagent MESSAGE recovery, conversation affinity and effective policy capabilities. +- Non-goals: B/C/D implementation, release promotion/publication, production service/config/credential changes. #3661 remains open unless its complete residual scope is independently proven solved. +- Verification: remote focused activation checks during each implementation cycle; required current-head hosted CI before readiness/merge; final dev ancestry and CI. No local tests, typecheck or builds. Git diff checks and prose validation only locally. +- Stop: all five changes or proven equivalents on dev; original PRs closed with attribution and landing references; fully solved linked issues closed; unresolved issue scope documented. +- Memory: this unit, the session-bound goalplan/ledger, and ignored `.tmp/a-runtime-stack/` evidence. +- Outcomes: DONE / proven NOOP; external blockers recorded, never inferred from ordinary conflicts or pending CI. +- Delegation: main owns FSM, branches, commits, pushes and merges. Plan/review lanes have disjoint file scope. Two distinct failed dispatches return ownership to main; new worker scope is added at P. +- Resource scope: existing git/gh identity, owned `codex/a-*` branches, public contributor PR reads, and existing the isolated remote verification host SSH for isolated verification. No new account credentials or provider requests. User imposed no subagent/model-inheritance budget cap; no model override. Two-hour checkpoint per work phase triggers evidence/reliability reassessment; pending CI is monitored with bounded waits, not abandoned. + +## Phase map + +| Cycle | Artifact | Consumes | Delivers | +|---|---|---|---| +| roadmap | 000 + 010..060 | live dev and public contributor changes | audited full integration plan; docs only | +| sse | 010_sse.md | existing SSE relay boundary | failure notification independent of tee cancellation | +| ws | 020_ws.md | prior transport baseline | WS outbound policy and pool identity | +| recovery | 030_recovery.md | validated transport stack | MESSAGE recovery + reparse/cache semantics | +| affinity | 040_affinity.md | recovery/reparse fields | stable Command Code conversation identity | +| capabilities | 050_capabilities.md | final effective dispatch behavior | policy selection congruent with dispatch | +| landing | 060_landing.md | independently verified stack layers | current dev inclusion and closeout | + +The owner explicitly requested a stack. Independent transport fixes are retained as separate cumulative layers to expose interaction at each head; this publication order is not a claim of a hard dependency between SSE and WS. The actual code dependency is recovery before affinity. Each layer has its own PR diff, regression proof and CI. Bottom-up merge only; retarget before deleting parent branches. Keep stacks short by landing verified lower layers while subsequent cycles continue when possible. + +## Ownership + +A owns shared `src/server/responses/core.ts` integration for #3568 then #3581. C owns #3576 and may land its separate OAuth replay region first; both lanes refresh dev and preserve each other's changes. B owns `src/config.ts` final field reconciliation with #3679. Source snapshots use `refs/codex/a-original/N`, not remote-tracking scratch refs that concurrent fetch-prune can remove. + +## Evidence and provenance + +CI entry `.github/workflows/ci.yml` has unrestricted pull_request bases for stacks. `src/**`, `tests/**`, `scripts/**` are observed by its changes job; Linux test shards invoke `scripts/ci/run-bun-test-batches.sh`, gates run tsc/privacy, and macOS/Windows jobs validate platform behavior. These definitions were inspected without executing local suites. Remote-check scripts and real run IDs will be captured at C, not invented at P. Original source changes and review histories are public; any newly discovered security reasoning stays in ignored scratch. + +- #3672: `077dd61f66ac80678d071ae8fe516507f43a4264` +- #3679: `b05cccf264b4ab61db5d8dee8232c2f89bb1b541` +- #3568: `036a9321788464fdf33a387c9f44a834a844bdc1` +- #3581: `f60397d3408e0339ffc66acdcaca8133e40866c2` +- #3671: `7b1beb9c5eacd8dde22681a5df26804be52380b8` diff --git a/devlog/_plan/260906_a_runtime_stack/003_audit_resolution.md b/devlog/_plan/260906_a_runtime_stack/003_audit_resolution.md new file mode 100644 index 0000000000..e772aeb294 --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/003_audit_resolution.md @@ -0,0 +1,8 @@ +# Roadmap audit resolution + +Independent reviewer returned GO-WITH-FIXES (2). Both findings accepted and folded before B: + +1. Implementation-cycle D previously implied full CI/dev landing, inconsistent with prepared stack layers. 010..050 now explicitly distinguish exact-head remote focused/type verified draft preparation from 060 full-gate landing. Final objective and full-CI-before-merge criteria remain unchanged. +2. Affinity reparse tests required a cohort option the shared post helper did not accept. 040 now names tests/helpers/agent-task-recovery.ts option extension, internal handler forwarding, and true/false/undefined observation in real initial/cache-only adapter calls. + +Private remote host/user paths were replaced with placeholders; exact machine setup remains ignored scratch. No product edits or local suites in roadmap cycle. diff --git a/devlog/_plan/260906_a_runtime_stack/010_sse.md b/devlog/_plan/260906_a_runtime_stack/010_sse.md new file mode 100644 index 0000000000..e70fc2c223 --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/010_sse.md @@ -0,0 +1,218 @@ +# 010 — Surface SSE rewrite failure before tee cancellation (#3672) + +Status: candidate plan, docs-only; implementation class C3 (stream lifecycle). Evidence refreshed 2026-09-06 KST through GitHub API and local persistent refs. + +## Implementation-cycle completion versus landing + +This decade cycle ends with a reviewed prepared draft PR, exact-carried-head focused remote activation evidence and remote typecheck, with full CI dispatched. That cycle D does not claim the bug shipped, full CI passed, or an issue resolved. `060_landing.md` retains the mandatory full current-head cross-platform/type/privacy/docs evidence, review, dev ancestry and immediate source-PR/fully-resolved-issue closure gates. Later P consumes the verified prepared stack parent; it need not have landed yet. Only final landing yields feature DONE. + + +## Source, authorship and drift + +- Public PR: https://github.com/lidge-jun/opencodex/pull/3672 +- Exact original head/commit: `077dd61f66ac80678d071ae8fe516507f43a4264`, persistent ref `refs/codex/a-original/3672`. +- Original parent: `6585e6a70f42be8b6c81ff20d4fa0f39f7da03db`. +- Original author: Hako, GitHub `devswha`; trailer: `Co-authored-by: Hako <25837994+devswha@users.noreply.github.com>`. +- Planning dev/working HEAD: `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`, also confirmed by live dev API. Although commits differ, comparing original parent to dev restricted to the three original touched files returns no changed paths. Original patch applies to the same source blobs; later P must repeat this check. +- Live reviewThreads: zero. PR body reports focused/affected passes but explicitly does not claim a green full suite. No outstanding published code-review fix is presently known; exact carried-head remote tests and maintainer review remain acceptance gates. + +## Behavior and necessity + +Current `src/server/sse-payload-rewrite.ts:249-253` releases budget/disposes the rewrite, then awaits `reader.cancel(error)` before `controller.error(error)`. With a real tee and an open inspection sibling, cancellation waits for that sibling; the outer relay cannot observe the failure and abort the work that releases it. Reuse the existing failed-tail owner at `src/server/relay.ts:259`; no new error wrapper, retry mechanism, stream type or configuration is needed. Doing nothing leaves the wait cycle; deleting cancellation loses cleanup; configuration cannot fix the ordering. + +After the change, release/dispose remain synchronous, cancellation rejection is handled asynchronously, and `controller.error(error)` runs immediately. The outer failed-tail relay emits one `response.failed` then `[DONE]` and aborts upstream while inspection remains open. Budget overflow keeps `translation_buffer_limit`. Normal EOF, explicit client cancellation, rewriting, and disposal idempotence remain unchanged. + +## Exact file manifest and diff contract + +| Operation | Path | Required change | +|---|---|---| +| MODIFY | `src/server/sse-payload-rewrite.ts` | At catch line 252 replace awaited cancellation with `void reader.cancel(error).catch(() => {});` and explain the tee dependency. Keep release/dispose/error ordering. | +| MODIFY | `tests/responses/sse-payload-rewrite.test.ts` | Append the original parameterized real-tee regression after the current last test (line 153); cover source cancel resolve and reject, bounded completion and cleanup. | +| MODIFY | `docs-site/src/content/docs/reference/proxy-formats.md` | After line 83 add the five-line native rewrite failure/terminal/budget contract. | + +NEW: none. DELETE: none. Existing test file is already registered; no layout manifest edit. The appendix contains the exact original patch for all three paths, not an outline. No production implementation has been performed by this planning task. + +## Regression activation and independent acceptance + +1. Remote RED: place the original two added tests on the layer's current parent without the one-line production change in an isolated remote verification checkout. Hold a real tee sibling open, exhaust a 64-byte test budget with `data: partial` plus 80 bytes, and require both cases to reject with the one-second inspection-wait deadline. Record that failure, then restore the candidate patch remotely. +2. Remote GREEN: for resolve and reject cancellation, terminal arrives before inspection settles; exactly one `response.failed`, `translation_buffer_limit`, final `data: [DONE]`, abort signal true, zero source cancel calls before sibling release, one dispose, zero current budget bytes and one overflow. +3. Release inspection afterwards: underlying source cancel executes once; late cancellation rejection is observed/handled; no unhandled asynchronous error; disposal stays once. Test `finally` releases locks and budgets even on RED timeout. +4. Run adjacent failed-tail tests remotely to preserve disconnect, terminal and cancellation behavior. Existing Windows-sensitive composition must remain covered by an actual Windows run. +5. A reviewer confirms no awaited sibling-dependent cancellation remains on this exception path, no cancellation errors escape, and no downstream terminal duplication. This layer does not depend on #3679 or recovery/cache work. + +Remote focused command, after verifying remote checkout SHA and installing its pinned runtime/dependencies: + +```sh +bun test tests/responses/sse-payload-rewrite.test.ts tests/responses/sse-failed-tail.test.ts +``` + +Static anchors: `sse-payload-rewrite.ts:145` disposal guard, `:192` budget release, `:249` exception path, `:256` consumer cancellation; `relay.ts:259` failed-tail entry. The original regression fixture itself is the activation instrument; contributor-reported previous RED is context, not this layer's proof. + +#3679 shares only `docs-site/src/content/docs/reference/proxy-formats.md` with this layer. Preserve both paragraphs when the child lands. No release promotion or linked issue is bundled. + +## Execution boundary and resource scope + +This document is candidate planning for a later implementation P, authored during the first docs-only cycle. Main owns roadmap, FSM, goal, implementation and stack integration. This delegated task writes only this document and its sibling `010_sse.md`/`020_ws.md`; it does not run tests, typecheck, builds, Git mutations, GitHub mutations, FSM transitions or goal commands. + +Later implementation scope uses existing `gh` credentials and writes only the assigned own stack branches. Inherited parallel reviewers are authorized. There is no explicit user token/cost cap; a two-hour checkpoint triggers reassessment, not automatic success or abandonment. No production account probes, deployment or release actions belong to this layer. User explicitly forbids local suites; every executable verification below is for a remote isolated checkout or GitHub Actions later. No local typecheck/build is permitted here either. Security investigation material stays in `.tmp`; this public plan records only already-public PR behavior and general integration requirements. + +At the later P, refresh live dev and original PR head through main, compare touched-path blobs and parent changes, and amend this plan before implementation. A changed original SHA invalidates the carried-patch assumption. Preserve unrelated workers' changes. Main may carry the original commit with author identity preserved; every carry/superseding PR and squash message must include the exact `Co-authored-by` trailer below. Publish with the user's authorized `--no-verify` push, never a direct push to dev. Local hook bypass does not supply CI evidence. + +## Main-confirmed remote execution handoff + +Main reports the existing remote repository at `REMOTE_HOST:REMOTE_SOURCE_CHECKOUT` and Bun `1.3.14` have been verified. These are main-provided environment facts, not a local execution claim by this planner. Implementation C uses an isolated remote clone at the exact carried SHA; do not alter the existing remote checkout or its service. Record `git rev-parse HEAD` and `bun --version` from that isolated remote clone with focused activation-test and typecheck receipts. If the carried tree requires a different pinned Bun version, reconcile and record that runtime difference remotely before treating results as representative. + +Carry PRs remain draft until full current-head GitHub CI is green. Focused remote tests/typecheck are implementation evidence, not permission to skip full gates. The final landing cycle requires every full gate described below, including an actually executed Windows lane where Windows behavior is claimed, current-head review, and dev ancestry proof. No local project command execution is allowed at any point. Deeper implementation review belongs to the next cycle; this handoff completes only the concrete candidate plan. + +## Static workflow coverage and later remote evidence + +Inspected at `dev@81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`: + +- `.github/workflows/ci.yml:7` uses `pull_request: {}` without a base branch filter: an open stacked child gets the same workflow. Push trigger at line 27 covers integration branches only; pushing an own feature branch without opening its PR does not establish CI coverage. +- Runtime/test changes activate the `changes` gate and four Linux test shards (`ci.yml:255`), two macOS shards (`ci.yml:451`), and gates (`ci.yml:392`, typecheck at 422, privacy at 430). Linux test discovery is `scripts/ci/run-bun-test-batches.sh:197`; these layer tests are not the storage/API-usage exclusions at line 52. +- Windows full test shards are **dispatch-only**, `ci.yml:658-686`; ordinary PR CI cannot prove Windows behavior. `workflow_dispatch` has only `lane` (`ci.yml:46`), so use the own branch as `--ref`, not a nonexistent SHA input. `lane=all` runs Windows plus the unsharded macOS control (`ci.yml:549`). +- The aggregate `ci` accepts intentional skips (`ci.yml:927`); a green aggregate alone cannot prove a Windows run, regression activation, or even runtime tests on a docs-only PR. Check producer job conclusions and logs. +- `.github/actions/setup-project-bun/action.yml:18` resolves the runtime from `package.json.dependencies.bun`. Record actual Bun version rather than substituting contributor-reported Bun 1.4.0 results. + +Later main-owned CI commands (not executed by this planning task): + +```sh +# Freeze/read own branch head first; then dispatch its checked-in workflow. +gh workflow run ci.yml --repo lidge-jun/opencodex --ref "$A_LAYER_BRANCH" -f lane=all +gh run list --repo lidge-jun/opencodex --workflow ci.yml --branch "$A_LAYER_BRANCH" --limit 10 --json databaseId,headSha,event,status,conclusion +gh run view "$A_RUN_ID" --repo lidge-jun/opencodex --json headSha,event,conclusion,jobs +gh run view "$A_RUN_ID" --repo lidge-jun/opencodex --log +``` + +Assert dispatch `headSha` equals the frozen layer head. For PR merge-ref runs record actual checkout SHA and its head/base parents. A refresh/restack/new commit requires evidence for that resulting tree. Capture URLs, SHA, OS, runtime, command, exit code, failed/skipped test counts and any baseline comparison in main's evidence receipt. `action_required`, pending/cancelled checks, hygiene-only success and author attestations are not green test evidence. Do not check a contributor's local-CI attestation when no such local execution occurred. + +Full relevant suite coverage, typecheck, privacy and docs build must run remotely before readiness. For separately authorized remote checkout verification, install pinned dependencies there, run `bun run typecheck`, `bun run privacy:scan`, `bun run test`, and `(cd docs-site && bun run build)` there. Do not run those commands in the local managed workspace. Failures require a named current-base comparison and repair/reassessment; historic Windows failures do not automatically excuse a new failure. + +## Integration and close-out + +Each layer must be reviewable and independently acceptable against its immediate parent. No acceptance depends on a later A layer fixing its behavior. Main merges bottom-up with current-head CI and review evidence, retargets/restacks children before parent branch deletion, and preserves author trailers in squash/carry history. After main verifies the resulting merge commit is an ancestor of freshly fetched dev, immediately close the superseded original PR with the carry PR/commit reference. Close a linked issue only when its full acceptance scope is satisfied; do not infer an issue from a similar title. This planning task performs none of those actions. + +## Original patch appendix (candidate implementation) + +The following is source material already published in the linked PR. Revalidate context at the later P; do not apply during the docs-only cycle. + +```diff +diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md +index 77a67147a..19049e87f 100644 +--- a/docs-site/src/content/docs/reference/proxy-formats.md ++++ b/docs-site/src/content/docs/reference/proxy-formats.md +@@ -83,6 +83,11 @@ This applies to both tee inspection and eager relay, including Windows rewrite t + even when the upstream read rejects before the response-body cancellation hook runs. + A terminal captured during the bounded post-disconnect drain retains its actual outcome. + ++If native passthrough rewriting fails, including when it exceeds the translation ++buffer budget, the relay reports the failure without waiting for upstream inspection ++to finish. It cancels the upstream work and emits `response.failed` followed by ++`data: [DONE]`; a budget overflow uses the `translation_buffer_limit` error code. ++ + Client-facing Responses SSE frames are limited to 4 MiB per frame, measured in raw bytes before the + SSE block delimiter. On HTTP, an unterminated upstream frame that exceeds the limit fails closed + with a synthetic `response.failed` event followed by `data: [DONE]`. On the Responses WebSocket +diff --git a/src/server/sse-payload-rewrite.ts b/src/server/sse-payload-rewrite.ts +index 3c6d825e6..f9fb62065 100644 +--- a/src/server/sse-payload-rewrite.ts ++++ b/src/server/sse-payload-rewrite.ts +@@ -249,7 +249,9 @@ export function relaySseWithBlockRewrite( + } catch (error) { + releaseBuffer(); + disposeRewrite(); +- try { await reader.cancel(error); } catch { /* already closed */ } ++ // Cancelling one tee branch waits for its sibling. Surface the failure ++ // now so downstream can abort upstream and release the inspection branch. ++ void reader.cancel(error).catch(() => {}); + controller.error(error); + } + }, +diff --git a/tests/responses/sse-payload-rewrite.test.ts b/tests/responses/sse-payload-rewrite.test.ts +index 34dae59e0..773665a05 100644 +--- a/tests/responses/sse-payload-rewrite.test.ts ++++ b/tests/responses/sse-payload-rewrite.test.ts +@@ -153,4 +153,82 @@ describe("SSE payload rewrite composition", () => { + expect(budget.snapshot().currentBytes).toBe(0); + budget.dispose(); + }); ++ ++ test.each(["resolve", "reject"] as const)( ++ "surfaces a rewrite failure before tee cancellation can %s", ++ async cancellationOutcome => { ++ const budget = createTestTranslatorBudget({ maxTurnBytes: 64 }); ++ const upstream = new AbortController(); ++ const cancellation = Promise.withResolvers(); ++ const cancellationError = new Error("upstream cancellation failed"); ++ let cancelCalls = 0; ++ let disposeCalls = 0; ++ const source = new ReadableStream({ ++ start(controller) { ++ controller.enqueue(new TextEncoder().encode("data: partial")); ++ controller.enqueue(new TextEncoder().encode("x".repeat(80))); ++ // Keep the source open after exhausting the rewrite budget. ++ }, ++ cancel() { ++ cancelCalls += 1; ++ return cancellation.promise; ++ }, ++ }); ++ const [native, inspection] = source.tee(); ++ const inspectionReader = inspection.getReader(); ++ await inspectionReader.read(); ++ await inspectionReader.read(); ++ let inspectionSettled = false; ++ const pendingInspection = inspectionReader.read().then(() => { inspectionSettled = true; }); ++ const rewrite = Object.assign((block: string) => [block], { ++ dispose() { disposeCalls += 1; }, ++ }); ++ const rewritten = relaySseWithBlockRewrite(native, rewrite, budget); ++ const client = relaySseWithFailedTail(rewritten, upstream); ++ const completion = readAll(client); ++ let deadline: ReturnType | undefined; ++ ++ try { ++ const out = await Promise.race([ ++ completion, ++ new Promise((_, reject) => { ++ deadline = setTimeout(() => reject(new Error("rewrite failure waited for the inspection tee")), 1_000); ++ }), ++ ]); ++ expect(out.match(/event: response.failed/g)).toHaveLength(1); ++ expect(out).toContain('"code":"translation_buffer_limit"'); ++ expect(out).toEndWith("data: [DONE]\n\n"); ++ expect(upstream.signal.aborted).toBe(true); ++ expect(inspectionSettled).toBe(false); ++ expect(cancelCalls).toBe(0); ++ expect(disposeCalls).toBe(1); ++ expect(budget.snapshot().currentBytes).toBe(0); ++ expect(budget.snapshot().overflows).toBe(1); ++ ++ // Releasing inspection settles both tee cancellation promises. A late ++ // rejection must be handled by the rewriter as well as this reader. ++ const siblingCancellation = inspectionReader.cancel("inspection cleanup"); ++ expect(cancelCalls).toBe(1); ++ if (cancellationOutcome === "reject") { ++ cancellation.reject(cancellationError); ++ await expect(siblingCancellation).rejects.toBe(cancellationError); ++ } else { ++ cancellation.resolve(); ++ await siblingCancellation; ++ } ++ await pendingInspection; ++ await Bun.sleep(0); // Let the runner observe any unhandled cancellation rejection. ++ expect(disposeCalls).toBe(1); ++ } finally { ++ clearTimeout(deadline); ++ const cleanup = inspectionReader.cancel().catch(() => {}); ++ cancellation.resolve(); ++ await cleanup; ++ await pendingInspection; ++ await completion.catch(() => {}); ++ inspectionReader.releaseLock(); ++ budget.dispose(); ++ } ++ }, ++ ); + }); +``` diff --git a/devlog/_plan/260906_a_runtime_stack/011_sse_refresh.md b/devlog/_plan/260906_a_runtime_stack/011_sse_refresh.md new file mode 100644 index 0000000000..506c051db2 --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/011_sse_refresh.md @@ -0,0 +1,3 @@ +# SSE layer P refresh + +The original #3672 head remains 077dd61f66ac80678d071ae8fe516507f43a4264 and open. Fresh dev fetch and restricted original-parent/dev diff show no drift in all three touched files. Consume 010 unchanged. Implementation branch: codex/a-01-sse; base dev, including audited roadmap commit. Carry original Hako commit with -x and unchanged author. All project verification remote; actual tee failure/late reject regression plus adjacent failed-tail and typecheck on exact carried SHA. Full CI remains mandatory before landing. diff --git a/devlog/_plan/260906_a_runtime_stack/020_ws.md b/devlog/_plan/260906_a_runtime_stack/020_ws.md new file mode 100644 index 0000000000..766c662986 --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/020_ws.md @@ -0,0 +1,886 @@ +# 020 — Honor upstream WebSocket proxy routing (#3679) + +Status: candidate plan after layer 010, docs-only; implementation class C4 for the outbound routing boundary. Evidence refreshed 2026-09-06 KST; the 01:28 update supersedes the earlier triage snapshot. + +## Implementation-cycle completion versus landing + +This decade cycle ends with a reviewed prepared draft PR, exact-carried-head focused remote activation evidence and remote typecheck, with full CI dispatched. That cycle D does not claim the bug shipped, full CI passed, or an issue resolved. `060_landing.md` retains the mandatory full current-head cross-platform/type/privacy/docs evidence, review, dev ancestry and immediate source-PR/fully-resolved-issue closure gates. Later P consumes the verified prepared stack parent; it need not have landed yet. Only final landing yields feature DONE. + + +## Source, authorship and drift + +- Public PR: https://github.com/lidge-jun/opencodex/pull/3679 +- Exact current original head/commit: `b05cccf264b4ab61db5d8dee8232c2f89bb1b541`, persistent ref `refs/codex/a-original/3679`. +- Original parent and current live dev: `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`. +- Original author: Clive Rosfield, GitHub `S0RYUASUKA`; trailer: `Co-authored-by: Clive Rosfield <64878945+S0RYUASUKA@users.noreply.github.com>`. +- Ref/head equality verified. All 13 original touched files have identical parent/dev blobs. Layer 010 will additionally change `proxy-formats.md`; preserve its failure paragraph. `src/config.ts` overlaps lane B ownership, so main must recheck fresh dev and coordinate its comment hunk at later P. +- The body still names earlier tested head `182006615c484756012f2d0c1ba72f47c4e5cf5b`. Its counts are author-reported evidence for that head, not proof of this updated head or a later carry. Full suite is explicitly incomplete/non-green in the body. + +## Current review resolution + +All three live review threads are now resolved, not outstanding: + +- Companion documentation request was addressed by this head: https://github.com/lidge-jun/opencodex/pull/3679#discussion_r3941233811 . Provider guide and adapter reference now distinguish adapter selection from transport selection. +- Proxy precedence request was withdrawn; preserve scheme-specific environment precedence and `config.proxy` filling absent scheme variables. The resulting HTTPS proxy intentionally precedes ALL_PROXY. Current patch adds uppercase/lowercase ALL_PROXY regression coverage: https://github.com/lidge-jun/opencodex/pull/3679#discussion_r3941252968 . Do not reintroduce the withdrawn behavior change. +- The separate proxy policy request was withdrawn; retain established operator-selected HTTP/HTTPS proxy support in this routing-only layer: https://github.com/lidge-jun/opencodex/pull/3679#discussion_r3941252966 . Any new investigation belongs in scratch, not this document. + +Remaining gates: independent current-head routing/security review under MAINTAINERS.md and remote executed verification. A resolved bot discussion does not substitute for that review. + +## Behavior and reuse decision + +Current `src/server/responses/codex-ws-session.ts:12-14` constructs WebSocket with headers only. `ws-upstream.ts:167-169` does not resolve or pass a proxy, and pool identity at `codex-ws-pool.ts:55` does not distinguish routes. `src/lib/proxy-env.ts:28` already owns HTTP fetch proxy matching; `src/lib/provider-outbound.ts:79` owns NO_PROXY matching. Reuse and move that matcher rather than adding a second implementation or altering Bun HTTP fetch rules. + +After the patch, choose a route once before dialing. NO_PROXY wins (WSS default 443, WS default 80). Otherwise choose first nonempty HTTPS_PROXY/https_proxy/ALL_PROXY/all_proxy for WSS; HTTP_PROXY alone is not a WSS proxy. An unusable selected value returns immediate HTTP/SSE fallback without dialing WebSocket or trying a lower-priority proxy. HTTP/SSE continues its existing scheme-specific behavior; ALL_PROXY does not become an HTTP fetch input. One-shot and retained sessions receive the same selected route. Pool reuse key includes the route while scope still identifies account/thread/turn; changed route retires the old session. Existing dispatch refusal, abort, headers, quota handling and post-send no-replay behavior remain intact. + +No-code/config-only options do not cover Bun WebSocket construction or retained-session route affinity; no new transport, package dependency, proxy discovery method or routing flag is necessary. + +## Exact file manifest and diff contract + +All operations are MODIFY; NEW and DELETE are none. The appendix is the complete diff against the pinned original parent. No new test file means no layout registration additions. + +| Path | Before → after / exact change | +|---|---| +| `src/lib/proxy-env.ts` | After ProxyEnvMap (line 5), add ProxyRoute direct/proxy/fallback union; exported normalizeProxyHostname and noProxyMatches moved from provider-outbound; matcher accepts an env map and WSS default port. Add resolveProxyRoute with first-nonempty selection, HTTP/HTTPS scheme acceptance and fallback on parse/unsupported value. Keep effectiveProxyFor semantics unchanged. | +| `src/lib/provider-outbound.ts` | Import the shared matcher/normalizer, delete private copies and configuredProxyFor wrapper, call outboundProxyConfigured directly. Keep DNS/destination admission and effective HTTP proxy snapshot logic unchanged. | +| `src/config.ts` | Update only the applyProxyEnv comment at line 3739 to explain transport use and scheme-versus-ALL precedence. No executable ALL_PROXY guard is added. | +| `src/server/responses/ws-upstream.ts` | Import resolver; after frame-size guard at line 151 compute wsUrl, route and optional proxy; fallback before creating socket on route fallback; pass same proxy to identity, pool acquire and one-shot constructor. Existing admission hooks remain effective through HTTP fallback and WS exchange. | +| `src/server/responses/codex-ws-pool.ts` | Add optional proxy to identity/acquire signatures at lines 28/78, include proxy-or-null in hashed key at 55 and forward it into retained constructor at 97. Do not change scope, bounds or eviction. | +| `src/server/responses/codex-ws-session.ts` | Add optional fifth constructor argument; append proxy option only when selected. Preserve headers and all listener/lease lifecycle behavior. | +| `tests/server/proxy-env.test.ts` | Add ALL_PROXY spellings to saved/restored fixture env. Add resolver precedence/bypass/fallback cases, direct Bun WebSocket CONNECT fixture, Windows-only NO_PROXY fetch fixture, and both config-versus-ALL precedence cases. | +| `tests/responses/ws-upstream.test.ts` | Capture constructor options, isolate/restore all proxy env values, assert option+header propagation, zero sockets/one fallback for malformed/unsupported selection, existing upgrade fallback through proxy, NO_PROXY header/custom destination behavior. | +| `tests/responses/ws-upstream-reuse.test.ts` | Isolate/restore proxy env, capture options, exercise proxy A→B→NO_PROXY with two requests per route; expect three sockets, two frames each, old two closed and last retained. | +| `docs-site/src/content/docs/reference/proxy-formats.md` | Add canonical WSS routing, invalid-route fallback and scheme/config/ALL precedence paragraphs after line 113; retain 010's earlier SSE failure paragraph. | +| `docs-site/src/content/docs/guides/providers.md` | After line 620 distinguish adapter selection from transport with link to canonical rules. | +| `docs-site/src/content/docs/reference/adapters.md` | After line 95 add companion transport note, link and HTTP-vs-WSS distinction. | +| `structure/04_transports-and-sidecars.md` | At lines 438 and 647 include route in reuse identity and explain WSS route/fallback without changing HTTP rules. | + +Localized pages currently omit the new behavior; public review records no contradiction. Recheck that remains true at later P; do not add unrelated locale rewrites. The 13-file breadth is one route-selection contract with tests/docs, not 13 independent product changes; keep it one independently reviewed layer. + +## Regression activation and independent acceptance + +Remote RED/GREEN must prove each mechanism rather than only compiling the added API: + +1. Resolver: uppercase/lowercase ordering, blank values, fallback priority, invalid selected proxy, unsupported scheme; NO_PROXY exact/suffix/wildcard/port/IPv6/URL entry, uppercase-empty overriding lowercase; retain HTTP fetch behavior through provider-outbound tests. +2. Construction: one-shot and retained constructors get the chosen option with unchanged authorization/beta/originator/header filtering. Before the production change, a constructor-option assertion must fail on the parent. The test-only resolver import must not be mistaken for sufficient behavioral RED. +3. Invalid route: zero created sockets and exactly one fallback. NO_PROXY produces direct option omission; HTTP_PROXY-only does not create a WSS proxy route. Existing dispatch-refusal/aborted/post-send tests must retain no duplicate dispatch or replay. +4. Affinity: two requests on route A reuse, route B causes replacement, NO_PROXY causes another replacement; sockets `[A,B,direct]`, two frames each, states `[closed,closed,open]`. Parent without proxy in key must fail this assertion remotely. +5. Real runtime: original `proxy-env.test.ts` local CONNECT fixture executes **on the remote test runner**, observing `proxy-probe.invalid:443` with a loopback HTTP proxy. This fixture directly constructs Bun WebSocket; it does not by itself prove codexWsUpstreamFetch integration. Combine it with option-propagation tests and capture a separate remote loopback harness through codexWsUpstreamFetch if an end-to-end integration claim is made. No production credentials required. +6. Actual Windows execution must exercise the new Windows-only NO_PROXY fetch fixture; a Linux skip is expected and not Windows proof. Check full CI and privacy independently; validate disposal/no lingering test listener behavior. + +Remote focused command (only inside verified remote checkout, using fixture-specific env cleanup and restoration): + +```sh +bun test tests/server/proxy-env.test.ts tests/providers/provider-outbound.test.ts tests/providers/provider-outbound-private-network.test.ts tests/responses/ws-upstream.test.ts tests/responses/ws-upstream-reuse.test.ts tests/responses/reserve-dispatch-ws.test.ts --timeout 20000 +``` + +Control the eight HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY case variants within the isolated remote test process; never clear the user's global environment. Original contributor observed inherited-environment failures on an older baseline; reproduce any new discrepancy against this layer's exact parent before classifying it. Any skipped runtime probe must be recorded as unproven rather than silently accepted. + +## Execution boundary and resource scope + +This document is candidate planning for a later implementation P, authored during the first docs-only cycle. Main owns roadmap, FSM, goal, implementation and stack integration. This delegated task writes only this document and its sibling `010_sse.md`/`020_ws.md`; it does not run tests, typecheck, builds, Git mutations, GitHub mutations, FSM transitions or goal commands. + +Later implementation scope uses existing `gh` credentials and writes only the assigned own stack branches. Inherited parallel reviewers are authorized. There is no explicit user token/cost cap; a two-hour checkpoint triggers reassessment, not automatic success or abandonment. No production account probes, deployment or release actions belong to this layer. User explicitly forbids local suites; every executable verification below is for a remote isolated checkout or GitHub Actions later. No local typecheck/build is permitted here either. Security investigation material stays in `.tmp`; this public plan records only already-public PR behavior and general integration requirements. + +At the later P, refresh live dev and original PR head through main, compare touched-path blobs and parent changes, and amend this plan before implementation. A changed original SHA invalidates the carried-patch assumption. Preserve unrelated workers' changes. Main may carry the original commit with author identity preserved; every carry/superseding PR and squash message must include the exact `Co-authored-by` trailer below. Publish with the user's authorized `--no-verify` push, never a direct push to dev. Local hook bypass does not supply CI evidence. + +## Main-confirmed remote execution handoff + +Main reports the existing remote repository at `REMOTE_HOST:REMOTE_SOURCE_CHECKOUT` and Bun `1.3.14` have been verified. These are main-provided environment facts, not a local execution claim by this planner. Implementation C uses an isolated remote clone at the exact carried SHA; do not alter the existing remote checkout or its service. Record `git rev-parse HEAD` and `bun --version` from that isolated remote clone with focused activation-test and typecheck receipts. If the carried tree requires a different pinned Bun version, reconcile and record that runtime difference remotely before treating results as representative. + +Carry PRs remain draft until full current-head GitHub CI is green. Focused remote tests/typecheck are implementation evidence, not permission to skip full gates. The final landing cycle requires every full gate described below, including an actually executed Windows lane where Windows behavior is claimed, current-head review, and dev ancestry proof. No local project command execution is allowed at any point. Deeper implementation review belongs to the next cycle; this handoff completes only the concrete candidate plan. + +## Static workflow coverage and later remote evidence + +Inspected at `dev@81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`: + +- `.github/workflows/ci.yml:7` uses `pull_request: {}` without a base branch filter: an open stacked child gets the same workflow. Push trigger at line 27 covers integration branches only; pushing an own feature branch without opening its PR does not establish CI coverage. +- Runtime/test changes activate the `changes` gate and four Linux test shards (`ci.yml:255`), two macOS shards (`ci.yml:451`), and gates (`ci.yml:392`, typecheck at 422, privacy at 430). Linux test discovery is `scripts/ci/run-bun-test-batches.sh:197`; these layer tests are not the storage/API-usage exclusions at line 52. +- Windows full test shards are **dispatch-only**, `ci.yml:658-686`; ordinary PR CI cannot prove Windows behavior. `workflow_dispatch` has only `lane` (`ci.yml:46`), so use the own branch as `--ref`, not a nonexistent SHA input. `lane=all` runs Windows plus the unsharded macOS control (`ci.yml:549`). +- The aggregate `ci` accepts intentional skips (`ci.yml:927`); a green aggregate alone cannot prove a Windows run, regression activation, or even runtime tests on a docs-only PR. Check producer job conclusions and logs. +- `.github/actions/setup-project-bun/action.yml:18` resolves the runtime from `package.json.dependencies.bun`. Record actual Bun version rather than substituting contributor-reported Bun 1.4.0 results. + +Later main-owned CI commands (not executed by this planning task): + +```sh +# Freeze/read own branch head first; then dispatch its checked-in workflow. +gh workflow run ci.yml --repo lidge-jun/opencodex --ref "$A_LAYER_BRANCH" -f lane=all +gh run list --repo lidge-jun/opencodex --workflow ci.yml --branch "$A_LAYER_BRANCH" --limit 10 --json databaseId,headSha,event,status,conclusion +gh run view "$A_RUN_ID" --repo lidge-jun/opencodex --json headSha,event,conclusion,jobs +gh run view "$A_RUN_ID" --repo lidge-jun/opencodex --log +``` + +Assert dispatch `headSha` equals the frozen layer head. For PR merge-ref runs record actual checkout SHA and its head/base parents. A refresh/restack/new commit requires evidence for that resulting tree. Capture URLs, SHA, OS, runtime, command, exit code, failed/skipped test counts and any baseline comparison in main's evidence receipt. `action_required`, pending/cancelled checks, hygiene-only success and author attestations are not green test evidence. Do not check a contributor's local-CI attestation when no such local execution occurred. + +Full relevant suite coverage, typecheck, privacy and docs build must run remotely before readiness. For separately authorized remote checkout verification, install pinned dependencies there, run `bun run typecheck`, `bun run privacy:scan`, `bun run test`, and `(cd docs-site && bun run build)` there. Do not run those commands in the local managed workspace. Failures require a named current-base comparison and repair/reassessment; historic Windows failures do not automatically excuse a new failure. + +## Integration and close-out + +Each layer must be reviewable and independently acceptable against its immediate parent. No acceptance depends on a later A layer fixing its behavior. Main merges bottom-up with current-head CI and review evidence, retargets/restacks children before parent branch deletion, and preserves author trailers in squash/carry history. After main verifies the resulting merge commit is an ancestor of freshly fetched dev, immediately close the superseded original PR with the carry PR/commit reference. Close a linked issue only when its full acceptance scope is satisfied; do not infer an issue from a similar title. This planning task performs none of those actions. + +## Original patch appendix (candidate implementation) + +The following is source material already published in the linked PR. Revalidate context at the later P; do not apply during the docs-only cycle. + +```diff +diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md +index 6a37cf8a7..255c0d8dc 100644 +--- a/docs-site/src/content/docs/guides/providers.md ++++ b/docs-site/src/content/docs/guides/providers.md +@@ -620,6 +620,12 @@ A provider is included when opencodex has a matching wire adapter, **not** based + (AI Studio, Vertex, and Antigravity/Cloud Code Assist modes), `azure` / `azure-openai`, `kiro`, and + `cursor`. A proprietary API without one of these implementations, such as native Amazon Bedrock, + is not supported directly. ++ ++Provider configuration selects the adapter; upstream transport selection is separate. Eligible ++Responses traffic can use WSS with [explicit proxy routing](/reference/proxy-formats/#json-and-sse-output). ++Invalid or unsupported WebSocket proxy settings fall back to HTTP/SSE, which uses Bun's HTTP ++proxy rules rather than the WSS-specific `ALL_PROXY` fallback. ++ + **GitHub Copilot** is an OAuth provider (`ocx login github-copilot`) that exchanges a GitHub + device-flow login for a short-lived Copilot API token — not a pasted API key. **GitLab Duo** remains + a key/subscription-token gateway on its OpenAI-compatible endpoint. **Cloudflare AI +diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md +index 1db98357d..e2a24c67d 100644 +--- a/docs-site/src/content/docs/reference/adapters.md ++++ b/docs-site/src/content/docs/reference/adapters.md +@@ -95,6 +95,11 @@ body and response, with narrow compatibility rewrites for routed gateways. + `forward` uses configured static headers without relaying caller authorization; `key` uses the + configured provider key. + ++Adapter selection does not select the upstream transport. Eligible requests can use the ++[upstream WebSocket proxy route](/reference/proxy-formats/#json-and-sse-output); invalid or unsupported ++WebSocket proxy settings fall back to HTTP/SSE. HTTP fetch-based Responses handling uses Bun's ++HTTP proxy rules and does not inherit the WSS-specific `ALL_PROXY` fallback. ++ + Noncanonical Responses gateways receive Codex's client-executed `tool_search` declaration as a + collision-safe public function tool. Matching request history and JSON/SSE function calls are + translated back to the private `tool_search` lifecycle for the client. Canonical OpenAI forward +diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md +index 77a67147a..b4d7e5dea 100644 +--- a/docs-site/src/content/docs/reference/proxy-formats.md ++++ b/docs-site/src/content/docs/reference/proxy-formats.md +@@ -113,6 +113,19 @@ the raw JSON frame and its SSE envelope at 4 MiB, and closes the upstream when i + would overflow. That overflow emits a terminal downstream `response.failed` event followed by + `[DONE]`. + ++The upstream WebSocket checks `NO_PROXY`/`no_proxy` first. Otherwise it uses the first non-empty ++`HTTPS_PROXY`, `https_proxy`, `ALL_PROXY`, or `all_proxy` value; `HTTP_PROXY` alone does not proxy a ++WSS connection. HTTP and HTTPS proxy URLs are passed to Bun. If the selected value is invalid or ++uses an unsupported protocol, opencodex skips the WebSocket attempt and uses HTTP/SSE instead of ++dialing the upstream directly. ++ ++These rules belong to the upstream WebSocket transport, independently of the selected provider ++adapter. HTTP fetch-based Responses requests, including SSE fallback, use Bun's HTTP proxy rules ++and do not use `ALL_PROXY`. `config.proxy` fills missing `HTTP_PROXY`/`HTTPS_PROXY` values; the ++resulting scheme-specific value also takes precedence over an existing `ALL_PROXY` for WebSocket. ++For an HTTPS upstream that requires a proxy, set `HTTPS_PROXY` or `config.proxy`; `HTTP_PROXY` ++alone leaves both WSS and its HTTPS fallback without a scheme-matched proxy. ++ + Every terminal Responses usage object includes both detail objects, even when the provider did not + report those details: + +diff --git a/src/config.ts b/src/config.ts +index 5d67275dc..72da45538 100644 +--- a/src/config.ts ++++ b/src/config.ts +@@ -3738,11 +3738,12 @@ function warnProxyConfigDiscardOnce(kind: "proxy" | "noProxy" | "noProxyElements + } + + /** +- * Mirror `config.proxy` into HTTP(S)_PROXY env vars so Bun's native fetch routes every outbound +- * provider call through the proxy — no per-callsite changes (verified: Bun honors these plus +- * NO_PROXY). User-set env vars always win; localhost/127.0.0.1 are appended to NO_PROXY so the +- * CLI's own health checks and running-proxy API calls stay direct. Call once per process entry +- * that makes outbound provider requests (server start, catalog sync). ++ * Mirror `config.proxy` into HTTP(S)_PROXY env vars. Bun fetch consumes them natively; transports ++ * such as the ChatGPT upstream WebSocket select the same environment explicitly. User-set HTTP(S)_PROXY ++ * variables win; config fills missing scheme proxies, which take precedence over ALL_PROXY for WS. ++ * localhost/127.0.0.1 are appended to NO_PROXY so the CLI's own health checks and ++ * running-proxy API calls stay direct. Call once per process entry that makes outbound provider ++ * requests (server start, catalog sync). + */ + export function applyProxyEnv(config: OcxConfig): void { + applyProxyEnvWith(config); +diff --git a/src/lib/provider-outbound.ts b/src/lib/provider-outbound.ts +index 495fef0b8..02bdbc207 100644 +--- a/src/lib/provider-outbound.ts ++++ b/src/lib/provider-outbound.ts +@@ -7,7 +7,7 @@ import { + resolvePublicAddresses, + } from "./destination-policy"; + import { pinnedHttpGet, pinnedHttpPost } from "./pinned-http"; +-import { effectiveProxyFor, outboundProxyConfigured } from "./proxy-env"; ++import { effectiveProxyFor, noProxyMatches, normalizeProxyHostname, outboundProxyConfigured } from "./proxy-env"; + import { publicProviderBaseUrl } from "./provider-url"; + + type ProviderGetInit = Omit; +@@ -37,10 +37,6 @@ function pickPinnedAddress(addresses: Array<{ address: string; family: number }> + return addresses.find(address => address.family === 4) ?? addresses[0]!; + } + +-function configuredProxyFor(): boolean { +- return outboundProxyConfigured(); +-} +- + /** + * Registry-owned fake-IP transparency exception (Clash/Surge/Mihomo TUN mode). + * +@@ -76,45 +72,6 @@ function transparentFakeIpException( + return isCanonicalUrl(name, url); + } + +-function normalizeProxyHostname(hostname: string): string { +- const normalized = hostname.trim().toLowerCase().replace(/\.+$/, ""); +- return normalized.startsWith("[") && normalized.endsWith("]") +- ? normalized.slice(1, -1) +- : normalized; +-} +- +-function noProxyMatches(url: URL): boolean { +- const raw = process.env.NO_PROXY ?? process.env.no_proxy ?? ""; +- const hostname = normalizeProxyHostname(url.hostname); +- const port = url.port || (url.protocol === "https:" ? "443" : "80"); +- for (const rawEntry of raw.split(",")) { +- let entry = rawEntry.trim().toLowerCase(); +- if (!entry) continue; +- if (entry === "*") return true; +- entry = entry.replace(/^https?:\/\//, "").split("/", 1)[0]!; +- +- let entryHost = entry; +- let entryPort = ""; +- const bracketed = /^\[([^\]]+)](?::(\d+))?$/.exec(entry); +- if (bracketed) { +- entryHost = bracketed[1]!; +- entryPort = bracketed[2] ?? ""; +- } else if ((entry.match(/:/g)?.length ?? 0) === 1) { +- const separator = entry.lastIndexOf(":"); +- const possiblePort = entry.slice(separator + 1); +- if (/^\d+$/.test(possiblePort)) { +- entryHost = entry.slice(0, separator); +- entryPort = possiblePort; +- } +- } +- if (entryPort && entryPort !== port) continue; +- entryHost = normalizeProxyHostname(entryHost.replace(/^\*?\./, "")); +- if (!entryHost) continue; +- if (hostname === entryHost || hostname.endsWith(`.${entryHost}`)) return true; +- } +- return false; +-} +- + let proxyBoundaryWarned = false; + let proxyDnsDegradationWarned = false; + +@@ -181,7 +138,7 @@ async function providerOutboundRequest( + return provider.fetch(url, { ...init, method, redirect: "manual" }); + } + const parsed = postUrl ?? new URL(url); +- const proxyConfigured = configuredProxyFor(); ++ const proxyConfigured = outboundProxyConfigured(); + // Snapshot the scheme-matched proxy once, before the DNS await, so admission and transport + // below reason about the same value. `null` here means "no proxy fetch would actually use", + // even if some other proxy variable is set. +diff --git a/src/lib/proxy-env.ts b/src/lib/proxy-env.ts +index 46df59268..0ac9ed735 100644 +--- a/src/lib/proxy-env.ts ++++ b/src/lib/proxy-env.ts +@@ -3,6 +3,73 @@ export const PROXY_ENV_KEYS = [...OUTBOUND_PROXY_ENV_KEYS, "NO_PROXY"] as const; + + export type ProxyEnvKey = typeof PROXY_ENV_KEYS[number]; + export type ProxyEnvMap = Record; ++export type ProxyRoute = ++ | { kind: "direct" } ++ | { kind: "proxy"; proxy: string } ++ | { kind: "fallback" }; ++ ++export function normalizeProxyHostname(hostname: string): string { ++ const normalized = hostname.trim().toLowerCase().replace(/\.+$/, ""); ++ return normalized.startsWith("[") && normalized.endsWith("]") ++ ? normalized.slice(1, -1) ++ : normalized; ++} ++ ++export function noProxyMatches( ++ url: URL, ++ env: ProxyEnvMap = process.env, ++): boolean { ++ const raw = env.NO_PROXY ?? env.no_proxy ?? ""; ++ const hostname = normalizeProxyHostname(url.hostname); ++ const port = url.port || (url.protocol === "https:" || url.protocol === "wss:" ? "443" : "80"); ++ for (const rawEntry of raw.split(",")) { ++ let entry = rawEntry.trim().toLowerCase(); ++ if (!entry) continue; ++ if (entry === "*") return true; ++ entry = entry.replace(/^(?:https?|wss?):\/\//, "").split("/", 1)[0]!; ++ ++ let entryHost = entry; ++ let entryPort = ""; ++ const bracketed = /^\[([^\]]+)](?::(\d+))?$/.exec(entry); ++ if (bracketed) { ++ entryHost = bracketed[1]!; ++ entryPort = bracketed[2] ?? ""; ++ } else if ((entry.match(/:/g)?.length ?? 0) === 1) { ++ const separator = entry.lastIndexOf(":"); ++ const possiblePort = entry.slice(separator + 1); ++ if (/^\d+$/.test(possiblePort)) { ++ entryHost = entry.slice(0, separator); ++ entryPort = possiblePort; ++ } ++ } ++ if (entryPort && entryPort !== port) continue; ++ entryHost = normalizeProxyHostname(entryHost.replace(/^\*?\./, "")); ++ if (entryHost && (hostname === entryHost || hostname.endsWith(`.${entryHost}`))) return true; ++ } ++ return false; ++} ++ ++export function resolveProxyRoute( ++ url: URL, ++ env: ProxyEnvMap = process.env, ++): ProxyRoute { ++ if (noProxyMatches(url, env)) return { kind: "direct" }; ++ const key = url.protocol === "https:" || url.protocol === "wss:" ++ ? "HTTPS_PROXY" ++ : "HTTP_PROXY"; ++ const proxy = [key, key.toLowerCase(), "ALL_PROXY", "all_proxy"] ++ .map(candidate => env[candidate]?.trim()) ++ .find(Boolean); ++ if (!proxy) return { kind: "direct" }; ++ try { ++ const protocol = new URL(proxy).protocol; ++ return protocol === "http:" || protocol === "https:" ++ ? { kind: "proxy", proxy } ++ : { kind: "fallback" }; ++ } catch { ++ return { kind: "fallback" }; ++ } ++} + + export function proxyEnvPresent( + key: ProxyEnvKey, +diff --git a/src/server/responses/codex-ws-pool.ts b/src/server/responses/codex-ws-pool.ts +index 378cf2d4a..5d406bee4 100644 +--- a/src/server/responses/codex-ws-pool.ts ++++ b/src/server/responses/codex-ws-pool.ts +@@ -25,7 +25,7 @@ function digest(input: unknown): string { + } + + /** Identity comes from the selected outgoing request, never a model label or caller hint. */ +-export function codexWsReuseIdentity(url: string, headers: Record, frameText: string): CodexWsReuseIdentity | null { ++export function codexWsReuseIdentity(url: string, headers: Record, frameText: string, proxy?: string): CodexWsReuseIdentity | null { + if (url !== CODEX_RESPONSES_HTTP_URL) return null; + let body: unknown; + try { body = JSON.parse(frameText); } catch { return null; } +@@ -52,7 +52,7 @@ export function codexWsReuseIdentity(url: string, headers: Record): CodexWsSession | null { ++ acquire(identity: CodexWsReuseIdentity, url: string, headers: Record, proxy?: string): CodexWsSession | null { + this.sweep(); + for (const entry of this.entries.values()) { + if (entry.identity.scope !== identity.scope || entry.identity.key === identity.key) continue; +@@ -94,7 +94,7 @@ export class CodexWsPool { + this.remove(oldest); + } + const createdAt = this.now(); +- const session = new CodexWsSession(url, headers, true, () => this.changed(entry)); ++ const session = new CodexWsSession(url, headers, true, () => this.changed(entry), proxy); + const entry: Entry = { identity, session, createdAt, idleAt: createdAt, retired: false }; + session.reserve(); + this.entries.set(identity.key, entry); +diff --git a/src/server/responses/codex-ws-session.ts b/src/server/responses/codex-ws-session.ts +index bbf62f813..32716a529 100644 +--- a/src/server/responses/codex-ws-session.ts ++++ b/src/server/responses/codex-ws-session.ts +@@ -10,8 +10,8 @@ export class CodexWsSession { + private readonly completedIds = new Set(); + + constructor(url: string, headers: Record, readonly retainable = false, +- private readonly changed: () => void = () => {}) { +- this.socket = new WebSocket(url, { headers } as unknown as string[]); ++ private readonly changed: () => void = () => {}, proxy?: string) { ++ this.socket = new WebSocket(url, { headers, ...(proxy ? { proxy } : {}) } as unknown as string[]); + this.socket.addEventListener("open", this.onOpen); + this.socket.addEventListener("message", this.onIdleMessage); + this.socket.addEventListener("close", this.onClose); +diff --git a/src/server/responses/ws-upstream.ts b/src/server/responses/ws-upstream.ts +index e9773d02a..87b3767d2 100644 +--- a/src/server/responses/ws-upstream.ts ++++ b/src/server/responses/ws-upstream.ts +@@ -13,6 +13,7 @@ + // (passthrough relay, adapter parsers, usage sniffing) is unchanged. + + import { compareBunVersions } from "../../lib/bun-stream-caps"; ++import { resolveProxyRoute } from "../../lib/proxy-env"; + import type { CodexWsQuotaObserver } from "./codex-ws-metadata"; + import { CODEX_RESPONSES_HTTP_URL, CODEX_RESPONSES_WS_URL, prepareCodexHttpInit, prepareCodexWsRequest } from "./codex-ws-request"; + import { codexWsExchange } from "./codex-ws-exchange"; +@@ -150,6 +151,10 @@ export function codexWsUpstreamFetch( + return sseFallback(url, init); + } + ++ const wsUrl = wsUpstreamUrlFor(url); ++ const proxyRoute = resolveProxyRoute(new URL(wsUrl)); ++ if (proxyRoute.kind === "fallback") return sseFallback(url, init); ++ const proxy = proxyRoute.kind === "proxy" ? proxyRoute.proxy : undefined; + // A genuine caller `originator` is already in these headers via the forward + // set. Never fabricate one here: pool/forward traffic must not impersonate + // Codex CLI, per the metadata-integrity contract. (The backend's fast lane +@@ -164,9 +169,9 @@ export function codexWsUpstreamFetch( + } + let session: CodexWsSession; + try { +- const identity = codexWsReuseIdentity(url, headers, frameText); +- session = (identity ? codexWsPool.acquire(identity, wsUpstreamUrlFor(url), headers) : null) +- ?? new CodexWsSession(wsUpstreamUrlFor(url), headers); ++ const identity = codexWsReuseIdentity(url, headers, frameText, proxy); ++ session = (identity ? codexWsPool.acquire(identity, wsUrl, headers, proxy) : null) ++ ?? new CodexWsSession(wsUrl, headers, false, undefined, proxy); + if (!session.busy && !session.reserve()) { + session.dispose(); + return sseFallback(url, init); +diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md +index 4ee22c114..a45a98c87 100644 +--- a/structure/04_transports-and-sidecars.md ++++ b/structure/04_transports-and-sidecars.md +@@ -435,7 +435,7 @@ These are transport-fidelity guarantees, not a provider-billing guarantee. + + Eligible complete-input creates can retain a canonical upstream socket within + one selected account, credential, thread and turn. Model/tier and immutable +-handshake headers must also match. Turn-state and turn-metadata headers are ++handshake headers and the selected outbound proxy must also match. Turn-state and turn-metadata headers are + projected into their same-name per-frame metadata slots; explicit body values win. + The pool retains at most 32 sockets, expires idle sockets after 30 seconds, and + retires a socket after five minutes or 32 successful exchanges (after active work +@@ -644,7 +644,11 @@ the upgrade with 426 so Codex falls back to HTTP cleanly. + + That setting controls the client-facing upgrade only. The transparent upstream + ChatGPT WS optimization described above is selected independently and still +-returns the same downstream SSE contract. ++returns the same downstream SSE contract. Its WSS route checks NO_PROXY first, then selects the ++first non-empty HTTPS_PROXY, https_proxy, ALL_PROXY, or all_proxy value. HTTP_PROXY alone does not ++route WSS. Unsupported or malformed selected proxy values skip the WebSocket attempt and use the ++existing SSE path immediately; they never fall through to a lower-priority proxy or direct WebSocket ++egress. HTTP/SSE fallback retains Bun fetch's own proxy rules, which do not consult ALL_PROXY. + + The endpoint handles `response.create`, ignores `response.processed`, supports warmup + `generate: false`, and feeds the same request pipeline as HTTP/SSE. +diff --git a/tests/responses/ws-upstream-reuse.test.ts b/tests/responses/ws-upstream-reuse.test.ts +index fd0a8fb5a..b957fdb31 100644 +--- a/tests/responses/ws-upstream-reuse.test.ts ++++ b/tests/responses/ws-upstream-reuse.test.ts +@@ -6,6 +6,8 @@ import { prepareCodexWsRequest } from "../../src/server/responses/codex-ws-reque + + const URL = "https://chatgpt.com/backend-api/codex/responses"; + const realWebSocket = globalThis.WebSocket; ++const proxyEnvKeys = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]; ++let savedProxyEnv: Record; + let sequence = 0; + + class Socket extends EventTarget { +@@ -13,7 +15,7 @@ class Socket extends EventTarget { + static onSend: (socket: Socket, frame: Record) => void = (socket) => socket.complete(); + readyState = 0; + frames: Record[] = []; +- constructor(readonly url: string) { ++ constructor(readonly url: string, readonly options?: { proxy?: string }) { + super(); + Socket.all.push(this); + queueMicrotask(() => { if (this.readyState === 0) { this.readyState = 1; this.dispatchEvent(new Event("open")); } }); +@@ -58,7 +60,11 @@ function bodyWith(fields: Record) { + options.body = JSON.stringify({ ...JSON.parse(options.body as string), ...fields }); + return options; + } +-beforeEach(() => { globalThis.WebSocket = Socket as unknown as typeof WebSocket; }); ++beforeEach(() => { ++ globalThis.WebSocket = Socket as unknown as typeof WebSocket; ++ savedProxyEnv = Object.fromEntries(proxyEnvKeys.map(key => [key, process.env[key]])); ++ for (const key of proxyEnvKeys) delete process.env[key]; ++}); + + afterEach(() => { + runOptionalShutdownHooks(); +@@ -67,6 +73,25 @@ afterEach(() => { + Socket.onSend = socket => socket.complete(); + sequence = 0; + globalThis.WebSocket = realWebSocket; ++ for (const key of proxyEnvKeys) delete process.env[key]; ++ for (const key of proxyEnvKeys) { ++ if (savedProxyEnv[key] !== undefined) process.env[key] = savedProxyEnv[key]; ++ } ++}); ++ ++test("proxy changes and NO_PROXY retire the old route while unchanged routes reuse", async () => { ++ for (const proxy of ["http://proxy-a.example:8080", "http://proxy-b.example:8080"]) { ++ process.env.HTTPS_PROXY = proxy; ++ await drain(); ++ await drain(); ++ } ++ process.env.NO_PROXY = "chatgpt.com:443"; ++ await drain(); ++ await drain(); ++ expect(Socket.all.map(socket => socket.options?.proxy)) ++ .toEqual(["http://proxy-a.example:8080", "http://proxy-b.example:8080", undefined]); ++ expect(Socket.all.map(socket => socket.frames.length)).toEqual([2, 2, 2]); ++ expect(Socket.all.map(socket => socket.readyState)).toEqual([3, 3, 1]); + }); + + test("same account/thread/turn reuses one socket without trimming either HTTP input", async () => { +diff --git a/tests/responses/ws-upstream.test.ts b/tests/responses/ws-upstream.test.ts +index fd0951307..cfb087a4b 100644 +--- a/tests/responses/ws-upstream.test.ts ++++ b/tests/responses/ws-upstream.test.ts +@@ -1,4 +1,4 @@ +-import { afterEach, describe, expect, jest, test } from "bun:test"; ++import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test"; + import { providerFetch } from "../../src/server/responses/fetch-helpers"; + import { handleResponses } from "../../src/server/responses"; + import { isEagerRelaySseResponse } from "../../src/server/relay"; +@@ -162,18 +162,24 @@ describe("shouldUseCodexWsUpstream", () => { + }); + + type Listener = (event: unknown) => void; ++type FakeWebSocketOptions = { ++ headers?: Record; ++ proxy?: string; ++}; + + /** Minimal scriptable stand-in for Bun's WebSocket. */ + class FakeWebSocket { + static instances: FakeWebSocket[] = []; + static script: (ws: FakeWebSocket) => void = () => {}; + url: string; ++ options?: FakeWebSocketOptions; + sent: string[] = []; + closed = false; + listeners = new Map(); + +- constructor(url: string) { ++ constructor(url: string, options?: FakeWebSocketOptions) { + this.url = url; ++ this.options = options; + FakeWebSocket.instances.push(this); + queueMicrotask(() => FakeWebSocket.script(this)); + } +@@ -205,12 +211,23 @@ class FakeWebSocket { + + const RealWebSocket = globalThis.WebSocket; + const RealFetch = globalThis.fetch; ++const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"] as const; ++let savedProxyEnv: Record; ++ ++beforeEach(() => { ++ savedProxyEnv = Object.fromEntries(PROXY_ENV_KEYS.map(key => [key, process.env[key]])); ++ for (const key of PROXY_ENV_KEYS) delete process.env[key]; ++}); + + afterEach(() => { + globalThis.WebSocket = RealWebSocket; + globalThis.fetch = RealFetch; + FakeWebSocket.instances = []; + FakeWebSocket.script = () => {}; ++ for (const key of PROXY_ENV_KEYS) delete process.env[key]; ++ for (const key of PROXY_ENV_KEYS) { ++ if (savedProxyEnv[key] !== undefined) process.env[key] = savedProxyEnv[key]; ++ } + }); + + function installFake(script: (ws: FakeWebSocket) => void) { +@@ -525,6 +542,41 @@ describe("codexWsUpstreamFetch", () => { + expect(text).not.toContain("must-not-leak"); + }); + ++ test("passes the selected proxy without changing handshake headers", async () => { ++ process.env.HTTPS_PROXY = "http://proxy.example:8080"; ++ installFake(ws => { ++ ws.emit("open", {}); ++ ws.emit("message", { data: JSON.stringify({ type: "response.completed", response: {} }) }); ++ }); ++ ++ await codexWsUpstreamFetch(CODEX_URL, streamingInit(), (() => { ++ throw new Error("fallback must not run"); ++ }) as unknown as typeof fetch); ++ ++ const options = FakeWebSocket.instances[0]!.options; ++ expect(options?.proxy).toBe("http://proxy.example:8080"); ++ expect(options?.headers?.authorization).toBe("Bearer test"); ++ expect(options?.headers?.["openai-beta"]).toContain("responses_websockets"); ++ expect(options?.headers?.["content-type"]).toBeUndefined(); ++ }); ++ ++ test.each([ ++ ["unsupported protocol", "socks5://proxy.example:1080"], ++ ["invalid URL", "not a proxy URL"], ++ ])("falls back once without dialing for an %s", async (_label, proxy) => { ++ process.env.HTTPS_PROXY = proxy; ++ const sentinel = new Response("sse-fallback"); ++ let fallbackCalls = 0; ++ const response = await codexWsUpstreamFetch(CODEX_URL, streamingInit(), (async () => { ++ fallbackCalls += 1; ++ return sentinel; ++ }) as typeof fetch); ++ ++ expect(response).toBe(sentinel); ++ expect(fallbackCalls).toBe(1); ++ expect(FakeWebSocket.instances).toHaveLength(0); ++ }); ++ + test("relays event frames as an SSE response and sends one response.create frame", async () => { + installFake(ws => { + ws.emit("open", {}); +@@ -654,6 +706,7 @@ describe("codexWsUpstreamFetch", () => { + }); + + test("falls back to the HTTP fetch when the upgrade is rejected before open", async () => { ++ process.env.HTTPS_PROXY = "http://proxy.example:8080"; + installFake(ws => ws.close()); + const sentinel = new Response("sse-fallback", { status: 429 }); + let fallbackCalls = 0; +@@ -666,6 +719,7 @@ describe("codexWsUpstreamFetch", () => { + expect(response).toBe(sentinel); + expect(isCodexWsUpstreamResponse(response)).toBe(false); + expect(fallbackCalls).toBe(1); ++ expect(FakeWebSocket.instances[0]!.options?.proxy).toBe("http://proxy.example:8080"); + }); + + test("falls back to the HTTP fetch when the upgrade deadline elapses without open or close", async () => { +@@ -800,15 +854,17 @@ describe("codexWsUpstreamFetch", () => { + }); + + test("preserves caller headers on the handshake without fabricating an originator", async () => { +- const seen: Record[] = []; ++ process.env.HTTPS_PROXY = "http://proxy.example:8080"; ++ process.env.NO_PROXY = "chatgpt.com:443"; ++ const seen: FakeWebSocketOptions[] = []; + FakeWebSocket.script = ws => { + ws.emit("open", {}); + ws.emit("message", { data: JSON.stringify({ type: "response.completed", response: {} }) }); + }; + class HeaderCapturingWebSocket extends FakeWebSocket { +- constructor(url: string, options?: { headers?: Record }) { +- super(url); +- seen.push(options?.headers ?? {}); ++ constructor(url: string, options?: FakeWebSocketOptions) { ++ super(url, options); ++ seen.push(options ?? {}); + } + } + globalThis.WebSocket = HeaderCapturingWebSocket as unknown as typeof WebSocket; +@@ -817,18 +873,19 @@ describe("codexWsUpstreamFetch", () => { + await codexWsUpstreamFetch(CODEX_URL, streamingInit(), fallback); + // Without a caller originator none is invented: pool/forward traffic must + // not impersonate Codex CLI (metadata-integrity contract). +- expect(seen[0].originator).toBeUndefined(); +- expect(seen[0]["openai-beta"]).toContain("responses_websockets"); +- expect(seen[0].authorization).toBe("Bearer test"); ++ expect(seen[0].proxy).toBeUndefined(); ++ expect(seen[0].headers?.originator).toBeUndefined(); ++ expect(seen[0].headers?.["openai-beta"]).toContain("responses_websockets"); ++ expect(seen[0].headers?.authorization).toBe("Bearer test"); + // HTTP body-framing headers do not belong on a WS handshake. +- expect(seen[0]["content-type"]).toBeUndefined(); ++ expect(seen[0].headers?.["content-type"]).toBeUndefined(); + + // A genuine caller originator is forwarded verbatim. + await codexWsUpstreamFetch(CODEX_URL, { + ...streamingInit(), + headers: { ...streamingInit().headers as Record, originator: "codex_cli_rs" }, + }, fallback); +- expect(seen[1].originator).toBe("codex_cli_rs"); ++ expect(seen[1].headers?.originator).toBe("codex_cli_rs"); + }); + + test("aborting before open rejects like an aborted fetch", async () => { +@@ -1194,6 +1251,8 @@ describe("oversized Codex create frames", () => { + }); + + test("dials the configured provider's own wss URL for an opt-in upstream", async () => { ++ process.env.HTTPS_PROXY = "http://proxy.example:8080"; ++ process.env.NO_PROXY = "sub2api.example.com:443"; + installFake(ws => { + ws.emit("open", {}); + ws.emit("message", { data: JSON.stringify({ type: "response.completed", response: { id: "r-ws" } }) }); +@@ -1206,6 +1265,7 @@ describe("oversized Codex create frames", () => { + ); + expect(FakeWebSocket.instances).toHaveLength(1); + expect(FakeWebSocket.instances[0]!.url).toBe("wss://sub2api.example.com/v1/responses"); ++ expect(FakeWebSocket.instances[0]!.options?.proxy).toBeUndefined(); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + expect(await response.text()).toContain("response.completed"); + }); +diff --git a/tests/server/proxy-env.test.ts b/tests/server/proxy-env.test.ts +index e43ad2d9b..c795c6cf2 100644 +--- a/tests/server/proxy-env.test.ts ++++ b/tests/server/proxy-env.test.ts +@@ -1,8 +1,10 @@ + import { afterEach, beforeEach, describe, expect, test } from "bun:test"; ++import { createServer } from "node:http"; + import { applyProxyEnv } from "../../src/config"; ++import { resolveProxyRoute } from "../../src/lib/proxy-env"; + import type { OcxConfig } from "../../src/types"; + +-const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy", "OCX_TEST_PROXY_REF", "OCX_TEST_NO_PROXY_REF"] as const; ++const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy", "OCX_TEST_PROXY_REF", "OCX_TEST_NO_PROXY_REF"] as const; + let saved: Record; + + beforeEach(() => { +@@ -30,6 +32,128 @@ function configWithRawProxy(proxy: unknown, noProxy?: unknown): OcxConfig { + return { proxy, noProxy, providers: {} } as unknown as OcxConfig; + } + ++describe("resolveProxyRoute", () => { ++ test("wss uses HTTPS_PROXY and never HTTP_PROXY", () => { ++ const target = new URL("wss://chatgpt.com/backend-api/codex/responses"); ++ expect(resolveProxyRoute(target, { ++ HTTPS_PROXY: "http://secure-proxy.example:8443", ++ HTTP_PROXY: "http://plain-proxy.example:8080", ++ })).toEqual({ kind: "proxy", proxy: "http://secure-proxy.example:8443" }); ++ expect(resolveProxyRoute(target, { ++ HTTP_PROXY: "http://plain-proxy.example:8080", ++ })).toEqual({ kind: "direct" }); ++ }); ++ ++ test.each([ ++ ["exact host", "wss://chatgpt.com/path", "chatgpt.com", "direct"], ++ ["domain suffix", "wss://api.chatgpt.com/path", ".chatgpt.com", "direct"], ++ ["wildcard suffix", "wss://api.chatgpt.com/path", "*.chatgpt.com", "direct"], ++ ["wss default port", "wss://chatgpt.com/path", "chatgpt.com:443", "direct"], ++ ["ws default port", "ws://chatgpt.com/path", "chatgpt.com:80", "direct"], ++ ["port mismatch", "wss://chatgpt.com/path", "chatgpt.com:80", "proxy"], ++ ["bracketed IPv6", "wss://[2001:db8::1]/path", "[2001:db8::1]:443", "direct"], ++ ["URL-style entry", "wss://chatgpt.com/path", "https://chatgpt.com/ignored", "direct"], ++ ] as const)("honors NO_PROXY for %s", (_label, target, noProxy, expectedKind) => { ++ expect(resolveProxyRoute(new URL(target), { ++ HTTPS_PROXY: "http://secure-proxy.example:8443", ++ NO_PROXY: noProxy, ++ }).kind).toBe(expectedKind); ++ }); ++ ++ test("uses stable proxy precedence and fails closed on the first unusable proxy", () => { ++ const target = new URL("wss://chatgpt.com/backend-api/codex/responses"); ++ const route = (env: Record) => resolveProxyRoute(target, env); ++ expect([ ++ route({ HTTPS_PROXY: "http://upper-https:1", https_proxy: "http://lower-https:2", ALL_PROXY: "http://upper-all:3", all_proxy: "http://lower-all:4" }), ++ route({ HTTPS_PROXY: " ", https_proxy: "http://lower-https:2", ALL_PROXY: "http://upper-all:3" }), ++ route({ ALL_PROXY: "http://upper-all:3", all_proxy: "http://lower-all:4" }), ++ route({ all_proxy: "https://lower-all:4" }), ++ route({ HTTPS_PROXY: "socks5://unsupported:1080", ALL_PROXY: "http://must-not-win:3" }), ++ route({ HTTPS_PROXY: "not a proxy URL", ALL_PROXY: "http://must-not-win:3" }), ++ route({}), ++ ]).toEqual([ ++ { kind: "proxy", proxy: "http://upper-https:1" }, ++ { kind: "proxy", proxy: "http://lower-https:2" }, ++ { kind: "proxy", proxy: "http://upper-all:3" }, ++ { kind: "proxy", proxy: "https://lower-all:4" }, ++ { kind: "fallback" }, ++ { kind: "fallback" }, ++ { kind: "direct" }, ++ ]); ++ }); ++ ++ test("preserves uppercase NO_PROXY precedence when it is explicitly empty", () => { ++ expect(resolveProxyRoute(new URL("wss://chatgpt.com/path"), { ++ HTTPS_PROXY: "http://secure-proxy.example:8443", ++ NO_PROXY: "", ++ no_proxy: "chatgpt.com", ++ })).toEqual({ kind: "proxy", proxy: "http://secure-proxy.example:8443" }); ++ }); ++ ++ test("Bun WebSocket sends WSS through an HTTP CONNECT proxy", async () => { ++ let resolveConnect!: (target: string) => void; ++ const connected = new Promise(resolve => { resolveConnect = resolve; }); ++ const proxy = createServer(); ++ proxy.on("connect", (request, socket) => { ++ resolveConnect(request.url ?? ""); ++ socket.end("HTTP/1.1 502 Probe Complete\r\nContent-Length: 0\r\n\r\n"); ++ }); ++ await new Promise((resolve, reject) => { ++ proxy.once("error", reject); ++ proxy.listen(0, "127.0.0.1", resolve); ++ }); ++ const address = proxy.address(); ++ if (!address || typeof address === "string") throw new Error("proxy did not bind a TCP port"); ++ const socket = new WebSocket("wss://proxy-probe.invalid/backend-api/codex/responses", { ++ proxy: `http://127.0.0.1:${address.port}`, ++ } as unknown as string[]); ++ try { ++ expect(await Promise.race([ ++ connected, ++ new Promise((_, reject) => setTimeout(() => reject(new Error("CONNECT was not observed")), 5_000)), ++ ])).toBe("proxy-probe.invalid:443"); ++ } finally { ++ try { socket.close(); } catch { /* probe is already complete */ } ++ await new Promise(resolve => proxy.close(() => resolve())); ++ } ++ }, 10_000); ++ ++ test.skipIf(process.platform !== "win32")("Bun fetch honors NO_PROXY on Windows", async () => { ++ let providerRequests = 0; ++ let proxyRequests = 0; ++ const provider = createServer((_request, response) => { ++ providerRequests += 1; ++ response.end("direct"); ++ }); ++ const proxy = createServer((_request, response) => { ++ proxyRequests += 1; ++ response.end("proxied"); ++ }); ++ const listen = async (server: typeof provider): Promise => { ++ await new Promise((resolve, reject) => { ++ server.once("error", reject); ++ server.listen(0, "127.0.0.1", resolve); ++ }); ++ const address = server.address(); ++ if (!address || typeof address === "string") throw new Error("server did not bind a TCP port"); ++ return address.port; ++ }; ++ const [providerPort, proxyPort] = await Promise.all([listen(provider), listen(proxy)]); ++ process.env.HTTP_PROXY = `http://127.0.0.1:${proxyPort}`; ++ process.env.NO_PROXY = "127.0.0.1"; ++ try { ++ expect(await (await fetch(`http://127.0.0.1:${providerPort}/models`)).text()).toBe("direct"); ++ expect(providerRequests).toBe(1); ++ expect(proxyRequests).toBe(0); ++ } finally { ++ await Promise.all([ ++ new Promise(resolve => provider.close(() => resolve())), ++ new Promise(resolve => proxy.close(() => resolve())), ++ ]); ++ } ++ }); ++}); ++ + describe("applyProxyEnv with values the schema does not constrain", () => { + test("warns once per discarded proxy setting without exposing its raw value", () => { + const secret = "raw-proxy-credential-sentinel-2947"; +@@ -122,6 +246,14 @@ describe("applyProxyEnv", () => { + expect(process.env.HTTP_PROXY).toBe("http://proxy.corp:8080"); + }); + ++ test.each(["ALL_PROXY", "all_proxy"])("config fills a scheme proxy ahead of %s for WSS", key => { ++ process.env[key] = "http://fallback-proxy.example:8081"; ++ applyProxyEnv(configWithProxy("http://configured-proxy.example:8080")); ++ expect(process.env[key]).toBe("http://fallback-proxy.example:8081"); ++ expect(resolveProxyRoute(new URL("wss://chatgpt.com/backend-api/codex/responses"))) ++ .toEqual({ kind: "proxy", proxy: "http://configured-proxy.example:8080" }); ++ }); ++ + test("appends loopback entries to an existing NO_PROXY without duplicating", () => { + process.env.NO_PROXY = "internal.corp,localhost"; + applyProxyEnv(configWithProxy("http://proxy.corp:8080")); +@@ -217,4 +349,3 @@ describe("applyProxyEnv with proxy: \"auto\" (#1525)", () => { + expect(process.env.HTTP_PROXY).toBeUndefined(); + }); + }); +- +``` diff --git a/devlog/_plan/260906_a_runtime_stack/030_recovery.md b/devlog/_plan/260906_a_runtime_stack/030_recovery.md new file mode 100644 index 0000000000..99ea201b44 --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/030_recovery.md @@ -0,0 +1,208 @@ +# 030 — Native MESSAGE recovery and cached replay (#3568) + +Status: candidate implementation plan, researched 2026-09-06 KST. This is a +docs-only deliverable. Revalidate during this layer's P after preceding layers +land; no implementation or verification pass is claimed here. + +## Implementation-cycle completion versus landing + +This decade cycle ends with a reviewed prepared draft PR, exact-carried-head focused remote activation evidence and remote typecheck, with full CI dispatched. That cycle D does not claim the bug shipped, full CI passed, or an issue resolved. `060_landing.md` retains the mandatory full current-head cross-platform/type/privacy/docs evidence, review, dev ancestry and immediate source-PR/fully-resolved-issue closure gates. Later P consumes the verified prepared stack parent; it need not have landed yet. Only final landing yields feature DONE. + + +## Loop specification and scope + +- Class: C4 for the existing recovery admission boundary; C3 for destination + normalization. Archetype: spec-satisfaction repair, one implementation PABCD + cycle for this decade document. +- Trigger: native parent MESSAGE delivery or replayed encrypted task history on + an opted-in routed child. Goal: preserve the admitted plaintext assignment and + deliver supported plaintext Go Responses agent messages. +- Non-goals: #3571 catalog/effort ordering, multipart recovery, native-backend + retry policy, new credential sources, recovery enabled by default, new routing + metadata protocol, deployment/release work, or a general solution to #3661. +- Verifier: exact-layer remote focused regressions, full Cross-platform CI, + privacy and type gates, and independent recovery-boundary review. Commands + below are planned for remote execution only; none ran during planning. +- Stop condition: reviewed prepared draft and exact-head remote focused/type evidence; full CI/dev inclusion are required by 060 before feature completion. Partial #3661 stays open. +- Memory artifact: this file and main-owned `000` roadmap/evidence ledger. +- Outcomes: DONE only with the evidence above; NOOP only if current dev already + contains equivalent behavior and regressions; BLOCKED for external CI/review + dependencies; UNSAFE/NEEDS_HUMAN for a necessary expansion of admission policy. +- Delegation: inherited parallel read-only reviewers authorized. Downward scope + changes require a P amendment; main reclaims a packet after two distinct worker + failures. Main owns FSM, implementation, commits and stack integration. +- Resource scope: existing gh credentials; later writes restricted to own stack + branches and scoped PR administration. This worker writes only this plan and + `040_affinity.md`. No explicit user token/cost cap; a 2-hour checkpoint triggers + reassessment, not an automatic success or exhaustion claim. No local tests, + typecheck, build, Git mutation or GitHub mutation in this planning task. +- Public record rule: this file describes already-public PR behavior and general + integration requirements. Any new security investigation belongs in `.tmp/`. + +## Provenance and current source + +Live GitHub dev and local HEAD both resolve to +`81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`. Original PR +[3568](https://github.com/lidge-jun/opencodex/pull/3568) head is +`036a9321788464fdf33a387c9f44a834a844bdc1`, retained as +`refs/codex/a-original/3568`. The earlier `origin/a-original-*` refs were pruned; +do not depend on them. Read the complete feature diff using +`git diff origin/dev...refs/codex/a-original/3568`, not `HEAD^..HEAD` (the last +two commits are documentation corrections). + +Original author: `voiys ` (GitHub `voiys`). Preserve these +commits in order when carrying the work: + +1. `e8f8726040dbc45b1e946d59db6b9c477459b8d7` — recovery implementation. +2. `4464892336c75b8861ee4caeddfd97d6c4e0e6ab` — canonical Go destination docs. +3. `036a9321788464fdf33a387c9f44a834a844bdc1` — forward-auth exception docs. + +A rewritten/squashed carrying commit and final squash body must contain +`Co-authored-by: voiys `; cite the original PR in the new PR. +Do not force-push the contributor branch. + +Source anchors at the inspected dev SHA: + +- `src/server/responses/agent-task-recovery.ts:61`: envelope type; line 72 + accepts only NEW_TASK; line 74 selects the supported tail envelope; line 180 + injects validated plaintext; line 277 performs admission and line 287 creates + the existing cache key, including message type and parent scope. +- `src/server/responses/agent-task-recovery-cache.ts:23`: existing deletion/byte + accounting; line 43 sets original expiry; line 117 owns resolving cache/flight + behavior. Reuse these owners instead of adding another cache. +- `src/server/responses/core.ts:3233`: final-route recovery gate currently also + requires an unreadable current task. Lines 3261–3282 own reparsing, preserved + continuation fields and the existing non-persistable-body handling. +- `src/adapters/openai-responses.ts:2354`: body expansion/previous-response + handling before effort mapping is the original insertion point. +- `structure/10_adapter-registry.md:5`: adapter factory authority remains the + registry. `opencode-go.ts` below is a destination helper, not a new adapter id. + +Owner search used `isOpenCodeGo`, `normalizeOpenCodeGoAgentMessages`, +`recoverEncryptedAgentTask` and recovery-cache exports. No equivalent Go helper +exists in current dev. Doing nothing retains the public regression; configuration +alone cannot admit MESSAGE or restore history. Reuse admission, injection, cache +deletion and Responses construction; do not duplicate them. + +## Exact implementation change map + +| Action / path | Before → planned after | +|---|---| +| MODIFY `src/server/responses/agent-task-recovery.ts` | Widen `AgentEnvelope.messageType` and the local parse variable to `"NEW_TASK" \| "MESSAGE"`; ROUTING_HEADER captures either and assigns the captured value. Add `restoreCachedEncryptedAgentTasks(req,input,config,{parentThreadId})`: scan only agent_message entries, reuse `admittedRecovery` on each singleton, read the existing cache, and call `injectAssignment` only for a valid hit; return restored count. Fresh recovery continues to handle only the supported tail. | +| MODIFY `src/server/responses/agent-task-recovery-cache.ts` | Export `cachedAgentTaskRecovery(key): string \| null`; return null on miss; delete expired entries with existing `deleteRecoveryCacheEntry`; return live assignment without extending TTL, creating a flight or performing network I/O. | +| MODIFY `src/server/responses/core.ts` | Import restoration helper. Retain Responses/spawn/opt-in/final-route/combo/pass-through exclusions, remove only the outer unreadable-tail prerequisite, restore history first, recompute unreadability, and attempt fresh recovery only when still needed. Feed actual successful restoration/recovery into the existing reparse/route-selection path; preserve continuation fields and existing non-persistence handling. | +| NEW `src/adapters/opencode-go.ts` | Add `isOpenCodeGo(baseUrl)` using URL origin `https://opencode.ai` and normalized path `/zen/go/v1`; malformed/other URLs return false. Add `normalizeOpenCodeGoAgentMessages(body)` with unchanged-reference no-op; convert only nonempty agent_message content arrays entirely composed of input_text/input_image/input_file into user messages; preserve original content parts and add readable author/recipient context. No encrypted/unknown-part conversion. | +| MODIFY `src/adapters/openai-responses.ts` | Import helpers; after `stripPreviousResponseId`, apply normalization only for `!forward && isOpenCodeGo(provider.baseUrl)`, before effort mapping. Preserve raw replay body and existing session headers. | +| NEW `tests/providers/opencode-go-agent-messages.test.ts` | Carry original provider tests and add canonical-Go forward-auth, renamed-provider/trailing-slash URL, malformed/other URL and input_file/empty/mixed unknown-part cases. Assert adapter output and source-body identity, not helper existence. | +| NEW `tests/server/server-agent-task-recovery-replay.test.ts` | Carry original replay/MESSAGE/mixed-history tests. Extend real handler coverage for known history plus a fresh tail and for cache-only continued turns. Check outbound body and recovery fetch counts, not just helper return values. | +| MODIFY `tests/server/agent-task-recovery-cache.test.ts` | Exercise the new read-only accessor on hit, miss and exact expiry; assert repeated reads do not extend lifetime or create recovery flights and expiry uses existing byte-accounting deletion. Reuse existing clock isolation. | +| MODIFY `scripts/test-layout/layout.json` | Register `opencode-go-agent-messages.test.ts` under providers and `server-agent-task-recovery-replay.test.ts` under server in `explicit`. Preserve other registrations. | +| MODIFY `tests/fixtures/test-layout-expected.json` | Add the same two basename/domain mappings. | +| MODIFY `docs-site/src/content/docs/reference/adapters.md` | Carry original non-forward canonical-Go conversion paragraph and recovery link. | +| MODIFY `docs-site/src/content/docs/reference/configuration/providers.md` | Carry original Go section specifying URL, adapter, forward exclusion, cached history versus fresh-tail behavior and context-only identities. | + +No DELETE paths. Existing tests/security/fallback/combo helpers are read/reused; +extend an existing test file only by a documented P amendment if its fixture is +the right home for an uncovered acceptance row. No catalog files in this layer. + +The enum chain is complete: creation is ROUTING_HEADER capture in +`findEnvelope`; serialization is `recoveryPayload` at line 303 plus the existing +message-type cache-key hash at line 292; deserialization/unknown handling remains +the strict envelope matcher and assignment validation at line 171; consumers are +admission, fresh recovery, cache restoration and injection. There is no persisted +enum migration. Recipient consistency remains enforced by existing envelope +validation; do not claim a new independent recipient cache-key field. + +## Activation and independent acceptance + +| Trigger | Observable acceptance | +|---|---| +| Opted-in valid MESSAGE on routed spawned Responses | One recovery request containing MESSAGE; provider receives recovered text; response succeeds. NEW_TASK remains equivalent. | +| Previously admitted ciphertext replayed after tool output or user continuation | Restored plaintext reaches actual provider body; recovery-call count does not increase. | +| Cached NEW_TASK + cached MESSAGE + distinct uncached current MESSAGE | Each known entry restores its own payload; only tail creates one fresh recovery; later replay creates no further recoveries. | +| Unknown historical ciphertext and a recoverable tail | Historical entry remains unchanged; do not claim batch history recovery. Keep existing terminal decision behavior when unsupported unreadability remains. | +| Miss, exact expiry, repeated reads before expiry | No replacement/fetch on read miss; unchanged original expiry and bounded accounting. | +| Other parent/caller/account/message type, malformed envelope or unsupported type | No cache restoration; original input remains unchanged. Existing admission negative suite stays green. | +| Recovery absent/disabled, native forward, trusted pass-through, combo attempt | Existing routing/admission behavior remains; opt-out makes no newly introduced recovery request. | +| Canonical Go non-forward plaintext text/image/file message | Public user message with original parts and readable identities; raw replay input not mutated. | +| Go forward, another destination, unknown/encrypted part, empty content | No Go conversion. Test canonical Go forward directly, not only ChatGPT forward. | +| Recovery success followed by reparse | Continuation fields survive; current route/selection and existing non-persistable-body treatment remain correct. | + +This layer must pass without #3581 or #3571. Main integrates this core change +before #3581 and coordinates any C-lane #3576 core edits. Do not use stack order +to invent a dependency on unrelated SSE/WebSocket changes; revalidate shared +core and documentation context after their integration. + +## Reviews, drift and landing handoff + +Live PR is non-draft, MERGEABLE, REVIEW_REQUIRED. GraphQL returned two resolved +threads, zero unresolved. Preserve both corrections: +[canonical destination](https://github.com/lidge-jun/opencodex/pull/3568#discussion_r3939042861) +and [forward exception](https://github.com/lidge-jun/opencodex/pull/3568#discussion_r3939864549). +The earlier four-topic maintainer review was addressed by moving catalog work to +#3571; do not restore those removed hunks. Its mixed-history concern is represented +in current original tests and the acceptance table. Sender/recipient text is +model context only. The original author reports 19,287 full-suite passes and a +live test on an equivalent local release patch; neither proves the new stack head. + +At later P, compare original feature patch against the actual parent tree, +refresh PR head/reviews and identify new exact-path overlap. Carry all three +original commits, preserve authorship and review corrections, then add focused +integration corrections separately. Main may push own stack branches with +`--no-verify` as authorized. Parent merge/squash requires child replay onto the +new dev ancestry and new head evidence; retarget children before deleting parent +branches. Close carried #3568 only after dev contains the result. Reference +#3661 as partial coverage, never `Closes #3661` for this slice. + +## Remote-only verification plan + +Planning exception to PLAN-VERIFIER-REAL-01: user forbids running tests, +typecheck/build locally and requests static workflow inspection now. Every +command here has execution status **NOT RUN**, exit code **N/A**. Later main +records remote command, exact checkout SHA, result and log URL/receipt. + +Remote execution handoff (main verified): `the isolated remote verification host` has +`REMOTE_SOURCE_CHECKOUT` and Bun 1.3.14. Main creates an isolated remote clone +and checks out the exact carried SHA; the existing checkout is a source for +setup, not a shared mutable test directory. Implementation C runs focused +activation tests and typecheck there. Carry PR remains draft until full +current-head GitHub CI is green; final landing cycle requires every full gate. +The local package pins Bun 1.4.0, so the Bun 1.3.14 focused result is supplemental +and cannot replace the workflow's configured-runtime full gates. + +In that isolated remote checkout, focused C commands are: + +```sh +bun test tests/server/server-agent-task-recovery-replay.test.ts tests/providers/opencode-go-agent-messages.test.ts tests/server/agent-task-recovery-cache.test.ts +bun test tests/server/agent-task-recovery.test.ts tests/server/agent-task-recovery-security.test.ts tests/server/agent-task-recovery-fallback.test.ts tests/server/agent-task-recovery-combo.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts +bun run typecheck +``` + +Full landing gates, on remote runners only: + +```sh +bun run test +bun run privacy:scan +bun --cwd docs-site run build +``` + +Direct test arguments observe the named target/imports; layout guards observe +both manifests. `package.json:43` defines the full test script, +`scripts/test.ts:321` adds `./tests/`, and `tsconfig.json:15` includes `src`. +The docs build is a separate remote requirement; ordinary runtime CI does not +prove prose accuracy. Review the two docs against actual adapter conditions. + +Statically verified CI coverage: `.github/workflows/ci.yml:7` has no PR-base +filter, so child PRs qualify; lines 182–186 match `src/**`, `tests/**` and +`scripts/**`. Linux line 316 calls `scripts/ci/run-bun-test-batches.sh`, whose +line 197 enumerates tests recursively and line 58 accepts `.test.ts` files. +macOS line 532 and Windows line 754 run the tests directory in shards. Lines +422–431 run typecheck and privacy. Require actual producer jobs to succeed; +green intake/aggregate checks with skipped tests are insufficient. + +Main's alternative manual CI invocation is +`gh workflow run ci.yml --repo lidge-jun/opencodex --ref OWN_LAYER_BRANCH -f lane=all`. +The workflow supports lane, not an invented expected-SHA input. Capture the run's +headSha and checkout provenance and reject stale results; PR workflows normally +test the synthetic merge ref, so record both PR head and tested merge SHA. +No workflow or runner approval was issued by this planner. diff --git a/devlog/_plan/260906_a_runtime_stack/040_affinity.md b/devlog/_plan/260906_a_runtime_stack/040_affinity.md new file mode 100644 index 0000000000..c35e7c5db2 --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/040_affinity.md @@ -0,0 +1,210 @@ +# 040 — Command Code conversation affinity (#3581) + +Status: candidate implementation plan, researched 2026-09-06 KST. Depends on the +verified `030_recovery.md` layer for stack integration into its reparse owner. +This first-cycle artifact is docs only; re-read current source at this layer's P. + +## Implementation-cycle completion versus landing + +This decade cycle ends with a reviewed prepared draft PR, exact-carried-head focused remote activation evidence and remote typecheck, with full CI dispatched. That cycle D does not claim the bug shipped, full CI passed, or an issue resolved. `060_landing.md` retains the mandatory full current-head cross-platform/type/privacy/docs evidence, review, dev ancestry and immediate source-PR/fully-resolved-issue closure gates. Later P consumes the verified prepared stack parent; it need not have landed yet. Only final landing yields feature DONE. + + +## Loop specification and scope + +- Class: C4 for conversation/cohort isolation; archetype: spec-satisfaction + repair. One implementation PABCD cycle owns this document. +- Trigger: repeated Command Code requests from the same identifiable conversation. + Goal: stable opaque session affinity without treating shared cache cohorts as + individual conversations; enable API-key provider cache-key forwarding. +- Non-goals: Hermes #3433 diagnosis, measured cache-hit/cost promises, OAuth + refresh changes, a global session registry, prompt-text-derived identity, + default trust for unclassified cache keys, or extra OAuth cache-key forwarding. +- Verifier: remote identity/forwarding/reparse regressions, full current-head CI, + privacy/type gates and independent boundary review. No local verifier runs. +- Stop: reviewed prepared draft atop recovery, with exact-head remote focused/type evidence. Full current-head gates and dev ancestry remain required in 060. +- Memory artifact: this file plus main-owned roadmap/ledger. Main alone owns + FSM, goal, implementation, Git and stack integration. +- Resources: existing gh credentials and later own-branch writes only. Inherited + parallel reviewers authorized; downward changes are a P amendment and main + reclaims after two distinct worker failures. No explicit user token/cost cap; + 2-hour checkpoint triggers reassessment. This planner writes only the two + assigned documents; no Git/GitHub mutations or tests/typecheck/build. +- Public scope: already-public patch behavior and general integration plan only; + new security investigation notes belong in `.tmp/`. + +## Provenance and source anchors + +Live dev/local HEAD: `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`. +[Original PR #3581](https://github.com/lidge-jun/opencodex/pull/3581) head and its +single feature commit: `f60397d3408e0339ffc66acdcaca8133e40866c2`, retained at +`refs/codex/a-original/3581`. Author: `SB Yoon +<44089734+yansigit@users.noreply.github.com>` (GitHub `yansigit`), original +authored date 2026-09-05T01:58:50Z. Preserve original author on carry and include +`Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com>` in any +rewritten/squashed landing. Do not rewrite the contributor's branch. + +Current source still uses `randomUUID()` unconditionally at +`src/adapters/command-code.ts:528`. The helper insertion owner is the same file +after `projectSlug` at line 212. Current `src/server/responses/core.ts:2954` +parses the request, line 2987 assigns inbound thread id, lines 2990–3004 classify +the separate replay scope, and line 3262 preserves fields after recovery. +`src/types/request.ts:71` holds `_clientThreadId` without the proposed cohort +field. `src/providers/registry.ts:2169` is API-key `commandcode`; line 1332 is +OAuth `command-code`. They are separate transport contracts. + +Owner searches: `commandCodeSessionId`, `promptCacheKeyIsSharedCohort`, +`prompt_cache_key`, `_clientThreadId`, `_reasoningReplayScope`. Existing +classification, provider derivation and Chat serialization already exist; reuse +them. `src/providers/xai-transport.ts:101` has a different provider's derivation; +do not reuse its namespace/contract for Command Code. Doing nothing retains +random affinity, configuration cannot change the header builder, and no matching +Command Code helper exists. No new cache/service dependency is warranted. + +## Exact implementation change map + +| Action / path | Before → planned after | +|---|---| +| MODIFY `src/adapters/command-code.ts` | Import `createHash` alongside `randomUUID`; add exported `commandCodeSessionId(parsed)`. Select trimmed `_clientThreadId`, else trimmed replay `clientThreadId`, else trimmed `options.promptCacheKey` only when cohort marker is exactly false. With no identity return randomUUID. Hash `command-code:${kind}\0${identity}` with SHA-256 and form the original opaque UUID-shaped value, preserving explicit version/variant nibble comment. Use helper for x-session-id. | +| MODIFY `src/types/request.ts` | Add optional internal `_promptCacheKeyIsSharedCohort?: boolean` beside `_clientThreadId`; document true=shared, false=explicitly conversation-scoped, absent=unclassified. Do not expose it as a client JSON input field. | +| MODIFY `src/server/responses/core.ts` | Immediately after initial `parseRequest(body)`, copy `options.promptCacheKeyIsSharedCohort` onto parsed internal marker. Add marker to the existing `kept` list in recovery reparse, now containing #3568 restoration. Preserve all sibling fields and both true and false values (undefined-only filtering). | +| MODIFY `src/providers/registry.ts` | Add `promptCacheKey: true` only to `commandcode` API-key provider. Leave OAuth `command-code` transport setting unchanged. | +| MODIFY `tests/providers/command-code-provider.test.ts` | Carry stable/opaque identity, precedence, different-identity, UUID shape and random fallback tests; add whitespace-only fallback and same literal under different identity-kind cases. Assert actual built x-session-id as well as helper output. | +| MODIFY `tests/providers/commandcode-provider.test.ts` | Extend registry expectation and construct real Chat request with promptCacheKey, asserting prompt_cache_key body forwarding. Retain explicit disabled-provider override behavior. | +| MODIFY `tests/claude-integration/claude-code-thought-signature-scope.test.ts` | Carry true/false/undefined propagation assertions in existing drive helper; retain the independent replay-scope expectations. | +| MODIFY `tests/server/server-agent-task-recovery-replay.test.ts` | Parent-layer test file exists after 030. Add real handler/adaptor-boundary observation of marker preservation for recovery and cache-only restoration, with true/false/undefined cases. Use the existing fixture/post helper; no source-text assertion as a substitute for executing reparse. | +| MODIFY `docs-site/src/content/docs/reference/adapters.md` | Add a concise Command Code subsection describing OAuth x-session-id priority/random fallback and API-key commandcode prompt_cache_key forwarding separately; no cache-performance promise. This is a docs-sync addition beyond the original seven-file patch. | + +No NEW or DELETE production/test files. The modified replay test is owned by the +parent layer and already registered there. No layout manifest update is needed +for modifying it. Keep the new helper in its existing adapter: no parallel +factory registration or session cache. `structure/10_adapter-registry.md:5` +remains authoritative and needs no factory-policy change; adapters.md is the +user-visible contract sync target. + +## Explicit handler-fixture amendment + +MODIFY `tests/helpers/agent-task-recovery.ts:144-159`: extend the sixth `post` options argument with `promptCacheKeyIsSharedCohort?: boolean`, and forward it to the fourth `handleResponses` options argument alongside abortSignal and translatorBudget. Do not put this internal field in the JSON request body. Existing callers default to undefined and remain unchanged. + +MODIFY `tests/server/server-agent-task-recovery-replay.test.ts`: parameterize true/false/undefined, use the extended `post` helper for an initial admitted recovery and a continued cache-only replay, and observe the parsed request at the real selected adapter buildRequest boundary via a temporary spy restored after each test. Assert the exact internal marker and existing thread/replay metadata on both calls; assert only one recovery backend call. The later P must bind the spy to the actual exported adapter selector in that carried tree. A source-text assertion is not an alternative to the real reparse execution. + +## Complete field and value chain + +1. Creation: `src/server/claude-messages.ts:836` passes + `promptCacheKeyIsSharedCohort: cacheKeySource === "system"` into + `HandleResponsesOptions` (`core.ts:1548`). The new initial-parse assignment + carries true/false/undefined unchanged. `_clientThreadId` and replay scope use + their existing ingress owners; do not infer new trust from request content. +2. Internal transfer: `OcxParsedRequest` optional field and the `kept` list copy + it across `parseRequest` after both fresh and cached recovery. It is process + request metadata, not persisted configuration or continuation data. +3. Serialization/deserialization: the internal marker has no wire representation + and no persisted migration (N/A intentionally). `parseRequest` at + `src/responses/parser.ts:526` already maps public prompt_cache_key into options; + clients cannot supply the internal classification through that mapping. +4. Consumers: `commandCodeSessionId` permits the cache-key fallback only for + `=== false`; true/undefined both fail closed. Existing replay/cohort consumers + at `core.ts:2990`, `core.ts:3560` and + `src/oauth/anthropic-routing.ts:781` keep their distinct semantics; do not + broaden/rewrite those predicates as incidental cleanup. +5. Provider capability chain: registry promptCacheKey → + `src/providers/derive.ts:252` defaults and line 512 reconciliation → routed + provider config → `src/adapters/openai-chat.ts:1573` serialization (and raw + body forwarding at line 156). Original API-key regression observes the wire + body, rather than only asserting registry metadata. + +## Activation and independent acceptance + +| Trigger | Required observation | +|---|---| +| Same trimmed explicit thread, differing replay/cache values | Same opaque x-session-id in actual built requests; thread wins. | +| No explicit thread, same trimmed replay identity | Stable header; changing replay identity changes it. | +| Neither thread nor replay, nonempty key with marker false | Stable cache-derived header; whitespace trimmed. | +| Same literal in thread/replay/cache namespaces | Different opaque values by kind; preserve original hash namespace. | +| Shared=true or unclassified marker, only cache key/prompt text | Fresh UUID each request; no prompt/body-derived identity. | +| Empty/whitespace identity or no identity | Random fallback, no accidental stable empty-string cohort. | +| Explicit thread with shared=true | Explicit thread remains valid; shared classification disqualifies only cache fallback. | +| Initial parse then successful fresh or cache-only recovery reparse | Adapter observes original true/false/undefined marker and original thread/replay metadata; stable affinity semantics survive. | +| API-key commandcode using route-derived config | Chat body carries prompt_cache_key when present/enabled; absent key or explicit disabled capability omits it. | +| OAuth command-code | Uses proprietary x-session-id builder; this patch does not opt its registry entry into Chat cache-key forwarding. | +| Synthetic raw identity strings | Header matches UUID-shaped contract and contains no raw identity. No added identity logging. | + +C must drive both the helper and real adapter/handler paths. This plan claims a +stable request header, not proven provider cache savings or a provider guarantee +that distinct sessions receive distinct workers. Any credentialed live provider +smoke needs main's chosen authorized runtime scope; a synthetic wire test is not +misreported as real upstream acceptance. + +## Review disposition, drift and stack order + +Live PR is non-draft, MERGEABLE, REVIEW_REQUIRED. GraphQL has zero review threads; +there is no current formal approval. The author already incorporated UUID +nibble explanation and retained API-key-only forwarding/unclassified-key +fallback in the original head. Latest +[author update](https://github.com/lidge-jun/opencodex/pull/3581#issuecomment-5549518114) +reports 18,244 passes on `be81013fa` base; those historical results do not validate +the current parent tree. Older draft/failure commentary is superseded. + +The original patch context predates the current core: original initial-parse +line 2896 is now 2954 and original reparse area around 3210 is now 3262. Carry by +function/field ownership; never replace current core with the older file. Refresh +onto the completed 030 layer and preserve both restoration behavior and the new +cohort marker. Coordinate the shared core with C-lane #3576 through main. This +is not a fix for #3433 and must not close that issue. + +Main publishes a child PR targeting the recovery branch if that PR is still +open; after parent squash/merge, replay only this layer onto dev and retarget. +Revalidate exact diff, review and CI for every new head. Original contributor +credit survives cherry-pick/reimplementation/squash. Own-branch `--no-verify` +pushes are authorized; local prepush hooks must not start a suite. Close original +#3581 once the equivalent change is proven on dev; do not close merely because +a carrying child PR exists. No Git/GitHub action is performed by this planner. + +## Remote-only verification and CI coverage + +All commands below: **NOT RUN, exit N/A during planning**, per explicit user +instruction. Later main runs them only in the remote checkout of the exact layer +and records SHA, command result and artifact/CI URL. + +Main verified `REMOTE_HOST:REMOTE_SOURCE_CHECKOUT` and Bun 1.3.14. Use an isolated +remote clone at the exact carried SHA for focused activation tests/typecheck; +do not mutate the existing remote checkout for this layer. Its Bun version +differs from package.json's 1.4.0 pin, so this is supplemental evidence. Carry +PR stays draft until full current-head GitHub CI is green. Final landing cycle +requires every full gate on the configured remote runners. + +Implementation C, in the isolated remote clone: + +```sh +bun test tests/providers/command-code-provider.test.ts tests/providers/commandcode-provider.test.ts tests/claude-integration/claude-code-thought-signature-scope.test.ts tests/server/server-agent-task-recovery-replay.test.ts +bun run typecheck +``` + +Full landing gates, remote only: + +```sh +bun run test +bun run privacy:scan +bun --cwd docs-site run build +``` + +Focused direct arguments cover identity selection, actual request headers/body, +cohort propagation and parent recovery interaction. `tsconfig.json:15` includes +src; `package.json:43` maps full suite to `scripts/test.ts`, whose line 321 adds +`./tests/`. No claim that typecheck covers prose. Docs require remote build plus +manual comparison of actual transport semantics. + +Static workflow proof: `.github/workflows/ci.yml:7` permits child PR bases; +lines 182–186 select runtime/tests, line 316 runs Linux batches, and +`scripts/ci/run-bun-test-batches.sh:197` recursively enumerates tests (accepted +suffixes at line 58). macOS line 532 and Windows line 754 cover tests shards. +Lines 422–431 run typecheck/privacy. Thus this runtime layer should activate +real jobs even though its parent is not dev. Runtime CI does not guarantee the +new documentation subsection's accuracy; review it explicitly. + +Optional later manual dispatch: +`gh workflow run ci.yml --repo lidge-jun/opencodex --ref OWN_LAYER_BRANCH -f lane=all`. +Record run headSha and actual checkout SHA; this workflow exposes only lane, +not expected-SHA pinning. For PR CI record current PR head and synthetic merge +SHA. Require completed successful producer jobs and independent review of the +current patch; author-reported tests, skipped producers, stale green heads and +hygiene checks cannot complete this layer. diff --git a/devlog/_plan/260906_a_runtime_stack/050_capabilities.md b/devlog/_plan/260906_a_runtime_stack/050_capabilities.md new file mode 100644 index 0000000000..b545f21bae --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/050_capabilities.md @@ -0,0 +1,684 @@ +# 050 — Effective provider capabilities (#3671) + +## Implementation-cycle completion versus landing + +This decade cycle ends with a reviewed prepared draft PR, exact-carried-head focused remote activation evidence and remote typecheck, with full CI dispatched. That cycle D does not claim the bug shipped, full CI passed, or an issue resolved. `060_landing.md` retains the mandatory full current-head cross-platform/type/privacy/docs evidence, review, dev ancestry and immediate source-PR/fully-resolved-issue closure gates. Later P consumes the verified prepared stack parent; it need not have landed yet. Only final landing yields feature DONE. + + +## Candidate implementation contract + +Status: candidate planning, not implementation or merge approval. Revalidate at this layer's later P after its lower stack layer lands. This document is the delegated docs-only deliverable; the main agent owns roadmap registration, FSM, goal state, branch integration, CI dispatch and closure. + +- Class: C4 for the policy-boundary slice, based on the public PR's requested security review. Archetype: spec-satisfaction repair. +- Trigger: routing policy capability evidence must describe the effective provider dispatch uses, including unavailability. +- Goal: runtime selection and ordinary management dry-run agree on effective transport capabilities and exclude unresolved, missing, or disabled providers before scoring. +- Non-goals: new provider metadata, registry precedence redesign, catalog UI, OAuth refresh, request transport changes, Lab activation changes, release operations, or changing caller-supplied synthetic dry-run evidence semantics. +- Verifier: remote focused routing/API regressions plus exact-head full Cross-platform CI and a remote documentation build. No local tests, typecheck, builds, or verifier execution in this planning assignment. +- Stop: independently working reviewed draft with original authorship and exact-head remote focused/type evidence. Full current-head gates/dev ancestry remain required by 060. +- Memory artifact: this document and the main-owned roadmap/evidence ledger. +- Outcomes: DONE only after verified dev integration; NOOP only if current dev independently contains all behavior and regressions; BLOCKED for unavailable external CI/credentials; NEEDS_HUMAN/UNSAFE for a policy decision outside authorization; a resource checkpoint is reassessment, never fabricated completion. +- Delegation: inherited parallel read-only reviewers are authorized. Main reclaims a packet after two distinct failed workers; further write delegation requires a P amendment with exact ownership. +- Resources: existing gh credentials; future writes confined to the main's own stack branches and explicitly authorized PR/issue integration. This worker writes only this document. No explicit user token/cost cap. A two-hour checkpoint triggers reassessment and an evidence update. No deployment, account-state operation or provider request is necessary. + +## Provenance and refresh gate + +Inspected September 6, 2026 KST using read-only `gh pr view`, `gh api` reviews/workflow runs, `git show`, and `git diff`. + +- Public PR: https://github.com/lidge-jun/opencodex/pull/3671 +- Exact original head: `7b1beb9c5eacd8dde22681a5df26804be52380b8`. +- Stable source ref: `refs/codex/a-original/3671`; old `origin/a-original-*` refs were pruned by parallel workers and must not be relied upon. +- Original base: `6585e6a70f42be8b6c81ff20d4fa0f39f7da03db`. +- Inspected current dev/tree: `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`. +- Original commits, oldest first: `2b1e0e00c12d7287f9324a4a39ec7e966712affe` (effective capability evidence); `7b1beb9c5eacd8dde22681a5df26804be52380b8` (unresolved transport exclusion). +- Both commits authored by **Hako <25837994+devswha@users.noreply.github.com>**, GitHub `@devswha`. Preserve those authors when carrying commits. Any squash/reimplementation and the carrying PR must retain `Co-authored-by: Hako <25837994+devswha@users.noreply.github.com>` so attribution survives integration. +- A read-only diff of original base against inspected dev shows no drift in the eight original touched files. This is a snapshot, not a promise about the later stack parent. +- #3679's refreshed source head is `b05cccf264b4ab61db5d8dee8232c2f89bb1b541`; it does not replace #3671 provenance. Re-read parent changes and resolve integration ownership at later P. + +At later P, compare live PR head, stable ref, actual stack parent and dev tip. Inspect each named source hunk and public review again. If any changed, amend this document before carrying the patch. Treat stacked ordering as a user-requested integration constraint; #3671 does not need #3568/#3581 runtime code to function and must be independently testable. + +## Source ownership and before/after map + +Reuse the existing `routedProviderConfig` callback seam; no new resolver, registry, server endpoint or config option is required. Doing nothing leaves policy and effective transport divergent; changing configured URLs or deleting capability checks does not fix the contract; duplicating registry logic creates drift. + +| Operation | Exact path | Before → after | +|---|---|---| +| MODIFY | `src/routing/capability.ts` | Lines 153–160 read raw config plus registry by name → optional resolved-provider argument is authoritative; name-only registry fallback applies only to legacy three-argument callers. Add provider-wide reasoning ladder at lines 223–227, retaining no-reasoning precedence. | +| MODIFY | `src/routing/compatibility/assemble.ts` | Lines 52–60 derive capabilities directly → resolve each active configured candidate through the supplied callback, emit bounded unavailability state on missing/disabled/throw, and compute capabilities only from a resolved provider. | +| MODIFY | `src/routing/evaluator.ts` | Evidence type near line 54 and eligibility lines 280–313 lack transport status → optional `routeResolutionFailed`, `route-unavailable` exclusion and hard eligibility gate independent of unknown policy. | +| MODIFY | `tests/routing/routing-capability-model-matching.test.ts` | Existing model-family tests → retain them and add the complete original effective-transport regression group plus missing/disabled selection regressions below. | +| MODIFY | `tests/routing/routing-profile.test.ts` | Existing management dry-run parity fixture at line 446 → add ordinary dry-run missing/disabled candidate matrix without injected `candidates`. | +| MODIFY | `docs-site/src/content/docs/guides/routing-profile-editor.md` | Dry-run section near line 39 lacks effective transport contract → original explanation plus explicit missing/disabled exclusion. | +| MODIFY | `docs-site/src/content/docs/fr/guides/routing-profile-editor.md` | Same change in French near line 38, preserving corrected typographic apostrophe. | +| MODIFY | `docs-site/src/content/docs/tr/guides/routing-profile-editor.md` | Same change in Turkish near line 53. Original trailing blank-line removal is incidental. | +| MODIFY | `docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md` | Same change in Traditional Chinese near line 33. | +| MODIFY | `structure/01_runtime.md` | Router ownership row at line 18 says selection only → describe shared effective-provider evidence and hard unavailable-candidate exclusion. | +| NEW | None in production/tests | Existing test files already have layout entries; do not add layout manifest churn. | + +Only this plan file is created now. The future layer has ten MODIFY paths. General SOT follows `structure/01_runtime.md`; user-facing truth remains the routing guide. No unpublished investigation details belong in this public unit. + +Read-only caller proof: `src/router.ts:299` owns effective registry transport/metadata; `src/router.ts:622` supplies it to assembly and line 625 evaluates; lines 626–635 route the selected provider or throw. `src/server/management/routing-profile-routes.ts:100` supplies the same resolver; lines 384–390 use assembly when `body.candidates` is absent. Preserve the synthetic-evidence branch. `src/routing/capability.ts:130` classifies effective locality; lines 179–193 preserve no-vision precedence. Core/Lab imports remain behind the existing provider slot (`assemble.ts:45`), with no new import of router from assembly. + +## Public review disposition + +Two prior findings are resolved in original head: French typography and thrown route resolution under permissive unknown policy. One remains open: https://github.com/lidge-jun/opencodex/pull/3671#discussion_r3941006079 . At original-head `assemble.ts:57`, missing/disabled providers skip the resolver but leave failure false. Set the initial state to `!provider || provider.disabled === true` and prove both ordinary dry-run and runtime selection. Do not resolve the review on the basis of this plan. + +Current original-head Cross-platform CI run `33973108478` and React Doctor run `33973108496` have conclusion `action_required`; label/hygiene/target success is not product verification. The PR body reports focused successes and a timeout-adjusted affected run, but explicitly does not claim a green default full suite. No such reported run is accepted as this carried layer's verification. Maintainer approval and explicit security review remain pending under `MAINTAINERS.md:57–61`. + +## Exact original carry diff + +Apply this public source patch as one coherent layer, preserving both original commits/author identity. The subsequent corrections below are required in the same layer before review readiness. This is recorded patch text, not an instruction to run local Git mutations during planning. + +````diff +diff --git a/docs-site/src/content/docs/fr/guides/routing-profile-editor.md b/docs-site/src/content/docs/fr/guides/routing-profile-editor.md +index b437c28b3..84b4410f7 100644 +--- a/docs-site/src/content/docs/fr/guides/routing-profile-editor.md ++++ b/docs-site/src/content/docs/fr/guides/routing-profile-editor.md +@@ -37,6 +37,13 @@ résultat du plafond. + + ## Simuler un profil enregistré + ++Les capacités des candidats utilisent la configuration effective du fournisseur, ++après application du registre. Les exigences de localité (`localOnly` et ++`remoteAllowed`) utilisent donc l’adresse amont effective. Si elle ne peut pas être ++classée, `unknownEvidence.capability` détermine l’admissibilité du candidat. ++Une configuration de fournisseur invalide qui ne peut pas être résolue est toujours ++exclue avec `route-unavailable`, même si les capacités inconnues sont autorisées. ++ + Sélectionnez un profil enregistré et utilisez **Évaluation à sec** pour ajouter des éléments propres à la requête, tels que la taille de la fenêtre de contexte, l’utilisation d’outils, l’entrée d’images ou la sortie structurée. La simulation évalue l’admissibilité et la notation, mais n’envoie jamais de requête à un modèle en amont. + + Les modifications non enregistrées ne sont pas prises en compte par la simulation. Enregistrez d’abord le profil afin que la révision et l’évaluation affichées correspondent à la même configuration. +diff --git a/docs-site/src/content/docs/guides/routing-profile-editor.md b/docs-site/src/content/docs/guides/routing-profile-editor.md +index 5cf5fc6d7..7931f29ad 100644 +--- a/docs-site/src/content/docs/guides/routing-profile-editor.md ++++ b/docs-site/src/content/docs/guides/routing-profile-editor.md +@@ -38,6 +38,13 @@ cap outcome. + + ## Dry-run a saved profile + ++Candidate capabilities use the effective provider configuration after registry ++overrides are applied. Locality requirements (`localOnly` and `remoteAllowed`) ++therefore use the effective upstream address. If that address cannot be classified, ++the profile's `unknownEvidence.capability` setting decides eligibility. ++An invalid provider configuration that cannot be resolved is always excluded with ++`route-unavailable`, even when unknown capabilities are allowed. ++ + Select a saved profile and use **Dry-run evaluation** to add request evidence such as context-window size, tool use, image input, or structured output. Dry-run evaluates eligibility and scoring but never sends an upstream model request. + + Unsaved edits are not used by dry-run. Save the profile first so the displayed revision and evaluation refer to the same configuration. +diff --git a/docs-site/src/content/docs/tr/guides/routing-profile-editor.md b/docs-site/src/content/docs/tr/guides/routing-profile-editor.md +index dd7aa50d7..ec75bdd17 100644 +--- a/docs-site/src/content/docs/tr/guides/routing-profile-editor.md ++++ b/docs-site/src/content/docs/tr/guides/routing-profile-editor.md +@@ -52,6 +52,13 @@ ayrıdır. + + ## Kaydedilmiş bir profilde deneme çalıştırması (dry-run) yapma + ++Aday yetenekleri, kayıt defteri kuralları uygulandıktan sonraki etkin sağlayıcı ++yapılandırmasını kullanır. Yerellik gereksinimleri (`localOnly` ve `remoteAllowed`) ++bu nedenle etkin üst sunucu adresine göre değerlendirilir. Adres sınıflandırılamıyorsa, ++adayın uygunluğunu profilin `unknownEvidence.capability` ayarı belirler. ++Çözümlenemeyen geçersiz sağlayıcı yapılandırmaları, bilinmeyen yeteneklere izin ++verilse bile `route-unavailable` ile her zaman dışlanır. ++ + Kaydedilmiş bir profili seçin ve bağlam penceresi boyutu, araç kullanımı, görsel + girişi veya yapılandırılmış çıktı gibi istek kanıtları eklemek için **Deneme + çalıştırması değerlendirmesi (Dry-run evaluation)**'ı kullanın. Deneme +@@ -99,5 +106,3 @@ Düzenleyici şu uç noktaları kullanır: + } + } + ``` +- +- +diff --git a/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md b/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md +index e6ae93a76..0b54e70d5 100644 +--- a/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md ++++ b/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md +@@ -32,6 +32,9 @@ OpenCodex 儀表板中的 **Models → Routing** 分頁可以直接管理 `confi + + ## 試跑已儲存的設定檔 + ++候選能力使用套用 registry 覆寫後的有效供應商設定。因此,本地性需求(`localOnly` 與 `remoteAllowed`)會依據實際上游位址判定。若無法分類該位址,則由設定檔的 `unknownEvidence.capability` 決定候選是否合格。 ++無法解析的無效供應商設定一律以 `route-unavailable` 排除,即使原則允許未知能力也是如此。 ++ + 選取一個已儲存的設定檔,使用 **Dry-run evaluation** 加入請求證據,例如 context-window 大小、工具使用、圖片輸入或結構化輸出。試跑會評估資格與評分,但永遠不會送出上游模型請求。 + + 未儲存的編輯不會被試跑使用。請先儲存設定檔,讓顯示的 revision 與評估參照同一份設定。 +diff --git a/src/routing/capability.ts b/src/routing/capability.ts +index 8495951a0..7f26e8bbd 100644 +--- a/src/routing/capability.ts ++++ b/src/routing/capability.ts +@@ -10,7 +10,7 @@ + * how that affects eligibility. + */ + +-import { modelInList, type OcxConfig } from "../types"; ++import { modelInList, type OcxConfig, type OcxProviderConfig } from "../types"; + import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; + import { serviceTierSupportForModel } from "../providers/service-tier"; + import { PROVIDER_REGISTRY } from "../providers/registry"; +@@ -149,14 +149,20 @@ function localRemoteEvidence(baseUrl: string | undefined): Pick entry.id === providerName); ++ const provider = resolvedProvider ?? config.providers[providerName]; ++ const registryEntry = resolvedProvider === undefined ++ ? PROVIDER_REGISTRY.find(entry => entry.id === providerName) ++ : undefined; + const catalogRow = cachedCatalogModels().find(model => model.provider === providerName && model.id === modelId); + const isNative = providerName === OPENAI_CODEX_PROVIDER_ID && !modelId.includes("/"); + +@@ -224,6 +230,7 @@ export function candidateCapabilityEvidence( + ? [] + : modelRecordValue(provider?.modelReasoningEfforts, modelId) + ?? modelRecordValue(registryEntry?.modelReasoningEfforts, modelId) ++ ?? provider?.reasoningEfforts + ?? (isNative ? nativeReasoningEfforts(modelId) : undefined); + + const tierSupport = provider +diff --git a/src/routing/compatibility/assemble.ts b/src/routing/compatibility/assemble.ts +index 1d543690a..d7cebc94b 100644 +--- a/src/routing/compatibility/assemble.ts ++++ b/src/routing/compatibility/assemble.ts +@@ -52,11 +52,26 @@ export function assemblePolicyCandidateEvidence( + return profile.candidates.map(candidate => { + const key = `${candidate.provider}/${candidate.model}`; + const compatibility = compatibilityByCandidate?.get(key); ++ const provider = config.providers[candidate.provider]; ++ let routed: OcxProviderConfig | undefined; ++ let routeResolutionFailed = false; ++ if (provider && provider.disabled !== true) { ++ try { ++ routed = options.routedProviderConfig(candidate.provider, provider); ++ } catch { ++ // This is known unavailability, not unknown capability evidence. Keep ++ // the failure separate so permissive unknown policies cannot select it. ++ routeResolutionFailed = true; ++ } ++ } + + return { + provider: candidate.provider, + model: candidate.model, +- capability: candidateCapabilityEvidence(config, candidate.provider, candidate.model), ++ ...(routeResolutionFailed ? { routeResolutionFailed: true } : {}), ++ capability: routed ++ ? candidateCapabilityEvidence(config, candidate.provider, candidate.model, routed) ++ : undefined, + health: policyCandidateHealthEvidence(config, candidate, now), + quota: quotaEvidenceForCandidate({ + provider: candidate.provider, +diff --git a/src/routing/evaluator.ts b/src/routing/evaluator.ts +index a07b83306..7cf801bfe 100644 +--- a/src/routing/evaluator.ts ++++ b/src/routing/evaluator.ts +@@ -54,6 +54,8 @@ export interface PolicyCandidateEvidence { + accountRef?: string; + /** Codex pool account id (provider "openai"); used to derive account-scoped quota evidence. */ + codexAccountId?: string; ++ /** A failed effective-transport resolution excludes the candidate under every unknown policy. */ ++ routeResolutionFailed?: boolean; + capability?: RouteCapabilityEvidence; + health?: RouteHealthEvidence; + quota?: RouteQuotaEvidence; +@@ -278,6 +280,8 @@ export function evaluatePolicyProfile( + ...requestRequirementFor(requestEvidence, evidence.capability), + ]; + const exclusions: RouteExclusionReason[] = []; ++ const routeUnavailable = evidence.routeResolutionFailed === true; ++ if (routeUnavailable) exclusions.push({ code: "route-unavailable" }); + const bad = unsatisfiedOrUnknown(requirements); + for (const requirement of bad) { + if (requirement.outcome === "unsatisfied") { +@@ -310,7 +314,7 @@ export function evaluatePolicyProfile( + if (unknownCostBlocked) { + exclusions.push({ code: "cost-limit-unknown", detail: "maxEstimatedCostUsd" }); + } +- let eligible = !unsatisfied && !excludedByUnknown && !overCostLimit && !unknownCostBlocked; ++ let eligible = !routeUnavailable && !unsatisfied && !excludedByUnknown && !overCostLimit && !unknownCostBlocked; + + // Trace/dry-run copy only: report the profile cap that was applied and the + // operator-visible outcome. Do not feed this copy into costScore() — that +diff --git a/tests/routing/routing-capability-model-matching.test.ts b/tests/routing/routing-capability-model-matching.test.ts +index bb956c2d8..509eeec2e 100644 +--- a/tests/routing/routing-capability-model-matching.test.ts ++++ b/tests/routing/routing-capability-model-matching.test.ts +@@ -1,10 +1,19 @@ +-import { describe, expect, test } from "bun:test"; ++import { afterEach, beforeEach, describe, expect, test } from "bun:test"; ++import { mkdtempSync } from "node:fs"; ++import { tmpdir } from "node:os"; ++import { join } from "node:path"; ++import { validateConfigCandidate } from "../../src/config"; ++import { NoEligiblePolicyCandidateError, routeModel, routedProviderConfig } from "../../src/router"; + import { candidateCapabilityEvidence } from "../../src/routing/capability"; ++import { assemblePolicyCandidateEvidence } from "../../src/routing/compatibility/assemble"; + import { evaluatePolicyProfile } from "../../src/routing/evaluator"; ++import { closeRequestHistoryIndex } from "../../src/routing/history/indexer"; ++import { getRoutingProfile } from "../../src/routing/profile"; + import { PROVIDER_REGISTRY } from "../../src/providers/registry"; + import { modelRecordValue } from "../../src/reasoning-effort"; + import { isModelTextOnly } from "../../src/vision"; +-import type { OcxConfig, OcxProviderConfig } from "../../src/types"; ++import type { OcxConfig, OcxProviderConfig, OcxRoutingProfileConfig } from "../../src/types"; ++import { removeTreeWithRetry } from "../helpers/remove-tree"; + + /** + * `candidateCapabilityEvidence` describes what the resolver will do with a candidate, +@@ -35,6 +44,224 @@ function configFor(provider: OcxProviderConfig): OcxConfig { + return { providers: { custom: provider } } as unknown as OcxConfig; + } + ++describe("policy capability evidence uses the effective provider", () => { ++ let testDir: string; ++ let previousHome: string | undefined; ++ ++ beforeEach(() => { ++ previousHome = process.env.OPENCODEX_HOME; ++ testDir = mkdtempSync(join(tmpdir(), "ocx-effective-capability-")); ++ process.env.OPENCODEX_HOME = testDir; ++ }); ++ ++ afterEach(() => { ++ closeRequestHistoryIndex(); ++ if (previousHome === undefined) delete process.env.OPENCODEX_HOME; ++ else process.env.OPENCODEX_HOME = previousHome; ++ removeTreeWithRetry(testDir); ++ }); ++ ++ function policyConfig( ++ name: string, ++ provider: OcxProviderConfig, ++ model: string, ++ require: OcxRoutingProfileConfig["require"], ++ ): OcxConfig { ++ const result = validateConfigCandidate({ ++ port: 10100, ++ defaultProvider: name, ++ providers: { [name]: provider }, ++ routingProfiles: { guarded: { candidates: [{ provider: name, model }], require } }, ++ }); ++ if (!result.ok) throw new Error(result.error); ++ return result.config; ++ } ++ ++ const localOnly = { localOnly: true, remoteAllowed: false }; ++ const loopback = "http://127.0.0.1:11434/v1"; ++ ++ test("a loopback URL discarded by registry routing cannot satisfy a local-only policy", () => { ++ const config = policyConfig("deepseek", { ++ adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, ++ }, "deepseek-v4-flash", localOnly); ++ const before = structuredClone(config); ++ ++ expect(routeModel(config, "deepseek/deepseek-v4-flash").provider.baseUrl) ++ .toBe("https://api.deepseek.com"); ++ expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); ++ expect(config).toEqual(before); ++ }); ++ ++ test.each(["custom-local", "ollama"])("a genuine local %s endpoint remains eligible", name => { ++ const config = policyConfig(name, { ++ adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, ++ }, "local-model", localOnly); ++ const before = structuredClone(config); ++ ++ const route = routeModel(config, "policy/guarded"); ++ expect(route.providerName).toBe(name); ++ expect(route.provider.baseUrl).toBe(loopback); ++ expect(route.routeDecision?.requirements).toEqual([ ++ { id: "local-only", expected: true, actual: true, outcome: "satisfied" }, ++ { id: "remote-allowed", expected: false, actual: false, outcome: "satisfied" }, ++ ]); ++ expect(config).toEqual(before); ++ }); ++ ++ test("an explicitly public endpoint remains ineligible for a local-only policy", () => { ++ const config = policyConfig("deepseek", { ++ adapter: "openai-chat", baseUrl: "https://api.deepseek.com", ++ }, "deepseek-v4-flash", localOnly); ++ expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); ++ }); ++ ++ test("a local candidate is selected after excluding a registry-pinned remote candidate", () => { ++ const config = policyConfig("deepseek", { ++ adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, ++ }, "deepseek-v4-flash", localOnly); ++ config.providers.local = { adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true }; ++ config.routingProfiles!.guarded!.candidates.push({ provider: "local", model: "local-model" }); ++ ++ const route = routeModel(config, "policy/guarded"); ++ expect(route.providerName).toBe("local"); ++ expect(route.provider.baseUrl).toBe(loopback); ++ expect(route.routeDecision?.candidates.map(candidate => candidate.eligible)).toEqual([false, true]); ++ }); ++ ++ test("registry no-vision defaults participate before policy image requirements", () => { ++ const config = policyConfig("deepseek", { ++ adapter: "openai-chat", baseUrl: "https://api.deepseek.com", ++ modelInputModalities: { "deepseek-v4-flash": ["text", "image"] }, ++ }, "deepseek-v4-flash", { imageInput: true }); ++ const routed = routeModel(config, "deepseek/deepseek-v4-flash"); ++ expect(isModelTextOnly(routed.provider, routed.modelId)).toBe(true); ++ expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); ++ }); ++ ++ test("the effective model context ceiling gates a policy requirement", () => { ++ const config = policyConfig("openai-apikey", { ++ adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", ++ modelContextWindows: { "gpt-6-astra": 2_000_000 }, ++ }, "gpt-6-astra", { minContextWindow: 1_500_000 }); ++ const routed = routeModel(config, "openai-apikey/gpt-6-astra"); ++ expect(routed.provider.modelContextWindows?.["gpt-6-astra"]).toBe(1_050_000); ++ expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); ++ }); ++ ++ test("canonical forward auth filled by routing satisfies the encrypted-task requirement", () => { ++ const config = policyConfig("openai", { ++ adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", ++ }, "gpt-5.5", { encryptedCodexTasks: true }); ++ ++ const route = routeModel(config, "policy/guarded"); ++ expect(route.provider.authMode).toBe("forward"); ++ expect(route.routeDecision?.candidates[0]?.capability?.encryptedCodexTasks).toBe(true); ++ expect(config.providers.openai!.authMode).toBeUndefined(); ++ }); ++ ++ test("the effective provider-wide reasoning ladder participates in policy selection", () => { ++ const config = policyConfig("xiaomi-mimo", { ++ adapter: "openai-chat", baseUrl: "https://api.xiaomimimo.com/v1", ++ }, "mimo-v2.5", { reasoningEffort: "high" }); ++ ++ const route = routeModel(config, "policy/guarded"); ++ expect(route.provider.reasoningEfforts).toEqual(["low", "medium", "high"]); ++ expect(route.routeDecision?.candidates[0]?.capability?.reasoningEfforts) ++ .toEqual(["low", "medium", "high"]); ++ expect(config.providers["xiaomi-mimo"]!.reasoningEfforts).toBeUndefined(); ++ }); ++ ++ test("a same-named custom transport does not inherit an unrelated registry model map", () => { ++ const config = policyConfig("meta-model", { ++ adapter: "openai-responses", baseUrl: "https://custom.example/v1", ++ }, "muse-spark-1.3", { reasoningEffort: "high" }); ++ const routed = routeModel(config, "meta-model/muse-spark-1.3"); ++ expect(routed.provider.baseUrl).toBe("https://custom.example/v1"); ++ expect(routed.provider.modelReasoningEfforts).toBeUndefined(); ++ expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); ++ }); ++ ++ test("an invalid unselected transport cannot prevent a healthy sibling from routing", () => { ++ const config = policyConfig("local", { ++ adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, ++ }, "local-model", {}); ++ config.providers.ollama = { adapter: "openai-chat", baseUrl: " " }; ++ config.routingProfiles!.guarded!.candidates.push({ provider: "ollama", model: "local-model" }); ++ ++ const route = routeModel(config, "policy/guarded"); ++ expect(route.providerName).toBe("local"); ++ expect(route.provider.baseUrl).toBe(loopback); ++ expect(route.routeDecision?.candidates[1]?.capability).toBeUndefined(); ++ }); ++ ++ test("an unresolved transport contributes no positive capability evidence", () => { ++ const config = policyConfig("ollama", { ++ adapter: "openai-chat", baseUrl: loopback, ++ modelInputModalities: { "local-model": ["text", "image"] }, ++ }, "local-model", { imageInput: true }); ++ config.providers.ollama!.baseUrl = " "; ++ ++ const evidence = assemblePolicyCandidateEvidence(config, getRoutingProfile(config, "guarded")!, Date.now(), { ++ routedProviderConfig, ++ }); ++ expect(evidence[0]?.capability).toBeUndefined(); ++ expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); ++ }); ++ ++ test("missing and disabled providers are not resolved for capability evidence", () => { ++ const config = policyConfig("local", { ++ adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, ++ }, "local-model", { tools: true }); ++ config.providers.disabled = { ...config.providers.local!, disabled: true }; ++ config.routingProfiles!.guarded!.candidates.push( ++ { provider: "missing", model: "model" }, ++ { provider: "disabled", model: "model" }, ++ ); ++ const resolved: string[] = []; ++ const evidence = assemblePolicyCandidateEvidence(config, getRoutingProfile(config, "guarded")!, Date.now(), { ++ routedProviderConfig: (name, provider) => { ++ resolved.push(name); ++ return routedProviderConfig(name, provider); ++ }, ++ }); ++ ++ expect(resolved).toEqual(["local"]); ++ expect(evidence[0]?.capability?.tools).toBe(true); ++ expect(evidence[1]?.capability).toBeUndefined(); ++ expect(evidence[2]?.capability).toBeUndefined(); ++ }); ++ ++ test.each(["allow", "penalize", "exclude"] as const)( ++ "an unresolved first candidate is excluded when unknown capabilities are %s", ++ capability => { ++ const config = policyConfig("ollama", { ++ adapter: "openai-chat", baseUrl: loopback, ++ }, "local-model", {}); ++ config.providers.ollama!.baseUrl = " "; ++ config.providers.local = { adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true }; ++ const profile = config.routingProfiles!.guarded!; ++ profile.candidates.push({ provider: "local", model: "local-model" }); ++ profile.unknownEvidence = { ...profile.unknownEvidence, capability }; ++ ++ const route = routeModel(config, "policy/guarded"); ++ expect(route.providerName).toBe("local"); ++ expect(route.routeDecision?.candidates.map(candidate => candidate.eligible)).toEqual([false, true]); ++ expect(route.routeDecision?.candidates[0]?.exclusions).toContainEqual({ code: "route-unavailable" }); ++ expect(JSON.stringify(route.routeDecision)).not.toContain("Invalid baseUrl"); ++ }, ++ ); ++ ++ test("all unresolved candidates produce a policy exclusion while explicit routing keeps validation", () => { ++ const config = policyConfig("ollama", { ++ adapter: "openai-chat", baseUrl: loopback, ++ }, "local-model", {}); ++ config.providers.ollama!.baseUrl = " "; ++ ++ expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); ++ expect(() => routeModel(config, "ollama/local-model")).toThrow('Invalid baseUrl for provider "ollama"'); ++ }); ++}); ++ + describe("candidateCapabilityEvidence model matching", () => { + test("a family entry covers its tagged siblings, as the resolver does", () => { + const provider = providerWithFamilyEntries(); + +```` + +## Required correction on top of the original head + +In `src/routing/compatibility/assemble.ts`, change exactly: + +```diff +- let routeResolutionFailed = false; ++ let routeResolutionFailed = !provider || provider.disabled === true; +``` + +Keep the active-provider `if` and catch intact. Missing/disabled providers must never invoke the resolver. Exception messages must not be copied into evidence or traces. + +Append this test inside the original effective-provider describe block in `tests/routing/routing-capability-model-matching.test.ts`, using its `policyConfig`, `loopback` and cleanup fixtures: + +```ts + for (const unavailable of ["missing", "disabled"] as const) { + test.each(["allow", "penalize", "exclude"] as const)( + `${unavailable} first candidate is excluded under %s unknown policy`, + capability => { + const config = policyConfig("local", { + adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, + }, "local-model", {}); + if (unavailable === "disabled") { + config.providers.disabled = { ...config.providers.local!, disabled: true }; + } + const profile = config.routingProfiles!.guarded!; + profile.candidates.unshift({ provider: unavailable, model: "local-model" }); + profile.unknownEvidence = { ...profile.unknownEvidence, capability }; + const resolved: string[] = []; + const evidence = assemblePolicyCandidateEvidence( + config, getRoutingProfile(config, "guarded")!, Date.now(), { + routedProviderConfig: (name, provider) => { + resolved.push(name); + return routedProviderConfig(name, provider); + }, + }, + ); + expect(resolved).toEqual(["local"]); + expect(evidence[0]?.routeResolutionFailed).toBe(true); + expect(evidence[0]?.capability).toBeUndefined(); + const evaluation = evaluatePolicyProfile(config, "guarded", {}, evidence); + expect(evaluation.selectedIndex).toBe(1); + expect(evaluation.candidates[0]?.eligible).toBe(false); + expect(evaluation.candidates[0]?.exclusions).toContainEqual({ code: "route-unavailable" }); + expect(routeModel(config, "policy/guarded").providerName).toBe("local"); + profile.candidates.pop(); + expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); + }, + ); + } +``` + +Append inside the existing describe in `tests/routing/routing-profile.test.ts`. Imports and `baseConfig`/ManagementRequest fixtures already exist: + +```ts + for (const unavailable of ["missing", "disabled"] as const) { + test.each(["allow", "penalize", "exclude"] as const)( + `API dry-run excludes ${unavailable} provider under %s unknown policy`, + async capability => { + const config = baseConfig({ + providers: { + local: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:11434/v1", allowPrivateNetwork: true }, + }, + defaultProvider: "local", + routingProfiles: { + guarded: { + candidates: [ + { provider: unavailable, model: "local-model" }, + { provider: "local", model: "local-model" }, + ], + require: {}, + unknownEvidence: { capability }, + }, + }, + }); + if (unavailable === "disabled") { + config.providers.disabled = { ...config.providers.local!, disabled: true }; + } + for (const withSibling of [true, false]) { + if (!withSibling) config.routingProfiles!.guarded!.candidates.pop(); + const req = new ManagementRequest("http://localhost/api/routing-profiles/dry-run", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ profile: "guarded", evidence: {} }), + }); + const response = await handleManagementAPI(req, new URL(req.url), config, { + refreshCodexCatalog: async () => {}, + }); + expect(response!.status).toBe(200); + const body = await response!.json() as { + selectedIndex: number | null; + candidates: Array<{ eligible: boolean; exclusions: Array<{ code: string }> }>; + }; + expect(body.selectedIndex).toBe(withSibling ? 1 : null); + expect(body.candidates[0]?.eligible).toBe(false); + expect(body.candidates[0]?.exclusions).toContainEqual({ code: "route-unavailable" }); + } + }, + ); + } +``` + +The empty `require` is deliberate: it activates the bug even when capability unknown handling has no unsatisfied requirement to mask it. Existing synthetic `candidates` fixtures retain their meaning. No upstream request is needed for these scenarios. + +After each original guide paragraph, add the corresponding exact sentence: + +| Path | Added text | +|---|---| +| `docs-site/src/content/docs/guides/routing-profile-editor.md` | Missing or disabled providers are also excluded with `route-unavailable` before scoring. | +| `docs-site/src/content/docs/fr/guides/routing-profile-editor.md` | Les fournisseurs absents ou désactivés sont également exclus avec `route-unavailable` avant le calcul des scores. | +| `docs-site/src/content/docs/tr/guides/routing-profile-editor.md` | Eksik veya devre dışı sağlayıcılar da puanlama öncesinde `route-unavailable` ile dışlanır. | +| `docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md` | 缺少或停用的供應商也會在評分前以 `route-unavailable` 排除。 | + +SOT edit: + +```diff +-| `src/router.ts` | Provider/model selection before adapter dispatch. | ++| `src/router.ts` | Provider/model selection before adapter dispatch. Policy execution and ordinary management dry-run share effective-provider capability evidence; unresolved, missing, and disabled providers are excluded before scoring. | +``` + +## Activation and independent acceptance + +| Trigger | Observable proof, required remotely | +|---|---| +| Canonical DeepSeek with configured loopback URL overridden by registry | Direct resolution shows canonical remote destination; local-only policy rejects it. Genuine custom-local and Ollama loopback remain eligible; config snapshots are unchanged. | +| Registry no-vision defaults, capped native API context, filled forward auth and provider-wide reasoning ladder | Policy sees exactly the effective dispatch values; no-reasoning empty ladder remains a known negative. Same-name custom transport does not gain unrelated registry metadata. | +| Resolver throws for first candidate under allow/penalize/exclude | First candidate has `route-unavailable`, no positive capability and `eligible=false`; healthy sibling wins. All invalid candidates produce NoEligiblePolicyCandidateError; explicit routing preserves its validation error. | +| Missing/disabled first candidate; empty requirements under each unknown policy | Resolver spy only sees active sibling, unavailable evidence true; evaluator and runtime choose sibling, all-unavailable policy fails. | +| Ordinary management dry-run without supplied candidate evidence | Missing/disabled candidates remain excluded; selectedIndex is sibling index or null with no sibling. This proves the public review fix at the actual API owner. | +| Core-only runtime and compatibility provider slot | Core/Lab boundary tests pass; no new import chain, timer or asynchronous activation is introduced. | +| Existing three-argument capability helper callers and synthetic dry-run fixtures | Existing model-family/catalog/profile tests remain green; optional argument is backward compatible. | + +This layer is independently acceptable only with the original carry plus the review correction and all regression cases together. The predecessor contributes the integration baseline, not deferred tests. Main must independently review the public policy-boundary change; do not rely on the original CodeRabbit status alone. + +## Remote verifiers and static workflow coverage + +Main verified `the isolated remote verification host`, its existing `REMOTE_SOURCE_CHECKOUT` checkout, and Bun `1.3.14`. Implementation C uses a separate isolated remote clone at the exact carried SHA; do not alter or run checks in that existing checkout. Record the clone path and resolved SHA with every receipt. No local project command execution is permitted at any point. + +Implementation C runs focused activation regressions and typecheck remotely. The carrying PR stays draft until full current-head GitHub CI is green; focused success alone does not authorize readiness or landing. The final landing cycle requires every full gate, including the separately verified documentation build and required reviews. Deeper implementation review belongs to the next cycle, after candidate-plan revalidation. + +These commands are plans for an isolated remote checkout of the exact carried commit, **not commands to execute on the local Mac**. Record host, exact SHA, command, exit status and full output artifact. Use fixture homes and no real provider traffic. + +```sh +# REMOTE ONLY: exact-head focused behavior and core/Lab boundaries +bun test tests/routing/routing-capability-model-matching.test.ts tests/routing/routing-capability-catalog.test.ts tests/routing/policy-execution.test.ts tests/routing/routing-profile.test.ts tests/routing/routing-compatibility.test.ts tests/routing/compatibility-provider-equivalence.test.ts tests/lab/core-lab-boundary.test.ts +# REMOTE ONLY: complete source checks; hosted CI may supply this evidence instead +bun run typecheck +bun run test +bun run privacy:scan +# REMOTE ONLY: docs build in an isolated verification checkout, without publication +cd docs-site +bun install --frozen-lockfile +bun run build +``` + +For red/green proof, use the remote original carry head without the one-line correction but with the new regression cases: new missing/disabled cases must fail. Add correction on the remote verification candidate and show the same cases green. Do not run this experiment by rewriting this shared worktree. + +Static inspection at `81871b3fa` confirms: + +- `.github/workflows/ci.yml:7` uses `pull_request: {}` without a base filter, so a child PR targeting a parent branch is covered. Push alone to `codex/*` does not trigger it (`:26–27`); an opened PR or authorized workflow_dispatch is necessary. +- `ci.yml:182–185` includes `src/**` and `tests/**`; this layer must produce `changes.ci=true`. Original workflows awaiting approval provide no test evidence. +- `ci.yml:254–316` runs four Linux shards with `scripts/ci/run-bun-test-batches.sh`. Inspect logs for actual file execution and all shards, including split API/storage jobs, rather than only aggregate status. +- `ci.yml:422–431` covers typecheck and privacy. macOS and Windows suites are configured at `:451`, `:532`, `:625`, and `:754`; inspect the actual selected lane and successful test jobs, not skipped jobs. +- `ci.yml:906` aggregate permits intentionally skipped jobs. An aggregate green with skipped required product jobs is insufficient for this source-changing layer. +- `.github/workflows/deploy-docs.yml:3–9` triggers on main/docs changes or manual dispatch, and contains deployment. It is **not** a PR docs-build verifier. Do not dispatch publication to obtain validation. Use the remote build-only commands above. No CI workflow edits are needed in this layer. + +The roadmap cycle changes documentation only; expensive tests may correctly skip there. That success cannot be reused for the later implementation head. Each restack or review fix requires new exact-head evidence. + +## Main-owned stack delivery and closure + +Carry original commits in order, then commit the targeted review correction and regressions. Publish only own branches with the user's authorized `--no-verify` push policy. Do not rewrite the contributor branch. Populate every repository PR-template section, stack parent link and source attribution. No local gate bypass can substitute for remote product CI. + +Merge bottom-up once each layer's exact current head meets review/CI gates. When a lower PR is squash-merged, restack/retarget descendants before deleting lower branches; verify the new parent ancestry and each child diff. Preserve author trailers in the final squash body. + +After main verifies the carrying merge SHA is an ancestor of freshly fetched `origin/dev`, immediately close original #3671 as superseded if it did not auto-close, linking the carrying PR/merge. If original #3671 itself is merged, verify its merged state. Do not close it on branch push, PR creation, CI success alone, or merge only into a stack parent. No additional issue is identified as fully resolved by this unit. + +## Planning proof + +Only this document was written by this worker. The embedded original patch was read directly from the pinned original objects. Source/caller/workflow checks are static; no local tests, typecheck, build, Git mutation, GitHub mutation, goal or FSM transition was performed. The main-owned later P must revalidate all candidate hunks and review state. diff --git a/devlog/_plan/260906_a_runtime_stack/060_landing.md b/devlog/_plan/260906_a_runtime_stack/060_landing.md new file mode 100644 index 0000000000..760192edb8 --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/060_landing.md @@ -0,0 +1,23 @@ +# Verified stacked integration and closeout + +## Changes + +No production changes planned. MODIFY phase records with evidence; move this completed public unit from `devlog/_plan/260906_a_runtime_stack/` to `_fin/` only after all units have a terminal outcome. Private security evidence remains ignored. + +## Procedure + +1. Refresh each original and replacement PR head, base, unresolved reviews, required checks, and live dev. If source author advanced, compare unique new commits before disposition. +2. Require actual current-head full cross-platform tests and typecheck/privacy gates, not intake-only green or old author attestations. Record remote focused triggers as supplementary proof. Repair test failures by cause; never claim retry alone fixed a failure. +3. Verify each child contains its current parent's head and its base points at the parent. Merge the bottom with source author's account-linked Co-authored-by trailer preserved in squash body, or use merge commits to retain ancestry. Push owned branches with --no-verify; any necessary rewrite uses explicit --force-with-lease on owned refs only. +4. Retarget the next child to dev, rebase/merge as needed after a squash, verify its diff and fresh checks. Do not delete a parent branch before child retargeting. +5. Immediately fetch dev and prove `git merge-base --is-ancestor origin/dev`. Close the superseded source PR with the replacement PR and landing SHA. For original PRs directly merged, record merged state. +6. Read linked issue acceptance scope. Close only fully solved issues; #3661 MESSAGE recovery is partial and must retain the residual issue. Do not use automatic Closes for partial work. +7. Check final dev CI on its exact current SHA. Record all original/replacement PR URLs, contributor trailers, merge SHAs and issue dispositions. Complete goal only when every criterion has captured evidence and all FSM cycles closed. + +## Activation and failure cases + +Source-author update: compare the new head, carry any still-needed change and reverify. Concurrent dev movement: integrate it without losing another lane's changes. Squash changes parent identity: cascade child before any merge. Failed CI: inspect exact job/step logs, fix the failing scope, re-run on new head. Unresolved issue scope: leave open with specific residual, never close to improve counts. + +## Verifiers + +Run read-only `gh pr view`, `gh pr checks`, `gh run view` and `git merge-base --is-ancestor` at the real current heads. Local git ancestry and static diff checks are allowed. Runtime/typecheck/build verification occurs on GitHub Actions or isolated the isolated remote verification host checkouts only. Full CI pending means this landing cycle remains active. diff --git a/devlog/_plan/260906_a_windows_fixture/000_plan.md b/devlog/_plan/260906_a_windows_fixture/000_plan.md new file mode 100644 index 0000000000..b858312f61 --- /dev/null +++ b/devlog/_plan/260906_a_windows_fixture/000_plan.md @@ -0,0 +1,9 @@ +# A Windows verifier foundation + +This is the independently reviewed foundation required to unblock A runtime-stack current-head verification. Failed Windows job101339545421 exposed non-hermetic shutdown spill fixtures; no production regression is inferred. The full diff-level specification is010_fixture_plan.md, copied from the audited A roadmap amendment and rechecked against currentdev. + +Loop: spec-satisfaction repair. Test-only fixture classC2; temporary CI verification workflow classC4 with explicit independent security audit. Main owns this branch, commits, --no-verify pushes, source preservation and owner-authorized --admin merges. No local suites/typecheck/build; remote pinnedBun1.4 only. Existing credentials permit repository GitHubActions, read-only-content hostedWindows verification and ownbranch writes; no release/service/account changes. No new credential inputs or command injection. Token/cost cap not specified;2h checkpoint reassesses evidence and progress. + +One workphase owns fixture correctness plus its verified testing foundation; no A feature implementation is included. Only tests/responses/responses-state.test.ts and numberedunitdocs belong in the product PR. A separate owner-only codex/a-verify-windows branch may add the concrete audited probe workflow, never merged todev. Feature/runtime/test blobs must match the product candidate; probe-only workflow excluded by explicit path equality proof. Final full CI on the exact product candidate remains mandatory. + +P refreshes failedfixtures and clock hooks. A audits both minimalfix and concrete CI workflow beforepush. B changes ordering/budget fixtures, pinsverificationartifacts and commits. C executes realWindows controls and fullrelevantgates, plus independent review. D records a preparedverified foundation; the next landing phase inserts/merges this foundation below A layers, refreshesheads and obtains allrequiredCI before eachadminmerge. No test skip, broader production budget, or unverified retry. Existing source and issue disposition criteria remainunchanged. diff --git a/devlog/_plan/260906_a_windows_fixture/010_fixture_plan.md b/devlog/_plan/260906_a_windows_fixture/010_fixture_plan.md new file mode 100644 index 0000000000..d5f6bb017f --- /dev/null +++ b/devlog/_plan/260906_a_windows_fixture/010_fixture_plan.md @@ -0,0 +1,123 @@ +# 010 — Deterministic Windows shutdown-spill fixtures + +Status: P amendment; documentation only. Future implementation is a separate C2 test-harness cycle after 050, before 080. C confirmed no ownership collision. Main owns the FSM, implementation, remote execution and insertion of this foundation beneath the runtime stack. + +## Evidence and boundary + +[Windows job 101339545421](https://github.com/lidge-jun/opencodex/actions/runs/33978547130/job/101339545421), head `4b34cbb8d3f308cd2b01e8d87784c65afb50a40f`, Bun 1.4.0: 3048 pass, 39 skip, 2 fail, 1 unhandled error. The two failures are in `tests/responses/responses-state.test.ts`: + +- Stable-tail (1297): the 500 ms drain timer selected synchronous fallback; its unmocked ACL runner failed with EICACLS. The actual async-delay cause is unmeasured. Global ACL call 7 is also an unreliable publication marker: snapshot and directory hardening share this runner. +- Reserved budget (1438): only the ACL clock is synthetic. `spill-store.ts:232` charged real serialization/filesystem elapsed time and exhausted the deadline before temp-file hardening. The expensive operation is not identified. + +Local evidence inputs: `.tmp/a-runtime-stack/ci-triage/report.md`, `sse-windows5.log:4038–4217`, and `prior-101262480176.log:3894–3897` in that same scratch directory. The earlier job passed the two cases; its overall run was not green. Unchanged source on the sampled dev is not an independently reproduced current-dev failure. Do not describe this as an SSE regression, a proven harmless transient, or a green Windows gate. + +Future edit set: **only `tests/responses/responses-state.test.ts`**. No production, workflow, manifest, shared fixture or budget changes. Reuse `forceWindowsAclLane`, `isSpillAclTarget`, `ICACLS_OK`, existing clock/runner setters, and spill event recording. Keep existing deadline, fallback, exhaustion and watchdog tests. No sleeps for synchronization, timeout increases, skips or relaxed assertions. + +Read-only owners inspected: + +| Owner | Contract retained | +|---|---| +| `src/responses/state.ts:595` | Drain races the observed tail against a real timer; `Date.now` alone cannot freeze that timer. | +| `src/responses/state.ts:771` and `:813` | Separate fallback reserve, remaining-budget forwarding, and repeated observation until the publication tail is stable. | +| `src/responses/spill-store.ts:100`, `:153`, `:225` | Existing I/O events and injectable spill clock; each harden gets min(per-call cap, remaining whole-write budget). | +| `src/lib/windows-secret-acl.ts:360`, `:410`, `:589` | Async runner timer; injected ACL clock; grant/inheritance/remove calls consume one harden deadline. | +| `tests/responses/ws-upstream.test.ts:725` | Existing Bun `jest.useFakeTimers` / `advanceTimersByTime` / `useRealTimers` convention. | + +## Hunk 1 — Stable-tail ordering, not elapsed disk time + +At the test import, add `jest`. Retain 1000/500 budgets. Use fake timers **only within this test**, with `Date.now` fixed to a captured real epoch and ACL/spill clocks fixed consistently. Capture native `setImmediate` before enabling fake timers for an event-loop checkpoint; this drains runnable promise work without a sleep or timer advance. No new shared helper. + +Replace the global `aclCalls === 1/7` runner with gates on the first two distinct spill temp paths at `/grant:r`: + +```ts +const gatedTemps = new Set(); +setAsyncIcaclsRunnerForTests(async args => { + const target = args[0] ?? ""; + if (!isSpillAclTarget(args) || !target.endsWith(".tmp") || args[1] !== "/grant:r") { + return ICACLS_OK; // includes snapshot, directory and later ACL steps + } + if (!gatedTemps.has(target)) { + gatedTemps.add(target); + if (gatedTemps.size === 1) { firstEntered(); await firstGate; } + if (gatedTemps.size === 2) { secondEntered(); await secondGate; } + } + return ICACLS_OK; +}); +let syncSpillCalls = 0; +setIcaclsRunnerForTests(args => { + if (isSpillAclTarget(args)) syncSpillCalls++; + return ICACLS_OK; +}); +``` + +Both principal resolvers remain synthetic through `forceWindowsAclLane`. Both ACL runners cover **every** target; filtering controls gating/counting, never whether a real subprocess is used. A fallback must fail the ordering oracle (`syncSpillCalls === 0`), rather than being hidden by the successful mock. + +Replace the current orchestration and 25 ms sleep with this exact ordering: + +1. Enter `try/finally` before the first enqueue/await. Enable fake timers and fixed epoch clock; install both clock setters. Enqueue first response and await its temp gate. +2. Start `flushResponseState`, immediately attach both settlement handlers, recording `flushed` and any error in a resolved outcome object. This avoids an unhandled rejection if an earlier assertion fails. +3. Enqueue second response **after** starting flush, then release first. Await second temp gate. Await a native `setImmediate` checkpoint, advance fake timers by 25 ms, then another native checkpoint. The drain timer stays below 500 ms; no real elapsed filesystem time can fire it. +4. Assert flush is still pending, exactly two distinct temp paths were gated, and no synchronous spill ACL calls occurred. Record `setSpillIoForTest({ record })` events and assert exactly one `stub-swap` so the first publication actually installed while the second is gated. +5. Release second, await the handled flush outcome and rethrow any captured error. Retain `{ residentCount: 0, spillStubCount: 2 }`; add pending `{ count: 0, bytes: 0 }`, two `stub-swap` events, and zero synchronous spill calls. Both stored response IDs must still expand to their distinct payloads. +6. `finally`: release **both** gates, await any started flush outcome and `flushPendingResponseSpillsForTests()` while mocks/clocks remain installed, then restore the Date spy and real timers in a nested `finally`. Existing `afterEach` restores setters. Never restore mocks while a gated async operation still owns work. + +Use a discriminated outcome (`{ ok: true } | { ok: false; error: unknown }`) rather than an undefined-error sentinel. Keep cleanup valid when either startup await/assertion fails. Fake-timer compatibility and the native checkpoint are remote Windows acceptance items, not assumed proof. Do not solve a failed fixture by globally suppressing timers or adding a production seam. + +## Hunk 2 — One logical fallback budget, actual drain timer + +At 1438, preserve `totalMs = 500`, `fallbackReserveMs = 300` and the pending async spill gate. Add the missing spill clock; scope a Date spy to the flush so outer fallback accounting and nested ACL accounting advance together. Keep native timers in this test: the unchanged 200 ms drain timer must expire while the async gate remains held. + +```diff + let aclClock = 0; + setNowForTests(() => aclClock); ++setResponseSpillNowForTests(() => aclClock); +``` + +Record `{ target, timeoutMs, spentBefore }` for **spill** synchronous ACL calls. Snapshot ACL calls return `ICACLS_OK` without charging the spill clock. For each spill call, record before incrementing `aclClock += 20`; preserve successful command results. + +```ts +const epoch = Date.now(); +const nowSpy = spyOn(Date, "now").mockImplementation(() => epoch + aclClock); +// Start only after the async spill gate announces entry. +try { + await flushResponseState(); // native 200 ms drain timer selects sync fallback +} finally { + release(); + try { await flushPendingResponseSpillsForTests(); } + finally { nowSpy.mockRestore(); } +} +``` + +An enclosing `try/finally` must also cover enqueue and `await started`, releasing the gate on early failure. Preserve all three original assertions: at least six spill commands, maximum deadline <= 150, and `200 + aclClock <= 500`. Add: + +- Every timeout is positive and <= `300 - spentBefore` (independent literal budget oracle). +- Within each target's grant/inheritance/remove sequence, each next timeout is exactly 20 ms smaller; do **not** assert global monotonicity across targets because a new harden has its own per-call cap. +- The async gate has not been released when synchronous spill work begins; fallback actually ran, pending count/bytes become zero, one spill stub remains, and replay contains the original payload. + +The Date spy prevents unmeasured real disk latency from consuming this logical-budget fixture. It does not disable the native drain timer. Real-time termination coverage remains in the unchanged cap-expiry test (1339) and `shutdown fallback budget exhaustion is contained by a child watchdog` (1613), using `tests/helpers/responses-state-shutdown-budget-child.ts`. Do not claim this test measures OS elapsed latency. + +## Windows red, control and proof + +Main executes these later on real Windows with the repository-pinned Bun, in isolated remote checkouts. Nothing below authorizes local tests in this documentation task. + +1. Preserve the failed root-head job/logs above. Run the original two tests on the pinned pre-fix baseline; record actual results, including a pass. Do not require random failure or accept retries as a fix. +2. In remote scratch only, force the old stable-tail drain to expire by holding the second gate until a recorded fallback entry. Use a counted synchronous sentinel that reports EICACLS instead of invoking native ACL tools. Confirm rejection and the fallback call; never infer the missing-mock path from elapsed time alone. This is a controlled mechanism probe, not proof that the same delay happened in CI. +3. In remote scratch only, use the existing spill `record("write")` event to advance a separate wall clock by 301 ms once synchronous fallback has begun. On the original reserved-budget fixture, spill uses that clock and fails before temp hardening; with the proposed shared logical spill clock, the same wall-clock perturbation cannot consume the ACL budget. Record entry and clock values. Keep this probe separate from production and from the committed passing fixture. +4. Prove oracle sensitivity with isolated remote mutations: (a) stop drain after its first observed tail, expecting the revised stable-tail pending/zero-fallback oracle to fail; (b) reset the ACL deadline for each command, expecting per-target 20 ms decrease assertions to fail. Separately advance the **injected spill clock** beyond 300 at the write event and require ETIMEDOUT, proving deadline enforcement remains active. Restore every mutation before green verification; retain diff and failing assertion for each probe. +5. Run the unchanged named cap-expiry and child-watchdog controls, then the whole focused file on the new exact head: + +```sh +# Remote Windows only; these commands are a future verifier recipe. +bun test --isolate --timeout 60000 tests/responses/responses-state.test.ts +bun run typecheck +``` + +6. Dispatch the actual Windows full-suite workflow on that exact head, including `bun test --isolate --timeout 60000 tests --shard=5/6` and every other required shard. Inspect job execution, not aggregate success with skipped tests. Record head SHA, Bun version, commands, job URLs, counts and absence of unhandled errors. Run current-head Linux/macOS gates and required scans as well. + +Implementation D means an independently reviewed prepared foundation draft with exact-head focused Windows evidence and remote typecheck; it is **not landing**. Main inserts the verified foundation beneath the stack, refreshes descendants bottom-up with original attribution intact, obtains required current-head gates, then admin-merges in dependency order. Verify each landed SHA is an ancestor of freshly fetched dev before closing a superseded PR or fully resolved issue. Partial issues retain their residual scope. See `080_landing.md`. + +Documentation acceptance: this file names both failed fixtures, all clock/timer boundaries, complete runner/cleanup coverage, executable negative controls, one-file implementation scope and separate landing gates. No test execution or implementation success is claimed here. + +## Remote execution fallback amendment + +The existing direct Windows SSH endpoint is unavailable; the reachable auxiliary host is Linux without Windows interop. Use GitHub Actions for actual Windows proof. If the existing full-suite workflow cannot execute focused causal probes, a separate owner-only `codex/a-verify-windows` branch may hold a temporary verification workflow triggered only by pushes to that exact branch. This workflow is never included in a product PR or merged to dev. It uses `windows-latest`, read-only contents permission, pinned checkout with `persist-credentials: false`, the existing pinned-Bun setup, fixed repository test commands and the exact carried fixture commit. No secrets, untrusted command inputs, self-hosted runner access or release permissions. It may execute the narrowly specified scratch mutations with guaranteed source restoration and upload logs. Independent security audit of the concrete workflow is required before pushing it. Standard per-head full CI remains the final gate; the temporary verifier cannot mark those checks green. diff --git a/devlog/_plan/260906_a_windows_fixture/011_review_resolution.md b/devlog/_plan/260906_a_windows_fixture/011_review_resolution.md new file mode 100644 index 0000000000..5b230ea087 --- /dev/null +++ b/devlog/_plan/260906_a_windows_fixture/011_review_resolution.md @@ -0,0 +1,3 @@ +# Fixture review correction + +Independent implementation reviewer found that full flush settlement includes later snapshot I/O, which could hide an incorrect first-tail drain return. Accepted: the ordering test now starts the existing drain-only helper before appending the second publication and observes its settlement independently. Both drain and full-flush promises attach rejection handlers immediately and are joined during cleanup. Existing publication, replay and zero-fallback assertions remain. No production changes. A remote first-tail mutation must fail this direct drain oracle before completion. diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 77a67147ac..19049e87f5 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -83,6 +83,11 @@ This applies to both tee inspection and eager relay, including Windows rewrite t even when the upstream read rejects before the response-body cancellation hook runs. A terminal captured during the bounded post-disconnect drain retains its actual outcome. +If native passthrough rewriting fails, including when it exceeds the translation +buffer budget, the relay reports the failure without waiting for upstream inspection +to finish. It cancels the upstream work and emits `response.failed` followed by +`data: [DONE]`; a budget overflow uses the `translation_buffer_limit` error code. + Client-facing Responses SSE frames are limited to 4 MiB per frame, measured in raw bytes before the SSE block delimiter. On HTTP, an unterminated upstream frame that exceeds the limit fails closed with a synthetic `response.failed` event followed by `data: [DONE]`. On the Responses WebSocket diff --git a/src/server/sse-payload-rewrite.ts b/src/server/sse-payload-rewrite.ts index 3c6d825e6c..f9fb62065d 100644 --- a/src/server/sse-payload-rewrite.ts +++ b/src/server/sse-payload-rewrite.ts @@ -249,7 +249,9 @@ export function relaySseWithBlockRewrite( } catch (error) { releaseBuffer(); disposeRewrite(); - try { await reader.cancel(error); } catch { /* already closed */ } + // Cancelling one tee branch waits for its sibling. Surface the failure + // now so downstream can abort upstream and release the inspection branch. + void reader.cancel(error).catch(() => {}); controller.error(error); } }, diff --git a/tests/responses/responses-state.test.ts b/tests/responses/responses-state.test.ts index 831a8df29c..5442910aec 100644 --- a/tests/responses/responses-state.test.ts +++ b/tests/responses/responses-state.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, jest, spyOn, test } from "bun:test"; import { BULK_DURABLE_IO_BUDGET_MS } from "../helpers/test-budget"; import { findDeadPid } from "../helpers/dead-pid"; import { @@ -1297,6 +1297,8 @@ describe("Responses previous_response_id state", () => { test("shutdown drain reaches a stable tail after a publication is appended mid-drain", async () => { forceWindowsAclLane(); setResponseSpillShutdownBudgetForTests({ totalMs: 1_000, fallbackReserveMs: 500 }); + const nativeSetImmediate = setImmediate; + const epoch = Date.now(); let releaseFirst!: () => void; let releaseSecond!: () => void; let firstEntered!: () => void; @@ -1305,35 +1307,99 @@ describe("Responses previous_response_id state", () => { const secondGate = new Promise(resolve => { releaseSecond = resolve; }); const firstStarted = new Promise(resolve => { firstEntered = resolve; }); const secondStarted = new Promise(resolve => { secondEntered = resolve; }); - let aclCalls = 0; - setAsyncIcaclsRunnerForTests(async () => { - aclCalls += 1; - if (aclCalls === 1) { - firstEntered(); - await firstGate; - } else if (aclCalls === 7) { - secondEntered(); - await secondGate; + const gatedTemps = new Set(); + setAsyncIcaclsRunnerForTests(async args => { + const target = args[0] ?? ""; + if (!isSpillAclTarget(args) || !target.endsWith(".tmp") || args[1] !== "/grant:r") { + return ICACLS_OK; } - return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + if (!gatedTemps.has(target)) { + gatedTemps.add(target); + if (gatedTemps.size === 1) { + firstEntered(); + await firstGate; + } else if (gatedTemps.size === 2) { + secondEntered(); + await secondGate; + } + } + return ICACLS_OK; + }); + let syncSpillCalls = 0; + setIcaclsRunnerForTests(args => { + if (isSpillAclTarget(args)) syncSpillCalls += 1; + return ICACLS_OK; + }); + let stubSwaps = 0; + setSpillIoForTest({ + record: event => { if (event === "stub-swap") stubSwaps += 1; }, }); setResponseStateByteCapForTests(1_024); - rememberLarge("resp_fixed_point_first", "a".repeat(8_000)); - await firstStarted; - let flushed = false; - const flushing = flushResponseState().then(() => { flushed = true; }); - rememberLarge("resp_fixed_point_second", "b".repeat(8_000)); - releaseFirst(); - await secondStarted; + let drained = false; + let draining: Promise<{ ok: true } | { ok: false; error: unknown }> | undefined; + let flushing: Promise<{ ok: true } | { ok: false; error: unknown }> | undefined; + let restoreClock: (() => void) | undefined; try { - await new Promise(resolve => setTimeout(resolve, 25)); + // This case proves publication ordering; real disk latency must not fire the drain timer. + jest.useFakeTimers(); + const nowSpy = spyOn(Date, "now").mockReturnValue(epoch); + restoreClock = () => { nowSpy.mockRestore(); }; + setNowForTests(() => epoch); + setResponseSpillNowForTests(() => epoch); + rememberLarge("resp_fixed_point_first", "a".repeat(8_000)); + await firstStarted; + + // Handle rejection immediately, including when a gate/assertion fails before this await. + draining = flushPendingResponseSpillsForTests().then( + () => { drained = true; return { ok: true } as const; }, + (error: unknown) => { drained = true; return { ok: false, error } as const; }, + ); + flushing = flushResponseState().then( + () => { flushed = true; return { ok: true } as const; }, + (error: unknown) => { flushed = true; return { ok: false, error } as const; }, + ); + rememberLarge("resp_fixed_point_second", "b".repeat(8_000)); + releaseFirst(); + await secondStarted; + await new Promise(resolve => nativeSetImmediate(resolve)); + jest.advanceTimersByTime(25); + await new Promise(resolve => nativeSetImmediate(resolve)); + // Snapshot I/O after draining must not mask a premature drain return. + expect(drained).toBe(false); expect(flushed).toBe(false); + expect(gatedTemps.size).toBe(2); + expect(stubSwaps).toBe(1); + expect(syncSpillCalls).toBe(0); + + releaseSecond(); + const drainOutcome = await draining; + if (!drainOutcome.ok) throw drainOutcome.error; + const outcome = await flushing; + if (!outcome.ok) throw outcome.error; + expect(responseStateMetrics()).toMatchObject({ residentCount: 0, spillStubCount: 2 }); + expect(pendingResponseSpillMetricsForTests()).toEqual({ count: 0, bytes: 0 }); + expect(stubSwaps).toBe(2); + expect(syncSpillCalls).toBe(0); + for (const [id, payload] of [["resp_fixed_point_first", "a"], ["resp_fixed_point_second", "b"]] as const) { + expect(JSON.stringify(expandPreviousResponseInput({ + previous_response_id: id, + input: "next", + }))).toContain(payload.repeat(8_000)); + } } finally { + releaseFirst(); releaseSecond(); + try { + await draining; + await flushing; + await awaitResponseSpillPublicationTailForTests(); + await flushPendingResponseSpillsForTests(); + } finally { + restoreClock?.(); + jest.useRealTimers(); + } } - await flushing; - expect(responseStateMetrics()).toMatchObject({ residentCount: 0, spillStubCount: 2 }); }); test("shutdown drain cap expiry enters the synchronous spill fallback", async () => { @@ -1446,31 +1512,61 @@ describe("Responses previous_response_id state", () => { const started = new Promise(resolve => { entered = resolve; }); let aclClock = 0; setNowForTests(() => aclClock); + setResponseSpillNowForTests(() => aclClock); setAsyncIcaclsRunnerForTests(async args => { if (!isSpillAclTarget(args)) return ICACLS_OK; entered(); await gate; return ICACLS_OK; }); - const deadlines: number[] = []; - setIcaclsRunnerForTests((_args, timeoutMs) => { - deadlines.push(timeoutMs); + let released = false; + const deadlines: Array<{ target: string; timeoutMs: number; spentBefore: number; gateReleased: boolean }> = []; + setIcaclsRunnerForTests((args, timeoutMs) => { + if (!isSpillAclTarget(args)) return ICACLS_OK; + deadlines.push({ target: args[0]!, timeoutMs, spentBefore: aclClock, gateReleased: released }); aclClock += 20; - return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + return ICACLS_OK; }); setResponseStateByteCapForTests(1_024); - rememberLarge("resp_shutdown_budget", "b".repeat(2 * 1024 * 1024 + 4_096)); - await started; - + let restoreClock: (() => void) | undefined; try { + rememberLarge("resp_shutdown_budget", "b".repeat(2 * 1024 * 1024 + 4_096)); + await started; + // Keep the native 200 ms drain timer, but charge only logical ACL work to the reserve. + const epoch = Date.now(); + const nowSpy = spyOn(Date, "now").mockImplementation(() => epoch + aclClock); + restoreClock = () => { nowSpy.mockRestore(); }; await flushResponseState(); + const logicalElapsedMs = totalMs - fallbackReserveMs + aclClock; + expect(deadlines.length).toBeGreaterThanOrEqual(6); + expect(Math.max(...deadlines.map(call => call.timeoutMs))).toBeLessThanOrEqual(Math.floor(fallbackReserveMs / 2)); + expect(logicalElapsedMs).toBeLessThanOrEqual(totalMs); + const previousDeadlineByTarget = new Map(); + for (const { target, timeoutMs, spentBefore, gateReleased } of deadlines) { + expect(gateReleased).toBe(false); + expect(timeoutMs).toBeGreaterThan(0); + expect(timeoutMs).toBeLessThanOrEqual(300 - spentBefore); + const previous = previousDeadlineByTarget.get(target); + if (previous !== undefined) expect(timeoutMs).toBe(previous - 20); + previousDeadlineByTarget.set(target, timeoutMs); + } + expect(pendingResponseSpillMetricsForTests()).toEqual({ count: 0, bytes: 0 }); + expect(responseStateMetrics()).toMatchObject({ residentCount: 0, spillStubCount: 1 }); + expect(JSON.stringify(expandPreviousResponseInput({ + previous_response_id: "resp_shutdown_budget", + input: "next", + }))).toContain("b".repeat(2 * 1024 * 1024 + 4_096)); } finally { + released = true; release(); + try { + // Shutdown can clear pending ownership before the superseded async runner settles. + await awaitResponseSpillPublicationTailForTests(); + await flushPendingResponseSpillsForTests(); + } finally { + restoreClock?.(); + } } - const logicalElapsedMs = totalMs - fallbackReserveMs + aclClock; - expect(deadlines.length).toBeGreaterThanOrEqual(6); - expect(Math.max(...deadlines)).toBeLessThanOrEqual(Math.floor(fallbackReserveMs / 2)); - expect(logicalElapsedMs).toBeLessThanOrEqual(totalMs); }); test("late async spill completion cannot overwrite the shutdown fallback", async () => { diff --git a/tests/responses/sse-payload-rewrite.test.ts b/tests/responses/sse-payload-rewrite.test.ts index 34dae59e07..773665a054 100644 --- a/tests/responses/sse-payload-rewrite.test.ts +++ b/tests/responses/sse-payload-rewrite.test.ts @@ -153,4 +153,82 @@ describe("SSE payload rewrite composition", () => { expect(budget.snapshot().currentBytes).toBe(0); budget.dispose(); }); + + test.each(["resolve", "reject"] as const)( + "surfaces a rewrite failure before tee cancellation can %s", + async cancellationOutcome => { + const budget = createTestTranslatorBudget({ maxTurnBytes: 64 }); + const upstream = new AbortController(); + const cancellation = Promise.withResolvers(); + const cancellationError = new Error("upstream cancellation failed"); + let cancelCalls = 0; + let disposeCalls = 0; + const source = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("data: partial")); + controller.enqueue(new TextEncoder().encode("x".repeat(80))); + // Keep the source open after exhausting the rewrite budget. + }, + cancel() { + cancelCalls += 1; + return cancellation.promise; + }, + }); + const [native, inspection] = source.tee(); + const inspectionReader = inspection.getReader(); + await inspectionReader.read(); + await inspectionReader.read(); + let inspectionSettled = false; + const pendingInspection = inspectionReader.read().then(() => { inspectionSettled = true; }); + const rewrite = Object.assign((block: string) => [block], { + dispose() { disposeCalls += 1; }, + }); + const rewritten = relaySseWithBlockRewrite(native, rewrite, budget); + const client = relaySseWithFailedTail(rewritten, upstream); + const completion = readAll(client); + let deadline: ReturnType | undefined; + + try { + const out = await Promise.race([ + completion, + new Promise((_, reject) => { + deadline = setTimeout(() => reject(new Error("rewrite failure waited for the inspection tee")), 1_000); + }), + ]); + expect(out.match(/event: response.failed/g)).toHaveLength(1); + expect(out).toContain('"code":"translation_buffer_limit"'); + expect(out).toEndWith("data: [DONE]\n\n"); + expect(upstream.signal.aborted).toBe(true); + expect(inspectionSettled).toBe(false); + expect(cancelCalls).toBe(0); + expect(disposeCalls).toBe(1); + expect(budget.snapshot().currentBytes).toBe(0); + expect(budget.snapshot().overflows).toBe(1); + + // Releasing inspection settles both tee cancellation promises. A late + // rejection must be handled by the rewriter as well as this reader. + const siblingCancellation = inspectionReader.cancel("inspection cleanup"); + expect(cancelCalls).toBe(1); + if (cancellationOutcome === "reject") { + cancellation.reject(cancellationError); + await expect(siblingCancellation).rejects.toBe(cancellationError); + } else { + cancellation.resolve(); + await siblingCancellation; + } + await pendingInspection; + await Bun.sleep(0); // Let the runner observe any unhandled cancellation rejection. + expect(disposeCalls).toBe(1); + } finally { + clearTimeout(deadline); + const cleanup = inspectionReader.cancel().catch(() => {}); + cancellation.resolve(); + await cleanup; + await pendingInspection; + await completion.catch(() => {}); + inspectionReader.releaseLock(); + budget.dispose(); + } + }, + ); });