diff --git a/devlog/_plan/260906_a_macos_verification/000_plan.md b/devlog/_plan/260906_a_macos_verification/000_plan.md new file mode 100644 index 0000000000..460d566fec --- /dev/null +++ b/devlog/_plan/260906_a_macos_verification/000_plan.md @@ -0,0 +1,5 @@ +# Final macOS verification repairs + +C4 spec-satisfaction repair of Unix probe cleanup classification. Consume the already reviewed replay-fixture commit7ff811ced to keep caller identity stable in the shared verification baseline. Main owns this new foundation PR below the two remaining A layers. No local suite/typecheck/build; all execution uses isolated remote Bun1.4.0 and CI. Existing GitHub/SSH identities and own branches only; no account/service/release changes. The only live processes exercised are temporary launchers created by the regression fixture. No additional termination signals or widened permissions are authorized. A2h checkpoint reassesses progress; no token/cost cap was specified. Detailed OS traces stay in ignored scratch. + +Goal: initial EPERM during an already-owned probe-group teardown does not prevent bounded observation of that group's disappearance. Success still requires an observed ESRCH. Persistent permission uncertainty or live groups continue to refuse installation and restore the launcher. Keep the existing one-second cleanup bound, one SIGKILL attempt, diagnostic sanitation and rollback guarantees. diff --git a/devlog/_plan/260906_a_macos_verification/010_cleanup_plan.md b/devlog/_plan/260906_a_macos_verification/010_cleanup_plan.md new file mode 100644 index 0000000000..403b1770e4 --- /dev/null +++ b/devlog/_plan/260906_a_macos_verification/010_cleanup_plan.md @@ -0,0 +1,11 @@ +# Diff-level cleanup plan + +1. Carry reviewed commit7ff811ced (test-only replay caller snapshots, forced second boundary and changed-token isolation) onto this dev foundation. Resolve only contextual offsets; do not introduce affinity production code or its cohort matrix. +2. MODIFY src/codex/shim.ts terminateUnixProcessGroup: retain the single initial SIGKILL. Save EPERM rather than immediately throwing it; other non-ESRCH errors still throw. Use the unchanged one-second passive signal-0 observation loop. An observed disappearance succeeds; if the group remains or cannot be observed, rethrow saved EPERM, otherwise retain the existing generic nontermination error. No new signal retry, timeout increase, cache change or test-only production export. +3. MODIFY tests/codex-integration/codex-shim.test.ts timeout rollback fixture. Keep its real native case, exact timeout message, restored launcher/no backup/no marker, native group-missing and child-dead/zombie assertions. Add scoped parent-only process.kill observation for its recorded negative PGID; unrelated calls delegate unchanged and spawned probes have independent native bindings. +4. Deterministic cases: SIGKILL throws sentinel EPERM then signal-0 EPERM→ESRCH must produce ordinary timeout refusal; persistent EPERM and continually-live signal-0 must retain fail-closed EPERM diagnostics. Assert one SIGKILL, actual passive probes, and the existing bounded runtime. Restore spies before native process cleanup proof; never count synthetic ESRCH as real cleanup. Passive bounded joining of the known fixture group is allowed for injected cases; native case retains its original immediate cleanup assertions. Finally restore environment/mocks and clean only fixture-owned paths/processes. +5. Emit bounded pid/state/error-code diagnostics on failure, with no commands, credentials or environment dumps. Actual CI EPERM is observed; the zombie-only-group explanation is a hypothesis, not claimed captured fact. +6. Remote proof: focused shim and replay/cache/security tests plus typecheck. Revert only the EPERM observation correction in remote scratch; the disappearing-group control must fail its exact diagnostic assertion. Candidate must pass transient, persistent and live controls, the native timeout integration and all original rollback checks. Restore source bytes. Independent implementation/security audit then exact-head full CI before admin landing. +7. Cascade verified foundation into affinity then capability, retain source-author commits, update PR bases before auto-deletion and reverify their current heads. No original remaining PR is closed before its change is on dev. Full current-head CI and final dev proof remain mandatory. + +Cleanup completion is not installation approval: existing timeout/recursive/descendant markers and the pre-cleanup group-survival result still refuse the launcher. The change only permits bounded absence proof before choosing the existing refusal diagnostic. No previously unsafe launcher is accepted. diff --git a/devlog/_plan/260906_a_macos_verification/020_direct_transport_watchdog.md b/devlog/_plan/260906_a_macos_verification/020_direct_transport_watchdog.md new file mode 100644 index 0000000000..708a1ce380 --- /dev/null +++ b/devlog/_plan/260906_a_macos_verification/020_direct_transport_watchdog.md @@ -0,0 +1,7 @@ +# Focused verification watchdog correction + +ClassC1: one test file, no production behavior or public API change. WindowsCIjob101361741694 hit the fixture's flat3000ms childwatchdog before routing assertions. The same child performs imports, an unbounded control fetch, two750ms probes and a2000ms read. The log cannot identify which stage consumed time. + +Use the existing CI-watchdog owner for a derived whole-child budget:3000ms startup +2000ms bounded control +750ms identity +750ms readiness +2000ms read +1000ms exit =9500ms. OnCI the existing30s/45s floor applies. Give the test itself the child budget plus1000ms cleanup. Add fixed child phase markers and bounded phase/request-count diagnostics, never capability values. Keep every exact routing/header assertion and existing per-operation budgets. Bound only the previously unbounded control fetch. + +Verify remotely on pinnedBun: originalfilechecks, an explicit3500ms pre-import delay underCI that succeeds withthecorrectbudget and fails withtheold3000ms guard, and an intentional memory-read misroute that fails the unchangedproxy/capability assertions despite valid-looking responses. No local execution. This is a causal verifier fix within the ongoing final landing repair loop, not an unconditional rerun or production timeout increase. diff --git a/devlog/_plan/260906_a_macos_verification/030_transition_probe_watchdog.md b/devlog/_plan/260906_a_macos_verification/030_transition_probe_watchdog.md new file mode 100644 index 0000000000..768c5104af --- /dev/null +++ b/devlog/_plan/260906_a_macos_verification/030_transition_probe_watchdog.md @@ -0,0 +1,9 @@ +# Transition probe readiness budget + +The next full Windows verification of the foundation (run33988432596, job101366851939) had one failure: the two-process transition initialization fixture reached its ten-second readiness deadline before both children published their barriers. No transition assertion ran. The same fixture passed in the fully verified stack tip33988434944. The failed log is retained; the exact slow operation on that runner was not captured. + +The harness nevertheless has a concrete budget defect: before publishing ready, each Windows child resolves the effective SID and the known folder through two separately bounded thirty-second PowerShell calls. A ten-second enclosing deadline can reject valid operation within those existing product limits. This is a C1 fixture-only follow-up within the final landing cycle. + +Derive the child budget from both identity calls plus startup headroom, use the existing CI watchdog on other platforms, and scale each outer test deadline to its sequential phases. Detect an exited child while waiting for a barrier so a crash cannot masquerade as slow startup, and await child exit before deleting its sandbox. Preserve every real process race, lock refusal, winner count, generation and database assertion; no product timing changes. + +Verification requires remote pinned-runtime focused tests and typecheck, a delayed-ready control that passes the new budget and fails the old ten-second budget, an early-exit diagnostic control, independent review, and full exact-head cross-platform CI on the final stacked follow-up. No local test, typecheck or build runs. diff --git a/devlog/_plan/260906_a_macos_verification/040_quota_observation_drain.md b/devlog/_plan/260906_a_macos_verification/040_quota_observation_drain.md new file mode 100644 index 0000000000..31e5091ce2 --- /dev/null +++ b/devlog/_plan/260906_a_macos_verification/040_quota_observation_drain.md @@ -0,0 +1,7 @@ +# Join asynchronous quota observations in fixtures + +The final top's Windows1 verification (run33990109175, job101372136435) found a concrete fixture ordering defect. The first two quota-reset seam assertions saw no event after six microtasks and five milliseconds. A later test that used the existing explicit drain received those earlier scheduled and surprise events instead. The fixed sleep did not join cold lazy imports or the serialized observation chain, and fixture reset replaced the capture sink while old work was still pending. + +This C1 test-only follow-up uses the existing flushQuotaObservationsForTests seam. Join observations before assertions; join before resetting a fixture or replacing its sink; and join asynchronous baseline forgetting after clearAccountQuota. Keep all event counts, reset kinds, account separation and no-notification assertions unchanged. Production quota logic and timing remain untouched. + +Verify on the remote pinned runtime with the full focused file and typecheck. Delay the existing observation/forget chain in scratch to prove the new drain still passes and the old five-millisecond fixture fails. Restore every temporary mutation. Require independent review and final exact-head CI before integration. No local tests, builds or typechecks. diff --git a/devlog/_plan/260906_a_replay_credentials/000_plan.md b/devlog/_plan/260906_a_replay_credentials/000_plan.md new file mode 100644 index 0000000000..01b5f57117 --- /dev/null +++ b/devlog/_plan/260906_a_replay_credentials/000_plan.md @@ -0,0 +1,3 @@ +# Stable replay-fixture caller identity + +C2 spec-satisfaction repair of a concrete macOS control failure. Two logical replay conversations generated a new synthetic credential for each request; a second-boundary change made them different callers. Preserve production credential scope and every existing response/cache assertion. Only tests/server/server-agent-task-recovery-replay.test.ts and this numbered unit change. No local tests/typecheck/build; pinned remote Bun1.4 isolated regressions, deterministic old/new control, typecheck and current-head CI before final landing. Owner-authorized no-verify pushes/admin merge remain scoped to A. No credential or service changes. Same session goal/ledger owns this extra mandatory cycle; no completion criteria removed. diff --git a/devlog/_plan/260906_a_replay_credentials/010_replay_plan.md b/devlog/_plan/260906_a_replay_credentials/010_replay_plan.md new file mode 100644 index 0000000000..abe95054b7 --- /dev/null +++ b/devlog/_plan/260906_a_replay_credentials/010_replay_plan.md @@ -0,0 +1,9 @@ +# Replay fixture diff plan + +MODIFY tests/server/server-agent-task-recovery-replay.test.ts only: + +1. In the two original real-handler tests (cached NEW_TASK continuation and MESSAGE replay), capture one headers object before the first post and reuse it for the second. Keep status200, one recovery, two provider bodies, plaintext-present and ciphertext-absent assertions. +2. Scope a Date.now spy to each test at a real current second plus995ms. Advance controlled time by10ms between posts. Assert a newly constructed unused credential differs across that boundary, while the actual conversation continues with its original headers. Restore the clock in finally. No sleep or timeout increase. +3. Add a changed-token isolation control using the existing fakeChatGptJwt claim override: same account/envelope and two valid tokens differing in exp must not share cached plaintext. Reusing the original request still restores. Assert no extra network recovery and unchanged encrypted input on the miss. +4. Main performs exact-head remote isolated replay/cache/security tests and typecheck. A scratch red control restores per-post codexHeaders() calls while keeping the forced boundary; both conversations must lose the expected plaintext. The changed-token negative remains a pass. Restore candidate bytes after the probe. +5. Independent review checks fixture identity, clock cleanup and unchanged production boundary. Publish the own affinity branch, cascade the capability child and obtain fresh CI after all recorded verification repairs. Original source author commits remain intact. No new production file or test-layout entry. diff --git a/devlog/_plan/260906_a_runtime_stack/000_plan.md b/devlog/_plan/260906_a_runtime_stack/000_plan.md index 521e47c38a..c724b27854 100644 --- a/devlog/_plan/260906_a_runtime_stack/000_plan.md +++ b/devlog/_plan/260906_a_runtime_stack/000_plan.md @@ -17,13 +17,14 @@ | Cycle | Artifact | Consumes | Delivers | |---|---|---|---| -| roadmap | 000 + 010..060 | live dev and public contributor changes | audited full integration plan; docs only | +| roadmap | 000 + 010..080 | 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 | +| windows-fixtures | 070_windows_fixtures.md | current Windows failure evidence | deterministic verifier repair below A stack | +| landing | 080_landing.md | independently verified stack layers and verifier repair | 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. diff --git a/devlog/_plan/260906_a_runtime_stack/003_audit_resolution.md b/devlog/_plan/260906_a_runtime_stack/003_audit_resolution.md index e772aeb294..f871eeb920 100644 --- a/devlog/_plan/260906_a_runtime_stack/003_audit_resolution.md +++ b/devlog/_plan/260906_a_runtime_stack/003_audit_resolution.md @@ -2,7 +2,7 @@ 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. +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 080 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/004_windows_amendment.md b/devlog/_plan/260906_a_runtime_stack/004_windows_amendment.md new file mode 100644 index 0000000000..53b8712654 --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/004_windows_amendment.md @@ -0,0 +1,3 @@ +# Windows verifier amendment + +Full Windows CI for SSE head failed two unchanged shutdown-spill fixtures. Logs and causal analysis are retained in ignored ci-triage/report.md. C confirms no concurrent ownership of responses-state.test.ts. Add a separate windows-fixtures PABCD after capabilities and before final landing. It repairs test-only clocks/fallback isolation, independently validates on Windows, publishes a small foundation PR and inserts its verified change beneath the source stack. Refresh descendants bottom-up while preserving contributor commits and required current-head checks. No production ACL/budget change, no test skip, no unexamined rerun. The final landing document moves to080; no existing completion criterion is weakened. Owner explicitly authorized admin merge. diff --git a/devlog/_plan/260906_a_runtime_stack/005_amendment_audit.md b/devlog/_plan/260906_a_runtime_stack/005_amendment_audit.md new file mode 100644 index 0000000000..f871b8eb37 --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/005_amendment_audit.md @@ -0,0 +1,3 @@ +# Capability and verifier amendment audit + +Independent reviewer: capability plan PASS; roadmap GO-WITH-FIXES one prerequisite finding. Accepted. Added a new windows-fixtures prerequisite to landing while retaining its existing capabilities edge. No task/criterion completion states or existing prerequisite edges were removed. The durable dependency graph now prevents final landing from being selected before Windows verifier completion. Replaced stale060 landing references with080. Temporary Windows verification workflow still requires concrete security review before push. diff --git a/devlog/_plan/260906_a_runtime_stack/010_sse.md b/devlog/_plan/260906_a_runtime_stack/010_sse.md index e70fc2c223..27aee24e97 100644 --- a/devlog/_plan/260906_a_runtime_stack/010_sse.md +++ b/devlog/_plan/260906_a_runtime_stack/010_sse.md @@ -4,7 +4,7 @@ Status: candidate plan, docs-only; implementation class C3 (stream lifecycle). E ## 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. +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. `080_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 diff --git a/devlog/_plan/260906_a_runtime_stack/020_ws.md b/devlog/_plan/260906_a_runtime_stack/020_ws.md index 766c662986..2db3f2d07b 100644 --- a/devlog/_plan/260906_a_runtime_stack/020_ws.md +++ b/devlog/_plan/260906_a_runtime_stack/020_ws.md @@ -4,7 +4,7 @@ Status: candidate plan after layer 010, docs-only; implementation class C4 for t ## 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. +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. `080_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 diff --git a/devlog/_plan/260906_a_runtime_stack/030_recovery.md b/devlog/_plan/260906_a_runtime_stack/030_recovery.md index 99ea201b44..92accfca44 100644 --- a/devlog/_plan/260906_a_runtime_stack/030_recovery.md +++ b/devlog/_plan/260906_a_runtime_stack/030_recovery.md @@ -6,7 +6,7 @@ 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. +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. `080_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 @@ -23,7 +23,7 @@ This decade cycle ends with a reviewed prepared draft PR, exact-carried-head foc - 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. +- Stop condition: reviewed prepared draft and exact-head remote focused/type evidence; full CI/dev inclusion are required by 080 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 diff --git a/devlog/_plan/260906_a_runtime_stack/040_affinity.md b/devlog/_plan/260906_a_runtime_stack/040_affinity.md index c35e7c5db2..8c1380a72e 100644 --- a/devlog/_plan/260906_a_runtime_stack/040_affinity.md +++ b/devlog/_plan/260906_a_runtime_stack/040_affinity.md @@ -6,7 +6,7 @@ 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. +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. `080_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 @@ -21,7 +21,7 @@ This decade cycle ends with a reviewed prepared draft PR, exact-carried-head foc 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. +- Stop: reviewed prepared draft atop recovery, with exact-head remote focused/type evidence. Full current-head gates and dev ancestry remain required in 080. - 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 diff --git a/devlog/_plan/260906_a_runtime_stack/041_affinity_refresh.md b/devlog/_plan/260906_a_runtime_stack/041_affinity_refresh.md new file mode 100644 index 0000000000..38515e7742 --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/041_affinity_refresh.md @@ -0,0 +1,5 @@ +# Affinity layer P refresh + +Consume 040 on prepared recovery parent332a30e6d. Original #3581 remains f60397d3408e0339ffc66acdcaca8133e40866c2, with SB Yoon attribution preserved. Retain new recovery cache/history logic and termination WeakMap rebind when applying the two core hunks. The new cohort flag must survive initial parse and both fresh/cache-only reparse; true/undefined never authorize cache-key-based session identity. No changes to OAuth command-code cache-key forwarding; enable the existing API-key commandcode registry capability only. + +Scoped regression worker after carry owns tests/helpers/agent-task-recovery.ts, tests/server/server-agent-task-recovery-replay.test.ts and tests/providers/command-code-provider.test.ts. Use the actual ADAPTER_REGISTRY openai-chat create seam already proven in the parent regression to observe parsed fields at real buildRequest. Main owns production and adapters documentation. Remote helper asserts project Bun1.4.0; no local suites/typecheck/build. Full exact-head CI and --admin integration remain final gates. diff --git a/devlog/_plan/260906_a_runtime_stack/050_capabilities.md b/devlog/_plan/260906_a_runtime_stack/050_capabilities.md index b545f21bae..51514b6215 100644 --- a/devlog/_plan/260906_a_runtime_stack/050_capabilities.md +++ b/devlog/_plan/260906_a_runtime_stack/050_capabilities.md @@ -2,7 +2,7 @@ ## 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. +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. `080_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 @@ -14,7 +14,7 @@ Status: candidate planning, not implementation or merge approval. Revalidate at - 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. +- Stop: independently working reviewed draft with original authorship and exact-head remote focused/type evidence. Full current-head gates/dev ancestry remain required by 080. - 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. diff --git a/devlog/_plan/260906_a_runtime_stack/051_capability_refresh.md b/devlog/_plan/260906_a_runtime_stack/051_capability_refresh.md new file mode 100644 index 0000000000..190122e007 --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/051_capability_refresh.md @@ -0,0 +1,5 @@ +# Effective-capability layer P refresh + +Consume050 on prepared affinity6b00fa8d6. Original #3671 remains7b1beb9c5, with two Hako commits. Carry both; fix the remaining public review by initializing routeResolutionFailed to !provider || provider.disabled===true before resolving enabled candidates. Preserve synthetic caller-supplied dry-run evidence semantics and the core/Lab slot boundary. Explicitly test missing and disabled candidates under allow/penalize/exclude, with healthy sibling and with none, at both runtime/evaluator and ordinary management dry-run. + +Regression worker owns only tests/routing/routing-capability-model-matching.test.ts and tests/routing/routing-profile.test.ts. Main owns production, four existing guide locales and runtime SOT. No local tests/typecheck/build; pinned remote Bun1.4 --isolate focused checks, actual API assertions, privacy/docs/fullCI before landing. Windows verifier repair is registered as an additional required cycle and does not weaken any earlier gate. diff --git a/devlog/_plan/260906_a_runtime_stack/070_windows_fixtures.md b/devlog/_plan/260906_a_runtime_stack/070_windows_fixtures.md new file mode 100644 index 0000000000..daab52ed55 --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/070_windows_fixtures.md @@ -0,0 +1,123 @@ +# 070 — 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_runtime_stack/060_landing.md b/devlog/_plan/260906_a_runtime_stack/080_landing.md similarity index 100% rename from devlog/_plan/260906_a_runtime_stack/060_landing.md rename to devlog/_plan/260906_a_runtime_stack/080_landing.md 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 b437c28b3d..18575f21c8 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,14 @@ 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. +Les fournisseurs absents ou désactivés sont également exclus avec `route-unavailable` avant le calcul des scores. + 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 5cf5fc6d71..d53e0d3616 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,14 @@ 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. +Missing or disabled providers are also excluded with `route-unavailable` before scoring. + 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/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 2848fed564..3760ad2869 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -148,6 +148,19 @@ of the HTTP retry loop. ChatGPT account id, and the OpenAI beta/originator/session headers. This is the ChatGPT-login path that also powers the [sidecars](/guides/sidecars/). +## Command Code session affinity + +The OAuth `command-code` adapter derives an opaque `x-session-id` from the client +thread identity, then the reasoning-replay conversation identity. When neither is +available, it uses a prompt-cache key only if the integration has explicitly +classified that key as belonging to one conversation. Shared or unclassified cache +keys do not establish session affinity; requests without a usable identity receive +a fresh session ID. Recovery and cached-history replay preserve this classification. + +The API-key `commandcode` provider uses the `openai-chat` adapter and supports +forwarding `prompt_cache_key`. This is separate from the OAuth adapter's session +header and does not guarantee a provider cache hit. + ## `anthropic` **Targets:** Anthropic **Messages** (`/v1/messages`). 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 dd7aa50d72..74ab8a7bdc 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,14 @@ 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. +Eksik veya devre dışı sağlayıcılar da puanlama öncesinde `route-unavailable` ile 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 +107,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 e6ae93a76e..4aa10fcaa7 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,10 @@ OpenCodex 儀表板中的 **Models → Routing** 分頁可以直接管理 `confi ## 試跑已儲存的設定檔 +候選能力使用套用 registry 覆寫後的有效供應商設定。因此,本地性需求(`localOnly` 與 `remoteAllowed`)會依據實際上游位址判定。若無法分類該位址,則由設定檔的 `unknownEvidence.capability` 決定候選是否合格。 +無法解析的無效供應商設定一律以 `route-unavailable` 排除,即使原則允許未知能力也是如此。 +缺少或停用的供應商也會在評分前以 `route-unavailable` 排除。 + 選取一個已儲存的設定檔,使用 **Dry-run evaluation** 加入請求證據,例如 context-window 大小、工具使用、圖片輸入或結構化輸出。試跑會評估資格與評分,但永遠不會送出上游模型請求。 未儲存的編輯不會被試跑使用。請先儲存設定檔,讓顯示的 revision 與評估參照同一份設定。 diff --git a/src/adapters/command-code.ts b/src/adapters/command-code.ts index a9b429bc99..c20dc88be6 100644 --- a/src/adapters/command-code.ts +++ b/src/adapters/command-code.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { execFile as execFileCallback } from "node:child_process"; import { promisify } from "node:util"; import { opendir } from "node:fs/promises"; @@ -213,6 +213,27 @@ function projectSlug(cwd: string): string { return cwd.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase().slice(0, 64) || "workspace"; } +export function commandCodeSessionId(parsed: OcxParsedRequest): string { + // Shared prompt-cache cohorts identify a cache population, not one conversation. Using one + // for session affinity would pin unrelated conversations to the same upstream worker. + const threadId = parsed._clientThreadId?.trim(); + const replayId = parsed._reasoningReplayScope?.clientThreadId?.trim(); + const cacheKey = parsed._promptCacheKeyIsSharedCohort === false + ? parsed.options.promptCacheKey?.trim() + : undefined; + const identity = threadId + ? ["thread", threadId] + : replayId + ? ["replay", replayId] + : cacheKey + ? ["cache", cacheKey] + : undefined; + if (!identity) return randomUUID(); + const hex = createHash("sha256").update(`command-code:${identity[0]}\0${identity[1]}`).digest("hex"); + // Replace the digest nibbles at the UUID version and variant positions; the skipped hex characters are intentional. + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`; +} + interface GitWorkspaceInfo { isGitRepo: boolean; currentBranch: string; @@ -525,7 +546,7 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA "x-cli-environment": "production", "x-taste-learning": "false", "x-co-flag": "false", - "x-session-id": randomUUID(), + "x-session-id": commandCodeSessionId(parsed), }; if (cwd) headers["x-project-slug"] = projectSlug(cwd); return { diff --git a/src/codex/shim.ts b/src/codex/shim.ts index f53f778b12..a63c48fb7a 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -961,14 +961,20 @@ function unixProcessGroupAlive(groupId: number): boolean { } function terminateUnixProcessGroup(groupId: number): void { + let permissionError: unknown; try { process.kill(-groupId, "SIGKILL"); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + const code = (error as NodeJS.ErrnoException).code; + if (code === "EPERM") permissionError = error; + else if (code !== "ESRCH") throw error; } + // A concurrently exiting group can briefly reject a second signal. Only + // observed disappearance clears that uncertainty; never send another signal. const deadline = Date.now() + CODEX_SHIM_INSTALL_PROBE_EXIT_TIMEOUT_MS; while (Date.now() < deadline && unixProcessGroupAlive(groupId)) Bun.sleepSync(10); if (unixProcessGroupAlive(groupId)) { + if (permissionError) throw permissionError; throw new Error(`Codex shim install probe process group ${groupId} did not terminate`); } } diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 64c01dbb10..82f70a6a6a 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -2175,6 +2175,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ liveModels: true, preserveCustomDestination: true, defaultModel: "deepseek/deepseek-v4-flash", + promptCacheKey: true, // The default is also the cold-start seed: live discovery failure must not empty the catalog // for a freshly configured provider with no stale cache (issue #308 pattern). models: ["deepseek/deepseek-v4-flash"], diff --git a/src/routing/capability.ts b/src/routing/capability.ts index 8495951a0f..7f26e8bbdf 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 1d543690a4..5bcef26fc8 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 = !provider || provider.disabled === true; + 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 a07b833063..7cf801bfe9 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/src/server/responses/core.ts b/src/server/responses/core.ts index 0471617da0..fc0eaae543 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2953,6 +2953,7 @@ async function handleResponsesInner( let toolBridgeMaps: ReturnType; try { parsed = parseRequest(body); + parsed._promptCacheKeyIsSharedCohort = options.promptCacheKeyIsSharedCohort; // Captured before any parser mutates it, so both grammars see the client's id. const { fastRow, effortRow } = parseSyntheticRowId(parsed.modelId, config); if (fastRow) { @@ -3271,6 +3272,7 @@ async function handleResponsesInner( "_providerContinuationOwner", "_cursorConversationId", "_clientThreadId", + "_promptCacheKeyIsSharedCohort", "_cursorClientThreadId", "_reasoningReplayScope", "_cursorIsolateConversation", diff --git a/src/types/request.ts b/src/types/request.ts index ffee4eb8a3..1c6a5294da 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -68,6 +68,8 @@ export interface OcxParsedRequest { _cursorConversationId?: string; /** Stable upstream client thread identity, used only to derive provider-scoped continuation ids. */ _clientThreadId?: string; + /** True when promptCacheKey identifies a shared cache cohort rather than one conversation. */ + _promptCacheKeyIsSharedCohort?: boolean; /** Cursor-only thread owner; may be an opaque process-local Desktop session/thread identity. */ _cursorClientThreadId?: string; /** Conversation/provider/account/model-bound namespace for reasoning replay state. */ diff --git a/structure/01_runtime.md b/structure/01_runtime.md index 26f501419a..7fb1c00997 100644 --- a/structure/01_runtime.md +++ b/structure/01_runtime.md @@ -15,7 +15,7 @@ | `src/config/process-state.ts` | Owns `ocx.pid`, `runtime-port.json`, cheap liveness, full command-line identity verification, and snapshot-guarded cleanup. | | `src/server/ports.ts` | Owns bind availability and ephemeral-port selection. Temporary probes dispose accepted peers and wait for listener close before reporting success. | | `src/cli/status.ts` / `src/cli/status-probes.ts` | Status snapshot assembly and the shared read-only health/stale-process probes used by status and doctor. Probe evidence keeps recorded-port choice, before/after snapshots and per-call timer cleanup together. | -| `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. | | `src/types.ts` | Shared config, parsed request, adapter, and event types. | | `src/reasoning-effort.ts` | Codex reasoning-level definitions (`low`/`medium`/`high`/`xhigh`), per-model effort mapping, and catalog effort sanitization. | | `src/codex/shim.ts` | Codex autostart shim: replaces the `codex` binary with a wrapper that auto-starts the proxy on demand. It skips startup for management subcommands even when value-taking global flags precede the subcommand, and transactionally restores complete, stable external launcher replacements without a watcher or PATH rediscovery. | diff --git a/tests/claude-integration/claude-code-thought-signature-scope.test.ts b/tests/claude-integration/claude-code-thought-signature-scope.test.ts index 2437a8d157..eb544dce97 100644 --- a/tests/claude-integration/claude-code-thought-signature-scope.test.ts +++ b/tests/claude-integration/claude-code-thought-signature-scope.test.ts @@ -97,16 +97,19 @@ describe("Claude Code Anthropic inbound reasoning-replay scope", () => { const parsed = await drive({ promptCacheKey: "session-key-123", promptCacheKeyIsSharedCohort: false }); expect(parsed._clientThreadId).toBeUndefined(); expect(parsed._reasoningReplayScope?.clientThreadId).toBe("session-key-123"); + expect(parsed._promptCacheKeyIsSharedCohort).toBe(false); }); test("the shared Desktop prompt_cache_key cohort does not create a scope", async () => { const parsed = await drive({ promptCacheKey: "shared-cohort-key", promptCacheKeyIsSharedCohort: true }); expect(parsed._reasoningReplayScope).toBeUndefined(); + expect(parsed._promptCacheKeyIsSharedCohort).toBe(true); }); test("an Anthropic replay without prompt_cache_key does not create a scope", async () => { const parsed = await drive({}); expect(parsed._reasoningReplayScope).toBeUndefined(); + expect(parsed._promptCacheKeyIsSharedCohort).toBeUndefined(); }); test("an overlong prompt_cache_key is hashed, not stored raw", async () => { diff --git a/tests/codex-integration/codex-shim.test.ts b/tests/codex-integration/codex-shim.test.ts index f413fbdffb..406ed945bd 100644 --- a/tests/codex-integration/codex-shim.test.ts +++ b/tests/codex-integration/codex-shim.test.ts @@ -1,4 +1,4 @@ -import { afterAll, describe, expect, test } from "bun:test"; +import { afterAll, describe, expect, spyOn, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { chmodSync, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; import { delimiter, dirname, join } from "node:path"; @@ -712,53 +712,149 @@ os._exit(0) }, ); - test("Unix install rolls back when launcher validation times out", () => { - if (process.platform === "win32") return; - - const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-timeout-bin-")); - const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-timeout-home-")); - const oldPath = process.env.PATH; - const oldHome = process.env.OPENCODEX_HOME; - const codexPath = join(binDir, "codex"); - const childPidPath = join(home, "probe-child.pid"); - const groupIdPath = join(home, "probe-group.pid"); - const original = `#!/bin/sh + for (const [name, mode] of [ + ["Unix install rolls back when launcher validation times out", "native"], + ["Unix timeout cleanup observes disappearance after EPERM without another signal", "disappears"], + ["Unix timeout cleanup preserves EPERM when passive probes keep failing", "permission"], + ["Unix timeout cleanup preserves EPERM when passive probes report a live group", "live"], + ] as const) { + test(name, () => { + if (process.platform === "win32") return; + + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-timeout-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-timeout-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const codexPath = join(binDir, "codex"); + const childPidPath = join(home, "probe-child.pid"); + const groupIdPath = join(home, "probe-group.pid"); + const original = `#!/bin/sh /bin/sleep 30 & child=$! printf '%s\\n' "$child" > "${childPidPath}" printf '%s\\n' "$$" > "${groupIdPath}" wait "$child" `; - try { - process.env.PATH = prependPath(binDir, oldPath); - process.env.OPENCODEX_HOME = home; - writeFileSync(codexPath, original, "utf8"); - chmodSync(codexPath, 0o755); + const nativeKill = process.kill.bind(process); + const permissionError = Object.assign(new Error("fixture termination denied"), { code: "EPERM" }); + let restoreKill: (() => void) | undefined; + let killCalls = 0; + let passiveProbes = 0; + let terminationStartedAt = 0; + let terminationElapsedMs = 0; + let childPid = 0; + let groupId = 0; + try { + process.env.PATH = prependPath(binDir, oldPath); + process.env.OPENCODEX_HOME = home; + writeFileSync(codexPath, original, "utf8"); + chmodSync(codexPath, 0o755); - const installed = installCodexShim(); + if (mode !== "native") { + const killSpy = spyOn(process, "kill").mockImplementation((pid, signal) => { + // The child writes its own group identity before the parent resumes from spawnSync. + if (groupId === 0 && existsSync(groupIdPath)) { + const recorded = Number.parseInt(readFileSync(groupIdPath, "utf8").trim(), 10); + if (Number.isInteger(recorded) && recorded > 1) groupId = recorded; + } + if (groupId <= 1 || pid !== -groupId) return nativeKill(pid, signal); + if (signal === "SIGKILL") { + killCalls += 1; + if (killCalls === 1) terminationStartedAt = Date.now(); + throw permissionError; + } + if (signal === 0 && killCalls > 0) { + passiveProbes += 1; + if (mode === "permission" || (mode === "disappears" && passiveProbes === 1)) { + throw permissionError; + } + if (mode === "disappears") { + throw Object.assign(new Error("fixture group disappeared"), { code: "ESRCH" }); + } + return true; + } + return nativeKill(pid, signal); + }); + restoreKill = () => { killSpy.mockRestore(); }; + } + let installed: ReturnType; + try { + installed = installCodexShim(); + terminationElapsedMs = Date.now() - terminationStartedAt; + } finally { + restoreKill?.(); + restoreKill = undefined; + if (mode !== "native" && existsSync(groupIdPath)) { + groupId = Number.parseInt(readFileSync(groupIdPath, "utf8").trim(), 10); + if (Number.isInteger(groupId) && groupId > 1) { + // Join only this fixture's real group, even when a later assertion fails. + // Synthetic ESRCH never proves cleanup; these observations use the native binding. + const deadline = Date.now() + 1_000; + while (Date.now() < deadline) { + try { nativeKill(-groupId, 0); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") break; + } + Bun.sleepSync(10); + } + } + } + } + childPid = Number.parseInt(readFileSync(childPidPath, "utf8").trim(), 10); + groupId = Number.parseInt(readFileSync(groupIdPath, "utf8").trim(), 10); - expect(installed.installed).toBe(false); - expect(installed.message).toContain("did not finish --version within 5000ms"); - expect(installed.message).toContain("original launcher was restored"); - expect(readFileSync(codexPath, "utf8")).toBe(original); - expect(existsSync(`${codexPath}.opencodex-real`)).toBe(false); - expect(existsSync(join(home, "codex-shim.json"))).toBe(false); - const childPid = Number.parseInt(readFileSync(childPidPath, "utf8").trim(), 10); - const groupId = Number.parseInt(readFileSync(groupIdPath, "utf8").trim(), 10); - expect(Number.isInteger(childPid)).toBe(true); - expect(Number.isInteger(groupId)).toBe(true); - expectProcessGroupMissing(groupId); - const childState = processState(childPid); - expect(childState === "" || childState.startsWith("Z")).toBe(true); - } finally { - if (oldPath === undefined) delete process.env.PATH; - else process.env.PATH = oldPath; - if (oldHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = oldHome; - removeTreeWithRetry(binDir); - removeTreeWithRetry(home); - } - }, 10_000); + expect(installed.installed).toBe(false); + if (mode === "native" || mode === "disappears") { + expect(installed.message).toContain("did not finish --version within 5000ms"); + } else { + expect(installed.message).toContain("[phase=termination; code=EPERM; status=124; signal=none]"); + expect(installed.message).not.toContain("did not finish --version within 5000ms"); + expect(terminationElapsedMs).toBeGreaterThanOrEqual(1_000); + } + if (mode !== "native") { + expect(killCalls).toBe(1); + expect(passiveProbes).toBeGreaterThanOrEqual(2); + } + expect(installed.message).toContain("original launcher was restored"); + expect(readFileSync(codexPath, "utf8")).toBe(original); + expect(existsSync(`${codexPath}.opencodex-real`)).toBe(false); + expect(existsSync(join(home, "codex-shim.json"))).toBe(false); + expect(Number.isInteger(childPid)).toBe(true); + expect(Number.isInteger(groupId)).toBe(true); + expect(childPid).toBeGreaterThan(1); + expect(groupId).toBeGreaterThan(1); + expectProcessGroupMissing(groupId); + const childState = processState(childPid); + expect(childState === "" || childState.startsWith("Z")).toBe(true); + } catch (error) { + restoreKill?.(); + restoreKill = undefined; + let groupState = "unrecorded"; + if (groupId > 1) { + try { nativeKill(-groupId, 0); groupState = "present"; } + catch (probeError) { + const code = (probeError as NodeJS.ErrnoException).code; + groupState = code === "ESRCH" || code === "EPERM" ? code : "other-error"; + } + } + let childState = "unrecorded"; + if (childPid > 1) { + try { childState = processState(childPid).replace(/[^A-Za-z+<>N]/g, "").slice(0, 16) || "absent"; } + catch { childState = "unavailable"; } + } + console.error("[shim-timeout-fixture]", { mode, groupId, childPid, groupState, childState, killCalls, passiveProbes }); + throw error; + } finally { + restoreKill?.(); + if (oldPath === undefined) delete process.env.PATH; + else process.env.PATH = oldPath; + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + removeTreeWithRetry(binDir); + removeTreeWithRetry(home); + } + }, 10_000); + } test("Unix install preserves an existing backup without probing or mutation", () => { if (process.platform === "win32") return; diff --git a/tests/codex-integration/codex-transition-state-race.test.ts b/tests/codex-integration/codex-transition-state-race.test.ts index 31c87a194a..9d5aef73b8 100644 --- a/tests/codex-integration/codex-transition-state-race.test.ts +++ b/tests/codex-integration/codex-transition-state-race.test.ts @@ -19,9 +19,16 @@ import { resolveCodexCoordinatorDatabasePath, resolveEffectiveUserIdentity, } from "../../src/codex/user-identity"; +import { WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS } from "../../src/lib/windows-user-principal"; +import { watchdogMs } from "../helpers/ci-watchdog"; import { repoPath } from "../helpers/repo-root"; -const CHILD_TIMEOUT_MS = 10_000; +// Before publishing ready, a Windows probe resolves its SID and known folder +// with two separately bounded PowerShell calls. The harness must cover both; +// this watchdog bounds the fixture, not coordinator lock/transition latency. +const CHILD_TIMEOUT_MS = watchdogMs(process.platform === "win32" + ? 2 * WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS + 5_000 + : 10_000); const transitionStateModuleUrl = pathToFileURL( repoPath("src", "codex", "transition-state.ts"), ).href; @@ -169,9 +176,18 @@ async function collectProbe(child: ReturnType): Promise { +async function waitForFiles( + paths: readonly string[], + children: readonly ReturnType[], +): Promise { const deadline = Date.now() + CHILD_TIMEOUT_MS; while (!paths.every(existsSync)) { + for (const child of children) { + if (child.exitCode !== null) { + const stderr = await new Response(child.stderr).text(); + throw new Error(`probe exited before barrier (code=${child.exitCode}): ${stderr}`); + } + } if (Date.now() >= deadline) throw new Error(`timed out waiting for ${paths.join(", ")}`); await Bun.sleep(5); } @@ -198,9 +214,9 @@ test("two real processes racing first use publish exactly one initial transition )); try { - await waitForFiles([join(barrier, "a.ready"), join(barrier, "b.ready")]); + await waitForFiles([join(barrier, "a.ready"), join(barrier, "b.ready")], children); writeFileSync(releasePath, "go"); - await waitForFiles([join(barrier, "a.outcome"), join(barrier, "b.outcome")]); + await waitForFiles([join(barrier, "a.outcome"), join(barrier, "b.outcome")], children); writeFileSync(retryPath, "retry-busy-loser"); const results = await Promise.all(children.map(collectProbe)); @@ -248,9 +264,10 @@ test("two real processes racing first use publish exactly one initial transition } } finally { for (const child of children) child.kill(); + await Promise.all(children.map(child => child.exited)); cleanupSandbox(sandbox); } -}, { timeout: 30_000 }); +}, { timeout: 4 * CHILD_TIMEOUT_MS }); test("different OPENCODEX_HOME claimants advance the row under one CODEX_HOME", async () => { const sandbox = createSandbox("shared-codex-home"); @@ -293,7 +310,7 @@ test("different OPENCODEX_HOME claimants advance the row under one CODEX_HOME", } finally { cleanupSandbox(sandbox); } -}, { timeout: 30_000 }); +}, { timeout: 4 * CHILD_TIMEOUT_MS }); test("a locked coordinator returns the exact typed busy outcome", async () => { const sandbox = createSandbox("busy"); @@ -313,7 +330,7 @@ test("a locked coordinator returns the exact typed busy outcome", async () => { controller?.close(); cleanupSandbox(sandbox); } -}, { timeout: 20_000 }); +}, { timeout: 3 * CHILD_TIMEOUT_MS }); test("an unsafe coordinator path returns the exact typed unsafe-path outcome", async () => { const sandbox = createSandbox("unsafe-path"); @@ -329,4 +346,4 @@ test("an unsafe coordinator path returns the exact typed unsafe-path outcome", a } cleanupSandbox(sandbox); } -}, { timeout: 20_000 }); +}, { timeout: 2 * CHILD_TIMEOUT_MS }); diff --git a/tests/helpers/agent-task-recovery.ts b/tests/helpers/agent-task-recovery.ts index bf8a173f70..4a6a95c5ae 100644 --- a/tests/helpers/agent-task-recovery.ts +++ b/tests/helpers/agent-task-recovery.ts @@ -147,7 +147,7 @@ export async function post( input: unknown[], headers: HeadersInit = {}, abortSignal?: AbortSignal, - options: { tools?: unknown[]; translatorBudget?: TranslatorBudget } = {}, + options: { tools?: unknown[]; translatorBudget?: TranslatorBudget; promptCacheKeyIsSharedCohort?: boolean } = {}, ): Promise { return handleResponses(new Request("http://localhost/v1/responses", { method: "POST", @@ -156,7 +156,11 @@ export async function post( ...Object.fromEntries(new Headers(headers)), }, body: JSON.stringify({ model, input, stream: false, ...(options.tools ? { tools: options.tools } : {}) }), - }), config, { model: "", provider: "" }, { abortSignal, translatorBudget: options.translatorBudget }); + }), config, { model: "", provider: "" }, { + abortSignal, + translatorBudget: options.translatorBudget, + promptCacheKeyIsSharedCohort: options.promptCacheKeyIsSharedCohort, + }); } export function encryptedInput(options: { diff --git a/tests/providers/command-code-provider.test.ts b/tests/providers/command-code-provider.test.ts index 3e369a9c6b..a3b81e408d 100644 --- a/tests/providers/command-code-provider.test.ts +++ b/tests/providers/command-code-provider.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { createCommandCodeAdapter } from "../../src/adapters/command-code"; +import { commandCodeSessionId, createCommandCodeAdapter } from "../../src/adapters/command-code"; import { loginCommandCode, parseCommandCodeCallback, shouldImportLocalCommandCodeAuth } from "../../src/oauth/command-code"; import { buildModelsRequest, OAUTH_PROVIDERS } from "../../src/oauth"; import { @@ -796,4 +796,138 @@ describe("Command Code provider", () => { const built = await builtRequest({ ...parsed(), stream: false }); expect(JSON.parse(built.body).params.stream).toBe(true); }); + + test("derives an opaque stable session id from trusted conversation identity", async () => { + const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-8[0-9a-f]{3}-[0-9a-f]{12}$/i; + const identities = { + thread: "thread-secret-value", + replay: "replay-secret-value", + cache: "cache-secret-value", + }; + const thread = { + ...parsed(), + _clientThreadId: ` ${identities.thread} `, + _reasoningReplayScope: { clientThreadId: identities.replay }, + options: { ...parsed().options, promptCacheKey: identities.cache }, + }; + const sameThread = { + ...thread, + _reasoningReplayScope: { clientThreadId: "different-replay" }, + options: { ...thread.options, promptCacheKey: "different-cache" }, + }; + const replay = { + ...parsed(), + _reasoningReplayScope: { clientThreadId: identities.replay }, + options: { ...parsed().options, promptCacheKey: identities.cache }, + }; + const sameReplay = { ...replay, options: { ...replay.options, promptCacheKey: "different-cache" } }; + const cache = { + ...parsed(), + options: { ...parsed().options, promptCacheKey: ` ${identities.cache} ` }, + _promptCacheKeyIsSharedCohort: false, + }; + const sameCache = { + ...cache, + options: { ...cache.options, promptCacheKey: identities.cache }, + }; + + const threadId = commandCodeSessionId(thread); + expect(threadId).toBe(commandCodeSessionId(sameThread)); + expect(threadId).not.toBe(commandCodeSessionId({ ...thread, _clientThreadId: "different-thread" })); + expect(commandCodeSessionId(replay)).toBe(commandCodeSessionId(sameReplay)); + expect(commandCodeSessionId(cache)).toBe(commandCodeSessionId(sameCache)); + expect(commandCodeSessionId(replay)).not.toBe(commandCodeSessionId(cache)); + expect(threadId).toMatch(uuid); + expect(commandCodeSessionId(replay)).toMatch(uuid); + expect(commandCodeSessionId(cache)).toMatch(uuid); + for (const raw of Object.values(identities)) expect(threadId).not.toContain(raw); + + const built = await builtRequest(thread); + expect(built.headers["x-session-id"]).toBe(threadId); + }); + + test("whitespace thread and replay identities fall through to the next trusted identity at the wire", async () => { + const replay: OcxParsedRequest = { + ...parsed(), + _clientThreadId: " \t\n ", + _reasoningReplayScope: { clientThreadId: " replay-after-blank-thread " }, + _promptCacheKeyIsSharedCohort: false, + options: { ...parsed().options, promptCacheKey: "distinct-cache-fallback" }, + }; + const cache: OcxParsedRequest = { + ...replay, + _reasoningReplayScope: { clientThreadId: " \t\n " }, + options: { ...parsed().options, promptCacheKey: " cache-after-blank-replay " }, + }; + const cleanReplay: OcxParsedRequest = { + ...parsed(), + _reasoningReplayScope: { clientThreadId: "replay-after-blank-thread" }, + }; + const cleanCache: OcxParsedRequest = { + ...parsed(), + _promptCacheKeyIsSharedCohort: false, + options: { ...parsed().options, promptCacheKey: "cache-after-blank-replay" }, + }; + const cases: Array<[OcxParsedRequest, OcxParsedRequest]> = [[replay, cleanReplay], [cache, cleanCache]]; + for (const [withWhitespace, clean] of cases) { + const built = await builtRequest(withWhitespace); + const expected = await builtRequest(clean); + expect(built.headers["x-session-id"]).toBe(expected.headers["x-session-id"]); + expect(commandCodeSessionId(withWhitespace)).toBe(built.headers["x-session-id"]); + } + }); + + test("whitespace-only trusted identities produce fresh session headers", async () => { + const blank: OcxParsedRequest = { + ...parsed(), + _clientThreadId: " \t ", + _reasoningReplayScope: { clientThreadId: "\n " }, + _promptCacheKeyIsSharedCohort: false, + options: { ...parsed().options, promptCacheKey: " \t\n " }, + }; + const first = (await builtRequest(blank)).headers["x-session-id"]; + const second = (await builtRequest(blank)).headers["x-session-id"]; + const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + expect(first).toMatch(uuid); + expect(second).toMatch(uuid); + expect(first).not.toBe(second); + }); + + test("the same literal in thread, replay and cache namespaces yields distinct stable session headers", async () => { + const literal = "same-identity-in-every-kind"; + const requests: OcxParsedRequest[] = [ + { ...parsed(), _clientThreadId: literal }, + { ...parsed(), _reasoningReplayScope: { clientThreadId: literal } }, + { + ...parsed(), + _promptCacheKeyIsSharedCohort: false, + options: { ...parsed().options, promptCacheKey: literal }, + }, + ]; + const ids: string[] = []; + for (const request of requests) { + const id = (await builtRequest(request)).headers["x-session-id"]!; + expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-8[0-9a-f]{3}-[0-9a-f]{12}$/i); + expect(id).not.toContain(literal); + expect((await builtRequest(request)).headers["x-session-id"]).toBe(id); + expect(commandCodeSessionId(request)).toBe(id); + ids.push(id); + } + expect(new Set(ids).size).toBe(3); + }); + + test("does not derive affinity from a shared cohort or prompt text", () => { + const shared = { + ...parsed(), + options: { ...parsed().options, promptCacheKey: "shared-cache-key" }, + _promptCacheKeyIsSharedCohort: true, + }; + expect(commandCodeSessionId(shared)).not.toBe(commandCodeSessionId(shared)); + const unclassifiedCache = { + ...parsed(), + options: { ...parsed().options, promptCacheKey: "possibly-shared-cache-key" }, + }; + expect(commandCodeSessionId(unclassifiedCache)).not.toBe(commandCodeSessionId(unclassifiedCache)); + expect(commandCodeSessionId(parsed())).not.toBe(commandCodeSessionId(parsed())); + }); }); diff --git a/tests/providers/commandcode-provider.test.ts b/tests/providers/commandcode-provider.test.ts index e76355dc4f..f70df51093 100644 --- a/tests/providers/commandcode-provider.test.ts +++ b/tests/providers/commandcode-provider.test.ts @@ -67,6 +67,7 @@ describe("Command Code provider", () => { liveModels: true, preserveCustomDestination: true, defaultModel: "deepseek/deepseek-v4-flash", + promptCacheKey: true, apiKeyValidation: "unknown", reasoningEfforts: [], modelReasoningEfforts: { @@ -176,6 +177,20 @@ describe("Command Code provider", () => { expect(body).not.toHaveProperty("parallel_tool_calls"); }); + test("forwards the enabled prompt cache key to chat completions", () => { + const route = routeModel( + commandcodeConfig(), + "commandcode/deepseek/deepseek-v4-flash", + ); + const request = createOpenAIChatAdapter(route.provider).buildRequest({ + modelId: route.modelId, + context: { messages: [{ role: "user", content: "ping", timestamp: 0 }] }, + stream: true, + options: { promptCacheKey: "command-code-session-cache" }, + }); + expect(JSON.parse(String(request.body)).prompt_cache_key).toBe("command-code-session-cache"); + }); + test("discovers the live catalog with context windows and preserves slash ids", async () => { globalThis.fetch = (async (input, init) => { expect(String(input)).toBe("https://api.commandcode.ai/provider/v1/models"); diff --git a/tests/routing/routing-capability-model-matching.test.ts b/tests/routing/routing-capability-model-matching.test.ts index bb956c2d8d..df5a6b22c2 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,278 @@ 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(); + }); + + 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 => { + // Empty requirements prevent another capability guard from masking availability. + 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 }; + + for (const withSibling of [true, false]) { + if (!withSibling) profile.candidates.pop(); + 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(withSibling ? ["local"] : []); + expect(evidence).toHaveLength(withSibling ? 2 : 1); + expect(evidence[0]?.routeResolutionFailed).toBe(true); + expect(evidence[0]?.capability).toBeUndefined(); + const evaluation = evaluatePolicyProfile(config, "guarded", {}, evidence); + expect(evaluation.selectedIndex).toBe(withSibling ? 1 : null); + expect(evaluation.candidates[0]).toMatchObject({ + provider: unavailable, + eligible: false, + requirements: [], + exclusions: [{ code: "route-unavailable" }], + }); + if (withSibling) { + expect(evidence[1]?.routeResolutionFailed).toBeUndefined(); + expect(evidence[1]?.capability?.tools).toBe(true); + expect(evaluation.candidates[1]?.eligible).toBe(true); + 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).toEqual([{ code: "route-unavailable" }]); + } else { + expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); + } + } + }, + ); + } + + 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(); diff --git a/tests/routing/routing-profile.test.ts b/tests/routing/routing-profile.test.ts index aafca468dd..0736ec96fb 100644 --- a/tests/routing/routing-profile.test.ts +++ b/tests/routing/routing-profile.test.ts @@ -476,6 +476,67 @@ describe("routing profiles (RI-04)", () => { expect(body.candidates?.[1]).toMatchObject({ provider: "b", eligible: false }); }); + 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" }, + // No synthetic candidates: exercise the same assembly as runtime routing. + body: JSON.stringify({ profile: "guarded", evidence: {} }), + }); + const response = await handleManagementAPI(req, new URL(req.url), config, { + refreshCodexCatalog: async () => {}, + }); + expect(response).not.toBeNull(); + expect(response!.status).toBe(200); + const body = await response!.json() as { + selectedIndex: number | null; + candidates: Array<{ + provider: string; + eligible: boolean; + requirements: unknown[]; + exclusions: Array<{ code: string }>; + }>; + }; + expect(body.selectedIndex).toBe(withSibling ? 1 : null); + expect(body.candidates).toHaveLength(withSibling ? 2 : 1); + expect(body.candidates[0]).toMatchObject({ + provider: unavailable, + eligible: false, + requirements: [], + exclusions: [{ code: "route-unavailable" }], + }); + if (withSibling) { + expect(body.candidates[1]).toMatchObject({ provider: "local", eligible: true }); + } + } + }, + ); + } + test("API dry-run mirrors live codex cooldown for openai candidates", async () => { const { clearCodexUpstreamHealth, recordCodexUpstreamOutcome } = await import("../../src/codex/routing"); clearCodexUpstreamHealth(); diff --git a/tests/server/local-management-direct-transport.test.ts b/tests/server/local-management-direct-transport.test.ts index 2fda9cbcb3..c8c74ce4fa 100644 --- a/tests/server/local-management-direct-transport.test.ts +++ b/tests/server/local-management-direct-transport.test.ts @@ -6,9 +6,13 @@ import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { directLocalHttpFetch } from "../../src/server/direct-local-http"; import { repoPath, repoRoot } from "../helpers/repo-root"; +import { watchdogMs } from "../helpers/ci-watchdog"; const PID = 4242; const SECRET = "A".repeat(43); +const CONTROL_TIMEOUT_MS = 2_000; +// Startup/imports + control + two liveness probes + capability read + process exit. +const DIRECT_CHILD_BUDGET_MS = watchdogMs(3_000 + CONTROL_TIMEOUT_MS + 750 + 750 + 2_000 + 1_000); async function listen(server: Server, hostname = "127.0.0.1"): Promise { return await new Promise((resolve, reject) => { @@ -299,14 +303,22 @@ describe("local management direct transport", () => { const localClientUrl = pathToFileURL(repoPath("src", "server", "local-management-read-client.ts")).href; const capabilityUrl = pathToFileURL(repoPath("src", "lib", "local-management-capability.ts")).href; const childSource = ` + const phase = name => console.error("DIRECT_PHASE:" + name); + phase("imports"); const liveness = await import(${JSON.stringify(proxyLivenessUrl)}); const client = await import(${JSON.stringify(localClientUrl)}); const capability = await import(${JSON.stringify(capabilityUrl)}); const port = ${targetPort}; const pid = ${PID}; - const control = await fetch(\`http://127.0.0.1:\${port}/__proxy-control\`).then(response => response.json()); + phase("control"); + const control = await fetch(\`http://127.0.0.1:\${port}/__proxy-control\`, { + signal: AbortSignal.timeout(${CONTROL_TIMEOUT_MS}), + }).then(response => response.json()); + phase("identity"); const identity = await liveness.proxyIdentityAt(port, { hostname: "127.0.0.1", expectedPid: pid }); + phase("readiness"); const readiness = await liveness.probeReadiness(port, { hostname: "127.0.0.1", expectedPid: pid }); + phase("memory"); const read = await client.fetchBoundLocalManagementRead( { hostname: "127.0.0.1", port, pid, source: "runtime" }, capability.LOCAL_MANAGEMENT_READ_PATHS.systemMemory, @@ -319,6 +331,7 @@ describe("local management direct transport", () => { const memory = read.kind === "response" ? await read.response.json() : null; const result = { control, identity, readiness, readKind: read.kind, memory }; console.log(JSON.stringify(result)); + phase("complete"); if (control?.via !== "proxy" || identity?.pid !== pid || readiness?.ready !== true || read.kind !== "response" || memory?.pid !== pid) { process.exitCode = 2; } @@ -341,13 +354,14 @@ describe("local management direct transport", () => { const childWatchdog = setTimeout(() => { childTimedOut = true; child.kill(); - }, 3_000); + }, DIRECT_CHILD_BUDGET_MS); const [exitCode, stdout, stderr] = await Promise.all([ child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(), ]).finally(() => clearTimeout(childWatchdog)); - if (childTimedOut) throw new Error("direct-transport child timed out"); + const phase = [...stderr.matchAll(/DIRECT_PHASE:(imports|control|identity|readiness|memory|complete)/g)].at(-1)?.[1] ?? "startup"; + if (childTimedOut) throw new Error(`direct-transport child timed out (phase=${phase}; targetRequests=${targetPaths.length}; proxyRequests=${proxyPaths.length})`); if (exitCode !== 0) { throw new Error(`direct-transport child failed (${exitCode}): ${stderr.trim()}\n${stdout.trim()}`); } @@ -375,5 +389,5 @@ describe("local management direct transport", () => { if (proxyPort !== 0) await close(proxy); if (targetPort !== 0) await close(target); } - }); + }, DIRECT_CHILD_BUDGET_MS + 1_000); }); diff --git a/tests/server/server-agent-task-recovery-replay.test.ts b/tests/server/server-agent-task-recovery-replay.test.ts index fd572e9fd8..28050c5369 100644 --- a/tests/server/server-agent-task-recovery-replay.test.ts +++ b/tests/server/server-agent-task-recovery-replay.test.ts @@ -6,7 +6,7 @@ import { bindTurnTerminationScope, rememberDeliveredFinalAnswer } from "../../sr import { conversationIdFromResponsesRequest } from "../../src/server/request-log-conversation"; import type { OcxParsedRequest } from "../../src/types"; import { recoverEncryptedAgentTask, resetAgentTaskRecoveryState, restoreCachedEncryptedAgentTasks } from "../../src/server/responses/agent-task-recovery"; -import { codexHeaders, encryptedInput, FERNET_TASK, SECOND_FERNET_TASK, originalFetch, recoverySse, routedConfig } from "../helpers/agent-task-recovery"; +import { codexHeaders, encryptedInput, fakeChatGptJwt, FERNET_TASK, SECOND_FERNET_TASK, originalFetch, recoverySse, routedConfig } from "../helpers/agent-task-recovery"; afterEach(() => { globalThis.fetch = originalFetch; resetAgentTaskRecoveryState(); }); test("replay reuses admitted recovery after a tool result without another network call", async () => { @@ -51,18 +51,107 @@ test("Responses handler restores a cached task in a continued child turn", async return providerResponse(); }) as typeof fetch; const config = routedConfig({ enabled: true }); - expect((await post(config, "xai/grok-4.5", encryptedInput(), codexHeaders())).status).toBe(200); - expect((await post(config, "xai/grok-4.5", [...encryptedInput(), { type: "message", role: "user", content: "Continue the original task." }], codexHeaders())).status).toBe(200); - expect(recoveries).toBe(1); - expect(bodies).toHaveLength(2); - expect(bodies[1]).toContain("Read nonce.txt exactly."); - expect(bodies[1]).not.toContain(FERNET_TASK); + let now = Math.floor(Date.now() / 1_000) * 1_000 + 995; + const clock = spyOn(Date, "now").mockImplementation(() => now); + try { + const headers = codexHeaders(); + expect((await post(config, "xai/grok-4.5", encryptedInput(), headers)).status).toBe(200); + now += 10; + // A freshly generated fixture JWT would be a different caller across this boundary. + expect(codexHeaders().get("authorization")).not.toBe(headers.get("authorization")); + expect((await post(config, "xai/grok-4.5", [...encryptedInput(), { type: "message", role: "user", content: "Continue the original task." }], headers)).status).toBe(200); + expect(recoveries).toBe(1); + expect(bodies).toHaveLength(2); + expect(bodies[1]).toContain("Read nonce.txt exactly."); + expect(bodies[1]).not.toContain(FERNET_TASK); + } finally { + clock.mockRestore(); + } }); function encryptedMessage(): unknown[] { return JSON.parse(JSON.stringify(encryptedInput()).replace("Message Type: NEW_TASK", "Message Type: MESSAGE")); } +test.each([true, false, undefined])("fresh recovery and cache-only reparse preserve cohort marker %s and replay metadata", async (cohort) => { + const { post, providerResponse } = await import("../helpers/agent-task-recovery"); + const parentThread = `affinity-parent-${crypto.randomUUID()}`; + const headers = codexHeaders("acct-caller", { + "x-codex-parent-thread-id": parentThread, + "thread-id": "distinct-child-thread", + session_id: "distinct-session", + }); + const config = routedConfig({ enabled: true }); + let recoveries = 0; + const recoveryBodies: string[] = []; + const providerBodies: string[] = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const body = String(init?.body); + if (String(url).includes("chatgpt.com")) { + recoveries++; + recoveryBodies.push(body); + return new Response(recoverySse("Read the affinity assignment.")); + } + providerBodies.push(body); + return providerResponse(); + }) as typeof fetch; + + const observations: Array<{ + cohort: boolean | undefined; + thread: string | undefined; + replay: OcxParsedRequest["_reasoningReplayScope"]; + raw: string; + }> = []; + const createChat = ADAPTER_REGISTRY["openai-chat"].create; + const factory = spyOn(ADAPTER_REGISTRY["openai-chat"], "create").mockImplementation((provider, context) => { + const adapter = createChat(provider, context); + return { + ...adapter, + buildRequest(...[parsed, incoming]: Parameters) { + observations.push({ + cohort: parsed._promptCacheKeyIsSharedCohort, + thread: parsed._clientThreadId, + replay: structuredClone(parsed._reasoningReplayScope), + raw: JSON.stringify(parsed._rawBody), + }); + return adapter.buildRequest(parsed, incoming); + }, + }; + }); + try { + const turns = [ + encryptedInput(), + [...encryptedInput(), { type: "message", role: "user", content: "Continue the affinity assignment." }], + ]; + for (const [index, input] of turns.entries()) { + const response = await post(config, "xai/grok-4.5", input, headers, undefined, { + promptCacheKeyIsSharedCohort: cohort, + }); + expect(response.status).toBe(200); + await response.text(); + expect(recoveries).toBe(1); + expect(observations).toHaveLength(index + 1); + expect(providerBodies).toHaveLength(index + 1); + const observed = observations[index]!; + expect(observed.cohort).toBe(cohort); + expect(observed.thread).toBe(parentThread); + expect(observed.replay).toMatchObject({ clientThreadId: parentThread }); + expect(observed.replay).toEqual(observations[0]!.replay); + for (const body of [observed.raw, providerBodies[index]!]) { + expect(body).toContain("Read the affinity assignment."); + expect(body).not.toContain(FERNET_TASK); + expect(body).not.toContain("promptCacheKeyIsSharedCohort"); + } + } + expect(providerBodies[1]).toContain("Continue the affinity assignment."); + expect(recoveryBodies).toHaveLength(1); + expect(recoveryBodies[0]).toContain(FERNET_TASK); + expect(recoveryBodies[0]).not.toContain("promptCacheKeyIsSharedCohort"); + } finally { + factory.mockRestore(); + } +}); + test("MESSAGE recovery reaches the provider and survives tool-result replay", async () => { const { post, providerResponse } = await import("../helpers/agent-task-recovery"); let recoveries = 0; @@ -77,18 +166,57 @@ test("MESSAGE recovery reaches the provider and survives tool-result replay", as return providerResponse(); }) as typeof fetch; const config = routedConfig({ enabled: true }); - expect((await post(config, "xai/grok-4.5", encryptedMessage(), codexHeaders())).status).toBe(200); - expect((await post(config, "xai/grok-4.5", [...encryptedMessage(), { - type: "message", role: "user", content: "Continue after the tool result.", - }], codexHeaders())).status).toBe(200); - expect(recoveries).toBe(1); - expect(bodies).toHaveLength(2); - for (const body of bodies) { - expect(body).toContain("Stop waiting and report your result."); - expect(body).not.toContain(FERNET_TASK); + let now = Math.floor(Date.now() / 1_000) * 1_000 + 995; + const clock = spyOn(Date, "now").mockImplementation(() => now); + try { + const headers = codexHeaders(); + expect((await post(config, "xai/grok-4.5", encryptedMessage(), headers)).status).toBe(200); + now += 10; + expect(codexHeaders().get("authorization")).not.toBe(headers.get("authorization")); + expect((await post(config, "xai/grok-4.5", [...encryptedMessage(), { + type: "message", role: "user", content: "Continue after the tool result.", + }], headers)).status).toBe(200); + expect(recoveries).toBe(1); + expect(bodies).toHaveLength(2); + for (const body of bodies) { + expect(body).toContain("Stop waiting and report your result."); + expect(body).not.toContain(FERNET_TASK); + } + } finally { + clock.mockRestore(); } }); +test("a changed valid token cannot read another credential snapshot's recovery", async () => { + let recoveries = 0; + globalThis.fetch = (async () => { + recoveries++; + return new Response(recoverySse("Original caller assignment.")); + }) as typeof fetch; + const config = routedConfig({ enabled: true }); + const exp = Math.floor(Date.now() / 1_000) + 3_600; + const headers = codexHeaders("acct-caller"); + headers.set("authorization", `Bearer ${fakeChatGptJwt("acct-caller", { exp })}`); + const req = new Request("http://localhost/v1/responses", { headers }); + expect(await recoverEncryptedAgentTask(req, encryptedInput(), {}, config)).toBe(true); + + const changedHeaders = new Headers(headers); + changedHeaders.set("authorization", `Bearer ${fakeChatGptJwt("acct-caller", { exp: exp + 1 })}`); + expect(changedHeaders.get("authorization")).not.toBe(headers.get("authorization")); + const changedCallerInput = encryptedInput(); + expect(restoreCachedEncryptedAgentTasks(new Request("http://localhost/v1/responses", { + headers: changedHeaders, + }), changedCallerInput, config)).toBe(0); + expect(JSON.stringify(changedCallerInput)).toContain(FERNET_TASK); + expect(JSON.stringify(changedCallerInput)).not.toContain("Original caller assignment."); + + const sameCallerInput = encryptedInput(); + expect(restoreCachedEncryptedAgentTasks(req, sameCallerInput, config)).toBe(1); + expect(JSON.stringify(sameCallerInput)).toContain("Original caller assignment."); + expect(JSON.stringify(sameCallerInput)).not.toContain(FERNET_TASK); + expect(recoveries).toBe(1); +}); + test("MESSAGE cache remains isolated by message type, account, parent and sender", async () => { let calls = 0; globalThis.fetch = (async () => { calls++; return new Response(recoverySse("Private message.")); }) as typeof fetch; diff --git a/tests/usage/quota-reset-observation.test.ts b/tests/usage/quota-reset-observation.test.ts index 20cef41e31..39a18025ef 100644 --- a/tests/usage/quota-reset-observation.test.ts +++ b/tests/usage/quota-reset-observation.test.ts @@ -28,27 +28,30 @@ const HOUR = 60 * 60_000; let captured: QuotaResetEvent[] = []; -/** Let the seams' lazy import() chains settle. */ +/** Join the writer's ordered observation/forget chain, including cold imports. */ async function settle(): Promise { - for (let index = 0; index < 6; index += 1) await Promise.resolve(); - await new Promise(resolve => setTimeout(resolve, 5)); + await flushQuotaObservationsForTests(); } -beforeEach(() => { +beforeEach(async () => { + await settle(); captured = []; resetQuotaResetStoreForTests(); resetQuotaResetNotifyCacheForTests(); resetQuotaResetPollerForTests(); clearAccountQuota(); + await settle(); setQuotaResetSink(event => { captured.push(event); }); }); -afterEach(() => { +afterEach(async () => { + await settle(); setQuotaResetSink(null); resetQuotaResetPollerForTests(); clearAccountQuota(); + await settle(); }); describe("codex quota seam", () => {