diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2df7ddab928..028c28ca318 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -997,12 +997,15 @@ jobs: # now means Linux + macOS + the gates; Windows re-enters the gate when the # tracked failures are fixed, not before. # - # Dispatch-only proved too late. #6639, #6556, #6605 and #6606 each landed with - # green pull-request CI while breaking Windows, and the breakage surfaced days - # later on a manual dispatch. So a pull request whose changes job reports - # windows=true (a Windows path, or an added src/tests line naming a Windows - # marker; see scripts/ci/windows-sensitive-diff.sh) now runs the nine shards, - # and the daily schedule catches whatever that filter misses. + # Pull requests never run these shards (AGENTS.md "CI policy", a maintainer + # decision): Windows runs on the nightly schedule and on a lane=all dispatch. + # The changes job still computes windows=true for a Windows-sensitive pull + # request (a Windows path, or an added src/tests line naming a Windows marker; + # see scripts/ci/windows-sensitive-diff.sh). That verdict does not schedule + # these jobs; it posts a non-blocking notice, and a maintainer dispatches + # lane=all on the branch before merging. #6639, #6556, #6605, #6606 and #7468 + # each landed with green pull-request CI while breaking Windows, which is why + # that dispatch is part of merging a Windows-sensitive pull request. platform-windows: name: windows ${{ matrix.shard }}/9 needs: [changes, select-windows-runner] diff --git a/bunfig.toml b/bunfig.toml index 1a6ced2c5dc..7376005a5c5 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -16,8 +16,10 @@ root = "tests" # # That qualifier is load-bearing. Bun resolves bunfig.toml from the CURRENT WORKING # DIRECTORY, so `cd /tmp && bun test /tests/x.test.ts` loads no preload at all: nothing -# is sandboxed, the guard stays disarmed, and every `getConfigDir()` in that process returns -# the developer's real `~/.opencodex`. On 2026-09-15 such a run deleted one. A test that needs -# a config directory pins its own OPENCODEX_HOME instead of trusting this line, and -# `tests/ci-workflows/test-home-guard.test.ts` enforces that for the destructive case. +# is sandboxed, and every `getConfigDir()` in that process returns the developer's real +# `~/.opencodex`. On 2026-09-15 such a run deleted one, and in #7407 another wrote auth.json. +# The write guard now arms itself from the test-file entry in that case (src/lib/test-home-guard.ts), +# so such writes throw instead of landing, but a test that needs a config directory still pins its +# own OPENCODEX_HOME instead of trusting this line, and `tests/ci-workflows/test-home-guard.test.ts` +# enforces that for the destructive case. preload = ["./tests/preload.ts"] diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 5b8ff2963d2..33891d14bea 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2645,7 +2645,7 @@ dependencies = [ [[package]] name = "opencodex-desktop" -version = "2.90.0" +version = "2.91.0" dependencies = [ "base64 0.22.1", "dbus", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 0356f23c8a5..0016895bdde 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencodex-desktop" -version = "2.90.0" +version = "2.91.0" description = "OpenCodex desktop shell" authors = ["OpenCodex contributors"] license = "MIT" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index c6b4aa73ccd..e42874a9cf6 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCodex", - "version": "2.90.0", + "version": "2.91.0", "identifier": "com.opencodex.desktop", "build": { "frontendDist": "../ui", diff --git a/devlog/_plan/261021_fallback_off/000_design.md b/devlog/_plan/261021_fallback_off/000_design.md new file mode 100644 index 00000000000..6357bc48de1 --- /dev/null +++ b/devlog/_plan/261021_fallback_off/000_design.md @@ -0,0 +1,145 @@ +# OCX-11 fallback off — design + +> Written 2026-10-21, revised 2026-10-22 after the supervisor decisions (§9) · implemented in PR #7482. +> Consumer: GOAT hub design, goat-si/goat PR #684 (ADR-0021 §1.6, design doc §1.6 and DH3). +> The present-tense contract is [structure/fallback-policy.md](../../../structure/fallback-policy.md). + +## 1. Goal + +Give the operator one clear way to say "do not fall back; fail with a clear error instead", for two families: + +- **Hub down.** A Remote Link Child or Hub client whose Home is unreachable never silently serves from + its own local providers, and the operator can see and set the per-device local fallback permission. +- **Fallback chains.** Model and provider fallback (combos, subagent fallback, the main-account + hard-lock fallback, routing profiles) can be refused per scope: global, per provider, per model or + route, and per device. + +Defaults stay exactly as they are today (§7). + +## 2. Terms adopted from GOAT + +ADR-0021 §1.6 and its design doc define the hub-down policy. This note reuses these terms verbatim: + +- "a Child never silently serves its local providers when the Home is unreachable, and model, account and + provider fallback chains can be turned off per scope" (design §1.6, describing this package). The + account part is settled differently: see §9 C3. +- "**local fallback** opt-in per device" (DH3: "pause plus decision item; local fallback opt-in per device"). + GOAT's switch is `hub.devices[alias].localFallback`; the OpenCodex key is + `fallback.devices..localFallback`, so the leaf reads the same on both sides. +- "goal work on that device **pauses** (health condition `HubUnreachable`), people's own threads see + OpenCodex's 503 as today". OpenCodex keeps that 503 and adds `code: "home_unreachable"` (§6). +- "GOAT reads that state; it never changes OpenCodex fallback settings", shown as "**on, off, unknown** + when the Home predates OCX-11". The Home advertises `fallback-policy/1` in `/readyz` `features`; + GOAT reads its absence as unknown. +- "**GOAT fallback**" (ADR-0018 chains) is GOAT's word for moving GOAT work to another provider instance. + OpenCodex never uses that phrase; its own hops are "model fallback", "provider fallback" and "local + fallback". + +## 3. Inventory + +A fallback is a hop that serves a request from a **different model** or a **different provider** than +the one chosen or requested. + +| # | Fallback | Kind | Where | Governed | +|---|---|---|---|---| +| F1 | Combo failover | provider or model | `src/server/responses/core-combo.ts` `executeComboResponses`; decisions in `src/combos/failover.ts`, picker in `src/combos/resolve.ts` | yes: reactive hop at the dispatch loop, proactive for a cooling first `failover` target | +| F2 | Subagent model fallback | model or provider | `src/codex/subagent-model-fallback.ts` `applySubagentModelFallback` | yes, proactive | +| F3 | Main-account hard-lock fallback | provider or model | `src/server/responses/main-hard-lock-routing.ts` `applyMainHardLockFallbackOverride` | yes, proactive | +| F4 | Routing-profile fallback | provider or model | `src/server/responses/policy-fallback.ts` `handleResponsesWithPolicyFallback` | yes, reactive | +| F5 | Claude Code classifier fallbacks | — | `src/claude/inbound-model-options.ts` | not a hop: the first configured entry always wins, with no availability check | +| F6–F9 | Codex pool account switch (including pool → signed-in main), Anthropic OAuth, generic OAuth and API-key pool rotation | — | `src/codex/routing/selection.ts`, `src/oauth/anthropic-routing.ts`, `src/oauth/generic-account-failover.ts`, `src/providers/key-failover.ts` | not a fallback: intra-pool rotation (§9 C3) | +| F10 | Remote Link Child, Home unreachable | local | `src/client/link-relay.ts` | no local path exists; 503 gains `home_unreachable` | +| F11 | Hub client, Home unreachable | local | Codex on a Hub client talks to the Hub URL directly; the local listener carries only the dashboard relay (`src/client/hub-relay.ts`, 502) | no local path exists; 502 gains `home_unreachable` | + +Neither client kind can serve from local providers: both run the relay-only client runtime +(`src/client/runtime.ts`), `ocx start` refuses an invalid client state instead of starting standalone +(`src/cli/index.ts`), and the standalone recycle runs only when the link itself ends, never on an outage. +`tests/clients/client-home-unreachable.test.ts` pins this with a load-time import-graph check. + +Out of scope because the target does not change: same-target retries (`src/lib/upstream-retry.ts`), +the Antigravity direct lane's bridge fallback (`src/protocols/settings.ts`), transport fallbacks. + +### Fallback versus intra-pool rotation + +A **fallback** changes *what* answers: another model or another provider, with different capabilities, +pricing, privacy terms or quality. An operator can reasonably refuse that. + +**Intra-pool rotation** changes only *which credential* answers for the same provider and model: the +next account of an OAuth pool or the next key of an API-key pool when one is rate-limited, or the Codex +pool's move away from an account at its usage threshold. The request, model and provider are unchanged. +The maintainer rule recorded in `src/types/config.ts` says rotating away from an account upstream has +just rate-limited "only ever runs after a refusal, so stranding it while a second logged-in account sits +idle is a defect rather than a configuration choice". OCX-11 keeps that rule: rotation is resilience +inside one pool and these controls do not govern it. An operator who wants one account only configures +one account, or pauses the others, with the existing pool controls. + +## 4. Configuration + +```jsonc +"fallback": { + "model": true, // absent = true + "provider": true, + "providers": { "anthropic": { "model": false } }, + "routes": { "combo/coding": { "provider": false } }, + "devices": { "lnk_0123456789abcdef": { "localFallback": false, "model": false } } +} +``` + +A hop's kind is decided by what changes: provider differs → `provider`; same provider, different model → +`model`. A malformed leaf reads as absent and diagnostics name the section. Device keys are a link id +or a Hub data key id; the resolver maps a request's admission key id to its link through the link store. + +CLI: `ocx fallback status|off|on|clear [--kind model|provider|all] [--provider|--route|--device]` and +`ocx fallback local on|off --device `. Management: `GET|PUT /api/fallback`. + +## 5. Resolver + +`src/routing/fallback-policy.ts`: `resolveFallbackPolicy`, `fallbackHopKind`, `guardFallbackHop` (records +and refuses) and a 20-entry event ring. Nothing else reads `config.fallback` on the request path. +`src/routing/fallback-state.ts` builds the `ocx.fallback/1` view and applies the scoped write. + +## 6. Error contract + +A refused hop answers with the status of the failure that triggered it (with its `Retry-After`), or 429 +for proactive quota/cooldown/hard-lock refusals and 503 otherwise, and: + +```json +{ "error": { "type": "fallback_refused", "code": "fallback_refused", "message": "… To allow it: ocx fallback on --kind provider --route combo/coding", + "fallback": { "kind": "provider", "scope": "route", "scopeKey": "combo/coding", "from": "a/m", "to": "b/n", "trigger": "rate_limited" } } } +``` + +plus `x-opencodex-fallback: refused`. A taken hop is recorded in status; no header is added to the +answering response, because the combo and policy responses are built deep in child dispatch. + +Hub down keeps status and retry semantics: `{ "error": "link tunnel unavailable", "code": "home_unreachable", "message": "…" }` +on a Child (503) and `{ "error": "hub relay unavailable", "code": "home_unreachable", … }` on a Hub +client's dashboard relay (502). + +## 7. Defaults and migration + +No default changes and no migration: absent `fallback` keeps every chain as it was, and an absent +`localFallback` reads false, which is what a Child already does. + +## 8. Visibility + +`ocx status --json` `fallback` (live, the Home's over the link on a Child, or the local policy), `GET +/api/fallback` (also over the link relay, aliases omitted), `/readyz` `fallback-policy/1`, and the +dashboard Fallback panel on the Dashboard overview and on Remote Link. Events carry time, site, route, kind, from, to, +trigger, outcome, scope and device id; never bodies, keys or account identities. + +## 9. Decisions (supervisor, 2026-10-22) + +- **C1 precedence: accepted.** Route > provider > global, most specific first; a device set to off always refuses. +- **C2 `localFallback: true`: accepted as permission only.** OpenCodex keeps answering 503 when the Home is + unreachable; GOAT reads the flag and moves the work itself (ADR-0021 §1.6, "pause + decision item"). +- **C3 account rotation: overridden.** Account-pool 429 rotation stays not switchable; the maintainer rule + in `src/types/config.ts` stands. Rotation is intra-pool resilience, not fallback, so the `account` kind + is out of OCX-11 (§3, "Fallback versus intra-pool rotation"). + +## 10. Tests + +`tests/routing/fallback-policy.test.ts` (81-case precedence matrix, degrade, device identity, contract, +privacy), `tests/responses/responses-combo-fallback-off.test.ts`, `tests/routing/routing-policy-fallback-off.test.ts`, +`tests/routing/subagent-model-fallback-off.test.ts`, `tests/codex-integration/main-account-hard-lock-fallback-off.test.ts`, +`tests/server/fallback-routes.test.ts`, `tests/clients/client-home-unreachable.test.ts`, `tests/cli/cli-fallback.test.ts`, +`tests/server/readyz-features.test.ts` and `gui/tests/fallback-panel.test.tsx`. diff --git a/devlog/_plan/261021_link_apply_parity/000_plan.md b/devlog/_plan/261021_link_apply_parity/000_plan.md new file mode 100644 index 00000000000..ea02ac32019 --- /dev/null +++ b/devlog/_plan/261021_link_apply_parity/000_plan.md @@ -0,0 +1,220 @@ +# Remote Link parity on apply (OCX-6, #6957) + +Status: open. Branch `feat/link-apply-parity`, PR target `dev` (merges after 2.90.0). + +## Problem + +The hub design (goat-si/goat ADR-0021, devlog 261021_goatclaw_hub §1.4) checks two things on a +Remote Link Child that OpenCodex cannot answer today: + +- **P5 runtime freshness**: the Child runtime must have started after the last apply of the Home's + settings. Nothing records when the Home's settings were applied, and `ocx sync` on a linked Child + re-applies them without restarting the runtime. `ocx restart` cannot restart a linked Child at + all: the client listener's `/healthz` answers no attestation challenge and advertises no + `restartCapability`, so `requestBoundSystemRestart` refuses it (`restart_attestation_failed`). + Step 2 of the hub repair ladder (`ocx restart` on the device) therefore cannot work. +- **P6 Codex settings**: there is no stable digest of the applied settings to compare between + Home and Child. + +## Contract + +- `codexSettingsDigest(settings)`: `sha256:<64 hex>` of the canonical JSON (recursively sorted keys, + no whitespace) of the `HomeCodexSettings` object. Home name and version are excluded, so a Home + and its Child agree. Never the content. +- Child record `/link/home-codex-settings.json` gains `appliedAt`. A re-apply of the + same digest for the same link keeps the earlier `appliedAt` (idempotent); a new digest or a + first apply stamps now. Older records without it read `appliedAt = fetchedAt`. +- The client runtime publishes `startedAt` (ISO, its process start) on `/healthz`. +- `ocx status --json` gains top-level `codexSettings`: + `{ source: "published" | "home" | "local", digest, appliedAt, runtimeStartedAt }`. + Home/standalone: `published` with the digest of what it serves Children. Link Child: `home` with + the record's digest and `appliedAt`, plus the live client runtime's `startedAt`. Hub client or a + Child with no record: `local`, nulls. +- Home `GET /api/link/status` (and `ocx link status --json`) gains per link + `parity: null | { appliedAt, runtimeStartedAt, codexSettingsDigest, reportedAt }`, from the + Child's report. The CLI validator accepts the key absent (older runtime) and normalizes to null. + +## Restart (P5) + +Reuse the existing bound restart (`ocx restart` -> attested `/healthz` -> capability-signed +`POST /api/system/restart`). The client listener gains: + +- `/healthz`: `restartCapability: "v1"`, `startedAt`, and the attestation proof for a challenge, + keyed by the same secret the runtime already writes to `runtime-port.json`. +- `POST /api/system/restart`: admitted only with the HMAC restart capability bound to this pid and + port (no GUI session, no admin token). 202 with the standalone body shape; idempotent while one + restart is in flight (`alreadyDraining: true`). + +Client restart executor (`src/client/runtime-restart.ts`), same supervisor rules as the standalone +restart and the client recycle: + +1. Desktop-supervised: exit 75, the app starts the replacement. +2. `OCX_SERVICE=1` with a viable service: exit 1, the failure-only supervisor relaunches. +3. Unsupervised: stop the tunnel supervisor, stop the listener gracefully (bounded by the 60 s + drain), spawn `ocx start` with `replacementStartEnvironment(true)` and `waitForHealth: true` + (`spawnReplacementStart`). Success: exit 0. Failure: rebind the listener on the same port, + restart the supervisor, keep serving, and log the failure code. The CLI sees the same pid at its + deadline and reports the restart as failed (existing fail-closed path); the old runtime stays up. + +`ocx sync` on a linked Child: after a successful apply, if a client runtime is live and its +`startedAt` precedes the record's `appliedAt`, run the same `handleProxyRestart` as `ocx restart` +(no start when nothing is running). Unchanged digest + fresh runtime = no restart (idempotent). A +failed restart exits non-zero with a message naming the kept runtime; re-running `ocx sync` or +`ocx restart` retries. + +Enrollment (`POST /api/link/apply`) already runs `connect --link` (which records the settings) +then `ocx restart` once, so it gains `appliedAt` with no flow change. + +## Child -> Home report + +`POST /v1/link-management/child-parity` on the Home's hub-link ingress only, after link-key +admission. Body exactly `{ linkId, appliedAt, runtimeStartedAt, codexSettingsDigest }` (<= 1 KiB, +strict ISO timestamps or null, digest pattern or null, linkId must belong to the admitting key). +Stored in memory per link id (lost on Home restart until the next report), display only, never +used for admission or routing. 204 on accept, 400 on a malformed body, 404 for a key/link mismatch. + +The client runtime (link mode) sends it through the connection-bound relay +(`fetchBoundLinkRelay`, same as the settings read): first after start with backoff +(2 s, 5 s, 15 s, 30 s, 60 s), then every 5 minutes; a 404 from an older Home stops it. Unref'd +timers, stopped on shutdown/restart. + +## Files + +- src/link/codex-settings.ts: digest. +- src/link/child-parity.ts (new): report parse + Home in-memory store. +- src/link/management-routes.ts: report path in the route table. +- src/server/index/link-management.ts: report handler (needs the admitting key id). +- src/server/index/serve-options.ts: pass the admission key id. +- src/link/status-projection.ts, src/server/management/link-routes.ts: `parity` per link. +- src/cli/link.ts: validator accepts `parity`. +- src/client/link-home.ts: `appliedAt`, report send. +- src/client/connect.ts: enrollment write goes through the appliedAt helper. +- src/client/machine-listener.ts: healthz attestation/capability/startedAt, restart route. +- src/client/runtime.ts, src/client/runtime-restart.ts (new), src/client/link-parity-reporter.ts (new). +- src/cli/dispatch.ts, src/cli/link-apply-restart.ts (new): sync -> restart. +- src/cli/status.ts: `codexSettings` block. +- structure/remote-link.md, docs-site CLI reference (status, link, sync, restart). + +## Tests (isolated homes only) + +- digest: key order / whitespace independence, change detection, Home projection == Child parse. +- record: first apply stamps, same digest keeps appliedAt, new digest restamps, legacy record. +- client listener: healthz proof + capability; restart route refuses without/with wrong capability, + accepts a valid one once, idempotent second accept. +- restart executor: unsupervised success exits 0; replacement failure rebinds and keeps serving + (old runtime kept, failure reported); desktop -> 75; service -> 1. +- sync: stale runtime -> restart called; fresh runtime -> not called; restart failure -> exit 1. +- report: Home route accepts a valid report, rejects malformed / foreign link; status shows it. +- JSON shape: `ocx status --json` codexSettings (Home and Child) and `ocx link status --json` parity. + +## Verification + +typecheck, focused files, test:changed, privacy:scan, structure:check, full suite under the lock. + + +## Audit round 1 (gpt-6.1-sol, FAIL) and the revision + +1. **Not-ready replacement counted as success.** Folded. `spawnReplacementStart` gains + `notReady: "handoff" | "terminate"` (default `handoff`, standalone unchanged). The client restart + passes `terminate`: a replacement still unready at the deadline is killed and the call rejects + with `not_ready`, so rollback starts only once no replacement survives. Test: a replacement that + stays alive without answering is killed and the old runtime serves again. +2. **Rollback cannot restart the stopped tunnel supervisor; shutdown captured locals.** Folded. + `startClientRuntime` keeps its resources in module state (`activeServer`, `activeSupervisor`) + and the signal handlers read that state. Rollback is one transaction in `runtime.ts`: bind a + fresh listener (port wait capped at 10 s, inside the CLI's observation budget), create a fresh + supervisor with the same key source, republish pid + runtime record with the same attestation + secret, and clear the restart latch. +3. **Supervised handoff exits before validating the replacement.** Rebutted, kept as a stated + residual. Under the desktop app (exit 75) or a viable service (exit 1) the supervisor owns the + process identity and the port; the old process cannot hand the port back, and both supervisors + retry the relaunch (desktop on 75, launchd KeepAlive on unsuccessful exit, systemd + `Restart=on-failure`). This is exactly the standalone `ocx restart` semantics today. Refusing + instead would leave P5 unrepairable on service-installed Children, which is the common fleet + shape. A non-viable service falls through to the unsupervised path with rollback, as the + standalone restart does. Documented in structure/remote-link.md. +4. **Receipt best-effort and independently committed.** Partly folded. The record write now + returns the committed receipt or throws; `ocx sync` reports a failed receipt write and exits + non-zero, and enrollment's `connect --link` fails the same way instead of swallowing it. The write + runs under `withClientLifecycleSync` after `assertClientConnectionUnchanged`. Holding L across + the injection is not possible: the documented lock order is N -> L -> C and injection takes N + itself. Residual: two concurrent `ocx sync` runs on one Child that read two different Home + documents can leave a receipt naming the earlier one; the next sync corrects it, and P6 then + reports the mismatch rather than hiding it. + +Notes folded: the restart route requires the capability principal explicitly (GUI session, admin +token and link key are refused, with tests); it is outside `linkRouteAllowed`. The report POST is +handled before the passive-read dispatch without widening the GET/HEAD table, bound to the stored +link id and its admitted `apiKeyId`, read with a 1 KiB streaming cap and a 5 s deadline; reports for +links no longer in the store are pruned on every write and never projected. `ocx sync` passes a +start callback that never starts a proxy and rechecks connection ownership before restarting; a +live runtime without `startedAt` (older build) counts as stale. Wire names stay camelCase +(`appliedAt`, `runtimeStartedAt`) like every other `ocx ... --json` field; the docs map them to the +brief's `applied_at` / `runtime_started_at`. + + +Sync restart callback: when discovery first reads absent, start nothing (`skipped`); when an accepted restart left no runtime (`recoveringLiveRestart`), delegate to the ordinary recovery start (`handleRestartStartWhenStopped(true)`) so a vanished Child is brought back. + +## Audit round 2 (FAIL) and the revision + +1. **Supervised handoff.** Folded by refusal. Under the desktop app or a service supervisor the + client runtime answers the bound restart with `409 restart_supervised` and keeps serving; the + CLI reports the restart as not performed and names the supervisor's own restart + (`ocx service restart` / the desktop app). No supervised exit path exists in the client restart, + so no code path can leave a supervised Child down. A non-viable service (`OCX_SERVICE=1` without + viable assets) is treated as unsupervised and uses the rollback path. Consequence: P5 on a + supervised Child is repaired by the supervisor's restart, not by `ocx sync`; `ocx sync` says so. + Reported upstream as the open design question (supervisor-coordinated handoff with old-runtime + fallback would be a separate change in the desktop app and service assets). +2. **Rollback vs CLI recovery.** Folded with one budget. The client restart owns a single deadline + of 90 s from acceptance: listener drain at most 20 s, replacement readiness until 70 s, then + termination (SIGTERM, wait for the exit event up to 3 s, SIGKILL, wait up to 2 s; the exit event is + the confirmation), rebind with a 10 s port wait, publication. The CLI's replacement observation + runs to about 135 s, so the old pid is back before recovery can become eligible, and the CLI + then reads the same pid and fails closed. `spawnReplacementStart` takes the shared deadline via + `io.deadlineAt` and `notReady: "terminate"` returns only after the child's exit is observed. + Rollback keeps the original `startedAt`. +3. **Concurrent-sync receipt.** Folded by serialization. A link apply (Child `ocx sync` and the + link branch of `ocx connect`) holds a dedicated apply lock, outermost (before N), from the Home + read through injection to the receipt write. It reuses the lifecycle lock primitive + (`withClientLifecycle` with its own `lockPath` under `/link/`), `busy_timeout = 0`, so + a second concurrent apply fails fast with "another apply is in progress" and changes nothing. +4. **Enrollment receipt failure.** Folded as a post-commit warning. `connect --link` keeps the + committed connection; a failed receipt write is printed as a warning (`Codex settings applied; + apply record not written; run ocx sync`) and `ocx status` then reports `source: "local"` until a + sync records it, which P6 shows as missing rather than as parity. + + +## Audit round 3 (FAIL, two blockers) and the revision + +1. **Late acceptance can still overlap rollback with CLI recovery.** Folded with a pre-POST guard. + `CLIENT_RESTART_BUDGET_MS = 90_000` lives in `src/lib/system-restart-contract.ts`. The client + `/healthz` advertises `role: "client"`; `requestBoundSystemRestart` refuses to POST to a client + target (`restart_deadline_expired`, no request sent, runtime untouched) unless + `deadlineAt - RESTART_REOBSERVE_RESERVE_MS - now >= CLIENT_RESTART_BUDGET_MS + 5_000`. With the + 145 s deadline and 10 s reserve, a client restart is only accepted within the first 40 s, so the + rollback always finishes before replacement observation ends. The client enforces its own 90 s + from acceptance. Test: delayed acceptance is refused before POST; an accepted restart with a + failed replacement is back on the old pid inside the observation window. +2. **409 loses the guidance.** Folded. The client answers + `409 { error: { code: "restart_supervised", supervisor: "service" | "desktop" } }`. + `requestBoundSystemRestart` reads that bounded body and returns the validated code + `restart_supervised_service` / `restart_supervised_desktop`; `src/cli/restart-failure.ts` prints + "Restart not performed; the running Child runtime was kept. Restart it with `ocx service restart` + (or from the OpenCodex desktop app)." Any other 409 keeps the existing generic report. + + +## Contract change from the manager (GOAT H2, goat-si/goat PR #696) + +Supersedes the field names and formats above where they differ: + +- **OCX-6a**: every link row of the Home's `ocx link status --json` carries `lastApplyAt`, epoch + **milliseconds** of the Child's last successful settings apply (with `runtimeStartedAt`, + `appliedCodexSettingsDigest` and `reportedAt` beside it). The Child report uses epoch ms too. +- **OCX-6b**: `ocx status --json` on any device carries top-level `appliedCodexSettingsDigest` = + lowercase hex SHA-256 (no prefix) of the compact UTF-8 JSON + `{"version":1,"settings":{"fastMode":…,"multiAgentMode":…,"webSearchSidecarEnabled":…,"subagentDefaults":null|{"model":…,"reasoningEffort":…}}}` + in exactly that key order, from the values actually applied. Serialized by name, not by a sorting + canonicalizer. Golden digests for a null and a set `subagentDefaults` are pinned in + `tests/clients/link-codex-settings-digest.test.ts`. The Child's `lastApplyAt` and + `runtimeStartedAt` sit beside it as epoch ms. diff --git a/devlog/_plan/261021_readyz_features/000_plan.md b/devlog/_plan/261021_readyz_features/000_plan.md new file mode 100644 index 00000000000..745de5e35aa --- /dev/null +++ b/devlog/_plan/261021_readyz_features/000_plan.md @@ -0,0 +1,50 @@ +# OCX-3: `/readyz` advertises peer features + +Producer side of the GOAT hub design (goat-si/goat#684, ADR-0021, `261021_goatclaw_hub/000_design.md` §5 and +brief OCX-3). Peers decide compatibility from `features` instead of parsing versions. + +## Current state + +- `GET /readyz` (`src/server/index/serve-options.ts`) spreads `readyProtocolMetadata(config, req)` from + `src/remote/protocol.ts` into its body. `RemoteReadyMetadata.features?: string[]` already exists and + `parseRemoteReadyMetadata` validates it (at most 64 unique non-empty strings, each at most 80 chars, no control + characters), but no producer fills it. +- Relay auth version: `LINK_RELAY_AUTH_VERSION = "2"` in `src/link/relay-auth.ts`. +- Management routes are declared in `MANAGEMENT_ROUTES` (`src/server/management/route-registry.ts`), pure data + reconciled against dispatch source by `tests/server/management-route-registry.test.ts`. +- The Remote Link management relay read table is `LINK_MANAGEMENT_READ_PATHS` (`src/link/management-routes.ts`). +- No `x-opencodex-served-by` header is emitted yet (OCX-2). + +## Change + +1. New `src/remote/features.ts`: one ordered registry `PEER_FEATURES`, each entry an id plus a predicate over a + `PeerFeatureSources` snapshot read from the owning modules. `servedPeerFeatures(sources?)` returns the ids + whose predicate holds, in registry order. + - `quota-pool/1`: `MANAGEMENT_ROUTES` declares `GET /api/quota-pool` (OCX-1 turns it on by declaring the route, + which the registry reconciliation test already forces). + - `link-management-relay`: `LINK_MANAGEMENT_READ_PATHS` is non-empty. + - `served-by-header`: `SERVED_BY_HEADER_EMITTED`, the emitter's descriptor in a new + `src/remote/served-by.ts` (`false` until OCX-2, which flips it in the change that emits the header). + A live test on an isolated server keeps it honest: `x-opencodex-served-by` is present on the + `/readyz`, `/healthz` and unknown-`/v1/*` responses exactly when `/readyz` advertises the feature. + - `relay-auth/` (currently `relay-auth/2`): always. +2. `readyProtocolMetadata` adds `features: servedPeerFeatures()` (computed once; the sources are build constants). +3. Tests in a new `tests/server/readyz-features.test.ts` (registered in layout + expected fixture): registry + presence/absence follows each source, ids are unique and survive `parseRemoteReadyMetadata`, the live + `/readyz` body carries exactly the registry output and nothing account-shaped. Update the exact-shape + assertions line-neutrally in `tests/server/proxy-liveness.test.ts` (two `readyProtocolMetadata` `toEqual`s) + and `tests/server/server-live.test.ts:1442` (sorted key allowlist gains `features`; allowlist kept). +4. Docs: `structure/runtime.md` (owns `src/remote/`), `structure/remote-link.md` pointer for relay-auth, + `docs-site` lifecycle `ocx ready` field list. + +## Out of scope / open question + +The Child machine listener (`src/client/machine-listener.ts`) answers its own `/readyz` with a different fixed +shape (`protocolVersion: 1`, no `features`). Whether a Child should advertise features, and which (it serves no +`/api/quota-pool` of its own), is a product decision reported back, not chosen here. + +## Verification + +typecheck; focused files (new test, proxy-liveness, management-route-registry, core-link-boundary, +core-lab-boundary, client-machine-listener, server-live); `bun run test:changed`; `bun run structure:check`; +`bun run privacy:scan`; full `bun run test` under `/usr/bin/lockf -t 3600 /tmp/ocx-heavy-test.lock`. diff --git a/devlog/_plan/261021_service_status_json/000_plan.md b/devlog/_plan/261021_service_status_json/000_plan.md new file mode 100644 index 00000000000..5ce225f7b75 --- /dev/null +++ b/devlog/_plan/261021_service_status_json/000_plan.md @@ -0,0 +1,30 @@ +# 000 — OCX-10: passive service status JSON and install-method verdict + +Producer side of the GOAT hub inventory (goat-si/goat#684, design §5 detection steps 3–4). +GOAT reads an adopted OpenCodex install over SSH and must not change it. + +## Scope + +1. `ocx service status --json`: one stable document built from the same reads the text + status already performs — `diagnoseService()` (launchd / systemd user unit / Task Scheduler + / WinSW) and, when a registration exists, the loopback serving probe. It is answered before + `platformOps()` resolves, so no install, start, stop, repair or migration code is reachable. + The text output is unchanged. +2. `ocx status --json` gains an additive `install` field mapped from the existing + `detectInstallOwnership()` (`src/update/install-detection.mjs`), with the evidence basis and + no new paths. + +## Shape decisions + +- `running` is the service manager's verdict (the value `startup.serviceRunning` already + carries); `serving` says separately whether a proxy answers on the installed port. +- `backend` names the manager holding a registration: `launchd`, `systemd`, `schtasks`, + `winsw`, or `none` when nothing is registered or the platform is unsupported. +- `install.method` adds `standalone` to the requested set because the existing detector already + distinguishes the compiled desktop sidecar; `unknown` is reserved for a detector failure. + +## Verification + +Platform shape snapshots with mocked managers, a passive guard over every spawn entry point on +darwin/linux/win32, an install-method matrix, typecheck, test:changed, skill surface, structure, +privacy and the full suite. diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 47bc5b2d381..89ccd0728d6 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -103,6 +103,7 @@ export default defineConfig({ { label: "Desktop App", translations: { fr: "Application de bureau", ko: "데스크톱 앱", "zh-CN": "桌面应用", "zh-TW": "桌面 App", ru: "Настольное приложение", ja: "デスクトップアプリ", tr: "Masaüstü Uygulaması" }, slug: "guides/desktop-app" }, { label: "Model Ordering", translations: { fr: "Ordre des modèles", ko: "모델 정렬에 관하여", "zh-CN": "模型排序", "zh-TW": "模型排序", ru: "Сортировка моделей", ja: "モデルの並び順", tr: "Model Sıralaması" }, slug: "guides/model-ordering" }, { label: "Combos", translations: { fr: "Combinaisons", ko: "콤보", "zh-CN": "组合", "zh-TW": "組合", ru: "Комбо", ja: "コンボ", tr: "Kombolar" }, slug: "guides/combos" }, + { label: "Fallback off", translations: { fr: "Désactiver le repli", ko: "폴백 끄기", "zh-CN": "关闭回退", "zh-TW": "關閉備援", ru: "Отключение резерва", ja: "フォールバックをオフにする", tr: "Yedeği kapatma" }, slug: "guides/fallback-off" }, { label: "Protocol Paths", translations: { fr: "Chemins de protocole", ko: "프로토콜 경로", "zh-CN": "协议路径", "zh-TW": "協定路徑", ru: "Пути протоколов", ja: "プロトコル経路", tr: "Protokol Yolları" }, slug: "guides/protocol-paths" }, { label: "Claude Code", translations: { fr: "Claude Code", ko: "Claude Code", "zh-CN": "Claude Code", "zh-TW": "Claude Code", ru: "Claude Code", ja: "Claude Code", tr: "Claude Code" }, slug: "guides/claude-code" }, { label: "Grok Build", translations: { fr: "Grok Build", ko: "Grok Build", "zh-CN": "Grok Build", "zh-TW": "Grok Build", ru: "Grok Build", ja: "Grok Build", tr: "Grok Build" }, slug: "guides/grok-build" }, diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index 918e27f2e30..05309f2bbf1 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -141,7 +141,7 @@ Vérifie l’identité du proxy actif. La sortie destinée aux utilisateurs indi ### `ocx ready [--json] [--wait [--timeout ]]` -Vérifie l’état de préparation après synchronisation au moyen du point de terminaison non authentifié `GET /readyz`. Il renvoie `200` lorsque le service est prêt, ou `503` avec `Retry-After: 1` pour les états `pending` et terminal `failed`. Son identité HTTP expurgée est `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl}`. `protocol` est la version courante du protocole distant du hub, `minimumClientProtocol` la plus ancienne version cliente compatible et `managementUrl` l’origine canonique de gestion visible par le navigateur. Les anciens proxys dépourvus de `/readyz` échouent de manière sûre avec l’état `unreachable` ; `/healthz` mesure la disponibilité du processus, et non son état de préparation. +Vérifie l’état de préparation après synchronisation au moyen du point de terminaison non authentifié `GET /readyz`. Il renvoie `200` lorsque le service est prêt, ou `503` avec `Retry-After: 1` pour les états `pending` et terminal `failed`. Son identité HTTP expurgée est `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl, features}`. `protocol` est la version courante du protocole distant du hub, `minimumClientProtocol` la plus ancienne version cliente compatible et `managementUrl` l’origine canonique de gestion visible par le navigateur. Les anciens proxys dépourvus de `/readyz` échouent de manière sûre avec l’état `unreachable` ; `/healthz` mesure la disponibilité du processus, et non son état de préparation. Par défaut, la commande effectue une seule sonde. Avec `--wait`, elle interroge le service jusqu’à ce qu’il soit prêt ou jusqu’à l’expiration du délai, mais s’arrête immédiatement si elle observe l’état terminal `failed`. Le délai par défaut est de 45 secondes. `--timeout ` exige `--wait` et accepte un entier positif compris entre 1 et 300. La sortie JSON de la CLI est `{ready, status, pid, port}`, où `status` vaut `ready`, `pending`, `failed` ou `unreachable`. Les codes de sortie sont 0 si le service est prêt ; 1 s’il n’est pas prêt, reste en attente, échoue, dépasse le délai ou est inaccessible ; et 64 si les arguments sont invalides. @@ -155,6 +155,8 @@ La section **OAuth reliability** indique si le stockage des identifiants est acc ### `ocx sync [--restart-codex] [--restart-app-server-only]` +> **Enfant Remote Link :** `ocx sync` applique aussi les réglages Codex du Home et, si le runtime client a démarré avant cette application, le redémarre même sans option de redémarrage. Si le redémarrage échoue, l'ancien runtime continue de servir et la commande se termine avec un code non nul. Les champs correspondants de `ocx status --json` et `ocx link status --json` sont décrits dans [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity) (en anglais). + Un redémarrage explicitement demandé a également lieu après une modification de `config.toml` validée et enregistrée, même si les octets du catalogue et du cache restent identiques. Une opération sans changement de configuration, un refus, une validation préalable ou une écriture annulée ne le déclenche pas. Sans option de redémarrage, sync ne redémarre jamais Codex automatiquement. Récupère la liste active des modèles de chaque fournisseur configuré et réinjecte le catalogue fusionné dans Codex. Exécutez cette commande après l’ajout d’un fournisseur ou pour actualiser les modèles disponibles. diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 6dd84dcce22..57373b06ece 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -142,7 +142,7 @@ to restore the default upstream WebSocket selection. This setting does not chang client-facing `websockets` switch or the ChatGPT account used for the request. Native mid-turn steering and injection need upstream WebSocket and are unavailable while it is off. -**Reasoning usage metadata (pending next release).** For HTTP/SSE clients on the canonical +**Reasoning usage metadata (2.90.0 and later).** For HTTP/SSE clients on the canonical ChatGPT route, the upstream WebSocket relay preserves an actual `x-reasoning-included` connection-response header when the Bun runtime exposes it, including on a reused connection. This tells Codex that the server's reported usage already includes earlier reasoning. Metadata @@ -1012,6 +1012,22 @@ has reached Codex the turn is never sent again. If every attempt is declined, Co the same error it would have received without the retries. Native compact requests move to another Pool account on a server error in the same way. +### Runtime agent instances (Codex 0.164+) + +Codex 0.164 can sign each request with a runtime agent assertion when +`features.runtime_agent_instances` is enabled (it is off by default). opencodex supports one of +its two modes: + +- `mode = "dual_header"` keeps the normal `Authorization: Bearer` credential and adds an + `x-openai-agent-assertion` header. opencodex treats the request exactly as before. The assertion + is forwarded only when the turn goes to ChatGPT Direct with your own credential. It is removed + when a Pool account, a stored main credential, a routed provider, or a sidecar serves the request, + because it belongs to the credential Codex sent. +- `mode = "assertion_only"` replaces the bearer with `Authorization: AgentAssertion `. + opencodex refuses these requests with HTTP 400 and error code `agent_assertion_unsupported`. + It never substitutes a Pool or provider credential for the assertion, and it never forwards or + logs the assertion. Use `dual_header`, or turn the feature off, when Codex runs through opencodex. + ### Codex autostart shim Run `ocx codex-shim install` to install the optional launcher wrapper. It runs diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 5f38d243f6a..9d2f4122ee8 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -590,6 +590,8 @@ rows show decision count, applied count, and average latency for `typesafe`, `sy ## What happens when a target fails Combo failures are divided into **hop** failures and **terminal** failures. +To make a combo fail with a clear `fallback_refused` error instead of hopping to another model or +provider, see [Fallback off](/guides/fallback-off/). | Result | Behavior | | --- | --- | diff --git a/docs-site/src/content/docs/guides/fallback-off.md b/docs-site/src/content/docs/guides/fallback-off.md new file mode 100644 index 00000000000..32bb6c5faf4 --- /dev/null +++ b/docs-site/src/content/docs/guides/fallback-off.md @@ -0,0 +1,127 @@ +--- +title: "Fallback off" +description: Turn model and provider fallback off per scope, and see what happens on a Remote Link Child when its Home is unreachable. +--- + +OpenCodex can serve a request from a different model or provider when the one you chose fails: a +[combo](/guides/combos/) moves to its next target, a [routing profile](/guides/routing-profile-editor/) +tries its next candidate, a spawned subagent moves down its model fallback chain, and the main-account +hard lock can reroute to its configured fallback model. All of these stay on by default. + +Turn a kind of fallback **off** when you would rather get a clear error than an answer from somewhere +you did not pick. + +## What counts as a fallback + +A fallback is a hop to a **different model** or a **different provider**: + +| Kind | Example | Switch | +|---|---|---| +| Model fallback | `anthropic/claude-opus` fails, `anthropic/claude-sonnet` would answer | `model` | +| Provider fallback | `anthropic/claude-opus` fails, `openai/gpt-6-sol` would answer | `provider` | + +Rotation among the accounts or API keys of **one provider pool** for the same model is not a fallback. +It is how a pool absorbs a rate-limited account, and it keeps working whatever you set here. Retrying +the same target, and transport fallbacks that keep the same target, are not fallbacks either. + +## Turn fallback off + +```bash +ocx fallback off # model and provider fallback, everywhere +ocx fallback off --kind provider # only cross-provider hops +ocx fallback off --route combo/coding # one combo, routing profile or model selector +ocx fallback off --provider anthropic # hops that leave one provider +ocx fallback on --route combo/coding # an exception under a global off +ocx fallback clear --route combo/coding # remove an override +ocx fallback status # current settings and the last fallback +``` + +The same switches are on the dashboard: the **Fallback** card at the bottom of the **Dashboard** holds the +global switches, the overrides and the last fallback taken or refused. + +### Precedence + +1. A **device** set to off always refuses (see [per device](#per-device-settings)). A device setting can + only refuse; turning it on never reopens a hop another scope refuses. +2. Otherwise the most specific setting wins: **route**, then **provider**, then **global**. +3. Nothing set means allowed, which is today's behavior. + +The settings live in `config.json` under `fallback`: + +```json +{ + "fallback": { + "provider": false, + "routes": { "combo/coding": { "provider": true } }, + "providers": { "anthropic": { "model": false } }, + "devices": { "lnk_0123456789abcdef": { "localFallback": false, "model": false } } + } +} +``` + +A malformed leaf is ignored, so policy resolution uses the next applicable scope, and `ocx config` +diagnostics name it. A whole-config write that contains one is refused instead. + +## The error you get + +A refused fallback answers with the **status of the failure that triggered it**, so a refused 429 is +still a 429 with its `Retry-After`, and with this body: + +```json +{ + "error": { + "type": "fallback_refused", + "code": "fallback_refused", + "message": "combo/coding: anthropic/claude-opus failed (rate_limited). Provider fallback to openai/gpt-6-sol is off for route combo/coding, so OpenCodex did not send the request there. To allow it: ocx fallback on --kind provider --route combo/coding", + "fallback": { "kind": "provider", "scope": "route", "scopeKey": "combo/coding", + "from": "anthropic/claude-opus", "to": "openai/gpt-6-sol", "trigger": "rate_limited" } + } +} +``` + +The response also carries `x-opencodex-fallback: refused`. When the first target is already known to be +unavailable, the refusal happens before anything is sent, and the fallback target is never contacted. +The status depends on why the first target was skipped: + +- A combo's first target cooling down, or with its quota spent: 429. +- A combo's first target disabled or not usable for the request: 503. +- A blocked main account: 429. +- An unavailable subagent model: 503. + +After a real upstream failure, the refusal keeps that failure's status. + +## Remote Link Children and Hub clients + +A Remote Link Child and a Hub client never serve requests from their own local providers. When the +Home is unreachable, a Child answers a retryable `503` (and a Hub client's dashboard relay a `502`) with: + +```json +{ "error": "link tunnel unavailable", "code": "home_unreachable", "message": "The Home is unreachable, and OpenCodex did not serve this request from this device's local providers. …" } +``` + +Nothing is sent to a local provider, and the link key and the request body never leave the device. + +### Per-device settings + +On the Home, each linked device has two switches, on **Remote Link** or with the CLI: + +```bash +ocx fallback local on --device mac-studio # permission for local fallback while the Home is down +ocx fallback off --device mac-studio # refuse model and provider fallback for this device's requests +``` + +`--device` takes a link alias or id from `ocx link status`, or a Hub data key id. + +**Local fallback** is a permission that GOAT reads: with it on, GOAT may move that device's work to the +device's own sign-ins while the Home is unreachable. OpenCodex itself still answers `home_unreachable` +and never serves the device locally. It is off unless you turn it on. + +## Status + +`ocx status --json` includes a `fallback` object (on a Child it is the Home's, read over the link), and +`GET /api/fallback` returns the same `ocx.fallback/1` document: the effective global switches, the +overrides, every device with its local fallback permission, and the last 20 fallbacks taken or refused +with time, route, from, to, trigger and the scope that decided. The list is kept in memory and resets +when the proxy restarts. It never contains request bodies, keys or account identities. + +A Home that supports this advertises `fallback-policy/1` in `GET /readyz` `features`. diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index e3dbbf673e3..3add1b64d6e 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -65,6 +65,8 @@ A failed link does not silently switch to a local provider. The Child returns a retryable `503` without forwarding the link key or request when its tunnel is failed, stopped, or not supervised. It also keeps the key local when another process owns the tunnel port; free that port so the Child's SSH tunnel can bind it. +To confirm which Home is serving a Child, compare the `x-opencodex-served-by` header on the Child's `GET /v1/models` with the `instanceId` in the Home's `GET /healthz`. The Child passes the Home's header through unchanged, so the two match while the link works. The value is an opaque id, never a hostname; see [Home identity and the served-by header](/reference/management-api/#home-identity-and-the-served-by-header). + ## What a Child shows and manages A Child runs on the Home's providers, accounts and settings, so the Home is where you change them. @@ -95,6 +97,7 @@ When a step fails, the dashboard shows the reason and, when SSH reported one, a - **ocx was not found on the remote computer**: OpenCodex looks for `ocx` on the PATH of a non-interactive SSH session first, then in `~/.bun/bin`, `~/.local/bin`, `/opt/homebrew/bin` and `/usr/local/bin`. If it is installed elsewhere, add that directory to PATH in a file the remote shell reads for non-interactive sessions, such as `~/.zshenv` for zsh. - **OpenCodex on the remote computer is too old**: run `ocx update` on that computer. Remote Link needs 2.66.0 or later. - **The remote computer did not report an OpenCodex version**: `ocx --version` on that computer printed something else, for example the usage text of an unsupported Windows install. +- **Requests fail with `home_unreachable` while the Home is down**: a Child never serves requests from its own providers. When the tunnel is down before a request is forwarded, the Child answers a retryable 503 and sends nothing to the Home. If the tunnel drops after forwarding started, the answer is also `home_unreachable`, but the Home may already have received the request. A failed link relay authentication answers `link_relay_auth_failed` instead: upgrade the Home and Child or re-link. Whether GOAT may move that device's work to its own sign-ins is the per-device local fallback permission on the Home's **Remote Link** page, or `ocx fallback local on --device `; see [Fallback off](/guides/fallback-off/). ## Security @@ -123,6 +126,25 @@ Update both the Home and Child when upgrading to connection-bound relay authenti Removing the final Home link drains pending authenticated relay requests before releasing its listener. Stopping the process still cancels active connections. This does not change which caller credentials are stripped or which routes can be relayed, and it does not replace SSH's host-key verification. +### Home and Child versions + +On the Home, `ocx link status --json` reports the Home's own `local` version, relay authentication version and features, and gives every link a `peer` block. Nothing is read over SSH: the Home learns about a Child from the relay authentication check the Child sends before each relayed request. From 2.91.0 that check also carries the Child's version and feature list. The Home keeps what it learned in memory, so after a Home restart a link reads `unknown` until its Child relays a request again. + +| Part of the link | First release | +|---|---| +| `relay-auth/2`, connection-bound relay authentication | 2.69.0 | +| `link-management-relay`, read-only Home management on the Child | 2.84.0 | +| `link-peer-report/1`, the Child reports its version and features | 2.91.0 | + +`peer.compatibility` is one of: + +- `compatible`: the relay authentication versions match, and the Home serves every feature the Child reports. A Child older than 2.91.0 reports no version; `peer.version` is then `null` and `peer.versionRange` gives the range its relay authentication implies, for example `{ "min": "2.69.0", "below": "2.91.0" }`. +- `degraded`: the Child reports a feature this Home does not serve. Upgrade the Home. +- `incompatible`: the Child asked for a relay authentication version this Home does not speak, so its relayed requests are refused. `peer.reasons` names the side to upgrade. +- `unknown`: the Child has not relayed a request since the Home started, or it sent a version or feature list the Home could not read, so its features cannot be checked. + +The version a Child reports is its own claim, bound to its link key. OpenCodex uses it for display and never for admission. Older `ocx` installations keep receiving the previous status document; only a 2.91.0 or newer CLI asks for these fields. + ### Standalone pairing authorization @@ -142,6 +164,12 @@ On the Home, run `ocx link probe child-a --json` to inspect the offered SSH fing Home-initiated connections now restart the Child after saving its connection, including capable older Child installations. The Child must already serve standalone OpenCodex on its configured port, expose bound restart support, and have curl installed. Update or disconnect a Child that fails this preflight. Desktop and system OpenCodex installations take precedence over generic PATH shims; broken candidates are skipped. Windows Children remain unsupported. +### Windows computers + +`ocx link add` and the dashboard refuse a Windows computer during the probe or host confirmation, before any link key is issued or tunnel started. The refusal covers Windows OpenSSH with Git Bash, MSYS2 or Cygwin as its shell (`uname -s` reports MINGW, MSYS or CYGWIN) and with cmd or PowerShell as its shell. Connect a Windows computer in Hub mode instead: run `ocx hub invite` on a Hub and run the printed `ocx connect` command on the Windows computer. + +A Windows Child linked by an older release keeps its link. Run `ocx link cleanup` on the Home to find such links; add `--link-id ` to check one, or `--json` for a machine-readable report. It asks each Home-initiated Child for its platform over the link's pinned SSH trust and changes nothing. For each Windows Child it prints the recovery order: run `ocx disconnect` and `ocx restart` on the Windows computer, then `ocx link revoke --link-id --force --yes` on the Home, then connect it in Hub mode. A plain revoke fails on these Children because its remote disconnect runs a POSIX command that Windows refuses. + Restart waits for running turns and may take several minutes. Success requires the restarted Child to serve the client role and relay a fresh complete catalog to this Home. SSH setup has a bounded allowance derived from measured latency; relay verification starts after restart with its own 15-second window. Errors name the failing phase. If an error reports residual Child configuration, run `ocx disconnect` and `ocx restart` on that Child before retrying. Home cleanup alone does not restore Child configuration. A CLI timeout reports an uncertain outcome; inspect Home `ocx link status` and Child `ocx connect status` first. On a Home-initiated Child, `ocx link status` reports role `child`, link identity and tunnel port from its saved connection even though its Home-side link store is empty. Locally unobserved tunnel state is `idle`. `ocx status` calls the peer a Remote Link Home. When Home runs in Hub mode, status reads its supported projection through the local Child relay. A standalone Home reports that it does not publish Hub state. Child-initiated tunnel identity comes from its valid sidecar; unreadable evidence does not claim a direction. diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index df2d6de16f6..9cf957db195 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -137,7 +137,7 @@ ocx status --json 認証不要の `GET /readyz` エンドポイントで同期後の準備状態を確認します。準備完了時は `200`、 `pending` または終端状態の `failed` では `Retry-After: 1` とともに `503` を返します。HTTP の -サニタイズ済み識別フィールドは `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl}` です。`protocol` は hub の現在の remote protocol、`minimumClientProtocol` は互換性のある最小 client protocol、`managementUrl` は browser から見える canonical management origin です。`/readyz` がない +サニタイズ済み識別フィールドは `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl, features}` です。`protocol` は hub の現在の remote protocol、`minimumClientProtocol` は互換性のある最小 client protocol、`managementUrl` は browser から見える canonical management origin です。`/readyz` がない 旧プロキシは `unreachable` として fail-closed し、`/healthz` は readiness ではなく別の liveness 確認です。 デフォルトでは 1 回だけ probe します。`--wait` は準備完了または timeout まで polling しますが、 終端 `failed` を確認すると即座に終了します。デフォルト timeout は 45 秒で、`--timeout ` には @@ -156,6 +156,8 @@ ocx status --json ### `ocx sync [--restart-codex] [--restart-app-server-only]` +> **Remote Link Child:** `ocx sync` は Home の Codex 設定も適用し、クライアントランタイムがその適用より前に起動していた場合は、再起動フラグなしでもランタイムを再起動します。再起動に失敗した場合は以前のランタイムが動作し続け、コマンドは 0 以外で終了します。`ocx status --json` と `ocx link status --json` の関連フィールドは [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity)(英語)を参照してください。 + 再起動フラグを明示した場合、カタログとキャッシュのバイト列が変わらなくても、`config.toml` の変更が正常にコミットされれば再起動します。設定だけを扱う操作が変更なし、拒否、事前検証、ロールバックとなった場合は再起動しません。再起動フラグがなければ、sync が Codex を自動的に再起動することはありません。 構成されているすべてのプロバイダーからライブ モデル リストを取得し、マージされたカタログを Codex に再挿入します。プロバイダーを追加した後、または利用可能なモデルを更新するために実行します。 diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index 5c7c9ef3991..fb1c2370de0 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -219,7 +219,7 @@ Windows에서 Orca shell은 `CODEX_HOME`과 `ORCA_CODEX_HOME`을 Orca의 번들 전용 provider 모드의 `requires_openai_auth = true`는 Codex App/TUI의 계정 게이트 화면을 네이티브 Codex와 같은 조건으로 맞춥니다. opencodex는 `/v1/responses`도 WebSocket으로 제공합니다. 전용 provider는 `"websockets": true`일 때만 `supports_websockets = true`를 광고합니다. loopback에서는 Codex의 빌트인 provider가 먼저 WebSocket을 시도할 수 있으며, 비활성화된 proxy는 `426`을 반환해서 Codex가 HTTP/SSE로 fallback합니다. -**추론 사용량 표시 보존(다음 릴리스 반영 예정).** 정식 ChatGPT 경로를 HTTP/SSE로 받는 +**추론 사용량 표시 보존(2.90.0 이상).** 정식 ChatGPT 경로를 HTTP/SSE로 받는 클라이언트에는, Bun 런타임이 제공하는 상류 WebSocket 연결 응답의 실제 `x-reasoning-included` 헤더를 전달합니다. 같은 연결을 재사용하는 요청에서도 유지합니다. 이 표시는 서버가 보고한 사용량에 이전 추론량이 이미 포함됐음을 Codex에 알립니다. diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 5c353e5bd80..2b062a19c67 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -209,7 +209,7 @@ ocx status --json 인증이 필요 없는 `GET /readyz` 엔드포인트로 동기화 후 준비 상태를 확인합니다. 준비되면 `200`, `pending` 또는 종단 상태인 `failed`이면 `Retry-After: 1`과 함께 `503`을 반환합니다. HTTP의 정제된 -식별 필드는 `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl}`입니다. `protocol`은 허브의 현재 원격 프로토콜, `minimumClientProtocol`은 호환되는 최소 클라이언트 프로토콜, `managementUrl`은 브라우저에서 보이는 표준 관리 origin입니다. `/readyz`가 없는 이전 프록시는 +식별 필드는 `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl, features}`입니다. `protocol`은 허브의 현재 원격 프로토콜, `minimumClientProtocol`은 호환되는 최소 클라이언트 프로토콜, `managementUrl`은 브라우저에서 보이는 표준 관리 origin입니다. `/readyz`가 없는 이전 프록시는 `unreachable`로 fail-closed하며, `/healthz`는 준비 상태가 아닌 별도의 liveness 확인입니다. 기본값은 한 번의 probe이며, `--wait`는 준비 또는 timeout까지 polling하지만 종단 `failed`를 확인하면 즉시 종료합니다. 기본 timeout은 45초이며, `--timeout `는 `--wait`와 함께 써야 하고 양의 정수인 1~300초 범위를 받습니다. CLI JSON은 @@ -238,6 +238,8 @@ single-flight/lock 파일을 만들 수 있는지, 건강하지 않은 OAuth 또 ### `ocx sync [--restart-codex] [--restart-app-server-only]` +> **Remote Link Child:** `ocx sync`는 Home의 Codex 설정도 적용하며, 클라이언트 런타임이 그 적용보다 먼저 시작되었으면 재시작 플래그 없이도 런타임을 재시작합니다. 재시작이 실패하면 이전 런타임이 계속 서비스하고 명령은 0이 아닌 코드로 끝납니다. `ocx status --json`과 `ocx link status --json`의 관련 필드는 [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity)(영문)를 참고하세요. + 재시작 플래그를 명시하면 카탈로그와 캐시 바이트가 같아도 `config.toml` 변경이 성공적으로 커밋된 뒤 재시작합니다. 설정만 다루는 작업이 변경 없음, 거부, 사전 검증 또는 롤백으로 끝나면 재시작하지 않습니다. 재시작 플래그가 없으면 sync는 Codex를 자동으로 재시작하지 않습니다. 설정된 모든 공급자에서 라이브 모델 목록을 가져와 병합된 카탈로그를 Codex에 다시 주입합니다. diff --git a/docs-site/src/content/docs/ko/reference/proxy-formats.md b/docs-site/src/content/docs/ko/reference/proxy-formats.md index 03ae6b806ca..32963d7f7c3 100644 --- a/docs-site/src/content/docs/ko/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ko/reference/proxy-formats.md @@ -299,7 +299,8 @@ call creation과 sideband join은 같은 OpenAI 계정으로 이루어져야 하 계정 선택을 그 쌍에 묶어 두므로(프로세스 로컬) 프록시에 도착한 join은 통화를 만든 계정을 그대로 쓰고, Direct 모드는 두 요청 모두 호출자의 현재 bearer를 전달합니다. 릴레이되는 클라이언트 헤더는 정확히 `openai-alpha`, `x-session-id`, `session-id`, `thread-id`, `originator`, `x-oai-attestation`, -`x-codex-turn-metadata`(`src/server/live.ts`의 `LIVE_CLIENT_PROTOCOL_HEADERS`)이며, 각 헤더는 +`x-codex-turn-metadata`, `voice-session-id`(실시간 `x-session-id`와 별개인 클라이언트 음성 세션 id) +(`src/server/live.ts`의 `LIVE_CLIENT_PROTOCOL_HEADERS`)이며, 각 헤더는 호출자가 보낸 경우에만 전달되고 프록시가 만들어 내지 않습니다. `Authorization`과 ChatGPT 계정 id는 ChatGPT 경로에서 프록시가 소유합니다(Pool은 저장된 계정으로 교체, Direct는 검증된 호출자 bearer를 전달). API 키 프로바이더는 자체 bearer를 씁니다. Codex가 join을 프록시로 보내는 것은 diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index 22214a24ed2..d6ba1d76c35 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -264,6 +264,8 @@ local configuration and local Lab inspection do not require startup. `ocx status --json` includes `cliCommand`: configured Desktop intent, the expected executable, observed PATH candidates, `pathFirst`, `desktopFirstOnPath`, issue codes, and `shellResolution: "unobserved"`. The human status report prints one command-selection line. `ocx doctor` adds an “ocx command selection” section: invalid, unsafe (`record-unsafe`) or enabled-pending records and missing/unusable Desktop targets fail the check, while disabled cleanup-pending records, PATH ordering conflicts and incomplete scans warn. `OCX_NO_DESKTOP_HANDOFF=1` suppresses package-launcher handoff for one invocation; it leaves the Desktop shim and PATH configuration in place. These read-only observations do not execute candidates, resolve parent-shell aliases/functions, or identify the proxy’s runtime owner. On Windows, package-launcher handoff is disabled; user `Path` order selects the Desktop `ocx.exe`. Status and doctor still read the record and report the first PATH candidate. A possible cmd current-directory candidate is reported separately from PATH order. +`ocx status --json` also includes `install`: how this `ocx` was installed, using the same detector `ocx update` acts on. `install.method` is `bun`, `npm`, `pnpm`, `mise`, `source`, `standalone` (the compiled desktop binary) or `unknown` when detection failed. `install.evidence.basis` names what decided it (`package-layout`, `mise-backend-record`, `standalone-binary` or `unavailable`); a mise install adds the mise tool alias and backend, and a failure adds an `error` code. No path is added. + Output flags are per command. `doctor` rejects `--json` with exit 2; [`v2` (family reference)](/reference/cli/agents/) supports `--json` for local and `--live` targets. Even for JSON-capable management commands, API failures diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index c63aef492ca..31c87b8a1f1 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -334,6 +334,25 @@ There is no monetary cost or comparative savings field in this report. See [Combos](/guides/combos/) for routing behavior and configuration guidance. +## Fallback off + +### `ocx fallback ...` + +```bash +ocx fallback status --json +ocx fallback off --kind provider +ocx fallback on --route combo/coding +ocx fallback clear --provider anthropic --kind model +ocx fallback local on --device mac-studio +``` + +`off`, `on` and `clear` take `--kind model|provider|all` (default `all`) and at most one scope: +`--provider `, `--route ` or `--device `; with none they set the global switch. +A device set to off always refuses; otherwise route beats provider beats global. `local` records whether a +device may use its own providers while the Home is unreachable, a permission GOAT reads; OpenCodex itself +still answers `home_unreachable` there. The commands talk to the live proxy; on a Remote Link Child, +`status` reads the Home and writes are refused. See [Fallback off](/guides/fallback-off/). + ## Routing profiles Existing profiles support inspection and evaluation through the live API: diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 9a1d434ad74..758cbda5a05 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -153,6 +153,14 @@ Eligibility refusals name the reason and next action: use the owning installatio owning service/desktop app on Windows. Unknown ancestry or a changed target calls for `ocx status` before any restart. +On a connected client runtime (a Remote Link Child or a hub client), `ocx restart` asks the runtime +to start its own replacement on the same port. If the replacement does not answer within the +restart budget it is stopped, and the old runtime binds the port again and keeps serving; the +command then reports the restart as not completed. A client runtime run by a background service or +the desktop app refuses the request and keeps serving ("Restart not performed; the running client +runtime was kept"); restart it with `ocx service restart` or from the app. See +[Remote Link apply parity](#remote-link-apply-parity). + If package files or the Bun runtime remain incomplete after the installer exited or failed, run `ocx status`, stop any running proxy through its owner (`ocx stop`, or its service or desktop app), then reinstall with the same package manager and start it again. See @@ -279,6 +287,9 @@ only in build metadata; neither side is called older. Versions are not trimmed a is not normalized. JSON exposes the same advice in `versionSkew`, whose fields remain `cliVersion`, `proxyVersion`, `skewed`, and `warning`. +On a Remote Link Child and its Home, `--json` also reports `appliedCodexSettingsDigest`, +`lastApplyAt` and `runtimeStartedAt`; see [Remote Link apply parity](#remote-link-apply-parity). + Print a read-only diagnostic summary: proxy PID, `/healthz` reachability, dashboard URL, config path, default provider, Codex autostart setting, service state, shim state, and the redacted effective Codex home. Only the explicit, high-confidence Windows Orca runtime-home signature adds an actionable App-home @@ -394,12 +405,19 @@ not traffic missed between polls; injection seq cannot exclude restart/eviction Check post-sync readiness through the unauthenticated `GET /readyz` endpoint. It returns `200` when ready, or `503` with `Retry-After: 1` for `pending` and terminal `failed`. Its sanitized HTTP identity is `{service, version, uptime, pid, port, status}` plus the remote-hub protocol fields -`{protocol, minimumClientProtocol, managementUrl}`. `protocol` is the hub protocol this proxy +`{protocol, minimumClientProtocol, managementUrl, features}`. `protocol` is the hub protocol this proxy speaks and `minimumClientProtocol` the oldest client it still accepts, so a client can refuse an incompatible pairing before sending anything else. `managementUrl` is the origin a client should use for the management plane: the configured `hub.managementPublicOrigin` when `runtimeRole` is `hub`, and otherwise the origin the request itself arrived on. A readiness request with no -HTTP(S) origin is rejected rather than answered with a guess. Old proxies without `/readyz` fail +HTTP(S) origin is rejected rather than answered with a guess. +`features` lists the peer features this build serves, so a peer can check a capability without +comparing versions: `relay-auth/` names the Remote Link relay authentication version, +`link-management-relay` the read-only Remote Link management relay, `link-peer-report/` that +a Remote Link Home reports each Child's version and compatibility in `ocx link status`, `quota-pool/1` the +`GET /api/quota-pool` management read, and `served-by-header` the `x-opencodex-served-by` response +header. A feature this build does not serve is absent. The list holds fixed names only, never +account or configuration data. Old proxies without `/readyz` fail closed as `unreachable`; `/healthz` is separate liveness, not readiness. The command performs one probe by default; `--wait` polls until ready or timeout, but exits immediately when it observes the terminal `failed` state. The default timeout is 45 seconds; `--timeout ` requires `--wait` and accepts positive integer seconds from 1–300. @@ -495,6 +513,9 @@ upstream status but never consumes or resumes a grant. An explicitly requested restart also runs after a successful committed `config.toml` change, even if the catalog and cache bytes are unchanged. A configuration-only no-op, refusal, preflight, or rolled-back write does not trigger it. Without a restart flag, sync never automatically restarts Codex. +On a Remote Link Child, `ocx sync` also applies the Home's Codex settings and, when the client runtime +started before that apply, restarts it; see [Remote Link apply parity](#remote-link-apply-parity). + Fetch the live model list from every configured provider and re-inject the merged catalog into Codex. Run it after adding a provider or to refresh available models. @@ -681,7 +702,7 @@ If the proxy is newer instead, check the CLI installation and `PATH` as describe | `restart` | The same refresh and a guaranteed restart on every platform. On macOS an unchanged, already-loaded job is kickstarted in place. Not an alias of `repair`. | | `start` | Start an installed service. | | `stop` | Stop the service and restore native Codex. | -| `status` | Report service and proxy diagnostics plus log paths. | +| `status` | Report service and proxy diagnostics plus log paths. `--json` prints the passive document described below. | | `uninstall` | Remove the service and restore native Codex. | | `remove` | Alias of `uninstall`. | @@ -689,6 +710,8 @@ On Windows, a bare `ocx service` runs the install path only after both Task Sche proven absent. If either status query is inconclusive, it refuses to register anything and asks you to run `ocx service status`; use explicit `ocx service install` only after confirming absence. +`ocx service status --json` prints one passive document for scripts and peers such as GOAT's device inventory. It runs the same reads as the text report and never installs, starts, stops or repairs anything; `--json` on any other `service` verb is rejected. Fields: `schemaVersion` (1), `platform`, `supported`, `backend` (`launchd`, `systemd`, `schtasks`, `winsw`, or `none` when nothing is registered), `label` (the job, unit, task or WinSW service name), `installed`, `enabled`, `running` (the service manager's verdict), `viable`, `stale`, `conflict`, `serving` (`probed`, `ok`, `port`; probed only when a registration exists), `summary`, `logPath`, `ownershipLease` and `desktopLogin`. An unsupported platform or a Docker container still gets a document, with `supported: false`. + ### Runtime ownership The OpenCodex desktop app can take the background proxy over from a CLI installation. When it does, @@ -1097,6 +1120,46 @@ target, mode, schedule and optional last/next-run facts. `job.status` describes existing idle/running state, not a cleanup started by this command. Read back on an unknown outcome rather than replaying the write. +## Remote Link apply parity + +A Remote Link Child applies its Home's OpenCodex-owned Codex settings when it is linked +(`ocx link add` on the Home) and on every `ocx sync`. Three facts let a peer check that the +Child really runs under the settings it applied, without reading either machine's configuration. + +`ocx status --json`, on any device, carries: + +| Field | Meaning | +| --- | --- | +| `appliedCodexSettingsDigest` | Lowercase hex SHA-256 of the applied settings (below). On a Child, the values its last recorded apply wrote. On a Home or standalone install, the settings document it serves its Children right now, computed from its current configuration. `null` on a hub client, on a Child with no recorded apply, and on a Child whose apply of changed settings is in progress or failed (the previous digest is withdrawn first, so it is never reported for values Codex no longer has). | +| `lastApplyAt` | Child only: epoch milliseconds of the last successful apply that changed the applied settings. Re-applying the same values is not a new apply and keeps it, so an unchanged `ocx sync` never forces a restart. `null` elsewhere. | +| `runtimeStartedAt` | Child only: epoch milliseconds the running client runtime started. `null` when no client runtime answers. | + +The digest is computed over this compact UTF-8 JSON, with the keys in exactly this order and no +whitespace, from the values actually applied: + +```json +{"version":1,"settings":{"fastMode":true,"multiAgentMode":"v1","webSearchSidecarEnabled":false,"subagentDefaults":null}} +``` + +`subagentDefaults` is `null` when unset, otherwise `{"model":…,"reasoningEffort":…}` in that order. +The example above hashes to `57c751eff89844f93db71a6ee4acdac03afbc29fec52734ade80a40193e826cd`. The digest +never contains a setting value, and the Home's name and version are not part of it, so a Home and +a Child that applied the same values report the same digest. + +On the Home, every row of `ocx link status --json` carries what that Child last reported: +`lastApplyAt`, `runtimeStartedAt`, `appliedCodexSettingsDigest` (same meanings as above) and +`reportedAt` (epoch milliseconds, the Home's clock). A linked Child reports shortly after its runtime +starts and then once a minute; the Home keeps reports in memory only, so after a Home restart the +fields read `null` until the next report. A Child whose runtime started before `lastApplyAt` is +running an older runtime than its last apply. + +After `ocx sync` applies the Home's settings on a Child, it restarts the Child's client runtime when +that runtime started before `lastApplyAt`, using the same restart as `ocx restart`. A runtime that +already started after the apply is left alone, nothing is started when no client runtime is +running, and a failed restart exits nonzero while the previous runtime keeps serving; run +`ocx sync` or `ocx restart` again to retry. Two applies on one Child never interleave: a second +`ocx sync` while one is applying fails fast and changes nothing. + ## Forced link revoke and remote recovery Only when skipping remote cleanup is explicitly intended, use the actual selected diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 1ac81f11ec9..30a9732d990 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -54,6 +54,13 @@ ocx models live --provider ark --json Use it for scripts that process one configured provider object per line. `--json` and `--jsonl` cannot be combined. +Each configured row also has `active`. It is `false` when this device is linked to a Home +(a Remote Link Child or a Hub-mode client): the row is local configuration that the Home's +providers shadow, so it does not serve requests while the link is connected. It is `true` when the +row actually serves, including when the link state cannot be read. Rows never carry credential +values: `baseUrl` drops embedded userinfo and masks credential-shaped query values such as `key` +or `token`, while keeping ordinary parameters such as `api-version`. + :::caution[Custom headers are not a credential channel] `--headers` is for non-secret request metadata — routing hints, tenant or project selectors, tracing ids. It is **not** a place to put authentication diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index fd71559980a..19f0f05f4a1 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -658,6 +658,23 @@ API page. The CLI drives these routes with `ocx api protocols`, `ocx api explain [API surfaces](/reference/configuration/server/#api-surfaces-apisurfaces) for how the Messages setting interacts with `claudeCode.enabled` across upgrades and downgrades. +### Home identity and the served-by header + +`GET /healthz` includes `instanceId`, this install's opaque Home id: `home_` followed by 32 hex +characters, generated at random once per install. It is the same value as `producer.homeId` in +`GET /api/quota-pool`, it stays the same across restarts, and it is never a hostname or derived from +one. Every response to `GET /v1/models` carries the same value in the `x-opencodex-served-by` +header, so a client can tell which Home answered without reading logs. A Remote Link Child relays the +Home's header unchanged, and a Hub-mode client's Codex reads the Hub's response directly, so on any +device the header names the Home that served the model list. `GET /readyz` lists +`served-by-header` in `features` when the build sends it. + +Right after a fresh install starts, before its identity file exists, or when that file is +unreadable, both the `instanceId` field and the header are left out; an empty value is never sent. +Deleting `quota-pool-identity.json` from the OpenCodex home rotates the id together with every +quota-pool account reference when the proxy next starts; a running proxy keeps reporting the id it +already read. + ### Providers | Method and path | Purpose | Notable errors | @@ -668,7 +685,10 @@ setting interacts with `claudeCode.enabled` across upgrades and downgrades. | `DELETE /api/providers?name=...` | Delete a provider, reassigning the default when possible | 404 unknown provider; 409 `last_provider`; 409 `provider_has_dependent_combos` | | `POST /api/providers/test?name=...` | Perform a bounded live provider connectivity/model-discovery probe | 404 unknown provider; failures are normally returned as `ok: false` evidence | | `GET /api/provider-quotas` | Read provider quota reports; `refresh=1` forces refresh | — | +| `GET /api/quota-pool` | Read the passive `ocx.quota-pool/1` account quota pool (per-account and pooled windows, opaque account references) from cached evidence only; never probes or refreshes, and ignores `refresh`. Also readable from a Remote Link Child or Hub client at `/api/machine/hub-relay/api/quota-pool` | — | | `GET /api/quota-resets` | List recently detected quota-window resets and whether detection is enabled; `limit=` caps the count | 400 invalid `limit` | +| `GET /api/fallback` | Read the `ocx.fallback/1` fallback state: effective global switches, provider and route overrides, devices with local fallback permission, and the last 20 fallbacks taken or refused. Also readable from a Remote Link Child at `/api/machine/hub-relay/api/fallback` (link aliases omitted) | — | +| `PUT /api/fallback` | Set or clear switches: one write `{ scope: "global"\|"provider"\|"route"\|"device", key?, setting: "model"\|"provider"\|"localFallback", value: true\|false\|null }`, or `{ writes: [ … ] }` with up to 8 applied in one config mutation; `localFallback` is per device; a device key may be a link alias. Returns the new state | 400 invalid body; 404 `unknown_device` (nothing written); 409 `config_unavailable` | | `GET, PUT /api/provider-context-caps` | Read or update global, all-provider, or one-provider context caps | 400 invalid request; 404 unknown provider | | `GET /api/provider-presets` | Return GUI provider presets derived from the runtime registry | — | diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index de02c401ceb..fe6d7330b18 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -546,6 +546,18 @@ effort default to `reasoning.summary: "auto"` so thinking streams back as `reasoning.summary: "none"`. An explicit `reasoning.summary` of `auto`, `concise`, `detailed`, or `none` wins over `include_reasoning`. +When the model is Claude, the response also carries `reasoning_details` in the same shape +OpenRouter uses: `{"type": "reasoning.text", "text", "signature", "format": "anthropic-claude-v1"}` +for each signed thinking block and `{"type": "reasoning.encrypted", "data", ...}` for each redacted +one, on `message` or as `delta.reasoning_details` (each entry arrives complete in one chunk). Send +the array back unchanged on the assistant message, next to its `tool_calls`, and Claude keeps its +reasoning across tool steps. The signature is opaque and is the same value `/v1/messages` +clients receive. OpenCodex replays it only when the model routes to Anthropic's own API +(`api.anthropic.com`) as a single route; on any other destination, or when an entry is +malformed (an unknown type or format, or a missing signature), the turn falls back to the plain +`reasoning_content` text. An entry whose text or signature was edited is forwarded as is, and +Anthropic rejects it, so return the array exactly as received. + Structured output is part of that translation: `response_format` with `json_object` or `json_schema` is forwarded to routed `openai-chat` models. On `POST /v1/responses` the equivalent request field is `text.format`: native Responses routes preserve it in the raw @@ -741,7 +753,8 @@ upstream (`404`). Both legs carry Codex's `session-id` and `thread-id` headers; account choice is bound to that pair (process-local), so a join that reaches the proxy reuses the account that created the call, while Direct mode forwards the caller's current bearer on both legs. The relayed client headers are exactly `openai-alpha`, `x-session-id`, `session-id`, `thread-id`, -`originator`, `x-oai-attestation`, and `x-codex-turn-metadata` +`originator`, `x-oai-attestation`, `x-codex-turn-metadata`, and `voice-session-id` (the client voice +session id, separate from the realtime `x-session-id`) (`LIVE_CLIENT_PROTOCOL_HEADERS` in `src/server/live.ts`); each is relayed only when the caller sent it, and none is invented. `Authorization` and the ChatGPT account id are proxy-owned on ChatGPT-backed routes (Pool replaces diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 74f5088282c..08b8e46afa4 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -206,7 +206,7 @@ Identity-check живого прокси. Текстовый вывод сооб Проверяет готовность после синхронизации через не требующий аутентификации `GET /readyz`. При готовности возвращается `200`; для `pending` и терминального `failed` возвращается `503` с -`Retry-After: 1`. Санитизированные поля HTTP-ответа: `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl}`. `protocol` — текущая версия удалённого протокола hub, `minimumClientProtocol` — минимальная совместимая версия клиента, а `managementUrl` — канонический origin управления для браузера. +`Retry-After: 1`. Санитизированные поля HTTP-ответа: `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl, features}`. `protocol` — текущая версия удалённого протокола hub, `minimumClientProtocol` — минимальная совместимая версия клиента, а `managementUrl` — канонический origin управления для браузера. Старые прокси без `/readyz` fail-closed как `unreachable`; `/healthz` — отдельная проверка liveness, а не готовности. По умолчанию команда выполняет одну пробу. `--wait` опрашивает до готовности или тайм-аута, но при терминальном `failed` завершается немедленно. Тайм-аут по умолчанию — 45 секунд; @@ -234,6 +234,8 @@ credential'ы и не выполняет repair. ### `ocx sync [--restart-codex] [--restart-app-server-only]` +> **Дочерняя машина Remote Link:** `ocx sync` также применяет настройки Codex с Home и, если клиентский рантайм запущен раньше этого применения, перезапускает его даже без флага перезапуска. Если перезапуск не удался, прежний рантайм продолжает работать, а команда завершается с ненулевым кодом. Соответствующие поля `ocx status --json` и `ocx link status --json` описаны в разделе [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity) (на английском). + Явно запрошенный перезапуск также выполняется после успешного сохранения изменения `config.toml`, даже если байты каталога и кэша не изменились. Операция только с конфигурацией без изменений, отказ, предварительная проверка или отменённая запись не вызывают перезапуск. Без флага перезапуска sync никогда не перезапускает Codex автоматически. Получить живой список моделей от каждого настроенного провайдера и заново внедрить объединённый diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index 69f053509e4..208bf43c594 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -206,7 +206,7 @@ takdirde 1 ile çıkar, bu da onu servis probları için uygun hale getirir. Kimliği doğrulanmamış `GET /readyz` uç noktası aracılığıyla senkronizasyon sonrası hazırlığı kontrol edin. Hazır olduğunda `200` veya `pending` ve terminal `failed` için `Retry-After: 1` ile `503` döndürür. Temizlenmiş HTTP kimliği -`{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl}` şeklindedir. `protocol` hub'ın güncel uzak protokolünü, `minimumClientProtocol` uyumlu en düşük istemci protokolünü ve `managementUrl` tarayıcıya görünen kanonik yönetim origin'ini belirtir. `/readyz` içermeyen +`{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl, features}` şeklindedir. `protocol` hub'ın güncel uzak protokolünü, `minimumClientProtocol` uyumlu en düşük istemci protokolünü ve `managementUrl` tarayıcıya görünen kanonik yönetim origin'ini belirtir. `/readyz` içermeyen eski proxy'ler `unreachable` olarak kapalı başarısız olur; `/healthz` hazırlık değil, ayrı bir canlılıktır. Komut varsayılan olarak bir prob gerçekleştirir; `--wait`, hazır olana veya zaman aşımına kadar yoklar, ancak terminal `failed` @@ -238,6 +238,8 @@ Doctor asla kimlik bilgilerini değiştirmez veya onarımlar uygulamaz. ### `ocx sync [--restart-codex] [--restart-app-server-only]` +> **Remote Link Child:** `ocx sync` Home'un Codex ayarlarını da uygular ve istemci çalışma zamanı bu uygulamadan önce başlamışsa, yeniden başlatma bayrağı olmadan da onu yeniden başlatır. Yeniden başlatma başarısız olursa önceki çalışma zamanı hizmet vermeye devam eder ve komut sıfırdan farklı bir kodla çıkar. `ocx status --json` ve `ocx link status --json` içindeki ilgili alanlar için [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity) (İngilizce) bölümüne bakın. + Açıkça istenen yeniden başlatma, katalog ve önbellek baytları değişmese bile `config.toml` değişikliği başarıyla kaydedildikten sonra da çalışır. Yalnızca yapılandırmayı işleyen bir işlem değişiklik yapmadığında, reddedildiğinde, ön kontrolde kaldığında veya yazma geri alındığında yeniden başlatma tetiklenmez. Yeniden başlatma bayrağı olmadan sync, Codex’i otomatik olarak yeniden başlatmaz. Yapılandırılmış her sağlayıcıdan canlı model listesini alın ve birleştirilmiş diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index 62b44ac873d..92d9e89cb40 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -136,7 +136,7 @@ ocx status --json 通过无需认证的 `GET /readyz` 端点检查同步后的就绪状态。就绪时返回 `200`;状态为 `pending` 或 终态 `failed` 时返回 `503`,并带有 `Retry-After: 1`。HTTP 仅返回经脱敏的身份字段 -`{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl}`。`protocol` 是 Hub 当前的远程协议版本,`minimumClientProtocol` 是兼容的最低客户端协议版本,`managementUrl` 是浏览器可见的规范管理 origin。不支持 `/readyz` 的旧代理会按 `unreachable` 失败关闭; +`{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl, features}`。`protocol` 是 Hub 当前的远程协议版本,`minimumClientProtocol` 是兼容的最低客户端协议版本,`managementUrl` 是浏览器可见的规范管理 origin。不支持 `/readyz` 的旧代理会按 `unreachable` 失败关闭; `/healthz` 是独立的存活检查,不是就绪检查。默认只探测一次;`--wait` 会轮询到就绪或超时,但遇到终态 `failed` 会立即退出。默认超时为 45 秒;`--timeout ` 必须与 `--wait` 一起使用,取值范围为 1–300 秒的正整数。CLI JSON 输出 `{ready, status, pid, port}`,其中 `status` 为 `ready`、`pending`、`failed` 或 @@ -152,6 +152,8 @@ ocx status --json ### `ocx sync [--restart-codex] [--restart-app-server-only]` +> **Remote Link Child:** `ocx sync` 还会应用 Home 的 Codex 设置;如果客户端运行时在该次应用之前启动,即使没有重启参数也会重启运行时。重启失败时,原有运行时继续提供服务,命令以非零状态退出。`ocx status --json` 和 `ocx link status --json` 中的相关字段见 [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity)(英文)。 + 显式指定重启标志后,即使目录和缓存的字节未变,成功提交 `config.toml` 更改也会触发重启。仅处理配置的操作若没有更改、被拒绝、仅作预检或写入已回滚,则不会触发重启。未指定重启标志时,sync 不会自动重启 Codex。 从每个已配置的提供方获取实时模型列表,并将合并后的目录重新注入 Codex。在添加提供方后运行,或用于刷新可用模型。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index d65ca711fb1..1e726fe28d6 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -134,7 +134,7 @@ ocx status --json ### `ocx ready [--json] [--wait [--timeout ]]` -透過免認證的 `GET /readyz` 端點檢查同步後的就緒狀態。就緒時回傳 `200`,或 `pending` 與終端 `failed` 時回傳附帶 `Retry-After: 1` 的 `503`。其淨化的 HTTP 身分為 `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl}`。`protocol` 是 Hub 目前的遠端協定版本,`minimumClientProtocol` 是相容的最低用戶端協定版本,`managementUrl` 是瀏覽器可見的標準管理 origin。沒有 `/readyz` 的舊代理會以 `unreachable` 方式 fail closed;`/healthz` 是分開的存活檢查,而非就緒檢查。此指令預設執行一次探測;`--wait` 輪詢直到就緒或逾時,但在觀察到終端 `failed` 狀態時立即退出。預設逾時為 45 秒;`--timeout ` 需要 `--wait`,接受 1–300 的正整數秒。CLI JSON 輸出 `{ready, status, pid, port}`,其中 `status` 為 `ready`、`pending`、`failed` 或 `unreachable`。離開碼為:就緒 0;未就緒、pending、failed、逾時或 unreachable 1;無效引數 64。 +透過免認證的 `GET /readyz` 端點檢查同步後的就緒狀態。就緒時回傳 `200`,或 `pending` 與終端 `failed` 時回傳附帶 `Retry-After: 1` 的 `503`。其淨化的 HTTP 身分為 `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl, features}`。`protocol` 是 Hub 目前的遠端協定版本,`minimumClientProtocol` 是相容的最低用戶端協定版本,`managementUrl` 是瀏覽器可見的標準管理 origin。沒有 `/readyz` 的舊代理會以 `unreachable` 方式 fail closed;`/healthz` 是分開的存活檢查,而非就緒檢查。此指令預設執行一次探測;`--wait` 輪詢直到就緒或逾時,但在觀察到終端 `failed` 狀態時立即退出。預設逾時為 45 秒;`--timeout ` 需要 `--wait`,接受 1–300 的正整數秒。CLI JSON 輸出 `{ready, status, pid, port}`,其中 `status` 為 `ready`、`pending`、`failed` 或 `unreachable`。離開碼為:就緒 0;未就緒、pending、failed、逾時或 unreachable 1;無效引數 64。 ### `ocx doctor` @@ -146,6 +146,8 @@ ocx status --json ### `ocx sync [--restart-codex] [--restart-app-server-only]` +> **Remote Link Child:** `ocx sync` 也會套用 Home 的 Codex 設定;如果用戶端執行階段在該次套用之前啟動,即使沒有重新啟動旗標也會重新啟動執行階段。重新啟動失敗時,原有執行階段會繼續提供服務,指令以非零狀態結束。`ocx status --json` 與 `ocx link status --json` 的相關欄位請見 [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity)(英文)。 + 明確指定重新啟動旗標後,即使目錄和快取的位元組未變,成功提交 `config.toml` 變更也會觸發重新啟動。僅處理設定的操作若沒有變更、遭拒、只做預先檢查或寫入已回復,則不會觸發重新啟動。未指定重新啟動旗標時,sync 不會自動重新啟動 Codex。 從每個已設定的供應商擷取即時模型清單,並將合併後的目錄重新注入 Codex。在新增供應商後或要重新整理可用模型時執行它。 diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 99fa327d6b0..be51cc7f315 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -609,7 +609,7 @@ export default function App() { setSharedSessionEpoch(epoch => epoch + 1); }} /> )} - {page === "dashboard" && } {page === "startup" && } {page === "providers" && } diff --git a/gui/src/components/FallbackPanel.tsx b/gui/src/components/FallbackPanel.tsx new file mode 100644 index 00000000000..1d16bf64db7 --- /dev/null +++ b/gui/src/components/FallbackPanel.tsx @@ -0,0 +1,187 @@ +import { useCallback, useEffect, useRef, useState, type ReactElement } from "react"; +import { createBoundedFetch } from "../bounded-fetch"; +import { startVisibilityPoll } from "../visibility-poll"; +import { useT } from "../i18n/shared"; +import { Notice } from "../ui"; +import { parseFallbackState, type FallbackState, type FallbackSwitches, type FallbackWrite } from "../fallback-api"; +import "../styles-fallback.css"; + +/** + * OCX-11 fallback controls. `routing` shows the global switches, scoped overrides and the last + * fallback; `devices` shows per-device local fallback and chains (read-only on a Child, whose + * settings live on its Home). + */ +export default function FallbackPanel({ apiBase, section, readOnly = false }: { apiBase: string; section: "routing" | "devices"; readOnly?: boolean }): ReactElement | null { + const t = useT(); + const [state, setState] = useState(null); + const [loadFailed, setLoadFailed] = useState(false); + const [saveFailed, setSaveFailed] = useState(false); + const [saving, setSaving] = useState(false); + const savingRef = useRef(false); + // Every load and every write takes a sequence number; a load applies its answer only while it is + // still the newest, so a slow poll that started before a write cannot restore the old state. + const seqRef = useRef(0); + + const load = useCallback(async () => { + if (savingRef.current) return; + const seq = ++seqRef.current; + const bounded = createBoundedFetch(15_000); + try { + const response = await fetch(apiBase + "/api/fallback", { signal: bounded.signal }); + const parsed = response.ok ? parseFallbackState(await response.json()) : null; + if (!parsed) throw new Error("shape"); + if (!savingRef.current && seq === seqRef.current) { setState(parsed); setLoadFailed(false); } + } catch { + if (!savingRef.current && seq === seqRef.current) setLoadFailed(true); + } finally { + bounded.clear(); + } + }, [apiBase]); + + useEffect(() => { + const timeout = window.setTimeout(() => { void load(); }, 0); + const stop = startVisibilityPoll(() => { void load(); }, 30_000); + return () => { window.clearTimeout(timeout); stop(); }; + }, [load]); + + const write = useCallback(async (writes: FallbackWrite[]) => { + if (savingRef.current || readOnly) return; + savingRef.current = true; + seqRef.current += 1; + setSaving(true); + setSaveFailed(false); + const bounded = createBoundedFetch(15_000); + try { + // One request, one config mutation: a device's chains switch writes both kinds atomically. + const response = await fetch(apiBase + "/api/fallback", { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ writes }), signal: bounded.signal, + }); + const latest = response.ok ? parseFallbackState(await response.json()) : null; + if (!latest) throw new Error("save"); + seqRef.current += 1; + setState(latest); + } catch { + setSaveFailed(true); + } finally { + bounded.clear(); + savingRef.current = false; + setSaving(false); + } + }, [apiBase, readOnly]); + + const stateWord = (value: boolean | undefined | null): string => + t(value === false ? "fallback.state.off" : value === true ? "fallback.state.on" : "fallback.state.inherit"); + + const locked = saving || readOnly; + + if (!state) { + // The device rows are an addition to Remote Link: a Home or Child without the fallback state + // (an older OpenCodex, or a read that failed) shows nothing rather than a second error. + if (section === "devices") return null; + return ( +
+

{t(section === "routing" ? "fallback.title" : "fallback.devices.title")}

+

{loadFailed ? t("fallback.loadFailed") : t("common.loading")}

+ {loadFailed && } +
+ ); + } + + if (section === "devices") { + return ( +
+

{t("fallback.devices.title")}

+

{t(readOnly ? "fallback.devices.childNote" : "fallback.devices.desc")}

+ {state.devices.length === 0 + ?

{t("fallback.devices.empty")}

+ : + + + + + + + + + {state.devices.map(device => { + const chainsOn = device.model !== false && device.provider !== false; + const name = device.alias ?? device.id; + return ( + + + + + + ); + })} + +
{t("fallback.devices.device")}{t("fallback.devices.local")}{t("fallback.devices.chains")}
{name}{device.alias && {device.id}} { void write([{ scope: "device", key: device.id, setting: "localFallback", value: device.localFallback ? null : true }]); }} /> { + const value = chainsOn ? false : null; + void write([ + { scope: "device", key: device.id, setting: "model", value }, + { scope: "device", key: device.id, setting: "provider", value }, + ]); + }} />
} + {saveFailed && {t("fallback.saveFailed")}} +
+ ); + } + + const overrides: Array<{ scope: "provider" | "route"; key: string; value: FallbackSwitches }> = [ + ...Object.entries(state.providers).map(([key, value]) => ({ scope: "provider" as const, key, value })), + ...Object.entries(state.routes).map(([key, value]) => ({ scope: "route" as const, key, value })), + ]; + const last = state.last; + return ( +
+

{t("fallback.title")}

+

{t(readOnly ? "fallback.devices.childNote" : "fallback.desc")}

+ {(["model", "provider"] as const).map(kind => ( +
+
+ {t(kind === "model" ? "fallback.model" : "fallback.provider")} +
{t(kind === "model" ? "fallback.modelHint" : "fallback.providerHint")}
+
+ { void write([{ scope: "global", setting: kind, value: state.global[kind] ? false : null }]); }} /> +
+ ))} +

{t("fallback.rotationNote")}

+ {overrides.length > 0 && ( +
+ {t("fallback.overrides")} +
    + {overrides.map(row => ( +
  • + {t(row.scope === "provider" ? "fallback.scope.provider" : "fallback.scope.route", { name: row.key })} + {t("fallback.model")}: {stateWord(row.value.model)} · {t("fallback.provider")}: {stateWord(row.value.provider)} + +
  • + ))} +
+
+ )} +
+ {t("fallback.last")} + {last + ?

· {last.route}: {last.from} → {last.to} ({last.trigger}) · {t(last.outcome === "refused" ? "fallback.last.refused" : "fallback.last.taken")}

+ :

{t("fallback.last.none")}

} +
+ {saveFailed && {t("fallback.saveFailed")}} +
+ ); +} + +function FallbackToggle({ label, on, disabled, onToggle }: { label: string; on: boolean; disabled: boolean; onToggle: () => void }): ReactElement { + return ( + + ); +} diff --git a/gui/src/fallback-api.ts b/gui/src/fallback-api.ts new file mode 100644 index 00000000000..502c0e46efe --- /dev/null +++ b/gui/src/fallback-api.ts @@ -0,0 +1,33 @@ +/** Wire shape of GET|PUT /api/fallback (ocx.fallback/1), as the dashboard reads it. */ +export type FallbackKind = "model" | "provider"; +export type FallbackSwitches = { model?: boolean; provider?: boolean }; +export type FallbackDeviceRow = { id: string; kind: "link" | "key"; alias?: string; localFallback: boolean; model: boolean | null; provider: boolean | null }; +export type FallbackEventRow = { at: string; route: string; kind: FallbackKind; from: string; to: string; trigger: string; outcome: "taken" | "refused" }; +export type FallbackState = { + schema: "ocx.fallback/1"; + global: { model: boolean; provider: boolean }; + providers: Record; + routes: Record; + devices: FallbackDeviceRow[]; + last: FallbackEventRow | null; +}; +export type FallbackWrite = { + scope: "global" | "provider" | "route" | "device"; + key?: string; + setting: FallbackKind | "localFallback"; + value: boolean | null; +}; + +export function parseFallbackState(value: unknown): FallbackState | null { + if (!value || typeof value !== "object") return null; + const raw = value as Partial; + if (raw.schema !== "ocx.fallback/1" || !raw.global || !Array.isArray(raw.devices)) return null; + return { + schema: raw.schema, + global: { model: raw.global.model !== false, provider: raw.global.provider !== false }, + providers: raw.providers && typeof raw.providers === "object" ? raw.providers : {}, + routes: raw.routes && typeof raw.routes === "object" ? raw.routes : {}, + devices: raw.devices.filter((row): row is FallbackDeviceRow => !!row && typeof row.id === "string"), + last: raw.last && typeof raw.last === "object" ? raw.last : null, + }; +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 59d50597311..954ca8c9584 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -4055,4 +4055,33 @@ export const de: Record = { "cws.plan.description": "Fragt den Proxy, wie jedes Ziel eine Anfrage mit allen Funktionen der Client-API übertragen würde und welche Funktionen alle Ziele erhalten. Es wird nichts an den Anbieter gesendet.", "cws.plan.run": "Pfade anzeigen", "cws.plan.savedOnly": "Zeigt die gespeicherte Kombination. Speichern Sie Ihre Änderungen, um sie in der Vorschau zu sehen.", + "fallback.title": "Fallback", + "fallback.desc": "Wenn ein Modell oder Anbieter ausfällt, kann OpenCodex die Anfrage an das nächste Ziel einer Kombination, eines Routing-Profils oder einer Subagent-Kette senden. Schalte eine Art aus, um stattdessen einen klaren Fehler zu erhalten.", + "fallback.model": "Modell-Fallback", + "fallback.modelHint": "Ein anderes Modell desselben Anbieters", + "fallback.provider": "Anbieter-Fallback", + "fallback.providerHint": "Ein anderer Anbieter", + "fallback.rotationNote": "Der Wechsel zwischen Konten oder Schlüsseln eines Anbieters ist kein Fallback und bleibt aktiv.", + "fallback.overrides": "Überschreibungen", + "fallback.scope.provider": "Anbieter {name}", + "fallback.scope.route": "Route {name}", + "fallback.state.on": "an", + "fallback.state.off": "aus", + "fallback.state.inherit": "geerbt", + "fallback.clear": "Entfernen", + "fallback.last": "Letzter Fallback", + "fallback.last.none": "Seit dem Start des Proxys gab es keinen Fallback.", + "fallback.last.taken": "ausgeführt", + "fallback.last.refused": "abgelehnt", + "fallback.loadFailed": "Fallback-Einstellungen konnten nicht geladen werden.", + "fallback.saveFailed": "Die Fallback-Einstellung konnte nicht gespeichert werden.", + "fallback.devices.title": "Fallback pro Gerät", + "fallback.devices.desc": "Mit lokalem Fallback darf GOAT die Arbeit eines Geräts auf dessen eigene Anbieter verlagern, solange dieses Home nicht erreichbar ist. OpenCodex bedient ein Gerät nie über dessen lokale Anbieter. Sind Ketten aus, werden Modell- und Anbieter-Fallback für dieses Gerät abgelehnt.", + "fallback.devices.childNote": "Diese Einstellungen liegen auf dem Home dieses Geräts. Solange das Home nicht erreichbar ist, schlagen Anfragen fehl, statt die Anbieter dieses Geräts zu nutzen.", + "fallback.devices.empty": "Mit diesem Home sind keine Geräte verbunden.", + "fallback.devices.device": "Gerät", + "fallback.devices.local": "Lokaler Fallback", + "fallback.devices.chains": "Fallback-Ketten", + "fallback.devices.localFor": "Lokaler Fallback für {name}", + "fallback.devices.chainsFor": "Fallback-Ketten für {name}", }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 539edff3150..8d0b427cb0e 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -4089,6 +4089,35 @@ export const en = { "cws.plan.description": "Asks the proxy how each target would carry a request with every feature the client API can express, and which features all targets keep. Nothing is sent upstream.", "cws.plan.run": "Show candidate paths", "cws.plan.savedOnly": "Shows the saved combo. Save your changes to preview them.", + "fallback.title": "Fallback", + "fallback.desc": "When a model or provider fails, OpenCodex can send the request to the next one in a combo, routing profile or subagent chain. Turn a kind off to get a clear error instead.", + "fallback.model": "Model fallback", + "fallback.modelHint": "Another model from the same provider", + "fallback.provider": "Provider fallback", + "fallback.providerHint": "Another provider", + "fallback.rotationNote": "Switching between accounts or keys of one provider is not a fallback and stays on.", + "fallback.overrides": "Overrides", + "fallback.scope.provider": "Provider {name}", + "fallback.scope.route": "Route {name}", + "fallback.state.on": "on", + "fallback.state.off": "off", + "fallback.state.inherit": "inherit", + "fallback.clear": "Clear", + "fallback.last": "Last fallback", + "fallback.last.none": "No fallback since the proxy started.", + "fallback.last.taken": "taken", + "fallback.last.refused": "refused", + "fallback.loadFailed": "Could not load fallback settings.", + "fallback.saveFailed": "Could not save the fallback setting.", + "fallback.devices.title": "Fallback per device", + "fallback.devices.desc": "Local fallback lets GOAT move a device's work to that device's own providers while this Home is unreachable. OpenCodex never serves a device from its local providers. Turning chains off refuses model and provider fallback for that device.", + "fallback.devices.childNote": "These settings are kept on this device's Home. While the Home is unreachable, requests fail instead of using this device's providers.", + "fallback.devices.empty": "No devices are linked to this Home.", + "fallback.devices.device": "Device", + "fallback.devices.local": "Local fallback", + "fallback.devices.chains": "Fallback chains", + "fallback.devices.localFor": "Local fallback for {name}", + "fallback.devices.chainsFor": "Fallback chains for {name}", } as const; export type TKey = keyof typeof en; diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 4973ee9d269..3390706ce0d 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -4044,4 +4044,33 @@ export const fr: Record = { "cws.plan.description": "Demande au proxy comment chaque cible transporterait une requête utilisant toutes les fonctionnalités de l'API cliente, et lesquelles toutes les cibles conservent. Rien n'est envoyé en amont.", "cws.plan.run": "Afficher les chemins", "cws.plan.savedOnly": "Affiche le combo enregistré. Enregistrez vos modifications pour les prévisualiser.", + "fallback.title": "Repli", + "fallback.desc": "Quand un modèle ou un fournisseur échoue, OpenCodex peut envoyer la requête à la cible suivante d'un combo, d'un profil de routage ou d'une chaîne de sous-agents. Désactivez un type pour obtenir une erreur claire à la place.", + "fallback.model": "Repli de modèle", + "fallback.modelHint": "Un autre modèle du même fournisseur", + "fallback.provider": "Repli de fournisseur", + "fallback.providerHint": "Un autre fournisseur", + "fallback.rotationNote": "Passer d'un compte ou d'une clé à l'autre chez un même fournisseur n'est pas un repli et reste actif.", + "fallback.overrides": "Remplacements", + "fallback.scope.provider": "Fournisseur {name}", + "fallback.scope.route": "Sélecteur de route {name}", + "fallback.state.on": "activé", + "fallback.state.off": "désactivé", + "fallback.state.inherit": "hérité", + "fallback.clear": "Effacer", + "fallback.last": "Dernier repli", + "fallback.last.none": "Aucun repli depuis le démarrage du proxy.", + "fallback.last.taken": "effectué", + "fallback.last.refused": "refusé", + "fallback.loadFailed": "Impossible de charger les réglages de repli.", + "fallback.saveFailed": "Impossible d'enregistrer le réglage de repli.", + "fallback.devices.title": "Repli par appareil", + "fallback.devices.desc": "Le repli local permet à GOAT de déplacer le travail d'un appareil vers ses propres fournisseurs tant que ce Home est injoignable. OpenCodex ne sert jamais un appareil avec ses fournisseurs locaux. Désactiver les chaînes refuse le repli de modèle et de fournisseur pour cet appareil.", + "fallback.devices.childNote": "Ces réglages sont conservés sur le Home de cet appareil. Tant que le Home est injoignable, les requêtes échouent au lieu d'utiliser les fournisseurs de cet appareil.", + "fallback.devices.empty": "Aucun appareil n'est relié à ce Home.", + "fallback.devices.device": "Appareil", + "fallback.devices.local": "Repli local", + "fallback.devices.chains": "Chaînes de repli", + "fallback.devices.localFor": "Repli local pour {name}", + "fallback.devices.chainsFor": "Chaînes de repli pour {name}", }; diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 5241ebb7907..0b77c84b1f5 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -4077,4 +4077,33 @@ export const ja: Record = { "cws.plan.description": "クライアント API が表現できるすべての機能を含むリクエストを各ターゲットがどう運ぶか、すべてのターゲットで保たれる機能は何かをプロキシに問い合わせます。上流には何も送信しません。", "cws.plan.run": "経路を表示", "cws.plan.savedOnly": "保存済みのコンボを表示しています。変更をプレビューするには保存してください。", + "fallback.title": "フォールバック", + "fallback.desc": "モデルやプロバイダーが失敗すると、OpenCodex はコンボ、ルーティングプロファイル、サブエージェントチェーンの次の対象へリクエストを送れます。種類ごとにオフにすると、代わりに明確なエラーを返します。", + "fallback.model": "モデルのフォールバック", + "fallback.modelHint": "同じプロバイダーの別モデル", + "fallback.provider": "プロバイダーのフォールバック", + "fallback.providerHint": "別のプロバイダー", + "fallback.rotationNote": "同じプロバイダーのアカウントやキーの切り替えはフォールバックではなく、常に有効です。", + "fallback.overrides": "上書き設定", + "fallback.scope.provider": "プロバイダー {name}", + "fallback.scope.route": "ルート {name}", + "fallback.state.on": "オン", + "fallback.state.off": "オフ", + "fallback.state.inherit": "継承", + "fallback.clear": "クリア", + "fallback.last": "直近のフォールバック", + "fallback.last.none": "プロキシ起動以降、フォールバックはありません。", + "fallback.last.taken": "実行", + "fallback.last.refused": "拒否", + "fallback.loadFailed": "フォールバック設定を読み込めませんでした。", + "fallback.saveFailed": "フォールバック設定を保存できませんでした。", + "fallback.devices.title": "デバイスごとのフォールバック", + "fallback.devices.desc": "ローカルフォールバックを許可すると、この Home に接続できない間、GOAT がそのデバイスの作業をデバイス自身のプロバイダーへ移せます。OpenCodex がデバイスをローカルプロバイダーで処理することはありません。チェーンをオフにすると、そのデバイスのモデル・プロバイダーのフォールバックを拒否します。", + "fallback.devices.childNote": "この設定はこのデバイスの Home に保存されています。Home に接続できない間、リクエストはこのデバイスのプロバイダーを使わずに失敗します。", + "fallback.devices.empty": "この Home にリンクされたデバイスはありません。", + "fallback.devices.device": "デバイス", + "fallback.devices.local": "ローカルフォールバック", + "fallback.devices.chains": "フォールバックチェーン", + "fallback.devices.localFor": "{name} のローカルフォールバック", + "fallback.devices.chainsFor": "{name} のフォールバックチェーン", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index ed252d6cc67..d40d32edc6a 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -4077,4 +4077,33 @@ export const ko: Record = { "cws.plan.description": "클라이언트 API가 표현할 수 있는 모든 기능을 담은 요청을 각 대상이 어떻게 전달하는지, 모든 대상이 유지하는 기능은 무엇인지 프록시에 묻습니다. 업스트림으로는 아무것도 보내지 않습니다.", "cws.plan.run": "경로 보기", "cws.plan.savedOnly": "저장된 콤보를 보여 줍니다. 변경 사항을 미리 보려면 먼저 저장하세요.", + "fallback.title": "폴백", + "fallback.desc": "모델이나 프로바이더가 실패하면 OpenCodex는 콤보, 라우팅 프로필, 서브에이전트 체인의 다음 대상으로 요청을 보낼 수 있습니다. 종류별로 끄면 대신 명확한 오류를 반환합니다.", + "fallback.model": "모델 폴백", + "fallback.modelHint": "같은 프로바이더의 다른 모델", + "fallback.provider": "프로바이더 폴백", + "fallback.providerHint": "다른 프로바이더", + "fallback.rotationNote": "한 프로바이더의 계정이나 키 사이 전환은 폴백이 아니며 계속 켜져 있습니다.", + "fallback.overrides": "재정의", + "fallback.scope.provider": "프로바이더 {name}", + "fallback.scope.route": "경로 {name}", + "fallback.state.on": "켜짐", + "fallback.state.off": "꺼짐", + "fallback.state.inherit": "상속", + "fallback.clear": "지우기", + "fallback.last": "마지막 폴백", + "fallback.last.none": "프록시가 시작된 뒤 폴백이 없었습니다.", + "fallback.last.taken": "적용됨", + "fallback.last.refused": "거부됨", + "fallback.loadFailed": "폴백 설정을 불러오지 못했습니다.", + "fallback.saveFailed": "폴백 설정을 저장하지 못했습니다.", + "fallback.devices.title": "기기별 폴백", + "fallback.devices.desc": "로컬 폴백을 허용하면 이 Home에 연결할 수 없는 동안 GOAT가 해당 기기의 작업을 그 기기 자체의 프로바이더로 옮길 수 있습니다. OpenCodex는 기기를 로컬 프로바이더로 처리하지 않습니다. 체인을 끄면 그 기기의 모델·프로바이더 폴백을 거부합니다.", + "fallback.devices.childNote": "이 설정은 이 기기의 Home에 저장됩니다. Home에 연결할 수 없는 동안 요청은 이 기기의 프로바이더를 쓰지 않고 실패합니다.", + "fallback.devices.empty": "이 Home에 연결된 기기가 없습니다.", + "fallback.devices.device": "기기", + "fallback.devices.local": "로컬 폴백", + "fallback.devices.chains": "폴백 체인", + "fallback.devices.localFor": "{name}의 로컬 폴백", + "fallback.devices.chainsFor": "{name}의 폴백 체인", }; diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 0cfaae10c54..d9da4d0695c 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -4050,4 +4050,33 @@ export const pt: Record = { "cws.plan.description": "Pergunta ao proxy como cada destino carregaria uma requisição com todos os recursos que a API do cliente consegue expressar, e quais recursos todos os destinos preservam. Nada é enviado ao upstream.", "cws.plan.run": "Mostrar caminhos candidatos", "cws.plan.savedOnly": "Mostra o combo salvo. Salve suas alterações para visualizá-las.", + "fallback.title": "Fallback", + "fallback.desc": "Quando um modelo ou provedor falha, o OpenCodex pode enviar a requisição para o próximo alvo de um combo, perfil de roteamento ou cadeia de subagentes. Desative um tipo para receber um erro claro em vez disso.", + "fallback.model": "Fallback de modelo", + "fallback.modelHint": "Outro modelo do mesmo provedor", + "fallback.provider": "Fallback de provedor", + "fallback.providerHint": "Outro provedor", + "fallback.rotationNote": "Alternar entre contas ou chaves de um mesmo provedor não é fallback e continua ativo.", + "fallback.overrides": "Substituições", + "fallback.scope.provider": "Provedor {name}", + "fallback.scope.route": "Rota {name}", + "fallback.state.on": "ativado", + "fallback.state.off": "desativado", + "fallback.state.inherit": "herdado", + "fallback.clear": "Limpar", + "fallback.last": "Último fallback", + "fallback.last.none": "Nenhum fallback desde que o proxy iniciou.", + "fallback.last.taken": "executado", + "fallback.last.refused": "recusado", + "fallback.loadFailed": "Não foi possível carregar as configurações de fallback.", + "fallback.saveFailed": "Não foi possível salvar a configuração de fallback.", + "fallback.devices.title": "Fallback por dispositivo", + "fallback.devices.desc": "O fallback local permite que o GOAT mova o trabalho de um dispositivo para os provedores do próprio dispositivo enquanto este Home estiver inacessível. O OpenCodex nunca atende um dispositivo com seus provedores locais. Desativar as cadeias recusa o fallback de modelo e de provedor desse dispositivo.", + "fallback.devices.childNote": "Estas configurações ficam no Home deste dispositivo. Enquanto o Home estiver inacessível, as requisições falham em vez de usar os provedores deste dispositivo.", + "fallback.devices.empty": "Nenhum dispositivo está vinculado a este Home.", + "fallback.devices.device": "Dispositivo", + "fallback.devices.local": "Fallback local", + "fallback.devices.chains": "Cadeias de fallback", + "fallback.devices.localFor": "Fallback local de {name}", + "fallback.devices.chainsFor": "Cadeias de fallback de {name}", }; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index ab50a613b3d..0e1ef64c041 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -4078,4 +4078,33 @@ export const ru: Record = { "cws.plan.description": "Спрашивает прокси, как каждая цель передаст запрос со всеми возможностями клиентского API и какие возможности сохраняют все цели. Ничего не отправляется провайдеру.", "cws.plan.run": "Показать пути", "cws.plan.savedOnly": "Показан сохранённый комбо. Сохраните изменения, чтобы увидеть их в предпросмотре.", + "fallback.title": "Резерв", + "fallback.desc": "Если модель или провайдер дают сбой, OpenCodex может отправить запрос следующей цели в комбо, профиле маршрутизации или цепочке субагентов. Отключите вид, чтобы вместо этого получить понятную ошибку.", + "fallback.model": "Резервная модель", + "fallback.modelHint": "Другая модель того же провайдера", + "fallback.provider": "Резервный провайдер", + "fallback.providerHint": "Другой провайдер", + "fallback.rotationNote": "Переключение между аккаунтами или ключами одного провайдера не считается резервом и остаётся включённым.", + "fallback.overrides": "Переопределения", + "fallback.scope.provider": "Провайдер {name}", + "fallback.scope.route": "Маршрут {name}", + "fallback.state.on": "вкл.", + "fallback.state.off": "выкл.", + "fallback.state.inherit": "наследуется", + "fallback.clear": "Сбросить", + "fallback.last": "Последняя попытка резервного переключения", + "fallback.last.none": "С момента запуска прокси переходов на резерв не было.", + "fallback.last.taken": "выполнен", + "fallback.last.refused": "отклонён", + "fallback.loadFailed": "Не удалось загрузить настройки резерва.", + "fallback.saveFailed": "Не удалось сохранить настройку резерва.", + "fallback.devices.title": "Резерв по устройствам", + "fallback.devices.desc": "Локальный резерв позволяет GOAT переносить работу устройства на его собственных провайдеров, пока этот Home недоступен. OpenCodex никогда не обслуживает устройство его локальными провайдерами. Если цепочки выключены, резерв модели и провайдера для этого устройства отклоняется.", + "fallback.devices.childNote": "Эти настройки хранятся на Home этого устройства. Пока Home недоступен, запросы завершаются ошибкой и не используют провайдеров этого устройства.", + "fallback.devices.empty": "К этому Home не подключено ни одного устройства.", + "fallback.devices.device": "Устройство", + "fallback.devices.local": "Локальный резерв", + "fallback.devices.chains": "Цепочки резерва", + "fallback.devices.localFor": "Локальный резерв для {name}", + "fallback.devices.chainsFor": "Цепочки резерва для {name}", }; diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index b1f6670b294..7f4ad54d6b9 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -4078,4 +4078,33 @@ export const tr: Record = { "cws.plan.description": "Proxy'ye her hedefin, istemci API'sinin ifade edebildiği tüm özellikleri içeren bir isteği nasıl taşıyacağını ve hangi özellikleri tüm hedeflerin koruduğunu sorar. Yukarı akışa hiçbir şey gönderilmez.", "cws.plan.run": "Yolları göster", "cws.plan.savedOnly": "Kaydedilmiş kombo gösteriliyor. Değişikliklerinizi önizlemek için kaydedin.", + "fallback.title": "Yedek", + "fallback.desc": "Bir model veya sağlayıcı başarısız olduğunda OpenCodex isteği bir kombodaki, yönlendirme profilindeki veya alt ajan zincirindeki sonraki hedefe gönderebilir. Bunun yerine açık bir hata almak için bir türü kapatın.", + "fallback.model": "Model yedeği", + "fallback.modelHint": "Aynı sağlayıcının başka bir modeli", + "fallback.provider": "Sağlayıcı yedeği", + "fallback.providerHint": "Başka bir sağlayıcı", + "fallback.rotationNote": "Bir sağlayıcının hesapları veya anahtarları arasında geçiş yedek sayılmaz ve açık kalır.", + "fallback.overrides": "Geçersiz kılmalar", + "fallback.scope.provider": "Sağlayıcı {name}", + "fallback.scope.route": "Rota {name}", + "fallback.state.on": "açık", + "fallback.state.off": "kapalı", + "fallback.state.inherit": "devralınır", + "fallback.clear": "Temizle", + "fallback.last": "Son yedek", + "fallback.last.none": "Proxy başladığından beri yedeğe geçilmedi.", + "fallback.last.taken": "uygulandı", + "fallback.last.refused": "reddedildi", + "fallback.loadFailed": "Yedek ayarları yüklenemedi.", + "fallback.saveFailed": "Yedek ayarı kaydedilemedi.", + "fallback.devices.title": "Cihaz başına yedek", + "fallback.devices.desc": "Yerel yedek, bu Home'a ulaşılamadığı sürece GOAT'un bir cihazın işini o cihazın kendi sağlayıcılarına taşımasına izin verir. OpenCodex bir cihazı asla yerel sağlayıcılarıyla yanıtlamaz. Zincirler kapalıysa o cihaz için model ve sağlayıcı yedeği reddedilir.", + "fallback.devices.childNote": "Bu ayarlar bu cihazın Home'unda tutulur. Home'a ulaşılamadığı sürece istekler bu cihazın sağlayıcılarını kullanmak yerine başarısız olur.", + "fallback.devices.empty": "Bu Home'a bağlı cihaz yok.", + "fallback.devices.device": "Cihaz", + "fallback.devices.local": "Yerel yedek", + "fallback.devices.chains": "Yedek zincirleri", + "fallback.devices.localFor": "{name} için yerel yedek", + "fallback.devices.chainsFor": "{name} için yedek zincirleri", }; diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index f2e60deca7f..5a219ce31de 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -4047,4 +4047,33 @@ export const vi: Record = { "cws.plan.description": "Hỏi proxy xem mỗi đích sẽ chuyển một request có mọi tính năng mà API phía client biểu diễn được như thế nào, và tính năng nào được mọi đích giữ lại. Không gửi gì lên upstream.", "cws.plan.run": "Hiển thị đường đi", "cws.plan.savedOnly": "Đang hiển thị combo đã lưu. Hãy lưu thay đổi để xem trước chúng.", + "fallback.title": "Dự phòng", + "fallback.desc": "Khi một model hoặc nhà cung cấp lỗi, OpenCodex có thể gửi yêu cầu đến mục tiêu tiếp theo trong combo, hồ sơ định tuyến hoặc chuỗi subagent. Tắt một loại để nhận lỗi rõ ràng thay vào đó.", + "fallback.model": "Dự phòng model", + "fallback.modelHint": "Model khác của cùng nhà cung cấp", + "fallback.provider": "Dự phòng nhà cung cấp", + "fallback.providerHint": "Nhà cung cấp khác", + "fallback.rotationNote": "Chuyển giữa các tài khoản hoặc khóa của cùng một nhà cung cấp không phải là dự phòng và luôn bật.", + "fallback.overrides": "Ghi đè", + "fallback.scope.provider": "Nhà cung cấp {name}", + "fallback.scope.route": "Tuyến {name}", + "fallback.state.on": "bật", + "fallback.state.off": "tắt", + "fallback.state.inherit": "kế thừa", + "fallback.clear": "Xóa", + "fallback.last": "Lần dự phòng gần nhất", + "fallback.last.none": "Chưa có dự phòng nào kể từ khi proxy khởi động.", + "fallback.last.taken": "đã thực hiện", + "fallback.last.refused": "đã từ chối", + "fallback.loadFailed": "Không tải được cài đặt dự phòng.", + "fallback.saveFailed": "Không lưu được cài đặt dự phòng.", + "fallback.devices.title": "Dự phòng theo thiết bị", + "fallback.devices.desc": "Dự phòng cục bộ cho phép GOAT chuyển công việc của một thiết bị sang các nhà cung cấp của chính thiết bị đó khi không kết nối được Home này. OpenCodex không bao giờ phục vụ thiết bị bằng các nhà cung cấp cục bộ của nó. Tắt chuỗi sẽ từ chối dự phòng model và nhà cung cấp cho thiết bị đó.", + "fallback.devices.childNote": "Các cài đặt này được lưu trên Home của thiết bị này. Khi không kết nối được Home, yêu cầu sẽ thất bại thay vì dùng các nhà cung cấp của thiết bị này.", + "fallback.devices.empty": "Chưa có thiết bị nào liên kết với Home này.", + "fallback.devices.device": "Thiết bị", + "fallback.devices.local": "Dự phòng cục bộ", + "fallback.devices.chains": "Chuỗi dự phòng", + "fallback.devices.localFor": "Dự phòng cục bộ cho {name}", + "fallback.devices.chainsFor": "Chuỗi dự phòng cho {name}", }; diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 7dd5863bbf0..ac42881a939 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -4041,4 +4041,33 @@ export const zhTW: Record = { "cws.plan.description": "詢問代理每個目標會如何傳遞帶有用戶端 API 所有可表達功能的請求,以及哪些功能所有目標都保留。不會向上游傳送任何內容。", "cws.plan.run": "顯示路徑", "cws.plan.savedOnly": "顯示的是已儲存的組合。請先儲存變更再預覽。", + "fallback.title": "備援", + "fallback.desc": "當模型或服務商失敗時,OpenCodex 可以把請求送到組合、路由設定或子代理鏈中的下一個目標。關閉某一類後,會改為回傳明確的錯誤。", + "fallback.model": "模型備援", + "fallback.modelHint": "同一服務商的其他模型", + "fallback.provider": "服務商備援", + "fallback.providerHint": "其他服務商", + "fallback.rotationNote": "在同一服務商的帳號或金鑰之間切換不屬於備援,會一直開啟。", + "fallback.overrides": "覆寫設定", + "fallback.scope.provider": "服務商 {name}", + "fallback.scope.route": "路由 {name}", + "fallback.state.on": "開", + "fallback.state.off": "關", + "fallback.state.inherit": "繼承", + "fallback.clear": "清除", + "fallback.last": "最近一次備援", + "fallback.last.none": "代理啟動後尚無備援。", + "fallback.last.taken": "已執行", + "fallback.last.refused": "已拒絕", + "fallback.loadFailed": "無法載入備援設定。", + "fallback.saveFailed": "無法儲存備援設定。", + "fallback.devices.title": "依裝置備援", + "fallback.devices.desc": "允許本機備援後,在無法連線此 Home 時,GOAT 可以把該裝置的工作移到裝置自己的服務商。OpenCodex 不會用本機服務商處理裝置請求。關閉備援鏈會拒絕該裝置的模型與服務商備援。", + "fallback.devices.childNote": "這些設定儲存在此裝置的 Home 上。無法連線 Home 時,請求會直接失敗,不會使用此裝置的服務商。", + "fallback.devices.empty": "沒有裝置連結到此 Home。", + "fallback.devices.device": "裝置", + "fallback.devices.local": "本機備援", + "fallback.devices.chains": "備援鏈", + "fallback.devices.localFor": "{name} 的本機備援", + "fallback.devices.chainsFor": "{name} 的備援鏈", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 25550ed06c1..f95d00c5d1f 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -4076,4 +4076,33 @@ export const zh: Record = { "cws.plan.description": "询问代理每个目标会如何传递带有客户端 API 所有可表达功能的请求,以及哪些功能所有目标都保留。不会向上游发送任何内容。", "cws.plan.run": "显示路径", "cws.plan.savedOnly": "显示的是已保存的组合。请先保存更改再预览。", + "fallback.title": "回退", + "fallback.desc": "当模型或服务商失败时,OpenCodex 可以把请求发送到组合、路由配置或子代理链中的下一个目标。关闭某一类后,会改为返回明确的错误。", + "fallback.model": "模型回退", + "fallback.modelHint": "同一服务商的其他模型", + "fallback.provider": "服务商回退", + "fallback.providerHint": "其他服务商", + "fallback.rotationNote": "在同一服务商的账号或密钥之间切换不属于回退,始终开启。", + "fallback.overrides": "覆盖设置", + "fallback.scope.provider": "服务商 {name}", + "fallback.scope.route": "路由 {name}", + "fallback.state.on": "开", + "fallback.state.off": "关", + "fallback.state.inherit": "继承", + "fallback.clear": "清除", + "fallback.last": "最近一次回退", + "fallback.last.none": "代理启动后尚无回退。", + "fallback.last.taken": "已执行", + "fallback.last.refused": "已拒绝", + "fallback.loadFailed": "无法加载回退设置。", + "fallback.saveFailed": "无法保存回退设置。", + "fallback.devices.title": "按设备回退", + "fallback.devices.desc": "允许本地回退后,在无法连接此 Home 时,GOAT 可以把该设备的工作转到设备自己的服务商。OpenCodex 不会用本地服务商处理设备请求。关闭回退链会拒绝该设备的模型和服务商回退。", + "fallback.devices.childNote": "这些设置保存在此设备的 Home 上。无法连接 Home 时,请求会直接失败,不会使用此设备的服务商。", + "fallback.devices.empty": "没有设备连接到此 Home。", + "fallback.devices.device": "设备", + "fallback.devices.local": "本地回退", + "fallback.devices.chains": "回退链", + "fallback.devices.localFor": "{name} 的本地回退", + "fallback.devices.chainsFor": "{name} 的回退链", }; diff --git a/gui/src/pages/Dashboard.tsx b/gui/src/pages/Dashboard.tsx index 16b221834f7..911fd6f2111 100644 --- a/gui/src/pages/Dashboard.tsx +++ b/gui/src/pages/Dashboard.tsx @@ -18,8 +18,8 @@ function selectDashboardTab(next: DashboardSection) { navigateHash(dashboardHashForSection(next)); } -export default function Dashboard({ apiBase, connected = false, authenticationPending = false, refreshEpoch = 0 }: { - apiBase: string; connected?: boolean; authenticationPending?: boolean; refreshEpoch?: number; +export default function Dashboard({ apiBase, readOnly = false, connected = false, authenticationPending = false, refreshEpoch = 0 }: { + apiBase: string; readOnly?: boolean; connected?: boolean; authenticationPending?: boolean; refreshEpoch?: number; }) { const d = useDashboardData(apiBase, refreshEpoch); const { @@ -41,7 +41,7 @@ export default function Dashboard({ apiBase, connected = false, authenticationPe ); } - const overviewSection = ; + const overviewSection = ; const providersSection = ; const modelsSection = ( { void refreshStatus(); }} /> )} {(uiState === "connected" || uiState === "reconnecting" || uiState === "failed" || uiState === "restart-waiting" || status?.role === "child" || statusRows.length > 0 || uiState === "adding-child" || uiState === "confirming-host" || uiState === "applying" || uiState === "joining") &&

{role === "child" && standaloneRuntime ? t("remoteLink.findHome.title") : t("link.children")}

{uiState === "restart-waiting" ? t("remoteLink.restart.waiting") : t(roleLabel)}

{uiState === "restart-waiting" ?
{t("remoteLink.restart.title")}

{t("remoteLink.restart.body")}

{restartSlow && {t("remoteLink.restart.slow")}}
: status?.role === "child" ?
{status.child?.alias ?? t("remoteLink.role.child")}{status.child &&
{t(STATUS_LABEL[status.child.state])}
}
: statusRows.length > 0 ?
{statusRows.map(row =>
{row.alias}
{t(STATUS_LABEL[row.state])}{row.direction === "hub-initiated" ? t("remoteLink.direction.hub") : t("remoteLink.direction.client")}{row.reason && {row.reason in REASON_TKEY ? t(REASON_TKEY[row.reason]) : <>{t("remoteLink.reason.generic")} {row.reason}}}
)}
:

{t(role === "child" && standaloneRuntime ? "remoteLink.findHome.empty" : "link.noChildren")}

}{(!sheetOpen && (uiState === "reconnecting" || (uiState === "failed" && ((failedAction !== null && actionError?.key !== "remoteLink.error.join_restart_failed") || statusRows.some(row => row.state === "failed"))))) &&
{t(STATUS_LABEL[uiState === "failed" ? "failed" : "reconnecting"])}
}{uiState === "joining" &&

{t("remoteLink.joining")}

}{!sheetOpen && actionError && }
} + {(status?.role === "child" || statusRows.length > 0) && } { event.preventDefault(); closeSheet(); }}>
diff --git a/gui/src/pages/dashboard-overview-section.tsx b/gui/src/pages/dashboard-overview-section.tsx index 4e20772abf0..0c02a5c6325 100644 --- a/gui/src/pages/dashboard-overview-section.tsx +++ b/gui/src/pages/dashboard-overview-section.tsx @@ -1,14 +1,17 @@ import { DashboardOverviewHead } from "./dashboard-overview-head"; import { DashboardOverviewPanels } from "./dashboard-overview-panels"; +import FallbackPanel from "../components/FallbackPanel"; import type { useDashboardData } from "./use-dashboard-data"; type Dash = ReturnType; -export function DashboardOverviewSection(d: Dash) { +export function DashboardOverviewSection({ readOnly = false, ...d }: Dash & { readOnly?: boolean }) { return (
+ {/* A Remote Link Child shows its Home's settings; they are changed on the Home. */} +
); } diff --git a/gui/src/styles-fallback.css b/gui/src/styles-fallback.css new file mode 100644 index 00000000000..bbde6843909 --- /dev/null +++ b/gui/src/styles-fallback.css @@ -0,0 +1,14 @@ +/* OCX-11 fallback controls (gui/src/components/FallbackPanel.tsx). Existing tokens only. */ +.fallback-panel { display: flex; flex-direction: column; gap: var(--space-2); } +.fallback-panel .card-sub { padding: 2px 0 0; margin: 0; } +.fallback-panel h3 { margin: 0; } +.fallback-switch-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); } +.fallback-overrides ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-1); } +.fallback-overrides li { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; } +.fallback-overrides li > button { margin-left: auto; } +.fallback-devices { width: 100%; border-collapse: collapse; } +.fallback-devices th, .fallback-devices td { text-align: left; padding: var(--space-1) var(--space-3) var(--space-1) 0; vertical-align: middle; } +.fallback-devices th:not(:first-child), .fallback-devices td:not(:first-child) { width: 7rem; } +.fallback-device-head th { color: var(--muted); font-size: var(--text-label); font-weight: inherit; } +.fallback-device-id { margin-left: var(--space-2); color: var(--muted); } +.fallback-last p { margin: 0; } diff --git a/gui/tests/fallback-panel.test.tsx b/gui/tests/fallback-panel.test.tsx new file mode 100644 index 00000000000..a400280fbdd --- /dev/null +++ b/gui/tests/fallback-panel.test.tsx @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import FallbackPanel from "../src/components/FallbackPanel"; +import { parseFallbackState } from "../src/fallback-api"; +import { LanguageProvider } from "../src/i18n/provider"; + +const domGlobals = ["document", "window", "navigator", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousDomGlobals: Record<(typeof domGlobals)[number], unknown>; +let testWindow: Window; +let mountedRoot: Root | null; + +const LINK = "lnk_0123456789abcdef"; +const baseState = { + schema: "ocx.fallback/1", + global: { model: true, provider: true }, + providers: {}, + routes: { "combo/coding": { provider: false } }, + devices: [{ id: LINK, kind: "link", alias: "mac-studio", localFallback: false, model: null, provider: null }], + last: { at: "2026-10-22T00:00:00.000Z", route: "combo/coding", kind: "provider", from: "a/m", to: "b/n", trigger: "rate_limited", outcome: "refused" }, + recent: [], +}; + +async function flush(): Promise { + await Promise.resolve(); + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + await Promise.resolve(); +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +describe("FallbackPanel", () => { + beforeEach(() => { + previousDomGlobals = Object.fromEntries(domGlobals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousDomGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + mountedRoot = null; + }); + + afterEach(async () => { + if (mountedRoot) { + await act(async () => { mountedRoot?.unmount(); }); + mountedRoot = null; + } + for (const key of domGlobals) Object.defineProperty(globalThis, key, { configurable: true, value: previousDomGlobals[key] }); + await testWindow.happyDOM?.close?.(); + }); + + async function mount(fetchMock: typeof fetch, section: "routing" | "devices", readOnly = false): Promise { + globalThis.fetch = fetchMock; + const host = testWindow.document.createElement("div"); + testWindow.document.body.appendChild(host as never); + const { createRoot } = await import("react-dom/client"); + await act(async () => { + mountedRoot = createRoot(host); + mountedRoot.render(); + }); + await act(async () => { await flush(); }); + return host as unknown as HTMLElement; + } + + test("parseFallbackState rejects foreign shapes", () => { + expect(parseFallbackState({ schema: "other" })).toBeNull(); + expect(parseFallbackState(null)).toBeNull(); + expect(parseFallbackState(baseState)?.devices).toHaveLength(1); + }); + + test("routing: shows the switches, the override and the last refusal; turning provider off writes the global scope", async () => { + const writes: unknown[] = []; + const host = await mount((async (_url: string, init?: RequestInit) => { + if (init?.method === "PUT") { + writes.push(...(JSON.parse(String(init.body)) as { writes: unknown[] }).writes); + return json({ ...baseState, global: { model: true, provider: false } }); + } + return json(baseState); + }) as unknown as typeof fetch, "routing"); + expect(host.textContent).toContain("Model fallback"); + expect(host.textContent).toContain("Route combo/coding"); + expect(host.textContent).toContain("refused"); + const providerToggle = host.querySelector('button[aria-label="Provider fallback"]')!; + expect(providerToggle.getAttribute("aria-pressed")).toBe("true"); + await act(async () => { providerToggle.click(); await flush(); }); + expect(writes).toEqual([{ scope: "global", setting: "provider", value: false }]); + expect(host.querySelector('button[aria-label="Provider fallback"]')!.getAttribute("aria-pressed")).toBe("false"); + }); + + test("devices: local fallback writes the device permission; chains off refuses both kinds", async () => { + const requests: unknown[] = []; + const host = await mount((async (_url: string, init?: RequestInit) => { + if (init?.method === "PUT") { + requests.push(JSON.parse(String(init.body))); + return json(baseState); + } + return json(baseState); + }) as unknown as typeof fetch, "devices"); + expect(host.textContent).toContain("mac-studio"); + await act(async () => { host.querySelector('button[aria-label="Local fallback for mac-studio"]')!.click(); await flush(); }); + await act(async () => { host.querySelector('button[aria-label="Fallback chains for mac-studio"]')!.click(); await flush(); }); + // One request per switch; the chains switch writes both kinds in one batch. + expect(requests).toEqual([ + { writes: [{ scope: "device", key: LINK, setting: "localFallback", value: true }] }, + { writes: [ + { scope: "device", key: LINK, setting: "model", value: false }, + { scope: "device", key: LINK, setting: "provider", value: false }, + ] }, + ]); + }); + + test("devices: turning local fallback off sends a clearing write and the toggle shows off", async () => { + const requests: unknown[] = []; + const allowed = { ...baseState, devices: [{ ...baseState.devices[0]!, localFallback: true }] }; + const host = await mount((async (_url: string, init?: RequestInit) => { + if (init?.method === "PUT") { + requests.push(JSON.parse(String(init.body))); + return json({ ...allowed, devices: [{ ...allowed.devices[0]!, localFallback: false }] }); + } + return json(allowed); + }) as unknown as typeof fetch, "devices"); + await act(async () => { host.querySelector('button[aria-label="Local fallback for mac-studio"]')!.click(); await flush(); }); + expect(requests).toEqual([{ writes: [{ scope: "device", key: LINK, setting: "localFallback", value: null }] }]); + expect(host.querySelector('button[aria-label="Local fallback for mac-studio"]')!.getAttribute("aria-pressed")).toBe("false"); + }); + + test("on a Child the device rows are read-only and nothing is written", async () => { + let puts = 0; + const host = await mount((async (_url: string, init?: RequestInit) => { + if (init?.method === "PUT") puts += 1; + return json(baseState); + }) as unknown as typeof fetch, "devices", true); + const buttons = [...host.querySelectorAll("button.toggle")]; + expect(buttons.length).toBe(2); + expect(buttons.every(button => button.disabled)).toBe(true); + expect(host.textContent).toContain("kept on this device's Home"); + expect(puts).toBe(0); + }); + + test("a failed load says so and offers a retry", async () => { + const host = await mount((async () => json({ error: "nope" }, 500)) as unknown as typeof fetch, "routing"); + expect(host.textContent).toContain("Could not load fallback settings."); + }); + + test("a poll that started before a save cannot restore the state the save replaced", async () => { + // Capture the 30 s poll so the test decides when the second GET starts. + let poll: (() => void) | undefined; + const realSetInterval = testWindow.setInterval.bind(testWindow); + testWindow.setInterval = ((fn: () => void, ms?: number) => { + if (ms === 30_000) { poll = fn; return 0; } + return realSetInterval(fn, ms); + }) as typeof testWindow.setInterval; + let gets = 0; + let releaseStale: (() => void) | undefined; + const host = await mount((async (_url: string, init?: RequestInit) => { + if (init?.method === "PUT") return json({ ...baseState, global: { model: true, provider: false } }); + gets += 1; + if (gets === 1) return json(baseState); + await new Promise(resolve => { releaseStale = resolve; }); + return json(baseState); + }) as unknown as typeof fetch, "routing"); + expect(poll).toBeDefined(); + await act(async () => { poll!(); await flush(); }); + expect(gets).toBe(2); + await act(async () => { host.querySelector('button[aria-label="Provider fallback"]')!.click(); await flush(); }); + expect(host.querySelector('button[aria-label="Provider fallback"]')!.getAttribute("aria-pressed")).toBe("false"); + await act(async () => { releaseStale!(); await flush(); }); + expect(host.querySelector('button[aria-label="Provider fallback"]')!.getAttribute("aria-pressed")).toBe("false"); + }); + + test("on a Child the routing switches and the Clear button are read-only", async () => { + let puts = 0; + const host = await mount((async (_url: string, init?: RequestInit) => { + if (init?.method === "PUT") puts += 1; + return json(baseState); + }) as unknown as typeof fetch, "routing", true); + const buttons = [...host.querySelectorAll("button")]; + expect(buttons.some(button => button.textContent === "Clear")).toBe(true); + expect(buttons.every(button => button.disabled)).toBe(true); + expect(host.textContent).toContain("kept on this device's Home"); + for (const button of buttons) await act(async () => { button.click(); await flush(); }); + expect(puts).toBe(0); + }); +}); diff --git a/gui/tests/remote-link.test.tsx b/gui/tests/remote-link.test.tsx index ad2540c1c70..f5c92f1123d 100644 --- a/gui/tests/remote-link.test.tsx +++ b/gui/tests/remote-link.test.tsx @@ -558,7 +558,9 @@ test("Retry on a failed Home link restarts its tunnel instead of only re-reading await act(async () => { retry.click(); }); await settleUntil(() => host.querySelector(".remote-link-status[data-state='connecting']") !== null); expect(calls.filter(call => call.method === "POST").map(call => call.path)).toEqual(["/api/link/link-1/reconnect"]); - expect(calls.at(-1)).toEqual({ method: "GET", path: "/api/link/status" }); + // The page also hosts the per-device fallback panel, whose own GET /api/fallback may land + // after the refresh; the link flow's last request is still the status re-read. + expect(calls.filter(call => call.path.startsWith("/api/link")).at(-1)).toEqual({ method: "GET", path: "/api/link/status" }); expect(host.querySelector(".remote-link-status[data-state='connecting']")).not.toBeNull(); }); @@ -568,11 +570,13 @@ test.each([false, true])("Retry stays disabled through the deferred refresh (POS const refreshed = new Promise(resolve => { release = resolve; }); let posts = 0; let gets = 0; - globalThis.fetch = (async (_input, init) => { + globalThis.fetch = (async (input, init) => { if (init?.method === "POST") { posts += 1; return postFails ? response({ error: { code: "link_unavailable" } }, 503) : response({ linkId: "link-1" }); } + // Count only the link status reads; the fallback panel's GET is a separate resource. + if (new URL(String(input)).pathname !== "/api/link/status") return response({ error: "not_found" }, 404); gets += 1; return gets === 1 ? response(failed) : refreshed; }) as typeof fetch; diff --git a/package.json b/package.json index 75836a24e24..bbd5ecce9c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.90.0", + "version": "2.91.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", diff --git a/scripts/ci/windows-sensitive-diff.sh b/scripts/ci/windows-sensitive-diff.sh index 05bcaaba27e..03495f256c6 100644 --- a/scripts/ci/windows-sensitive-diff.sh +++ b/scripts/ci/windows-sensitive-diff.sh @@ -26,6 +26,14 @@ selected="${PATH_SELECTED:-}" emit() { printf 'windows=%s\n' "$1" >> "$GITHUB_OUTPUT" printf 'windows-sensitive: %s (%s)\n' "$1" "$2" + # Pull requests never run the Windows shards (AGENTS.md CI policy), so a sensitive one only + # gets a non-blocking notice: a maintainer dispatches lane=all on its branch before merging. + if [ "$event" = "pull_request" ] && [ "$1" = "true" ]; then + printf '::notice title=Windows-sensitive::Windows-sensitive (%s): dispatch Cross-platform CI lane=all on this branch before merge\n' "$2" + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + printf '**Windows-sensitive** (%s): pull requests do not run the Windows shards; dispatch Cross-platform CI `lane=all` on this branch before merge.\n' "$2" >> "$GITHUB_STEP_SUMMARY" + fi + fi } case "$selected" in @@ -79,6 +87,10 @@ get-ciminstance process.platform localappdata userprofile +spawnsync +sigterm +sigkill +process.kill MARKERS status=0 diff --git a/scripts/generate-ocx-skill-surface.ts b/scripts/generate-ocx-skill-surface.ts index ad5da0c4a21..4d7727104e3 100644 --- a/scripts/generate-ocx-skill-surface.ts +++ b/scripts/generate-ocx-skill-surface.ts @@ -19,7 +19,7 @@ const DOMAINS = [ { name: "lifecycle", roots: ["codex-provider", "chatgpt", "status", "resolve", "capabilities", "sync", "start", "stop", "restart", "service", "gui"] }, { name: "providers-models", roots: ["provider", "models", "alias"] }, { name: "accounts", roots: ["account", "auth", "login", "logout"] }, - { name: "agents-routing", roots: ["agent", "combo", "route", "v2", "effort", "memory", "message", "advisor"] }, + { name: "agents-routing", roots: ["agent", "combo", "route", "fallback", "v2", "effort", "memory", "message", "advisor"] }, { name: "integrations", roots: ["claude", "integration", "commandcode", "grok", "codex-shim"] }, { name: "observe-system", roots: ["companion", "usage", "logs", "storage", "inspect", "system", "observe", "debug", "export", "import", "cost", "update", "config", "tray"] }, { name: "access-remote", roots: ["link", "remote-workspace", "hub", "connect", "api", "access"] }, diff --git a/scripts/test-layout/layout-agent-assertion.json b/scripts/test-layout/layout-agent-assertion.json new file mode 100644 index 00000000000..d3a0675e38e --- /dev/null +++ b/scripts/test-layout/layout-agent-assertion.json @@ -0,0 +1,5 @@ +{ + "agent-assertion-auth.test.ts": "server", + "agent-assertion-routes.test.ts": "responses", + "authorization-scheme.test.ts": "lib" +} diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json index 2f4ef80fea4..9224a33affb 100644 --- a/scripts/test-layout/layout-clients.json +++ b/scripts/test-layout/layout-clients.json @@ -9,6 +9,7 @@ "desktop-supervised-restart.test.ts": "clients", "link-home-settings.test.ts": "clients", "link-relay-bound-transport.test.ts": "clients", + "link-peer-compatibility.test.ts": "clients", "aside-profile-identity.test.ts": "clients", "aside-profile-paths.test.ts": "clients", "aside-profile-sync-owner.test.ts": "clients", @@ -130,5 +131,9 @@ "link-status-projection.test.ts": "clients", "link-admission-wait.test.ts": "clients", "link-fingerprint.test.ts": "clients", - "link-compensation.test.ts": "clients" + "link-compensation.test.ts": "clients", + "link-codex-settings-digest.test.ts": "clients", + "link-apply-record.test.ts": "clients", + "client-runtime-restart.test.ts": "clients", + "link-parity-reporter.test.ts": "clients" } diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 60eee142abe..0d1c7696e5d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1,8 +1,8 @@ { "version": 1, "root": "tests", - "explicitShards": ["layout-clients.json", "layout-codex-collision.json"], - "explicit": { "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", + "explicitShards": ["layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json"], + "explicit": { "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "cli-provider-list-active.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "openai-chat-standalone-bare-block.test.ts": "adapters/openai", "owner-registry-isolation.test.ts": "config", "responses-auto-pause-replay-owner.test.ts": "responses", "responses-account-auto-pause.test.ts": "responses", "oauth-account-auto-pause.test.ts": "oauth", "responses-account-auto-pause-wire-owner.test.ts": "responses", "oauth-upsert-paused-api-key.test.ts": "oauth", "management-account-auto-pause.test.ts": "server", "cli-account-key-pause.test.ts": "cli", "oauth-account-auto-pause-revision.test.ts": "oauth", "config-account-auto-pause-revisions.test.ts": "config", "codex-history-pending-wal.test.ts": "codex-integration", "responses-anthropic-billing-send-budget.test.ts": "responses", "anthropic-billing-ownership.test.ts": "adapters/anthropic", "server-anthropic-billing.test.ts": "server", "server-anthropic-billing-boundaries.test.ts": "server", "router-anthropic-billing-failover.test.ts": "routing", "anthropic-billing-refusal.test.ts": "adapters/anthropic", "messages-request-id-headers.test.ts": "claude-integration", "bounded-file-read.test.ts": "lib", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "explicit-empty-tool-catalog.test.ts": "responses", "explicit-empty-tool-catalog-history.test.ts": "responses", @@ -33,7 +33,7 @@ "cli-access-audio.test.ts": "cli", "cli-access-audio-live.test.ts": "cli", "cli-log-follow.test.ts": "cli", "cli-injection-follow.test.ts": "cli", "cli-usage-scope.test.ts": "cli", "cli-system-health.test.ts": "cli", "cli-companion-timeline.test.ts": "cli", "server-cli-key-probe-admission.test.ts": "server", "cli-integration-preview.test.ts": "cli", "cli-integration-droid.test.ts": "cli", "cli-integration-journal.test.ts": "cli", "cli-aside-sync.test.ts": "cli", - "cli-claude-desktop-profile.test.ts": "cli", "cli-integration-cursor.test.ts": "cli", "cli-remote-workspace-hub.test.ts": "cli", "cli-storage-policy-fields.test.ts": "cli", "cli-link-force.test.ts": "cli", + "cli-claude-desktop-profile.test.ts": "cli", "cli-integration-cursor.test.ts": "cli", "cli-remote-workspace-hub.test.ts": "cli", "cli-storage-policy-fields.test.ts": "cli", "cli-link-force.test.ts": "cli", "cli-link-cleanup.test.ts": "cli", "cli-account-policy.test.ts": "cli", "cli-account-login-options.test.ts": "cli", "cli-logout-runtime.test.ts": "cli", "cli-agent-settings.test.ts": "cli", "cli-v2-runtime.test.ts": "cli", "cli-agent-runtime-settings.test.ts": "cli", "cli-system-settings-parity.test.ts": "cli", "cli-catalog-command-result.test.ts": "cli", "cli-models-local-result.test.ts": "cli", "cli-models-custom-runtime.test.ts": "cli", "cli-models-order.test.ts": "cli", @@ -144,7 +144,7 @@ "account-import.test.ts": "server", "account-import-project-fallback.test.ts": "server", "account-pool-management-api.test.ts": "server", - "link-listener-admission.test.ts": "server", "link-management-relay.test.ts": "server", + "link-listener-admission.test.ts": "server", "link-management-relay.test.ts": "server", "quota-pool-relay.test.ts": "server", "quota-pool.test.ts": "usage", "fallback-policy.test.ts": "routing", "responses-combo-fallback-off.test.ts": "responses", "main-account-hard-lock-fallback-off.test.ts": "codex-integration", "subagent-model-fallback-off.test.ts": "routing", "routing-policy-fallback-off.test.ts": "routing", "fallback-routes.test.ts": "server", "client-home-unreachable.test.ts": "clients", "cli-fallback.test.ts": "cli", "link-listener-lifecycle.test.ts": "server", "link-listener-mutation.test.ts": "server", "link-join-route.test.ts": "server", "core-link-boundary.test.ts": "lab", @@ -355,7 +355,7 @@ "messages-native-oauth-eligibility.test.ts": "responses", "chat-inbound-reasoning-none.test.ts": "responses", "chat-native-decline-reason.test.ts": "responses", - "chat-inbound-reasoning-replay.test.ts": "responses", + "chat-inbound-reasoning-replay.test.ts": "responses", "chat-anthropic-reasoning-details.test.ts": "responses", "chat-inline-document-bytes.test.ts": "responses", "chat-json-sse-fallback.test.ts": "responses", "chat-media-translation.test.ts": "responses", @@ -528,7 +528,7 @@ "sibling-home-client-sync.test.ts": "cli", "cross-home-loopback-family.test.ts": "cli", "cli-status-hub-state.test.ts": "cli", - "cli-status-json.test.ts": "cli", + "cli-status-json.test.ts": "cli", "status-install-method.test.ts": "cli", "cli-status-oauth-health.test.ts": "cli", "cli-stop-json.test.ts": "cli", "cli-storage-inspect.test.ts": "cli", @@ -1324,7 +1324,7 @@ "oauth-store-multi.test.ts": "oauth", "oauth-store-quota-isolation.test.ts": "oauth", "oauth-tos-warning.test.ts": "gui", "oauth-upsert-preserves-api-key.test.ts": "oauth", - "ocx-launcher-runtime.test.ts": "cli", + "ocx-launcher-runtime.test.ts": "cli", "ocx-launcher-fixture-trust.test.ts": "cli", "ocx-launcher-desktop-handoff.test.ts": "cli", "cli-path-diagnostics.test.ts": "cli", "ocx-launcher-source.test.ts": "cli", "ocx-run.test.ts": "cli", @@ -1453,7 +1453,7 @@ "proxy-env-macos.test.ts": "server", "proxy-env.test.ts": "server", "proxy-liveness-package-tree-fence.test.ts": "server", - "proxy-liveness.test.ts": "server", + "proxy-liveness.test.ts": "server", "readyz-features.test.ts": "server", "served-by-header.test.ts": "server", "qoder-adapter.test.ts": "providers", "qoder-live-models.test.ts": "providers", "qoder-mcp-server.test.ts": "providers", "qoder-scaffold-guard.test.ts": "providers", "quota-401-recovery-runtime.test.ts": "usage", @@ -1566,7 +1566,7 @@ "responses-hosted-tool-min-spread.test.ts": "responses", "responses-hide-raw-reasoning.test.ts": "responses", "responses-image-gen-repair.test.ts": "responses", - "responses-inbound-store-default.test.ts": "responses", + "responses-inbound-store-default.test.ts": "responses", "codex-0164-request-compat.test.ts": "responses", "responses-item-id-repair.test.ts": "responses", "responses-json-events.test.ts": "responses", "responses-legacy-dotted-tool-name-repair.test.ts": "responses", @@ -1820,7 +1820,7 @@ "terminal-continuation-owner-rotation.test.ts": "adapters", "terminal-guard-server.test.ts": "server", "terminal-guard.test.ts": "server", - "test-home-guard.test.ts": "ci-workflows", + "test-home-guard.test.ts": "ci-workflows", "test-home-guard-unpreloaded.test.ts": "ci-workflows", "test-runner.test.ts": "ci-workflows", "test-runner-bun.test.ts": "ci-workflows", "test-runner-spawnsync-finalizer.test.ts": "ci-workflows", "test-sandbox-cleanup.test.ts": "ci-workflows", "test-temp-quarantine.test.ts": "ci-workflows", "thought-signature-credential-scope.test.ts": "responses", "token-estimate.test.ts": "lib", @@ -1840,7 +1840,7 @@ "update-async-routes.test.ts": "server", "update-badge.test.ts": "update", "update-bun-ownership-lease.test.ts": "update", "update-desktop-badge.test.ts": "update", "update-desktop-owner.test.ts": "update", "update-supervised-recovery.test.ts": "update", - "update-job.test.ts": "update", "update-job-supervised-restart.test.ts": "update", "update-standalone.test.ts": "update", "update-worker-launch.test.ts": "update", + "update-job.test.ts": "update", "update-recovery-host-free.test.ts": "update", "update-job-supervised-restart.test.ts": "update", "update-standalone.test.ts": "update", "update-worker-launch.test.ts": "update", "update-notify.test.ts": "update", "update-npm-cache-preflight.test.ts": "update", "update-npm-invocation.test.ts": "update", "update-mise-launcher-target.test.ts": "update", "update-mise.test.ts": "update", "update-mise-node-runtime.test.ts": "update", @@ -1947,7 +1947,7 @@ "windows-user-principal-nonascii.test.ts": "windows", "windows-user-principal.test.ts": "windows", "winsw-stop-hardening.test.ts": "windows", - "winsw.test.ts": "service", + "winsw.test.ts": "service", "service-status-json.test.ts": "service", "workflow-budget.test.ts": "lib", "ws-ambiguous-resend.test.ts": "responses", "ws-pre-output-refusal.test.ts": "responses", "responses-pool-overload-failover.test.ts": "responses", "compact-transient-alternate.test.ts": "responses", "ws-capacity-reclamation.test.ts": "responses", @@ -1986,7 +1986,7 @@ "zz-ci-storage-policy-isolation.test.ts": "ci-workflows", "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows", "injection-link-websocket.test.ts": "codex-integration", - "cli-link.test.ts": "cli", "cli-link-enrollment.test.ts": "cli", + "cli-link.test.ts": "cli", "cli-link-enrollment.test.ts": "cli", "link-apply-restart.test.ts": "cli", "link-child-parity.test.ts": "server", "restart-replacement-not-ready.test.ts": "server", "link-management-routes.test.ts": "server", "link-enrollment.test.ts": "server", "web-search-run-turn-loop.test.ts": "web-search", "responses-run-turn-web-search.test.ts": "responses", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 1ca37bfd5f4..29b7b2b7471 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -28,10 +28,10 @@ These answer in the CLI head and never reach the proxy, so they work with nothin | [lifecycle](01_surface_lifecycle.md) | 13 | | [providers-models](01_surface_providers-models.md) | 49 | | [accounts](01_surface_accounts.md) | 42 | -| [agents-routing](01_surface_agents-routing.md) | 62 | +| [agents-routing](01_surface_agents-routing.md) | 67 | | [integrations](01_surface_integrations.md) | 41 | | [observe-system](01_surface_observe-system.md) | 96 | -| [access-remote](01_surface_access-remote.md) | 32 | +| [access-remote](01_surface_access-remote.md) | 33 | | [lab](01_surface_lab.md) | 21 | ## Read-only capabilities @@ -83,6 +83,10 @@ Original invocation order. These headings preserve links to the previous single- [State-changing task](01_surface_access-remote.md#ocx-link-reconnect) +### `ocx link cleanup` + +[Read-oriented task](01_surface_access-remote.md#ocx-link-cleanup) + ### `ocx remote-workspace pair` [State-changing task](01_surface_access-remote.md#ocx-remote-workspace-pair) @@ -751,6 +755,26 @@ Original invocation order. These headings preserve links to the previous single- [State-changing task](01_surface_agents-routing.md#ocx-agent-sidecar-vision) +### `ocx fallback` + +[Read-oriented task](01_surface_agents-routing.md#ocx-fallback) + +### `ocx fallback off` + +[State-changing task](01_surface_agents-routing.md#ocx-fallback-off) + +### `ocx fallback on` + +[State-changing task](01_surface_agents-routing.md#ocx-fallback-on) + +### `ocx fallback clear` + +[State-changing task](01_surface_agents-routing.md#ocx-fallback-clear) + +### `ocx fallback local` + +[State-changing task](01_surface_agents-routing.md#ocx-fallback-local) + ### `ocx effort` [State-changing task](01_surface_agents-routing.md#ocx-effort) @@ -1473,6 +1497,6 @@ Original invocation order. These headings preserve links to the previous single- ## Counts -- declared capabilities: 356 -- of those, state-changing: 217 +- declared capabilities: 362 +- of those, state-changing: 221 - head-resolved invocations: 2 diff --git a/skills/ocx/references/01_surface_access-remote.md b/skills/ocx/references/01_surface_access-remote.md index 56363d5d821..12cc418fc2c 100644 --- a/skills/ocx/references/01_surface_access-remote.md +++ b/skills/ocx/references/01_surface_access-remote.md @@ -8,7 +8,7 @@ Use these declarations to choose a task, then check its flags and authority before execution. Non-mutating probes may still contact providers, consume quota or refresh caches. -Declared capabilities: 32. +Declared capabilities: 33. ### `ocx link probe` @@ -150,6 +150,24 @@ State-changing: yes. JSON mode: `envelope`. - A Child-initiated link is refused with link_not_home_initiated: its own computer owns and retries that tunnel. - Always emits {linkId} JSON. +### `ocx link cleanup` + +Usage: `ocx link cleanup [--link-id ] [--json]` + +Find Windows Remote Link Children and explain how to move them to Hub mode. + +State-changing: no. JSON mode: `payload`. + +Drives no management route. + +| Flag | Value | Meaning | +|---|---|---| +| `--link-id` | string | Check only this link. | +| `--json` | boolean | Emit {links, changed:false} as JSON. | + +- Runs on the Home from its local link store. Asks each Home-initiated Child for uname -s over the link's pinned SSH trust; a Windows answer (Git Bash, MSYS, Cygwin, cmd or PowerShell) is reported with recovery steps. +- Never revokes, disconnects or edits a link. A Windows Child needs ocx disconnect on itself, then ocx link revoke --force --yes on the Home, then ocx hub invite and ocx connect. + ### `ocx remote-workspace pair` Usage: `ocx remote-workspace pair --pairing-code-stdin --root [--root ...] [--toolchain-root ...] [--executor-helper ] [--name ] [--json]` diff --git a/skills/ocx/references/01_surface_agents-routing.md b/skills/ocx/references/01_surface_agents-routing.md index 97ad5ffccde..a3ebc10254b 100644 --- a/skills/ocx/references/01_surface_agents-routing.md +++ b/skills/ocx/references/01_surface_agents-routing.md @@ -8,7 +8,7 @@ Use these declarations to choose a task, then check its flags and authority before execution. Non-mutating probes may still contact providers, consume quota or refresh caches. -Declared capabilities: 62. +Declared capabilities: 67. ### `ocx agent subagents force` @@ -590,6 +590,109 @@ State-changing: yes. JSON mode: `payload`. - Without --list at least one setting is required. Stored settings and Codex application outcomes remain distinct. - Stream-routed-output belongs only to web. Timeout changes preserve siblings; native apply and saved state are distinct. +### `ocx fallback` + +Usage: `ocx fallback [status] [--json]` + +Show which model and provider fallbacks are allowed, per-device local fallback, and the last fallback taken or refused. + +State-changing: no. JSON mode: `payload`. + +| Method | Route | +|---|---| +| GET | `/api/fallback` | + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit the ocx.fallback/1 state. | + +- Reads the live proxy. On a Remote Link Child it reads the Home's state over the link (aliases omitted). The last event is in memory and resets on restart. + +### `ocx fallback off` + +Usage: `ocx fallback off [--kind model|provider|all] [--provider | --route | --device ] [--json]` + +Refuse model or provider fallback globally or for one provider, route or device. + +State-changing: yes. JSON mode: `payload`. + +| Method | Route | +|---|---| +| PUT | `/api/fallback` | + +| Flag | Value | Meaning | +|---|---|---| +| `--kind` | string | model, provider or all (default). | +| `--provider` | string | Scope to the provider a request started on. | +| `--route` | string | Scope to an exact selector such as combo/ or policy/. | +| `--device` | string | Scope to a Remote Link id or alias, or a Hub data key id; device off always refuses. | +| `--json` | boolean | Emit the resulting state. | + +- A refused hop answers fallback_refused with the triggering failure's status. Rotation among accounts or keys of one provider pool is not a fallback and stays on. + +### `ocx fallback on` + +Usage: `ocx fallback on [--kind model|provider|all] [--provider | --route | --device ] [--json]` + +Allow model or provider fallback at one scope, overriding a broader off. + +State-changing: yes. JSON mode: `payload`. + +| Method | Route | +|---|---| +| PUT | `/api/fallback` | + +| Flag | Value | Meaning | +|---|---|---| +| `--kind` | string | model, provider or all (default). | +| `--provider` | string | Scope to a provider. | +| `--route` | string | Scope to an exact selector. | +| `--device` | string | Scope to a device. | +| `--json` | boolean | Emit the resulting state. | + +- Route beats provider beats global. A device setting can only refuse: on for a device records the value but never reopens a hop another scope refuses. + +### `ocx fallback clear` + +Usage: `ocx fallback clear [--kind model|provider|all] [--provider | --route | --device ] [--json]` + +Remove a fallback override so the scope inherits again. + +State-changing: yes. JSON mode: `payload`. + +| Method | Route | +|---|---| +| PUT | `/api/fallback` | + +| Flag | Value | Meaning | +|---|---|---| +| `--kind` | string | model, provider or all (default). | +| `--provider` | string | Scope to a provider. | +| `--route` | string | Scope to an exact selector. | +| `--device` | string | Scope to a device. | +| `--json` | boolean | Emit the resulting state. | + +- Clearing every override restores today's behavior: every fallback allowed. + +### `ocx fallback local` + +Usage: `ocx fallback local --device [--json]` + +Record whether a device may use its own providers while the Home is unreachable. + +State-changing: yes. JSON mode: `payload`. + +| Method | Route | +|---|---| +| PUT | `/api/fallback` | + +| Flag | Value | Meaning | +|---|---|---| +| `--device` | string | Remote Link id or alias, or Hub data key id. | +| `--json` | boolean | Emit the resulting state. | + +- Run on the Home. A permission GOAT reads; OpenCodex itself keeps answering home_unreachable on the device and never serves it from local providers. + ### `ocx effort` Usage: `ocx effort [status|low|medium|high|xhigh|max|ultra|->] [--json]` diff --git a/skills/ocx/references/01_surface_lifecycle.md b/skills/ocx/references/01_surface_lifecycle.md index 5c94694a48b..a661dda4707 100644 --- a/skills/ocx/references/01_surface_lifecycle.md +++ b/skills/ocx/references/01_surface_lifecycle.md @@ -49,6 +49,7 @@ Drives no management route. | `--json` | boolean | Emit the status envelope as JSON. | - Reads /healthz plus local config; drives no management API route. +- --json includes install: {method: bun|npm|pnpm|mise|source|standalone|unknown, evidence: {basis, mise?, error?}} from the updater's install detector. ### `ocx resolve` @@ -171,15 +172,20 @@ Drives no management route. ### `ocx service status` -Usage: `ocx service status` +Usage: `ocx service status [--json]` Inspect local service-manager status. -State-changing: no. JSON mode: `none`. +State-changing: no. JSON mode: `payload`. Drives no management route. -- Local OS service manager: macOS launchd, Linux systemd, Windows Task Scheduler or WinSW. No management API or JSON mode. +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit the passive service status document as JSON. | + +- Local OS service manager: macOS launchd, Linux systemd, Windows Task Scheduler or WinSW. No management API. +- --json is passive: schemaVersion 1 with installed, running, enabled, viable, stale, conflict, backend (launchd|systemd|schtasks|winsw|none), label, serving {probed, ok, port}, summary, logPath, ownershipLease and desktopLogin. It starts, stops, installs and repairs nothing. - Backend options apply only to install; other verbs use the installed backend. ### `ocx service uninstall` diff --git a/skills/ocx/references/01_surface_providers-models.md b/skills/ocx/references/01_surface_providers-models.md index 389b3d476ae..74abd07eda5 100644 --- a/skills/ocx/references/01_surface_providers-models.md +++ b/skills/ocx/references/01_surface_providers-models.md @@ -92,6 +92,7 @@ Drives no management route. | `--jsonl` | boolean | Emit one configured provider per JSON line. | - Local config and registry read, not GET /api/providers. --json and --jsonl are mutually exclusive. +- Each configured row carries active: false while this device is linked to a Home (a Remote Link Child or a Hub-mode client), whose providers serve instead; true when the row serves. Credential values are never printed. ### `ocx provider resets` diff --git a/skills/ocx/references/02_json_shapes.md b/skills/ocx/references/02_json_shapes.md index 5e80e75536c..b0ff57f0a8f 100644 --- a/skills/ocx/references/02_json_shapes.md +++ b/skills/ocx/references/02_json_shapes.md @@ -62,6 +62,11 @@ to `requestedModel` is how you get a wrong answer about which provider served it One configured provider per line. Each object has the same fields as an item in the `configured` array from `ocx provider list --json`; the `registryCount` summary is omitted. +Each row has `active`: `false` when this device is linked to a Home (a Remote Link Child or a +Hub-mode client), so the row is local configuration the Home's providers shadow; `true` when the +row actually serves. Rows carry no credential values: `baseUrl` drops embedded userinfo and masks +credential-shaped query values. + ## `ocx logs history --json` The management page: `entries[]`, `hasMore`, optional `nextCursor`, and `index` (schema version, diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index aef61bfad9d..4251de21e29 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -32,6 +32,7 @@ import { redactSecretString } from "../lib/redact"; import { CLAUDE_CODE_HEADERS, claudeCodeSessionId } from "./client-fingerprint"; import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; import { decodeServerSentEvents } from "../lib/sse-decoder"; +import { isLikelyRealAnthropicThinkingSignature } from "../responses/reasoning-envelope"; import { isTranslatorBudgetExceededError, retainTranslatedEventBatch, type TranslatorBudget } from "../lib/translator-budget"; import { isReasoningEffortOmitted, modelRecordValue } from "../reasoning-effort"; import { applyAgentRouterLanguageFraming, isAgentRouterEndpoint } from "./agentrouter"; @@ -291,11 +292,7 @@ function normalizeTtlOrdering(body: Record): void { } } -export function isLikelyRealAnthropicThinkingSignature(signature: string | undefined): signature is string { - if (typeof signature !== "string" || signature.length < 16) return false; - if (/^(fc|call|msg|rs|resp|reasoning|item|ws|tool|func|function)[-_]/i.test(signature)) return false; - return /^[A-Za-z0-9+/_=-]+$/.test(signature); -} +export { isLikelyRealAnthropicThinkingSignature }; /** * Bridge error fidelity (web-search/images loops): extract a display-safe summary from an diff --git a/src/adapters/claude-cli/usage-admission.ts b/src/adapters/claude-cli/usage-admission.ts index 548e03723e9..00446ee8438 100644 --- a/src/adapters/claude-cli/usage-admission.ts +++ b/src/adapters/claude-cli/usage-admission.ts @@ -181,6 +181,20 @@ async function checkAdmission(model: string, deps: AdmissionDeps, now: number): } let preflightOverride: ((model: string) => Promise) | undefined; + +/** The last usage the admission recorded, as `/api/quota-pool` reports it. */ +export type ClaudeUsageObservation = Readonly; + +/** + * The newest recorded usage snapshot, from memory or the admission file. Read-only: it resolves + * no sign-in (no Keychain read), runs no probe and writes nothing. `identity` is the admission's + * own SHA-256 account key, never the account uuid itself. + */ +export function peekClaudeUsageObservation(deps: Pick = {}): ClaudeUsageObservation | null { + const path = statePath(deps); + const rows = [load(path), memory.get(path)].filter((s): s is Snapshot => s !== undefined); + return rows.sort((a, b) => b.checkedAt - a.checkedAt)[0] ?? null; +} /** Test seam for the pre-dispatch check in the Responses executor; call with no argument to restore. */ export function setClaudeUsagePreflightForTests(fn?: (model: string) => Promise): void { preflightOverride = fn; } /** The pre-dispatch check the Responses executor runs before a claude-cli turn. */ diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 050686dcc3e..57c7ccf0e6b 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -40,7 +40,7 @@ import { emittedFastWire, } from "../../providers/fastwire"; import { dropResponsesReasoningInputItems, mapRoutedResponsesReasoningEffort, normalizeConfiguredReasoningSummaryDelivery, sanitizeReasoningInputContent, stripDisabledReasoningSummaries, stripDisabledVerbosity, stripNoneReasoningSummary, stripUnsupportedReasoningSummaryDelivery } from "./reasoning"; -import { scrubOcxCompactionItems, stripCanonicalOnlyToolFields, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough, stripInvalidItemIds, stripItemIdsWhenUnstored, stripProviderInputItemMetadata, stripRejectedSamplingParams } from "./request-strips"; +import { scrubOcxCompactionItems, stripCanonicalOnlyInputItems, stripCanonicalOnlyToolFields, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough, stripInvalidItemIds, stripItemIdsWhenUnstored, stripProviderInputItemMetadata, stripRejectedSamplingParams } from "./request-strips"; import { notePlaintextReasoningDropped } from "./plaintext-reasoning-notice"; import { stripCanonicalForwardPromptCacheOptions, stripDeprecatedPromptCacheRetention } from "./prompt-cache"; import { isPlainObject } from "./internal"; @@ -335,6 +335,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): if (!isOpenAiOperatedResponsesDestination(provider)) { if (parsed._compactionRequest !== true && isPlainObject(outBody)) outBody = frameCompactionHistory(outBody); outBody = stripCanonicalOnlyTopLevelFields(outBody); + outBody = stripCanonicalOnlyInputItems(outBody); } outBody = promoteClientLoadedTools(outBody); } diff --git a/src/adapters/openai-responses/request-strips.ts b/src/adapters/openai-responses/request-strips.ts index b74b92ad7bd..25587bb6c5b 100644 --- a/src/adapters/openai-responses/request-strips.ts +++ b/src/adapters/openai-responses/request-strips.ts @@ -187,6 +187,38 @@ export function stripCanonicalOnlyTopLevelFields(body: unknown): unknown { return next; } +/** + * OpenAI-private input item TYPES, the item-level sibling of the two tables above. + * + * Each one is a positional control the OpenAI Responses backend interprets where it sits in + * history, never a turn of the conversation, so dropping it leaves every message, call and output + * in place. Codex gates sending one on its own `is_openai` provider check, which loopback + * injection always satisfies, so a gateway this proxy does not operate can still receive one and + * reject the whole body as an unknown item type. + * + * Same rule as the top-level table: only types a client is OBSERVED to send. + */ +const CANONICAL_ONLY_INPUT_ITEM_TYPES: ReadonlySet = new Set([ + // Codex 0.164 `ResponseItem::ConfigurationUpdate` ({type, reasoning: {effort}}): a durable + // reasoning-effort change recorded at its position in history. codex-rs sends it only for a + // catalog row with `supports_reasoning_effort_updates`, and keeps it in saved history after a + // model switch. Mapping the effort onto another provider is out of scope (#6895). + "configuration_update", +]); + +/** + * Remove OpenAI-private input items. The caller decides the boundary, exactly as for + * {@link stripCanonicalOnlyTopLevelFields}; the canonical and OpenAI-operated destinations keep + * the items verbatim and in order. Returns the input unchanged when none is present. + */ +export function stripCanonicalOnlyInputItems(body: unknown): unknown { + if (!isPlainObject(body) || !Array.isArray(body.input)) return body; + const isPrivate = (item: unknown): boolean => isPlainObject(item) + && typeof item.type === "string" && CANONICAL_ONLY_INPUT_ITEM_TYPES.has(item.type); + if (!body.input.some(isPrivate)) return body; + return { ...body, input: body.input.filter(item => !isPrivate(item)) }; +} + /** * Sampling fields a model on the provider's `noStopModels` / `noPenaltyModels` list rejects on * every wire. Claude inbound translates `stop_sequences` into a Responses `stop` diff --git a/src/adapters/openai-responses/transport.ts b/src/adapters/openai-responses/transport.ts index 4250e167b0e..424d9f021f6 100644 --- a/src/adapters/openai-responses/transport.ts +++ b/src/adapters/openai-responses/transport.ts @@ -3,6 +3,7 @@ import type { OcxProviderConfig } from "../../types"; import { CODEX_RESPONSES_LITE_HEADER } from "../../codex/forward-transport-headers"; import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers-destination"; import { normalizeForwardedClientHeaderName } from "../../lib/provider-client-headers"; +import { AGENT_ASSERTION_HEADER } from "../../lib/authorization-scheme"; import { openaiResponsesUrl } from "../openai-responses-url"; // Caller credentials are forwarded only to the canonical Codex destination, never by capability. @@ -69,6 +70,11 @@ export function buildResponsesTransport(provider: OcxProviderConfig, incoming: P if (override && mayForwardCallerCredentials) { headers.authorization = `Bearer ${override.accessToken}`; headers["chatgpt-account-id"] = override.chatgptAccountId; + } else if (mayForwardCallerCredentials) { + // dual_header runtime assertion: first-party Direct only, beside the caller's own Bearer. + // A pool override above never carries it, and non-canonical transports never see it. + const assertion = incoming.headers.get(AGENT_ASSERTION_HEADER); + if (assertion) headers[AGENT_ASSERTION_HEADER] = assertion; } } else { url = provider.responsesPath === undefined diff --git a/src/chat/inbound.ts b/src/chat/inbound.ts index 7286bb5c5c8..4e60b362e2b 100644 --- a/src/chat/inbound.ts +++ b/src/chat/inbound.ts @@ -6,6 +6,7 @@ * responsesRequestSchema so routing/OAuth/pool/sidecars are inherited unchanged. */ import { chatImageUrlFromPart } from "./image-parts"; +import { signedReasoningInputItems } from "./reasoning-details"; import { untranslatedChatInputMedia, untranslatedInputMediaMessage } from "../responses/input-media"; export class ChatCompletionsRequestError extends Error {} @@ -439,7 +440,16 @@ function resolveReasoningSummary(raw: Rec): string | undefined { * Translate an OpenAI Chat Completions request body into a /v1/responses request body. * Throws ChatCompletionsRequestError (-> 400) on malformed input. */ -export function chatCompletionsToResponsesBody(raw: unknown): Rec { +export interface ChatResponsesProjectionOptions { + /** + * The route is one first-party Anthropic destination that cannot change credential domain, so + * signed thinking a client returns in `reasoning_details` may be replayed to it (#7429). + * Every other route keeps the plaintext projection. + */ + acceptAnthropicReasoningSignatures?: boolean; +} + +export function chatCompletionsToResponsesBody(raw: unknown, options: ChatResponsesProjectionOptions = {}): Rec { assertChatCompletionsRoutingBody(raw); // Only the translated path reaches this function. Native Chat can retain its // provider-specific file/audio blocks; projecting them here would discard them. @@ -510,7 +520,13 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec { // is no attachment point on the message itself, and the parser buffers a // reasoning item and prepends it to the NEXT assistant message. Emitting it // here keeps that adjacency intact. - const reasoningText = assistantReasoningText(msg); + const signedReasoning = options.acceptAnthropicReasoningSignatures === true + ? signedReasoningInputItems(msg.reasoning_details) + : undefined; + const reasoningText = signedReasoning ? undefined : assistantReasoningText(msg); + // Signed entries carry their own text, so `reasoning_content` (the same text) is not + // projected a second time beside them. + if (signedReasoning) input.push(...signedReasoning); if (reasoningText !== undefined) { // `summary` is required on a reasoning input item by the OpenAI Responses API, and our // own responsesRequestSchema marks it optional, so a summary-less item validated locally @@ -525,6 +541,8 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec { content: [{ type: "reasoning_text", text: reasoningText }], }); } + // An Opus progress update also sits in `content`. The wire has no marker that tells it + // from answer text, so the content is replayed as sent, as it was before #7429. const blocks = assistantContentToBlocks(msg.content); if (blocks.length > 0) input.push({ type: "message", role: "assistant", content: blocks }); if (msg.tool_calls !== undefined) { diff --git a/src/chat/outbound.ts b/src/chat/outbound.ts index d5da058caa8..191efcc460e 100644 --- a/src/chat/outbound.ts +++ b/src/chat/outbound.ts @@ -23,6 +23,7 @@ import { } from "../lib/errors"; import { redactSecretString } from "../lib/redact"; import { createSseBlockBuffer, sseDataPayload } from "../server/sse-payload-rewrite"; +import { reasoningDetailEntriesForItem } from "./reasoning-details"; function isRec(v: unknown): v is Rec { return !!v && typeof v === "object" && !Array.isArray(v); @@ -149,7 +150,7 @@ export function chunkBase(id: string, model: string, created: number): Rec { }; } -function appendedUtf8Bytes(previous: string, previousBytes: number, fragment: string): number { +export function appendedUtf8Bytes(previous: string, previousBytes: number, fragment: string): number { let nextBytes = previousBytes + Buffer.byteLength(fragment); const previousLast = previous.charCodeAt(previous.length - 1); const fragmentFirst = fragment.charCodeAt(0); @@ -511,6 +512,17 @@ export function responsesSseToChatCompletionsSse( frame.choices = [{ index: 0, delta: { reasoning_content: text }, finish_reason: null }]; emit(frame); }; + // #7429: provider-issued thinking state, one complete frame per finished reasoning item. + let nextReasoningDetailIndex = 0; + const emitReasoningDetails = (item: Rec) => { + const entries = reasoningDetailEntriesForItem(item, nextReasoningDetailIndex, translatorBudget); + if (entries.length === 0) return; + nextReasoningDetailIndex += entries.length; + ensureRole(); + const frame = chunkBase(id, model, created); + frame.choices = [{ index: 0, delta: { reasoning_details: entries }, finish_reason: null }]; + emit(frame); + }; const resolveFinalArguments = (candidate: unknown, streamed: string) => { if (typeof candidate !== "string") return streamed; // Some compatible providers send an empty final snapshot. Do not let that erase @@ -717,6 +729,7 @@ export function responsesSseToChatCompletionsSse( && refusalItems.has(data.output_index)) { refusalItem(data.output_index, data, "item_id"); } + if (item.type === "reasoning") emitReasoningDetails(item); if (item.type === "function_call") { sawToolUse = true; const callId = typeof item.call_id === "string" ? item.call_id : ""; @@ -872,6 +885,7 @@ export function responsesJsonToChatCompletion(json: unknown, model: string, tran let contentBytes = 0; let reasoningBytes = 0; const toolCalls: Rec[] = []; + const reasoningDetails: Rec[] = []; const append = (previous: string, previousBytes: number, fragment: string): { text: string; bytes: number } => { if (!fragment) return { text: previous, bytes: previousBytes }; const scope = { kind: "retained_collectors" as const }; @@ -902,6 +916,11 @@ export function responsesJsonToChatCompletion(json: unknown, model: string, tran } } } else if (raw.type === "reasoning") { + const entries = reasoningDetailEntriesForItem(raw, reasoningDetails.length, translatorBudget); + if (entries.length > 0) { + translatorBudget?.chargeRetained(Buffer.byteLength(JSON.stringify(entries)), { kind: "retained_collectors" }); + reasoningDetails.push(...entries); + } if (Array.isArray(raw.summary)) { for (const part of raw.summary) { if (isRec(part) && part.type === "summary_text" && typeof part.text === "string") { @@ -948,6 +967,7 @@ export function responsesJsonToChatCompletion(json: unknown, model: string, tran refusal, }; if (reasoning) message.reasoning_content = reasoning; + if (reasoningDetails.length > 0) message.reasoning_details = reasoningDetails; if (toolCalls.length > 0) message.tool_calls = toolCalls; return { @@ -981,7 +1001,8 @@ export async function collectChatCompletion( let content = ""; let refusal: string | null = null; let reasoning = ""; - const retainedBytes = { content: 0, refusal: 0, reasoning: 0 }; + const reasoningDetails: Rec[] = []; + const retainedBytes = { content: 0, refusal: 0, reasoning: 0, reasoningDetails: 0 }; const toolCalls = new Map(); // Per-call budget scopes (2 MiB/call enforced by the budget): the map key is the // wire index, which is stable across deltas and present before the call id. @@ -1005,8 +1026,8 @@ export async function collectChatCompletion( } }; const releaseCollectors = () => { - translatorBudget.releaseRetained(retainedBytes.content + retainedBytes.refusal + retainedBytes.reasoning, - { kind: "retained_collectors" }); + translatorBudget.releaseRetained(retainedBytes.content + retainedBytes.refusal + retainedBytes.reasoning + + retainedBytes.reasoningDetails, { kind: "retained_collectors" }); }; try { // Share native relay framing, while retaining the collector's existing admission @@ -1055,6 +1076,16 @@ export async function collectChatCompletion( refusal = appendRetained("refusal", refusal ?? "", delta.refusal); } if (typeof delta.reasoning_content === "string") reasoning = appendRetained("reasoning", reasoning, delta.reasoning_content); + // Each entry arrives complete in one frame (#7429), so the fold is a concatenation. + if (Array.isArray(delta.reasoning_details)) { + for (const entry of delta.reasoning_details) { + if (!isRec(entry)) continue; + const entryBytes = Buffer.byteLength(JSON.stringify(entry)); + translatorBudget.chargeRetained(entryBytes, { kind: "retained_collectors" }); + retainedBytes.reasoningDetails += entryBytes; + reasoningDetails.push(entry); + } + } if (Array.isArray(delta.tool_calls)) { for (const tc of delta.tool_calls) { if (!isRec(tc)) continue; @@ -1121,6 +1152,7 @@ export async function collectChatCompletion( refusal, }; if (reasoning) message.reasoning_content = reasoning; + if (reasoningDetails.length > 0) message.reasoning_details = reasoningDetails; if (toolCalls.size > 0) { // Final owner transfer is itself a charging operation: if it overflows, // release every copy already charged in this loop and close every scope diff --git a/src/chat/reasoning-details.ts b/src/chat/reasoning-details.ts new file mode 100644 index 00000000000..51cabea129a --- /dev/null +++ b/src/chat/reasoning-details.ts @@ -0,0 +1,137 @@ +/** + * Anthropic thinking state on the Chat Completions wire, as OpenRouter-style `reasoning_details` + * (#7429). + * + * Chat has no slot for a thinking block's signature, so a Chat client echoing `reasoning_content` + * gave Claude back unsigned text, which the Anthropic adapter must drop: every tool step lost its + * reasoning. The carrier is the one OpenRouter documents for Claude and Hermes and pi already round + * trip: an ordered array on the assistant message, `{type: "reasoning.text", text, signature}` + * for a signed thinking block and `{type: "reasoning.encrypted", data}` for a redacted one. + * + * Outbound, the entries are built from the signature and redacted payloads the provider issued, + * never from anything this proxy computes. A signature that signs text the client never saw + * (hidden thinking) is withheld, because pairing it with the visible text would attest to content + * Anthropic did not sign. Inbound, the entries are read only for a single-destination first-party + * Anthropic route (the caller decides that), only when every entry is well formed, and become the + * same `ocxr1` envelope the Responses wire already uses; anything else falls back to plaintext. + */ +import { + decodeReasoningEnvelope, + encodeReasoningEnvelope, + isLikelyRealAnthropicThinkingSignature, + type ReasoningEnvelope, +} from "../responses/reasoning-envelope"; +import type { TranslatorBudget } from "../lib/translator-budget"; + +type Rec = Record; + +function isRec(value: unknown): value is Rec { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** OpenRouter's `format` for Anthropic thinking entries. */ +export const ANTHROPIC_REASONING_DETAIL_FORMAT = "anthropic-claude-v1"; + +/** One closed reasoning item, as either outbound path sees it. */ +export interface ReasoningDetailSource { + /** The thinking text the client was shown for this item. */ + text: string; + signature?: string; + redacted?: readonly string[]; + /** The signature covers thinking text the client was not shown. */ + hidden?: boolean; +} + +/** + * The `reasoning_details` entries for one reasoning item, numbered from `firstIndex`. Redacted + * blocks come first: that is the order the adapter replays them in. Empty when the item carries no + * provider-issued state a Chat client can return. + */ +export function reasoningDetailEntries(source: ReasoningDetailSource, firstIndex: number): Rec[] { + const entries: Rec[] = []; + for (const data of source.redacted ?? []) { + if (typeof data !== "string" || data.length === 0) continue; + entries.push({ + type: "reasoning.encrypted", + data, + format: ANTHROPIC_REASONING_DETAIL_FORMAT, + index: firstIndex + entries.length, + }); + } + if (source.hidden !== true && isLikelyRealAnthropicThinkingSignature(source.signature)) { + entries.push({ + type: "reasoning.text", + text: source.text, + signature: source.signature, + format: ANTHROPIC_REASONING_DETAIL_FORMAT, + index: firstIndex + entries.length, + }); + } + return entries; +} + +/** The visible text of a Responses reasoning item, read the way the parser reads it. */ +export function responsesReasoningItemText(item: Rec): string { + const pick = (parts: unknown, type: string): string => Array.isArray(parts) + ? parts.filter((part): part is Rec => isRec(part) && part.type === type && typeof part.text === "string") + .map(part => part.text as string).join("") + : ""; + return pick(item.summary, "summary_text") || pick(item.content, "reasoning_text"); +} + +/** + * The entries for a finished Responses reasoning item from the internal bridge. Its `ocxr1` + * envelope holds what the provider issued. `txt` alone means the signed text was hidden; `txt` + * with `upd` is an Opus progress update, whose signed text the client received as `content`. + */ +export function reasoningDetailEntriesForItem(item: Rec, firstIndex: number, budget?: TranslatorBudget): Rec[] { + if (typeof item.encrypted_content !== "string") return []; + const envelope = decodeReasoningEnvelope(item.encrypted_content, budget); + if (!envelope || (!envelope.sig && !envelope.red)) return []; + const progress = envelope.upd !== undefined && typeof envelope.txt === "string"; + return reasoningDetailEntries({ + text: progress ? envelope.txt! : responsesReasoningItemText(item), + ...(envelope.sig ? { signature: envelope.sig } : {}), + ...(envelope.red ? { redacted: envelope.red } : {}), + hidden: !progress && envelope.txt !== undefined, + }, firstIndex); +} + +function reasoningInputItem(text: string, envelope: ReasoningEnvelope): Rec { + return { + type: "reasoning", + summary: text ? [{ type: "summary_text", text }] : [], + ...(text ? { content: [{ type: "reasoning_text", text }] } : {}), + encrypted_content: encodeReasoningEnvelope(envelope), + }; +} + +/** + * Responses reasoning input items carrying the signatures a client returned in + * `reasoning_details`, or `undefined` when the array is absent, empty or holds anything this + * carrier did not issue — an unsigned or malformed signature, a summary entry, another provider's + * encrypted format. Undefined means the caller keeps its plaintext projection unchanged. + */ +export function signedReasoningInputItems(details: unknown): Rec[] | undefined { + if (!Array.isArray(details) || details.length === 0) return undefined; + const items: Rec[] = []; + let redacted: string[] = []; + for (const raw of details) { + if (!isRec(raw)) return undefined; + if (raw.type === "reasoning.encrypted") { + if (raw.format !== ANTHROPIC_REASONING_DETAIL_FORMAT || typeof raw.data !== "string" || raw.data.length === 0) { + return undefined; + } + redacted.push(raw.data); + continue; + } + if (raw.type !== "reasoning.text") return undefined; + if (raw.format !== undefined && raw.format !== ANTHROPIC_REASONING_DETAIL_FORMAT) return undefined; + const signature = typeof raw.signature === "string" ? raw.signature : undefined; + if (typeof raw.text !== "string" || !isLikelyRealAnthropicThinkingSignature(signature)) return undefined; + items.push(reasoningInputItem(raw.text, { sig: signature, ...(redacted.length > 0 ? { red: redacted } : {}) })); + redacted = []; + } + if (redacted.length > 0) items.push(reasoningInputItem("", { red: redacted })); + return items; +} diff --git a/src/cli/capabilities-agents-routing.ts b/src/cli/capabilities-agents-routing.ts index 6b46c44eab7..f9d8d0b5e96 100644 --- a/src/cli/capabilities-agents-routing.ts +++ b/src/cli/capabilities-agents-routing.ts @@ -203,6 +203,56 @@ export const AGENT_ROUTING_CAPABILITIES: readonly Capability[] = [ json: "payload", details: ["Without --list at least one setting is required. Stored settings and Codex application outcomes remain distinct.","Stream-routed-output belongs only to web. Timeout changes preserve siblings; native apply and saved state are distinct."], }, + { + command: ["fallback"], + usage: "ocx fallback [status] [--json]", + summary: "Show which model and provider fallbacks are allowed, per-device local fallback, and the last fallback taken or refused.", + routes: [{"method": "GET", "path": "/api/fallback"}], + flags: [{"name": "--json", "value": "boolean", "summary": "Emit the ocx.fallback/1 state."}], + mutates: false, + json: "payload", + details: ["Reads the live proxy. On a Remote Link Child it reads the Home's state over the link (aliases omitted). The last event is in memory and resets on restart."], + }, + { + command: ["fallback", "off"], + usage: "ocx fallback off [--kind model|provider|all] [--provider | --route | --device ] [--json]", + summary: "Refuse model or provider fallback globally or for one provider, route or device.", + routes: [{"method": "PUT", "path": "/api/fallback"}], + flags: [{"name": "--kind", "value": "string", "summary": "model, provider or all (default)."}, {"name": "--provider", "value": "string", "summary": "Scope to the provider a request started on."}, {"name": "--route", "value": "string", "summary": "Scope to an exact selector such as combo/ or policy/."}, {"name": "--device", "value": "string", "summary": "Scope to a Remote Link id or alias, or a Hub data key id; device off always refuses."}, {"name": "--json", "value": "boolean", "summary": "Emit the resulting state."}], + mutates: true, + json: "payload", + details: ["A refused hop answers fallback_refused with the triggering failure's status. Rotation among accounts or keys of one provider pool is not a fallback and stays on."], + }, + { + command: ["fallback", "on"], + usage: "ocx fallback on [--kind model|provider|all] [--provider | --route | --device ] [--json]", + summary: "Allow model or provider fallback at one scope, overriding a broader off.", + routes: [{"method": "PUT", "path": "/api/fallback"}], + flags: [{"name": "--kind", "value": "string", "summary": "model, provider or all (default)."}, {"name": "--provider", "value": "string", "summary": "Scope to a provider."}, {"name": "--route", "value": "string", "summary": "Scope to an exact selector."}, {"name": "--device", "value": "string", "summary": "Scope to a device."}, {"name": "--json", "value": "boolean", "summary": "Emit the resulting state."}], + mutates: true, + json: "payload", + details: ["Route beats provider beats global. A device setting can only refuse: on for a device records the value but never reopens a hop another scope refuses."], + }, + { + command: ["fallback", "clear"], + usage: "ocx fallback clear [--kind model|provider|all] [--provider | --route | --device ] [--json]", + summary: "Remove a fallback override so the scope inherits again.", + routes: [{"method": "PUT", "path": "/api/fallback"}], + flags: [{"name": "--kind", "value": "string", "summary": "model, provider or all (default)."}, {"name": "--provider", "value": "string", "summary": "Scope to a provider."}, {"name": "--route", "value": "string", "summary": "Scope to an exact selector."}, {"name": "--device", "value": "string", "summary": "Scope to a device."}, {"name": "--json", "value": "boolean", "summary": "Emit the resulting state."}], + mutates: true, + json: "payload", + details: ["Clearing every override restores today's behavior: every fallback allowed."], + }, + { + command: ["fallback", "local"], + usage: "ocx fallback local --device [--json]", + summary: "Record whether a device may use its own providers while the Home is unreachable.", + routes: [{"method": "PUT", "path": "/api/fallback"}], + flags: [{"name": "--device", "value": "string", "summary": "Remote Link id or alias, or Hub data key id."}, {"name": "--json", "value": "boolean", "summary": "Emit the resulting state."}], + mutates: true, + json: "payload", + details: ["Run on the Home. A permission GOAT reads; OpenCodex itself keeps answering home_unreachable on the device and never serves it from local providers."], + }, { command: ["effort"], usage: "ocx effort [status|low|medium|high|xhigh|max|ultra|->] [--json]", diff --git a/src/cli/capabilities-base.ts b/src/cli/capabilities-base.ts index 1563cab2c48..3bda7c1e295 100644 --- a/src/cli/capabilities-base.ts +++ b/src/cli/capabilities-base.ts @@ -86,6 +86,16 @@ export const CAPABILITIES: readonly Capability[] = [ usage: "ocx link reconnect --link-id [--json]", details: ["Requires a valid link ID and loopback admin authority. Stops the current ssh child, clears any backoff or failed state and starts a fresh attempt; read ocx link status for the outcome.","A Child-initiated link is refused with link_not_home_initiated: its own computer owns and retries that tunnel.","Always emits {linkId} JSON."], }, + { + command: ["link","cleanup"], + summary: "Find Windows Remote Link Children and explain how to move them to Hub mode.", + routes: [], + flags: [{"name":"--link-id","value":"string","summary":"Check only this link."},{"name":"--json","value":"boolean","summary":"Emit {links, changed:false} as JSON."}], + mutates: false, + json: "payload", + usage: "ocx link cleanup [--link-id ] [--json]", + details: ["Runs on the Home from its local link store. Asks each Home-initiated Child for uname -s over the link's pinned SSH trust; a Windows answer (Git Bash, MSYS, Cygwin, cmd or PowerShell) is reported with recovery steps.","Never revokes, disconnects or edits a link. A Windows Child needs ocx disconnect on itself, then ocx link revoke --force --yes on the Home, then ocx hub invite and ocx connect."], + }, { command: ["remote-workspace","pair"], summary: "Enroll this executor with one Hub using a one-time code from stdin and locally approved roots.", @@ -156,7 +166,7 @@ export const CAPABILITIES: readonly Capability[] = [ flags: [{ name: "--json", value: "boolean", summary: "Emit the status envelope as JSON." }], mutates: false, json: "envelope", - details: ["Reads /healthz plus local config; drives no management API route."], + details: ["Reads /healthz plus local config; drives no management API route.", "--json includes install: {method: bun|npm|pnpm|mise|source|standalone|unknown, evidence: {basis, mise?, error?}} from the updater's install detector."], }, { command: ["resolve"], @@ -225,7 +235,10 @@ export const CAPABILITIES: readonly Capability[] = [ flags: [{"name":"--json","value":"boolean","summary":"Emit the provider list as JSON."},{"name":"--jsonl","value":"boolean","summary":"Emit one configured provider per JSON line."}], mutates: false, json: "envelope", - details: ["Local config and registry read, not GET /api/providers. --json and --jsonl are mutually exclusive."], + details: [ + "Local config and registry read, not GET /api/providers. --json and --jsonl are mutually exclusive.", + "Each configured row carries active: false while this device is linked to a Home (a Remote Link Child or a Hub-mode client), whose providers serve instead; true when the row serves. Credential values are never printed.", + ], usage: "ocx provider list [--json|--jsonl]", }, { diff --git a/src/cli/capabilities-observe-system.ts b/src/cli/capabilities-observe-system.ts index 51ee74b3944..7f9b627fc84 100644 --- a/src/cli/capabilities-observe-system.ts +++ b/src/cli/capabilities-observe-system.ts @@ -621,11 +621,11 @@ export const OBSERVE_SYSTEM_CAPABILITIES: readonly Capability[] = [ }, { command: ["service", "status"], summary: "Inspect local service-manager status.", - usage: "ocx service status", + usage: "ocx service status [--json]", routes: [], - flags: [], - mutates: false, json: "none", - details: ["Local OS service manager: macOS launchd, Linux systemd, Windows Task Scheduler or WinSW. No management API or JSON mode.", "Backend options apply only to install; other verbs use the installed backend."], + flags: [{"name": "--json", "value": "boolean", "summary": "Emit the passive service status document as JSON."}], + mutates: false, json: "payload", + details: ["Local OS service manager: macOS launchd, Linux systemd, Windows Task Scheduler or WinSW. No management API.", "--json is passive: schemaVersion 1 with installed, running, enabled, viable, stale, conflict, backend (launchd|systemd|schtasks|winsw|none), label, serving {probed, ok, port}, summary, logPath, ownershipLease and desktopLogin. It starts, stops, installs and repairs nothing.", "Backend options apply only to install; other verbs use the installed backend."], }, { command: ["service", "uninstall"], summary: "Uninstall the local service.", diff --git a/src/cli/codex-shim-autorestore.ts b/src/cli/codex-shim-autorestore.ts index 1030fbee473..2d0986960de 100644 --- a/src/cli/codex-shim-autorestore.ts +++ b/src/cli/codex-shim-autorestore.ts @@ -26,6 +26,11 @@ export function skipsCodexShimAutoRestore(command: string | undefined, args: str // `resolve` is read-only inspection for embedding shells: a lookup made to populate // a consent surface must not trigger a shim repair side effect first. if (command === "resolve") return true; + // `service status --json` is a passive inventory read for peers such as GOAT: a shim repair + // before dispatch would change the installation it was asked only to describe. + // Flags may precede the verb (`ocx service --json status`), exactly as parseServiceArgs reads it. + if (command === "service" && args.includes("--json") + && args.slice(1).find(arg => !arg.startsWith("--")) === "status") return true; // The desktop's approval-bound stop must validate its snapshot before any unrelated // CLI repair changes the installation it was approved against. if (command === "stop" && args.some(arg => arg.startsWith("--expect-"))) return true; diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 2870660ee7a..fe9324c1fb1 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -437,6 +437,32 @@ const commandRunners: Record = { } } await handleConnectedSyncCatalogWrite(result, restartScope); + if (result.applyRecordFailed) { + console.error("The Home's Codex settings were applied, but the apply record could not be written; the client runtime was not restarted. Run 'ocx sync' again."); + process.exitCode = 1; + } + // OCX-6: a link Child's runtime must have started after the apply it serves under. + if (link && result.appliedRecord) { + const { applyRestartMessage, restartRuntimeAfterApply } = await import("./link-apply-restart"); + const { sameClientConnectionOwner } = await import("../client/state"); + const applied = clientState.value; + const outcome = await restartRuntimeAfterApply(result.appliedRecord, { + restart: startWhenStopped => deps.handleProxyRestart(startWhenStopped), + recoveryStart: recovering => deps.handleRestartStartWhenStopped(recovering), + connectionUnchanged: () => { + const current = readClientConnectionState(); + return current.kind === "connected" && sameClientConnectionOwner(current.value, applied) + && current.value.link?.linkId === applied.link?.linkId; + }, + }); + const message = applyRestartMessage(outcome); + if (outcome.kind === "failed" || outcome.kind === "connection-changed") { + console.error(message); + process.exitCode = 1; + } else { + console.log(message); + } + } // `process.exitCode` rather than a literal 0, for the same reason every other // runner does it (tests/cli/cli-transport-honesty.test.ts): the catalog-write helper // drives app-server restarts, and one of those recording a failure must not be @@ -876,6 +902,10 @@ const commandRunners: Record = { const { handleEffortCommand } = await import("./effort"); return await handleEffortCommand(deps.args.slice(1), { findLiveProxy: deps.findLiveProxy }); }, + fallback: async deps => { + const { handleFallbackCommand } = await import("./fallback"); + return await handleFallbackCommand(deps.args.slice(1), { findLiveProxy: deps.findLiveProxy }); + }, agent: async deps => { const { handleAgentCommand } = await import("./agent"); return await handleAgentCommand(deps.args.slice(1)); diff --git a/src/cli/fallback.ts b/src/cli/fallback.ts new file mode 100644 index 00000000000..9bd31f6e2a6 --- /dev/null +++ b/src/cli/fallback.ts @@ -0,0 +1,127 @@ +import { + CliUsageError, + printData, + rejectArgs, + runCliAction, + runtimeRequest, + takeFlag, + takeOption, + terminalSafeText, + type RuntimeApiDeps, +} from "./runtime-api"; + +export const FALLBACK_USAGE = `Usage: + ocx fallback status [--json] + ocx fallback off [--kind model|provider|all] [--provider | --route | --device ] [--json] + ocx fallback on [--kind model|provider|all] [--provider | --route | --device ] [--json] + ocx fallback clear [--kind model|provider|all] [--provider | --route | --device ] [--json] + ocx fallback local --device [--json]`; + +type Switches = { model?: boolean; provider?: boolean }; +interface DeviceRow { id?: string; alias?: string; localFallback?: boolean; model?: boolean | null; provider?: boolean | null } +interface EventRow { at?: string; site?: string; route?: string; kind?: string; from?: string; to?: string; trigger?: string; outcome?: string; scope?: string } +interface FallbackState { + global?: { model?: boolean; provider?: boolean }; + providers?: Record; + routes?: Record; + devices?: DeviceRow[]; + last?: EventRow | null; +} + +const onOff = (value: boolean | null | undefined): string => value === false ? "off" : value === true ? "on" : "inherit"; + +function switchesLine(label: string, value: Switches): string { + const parts: string[] = []; + if (value.model !== undefined) parts.push("model " + onOff(value.model)); + if (value.provider !== undefined) parts.push("provider " + onOff(value.provider)); + return terminalSafeText(label) + ": " + parts.join(", "); +} + +export function fallbackStatusLines(state: FallbackState): string[] { + const lines = [ + "model fallback: " + (state.global?.model === false ? "off" : "on") + " (global)", + "provider fallback: " + (state.global?.provider === false ? "off" : "on") + " (global)", + ]; + for (const [name, value] of Object.entries(state.providers ?? {})) lines.push(switchesLine("provider " + name, value)); + for (const [name, value] of Object.entries(state.routes ?? {})) lines.push(switchesLine("route " + name, value)); + for (const device of state.devices ?? []) { + const label = "device " + (device.id ?? "?") + (device.alias ? " (" + device.alias + ")" : ""); + lines.push(terminalSafeText(label) + ": local fallback " + (device.localFallback ? "allowed" : "off") + + ", model " + onOff(device.model) + ", provider " + onOff(device.provider)); + } + const last = state.last; + lines.push(last + ? terminalSafeText("last: " + last.at + " " + last.outcome + " " + last.kind + " fallback on " + last.route + ": " + + last.from + " -> " + last.to + " (" + last.trigger + ", " + last.scope + ")") + : "last: no fallback since this proxy started"); + return lines; +} + +function takeScope(args: string[]): { scope: "global" | "provider" | "route" | "device"; key?: string } { + const provider = takeOption(args, "--provider"); + const route = takeOption(args, "--route"); + const device = takeOption(args, "--device"); + const given = [provider, route, device].filter(value => value !== undefined); + if (given.length > 1) throw new CliUsageError("use one of --provider, --route or --device", FALLBACK_USAGE); + if (provider !== undefined) return { scope: "provider", key: provider }; + if (route !== undefined) return { scope: "route", key: route }; + if (device !== undefined) return { scope: "device", key: device }; + return { scope: "global" }; +} + +function takeKinds(args: string[]): Array<"model" | "provider"> { + const kind = takeOption(args, "--kind") ?? "all"; + if (kind === "all") return ["model", "provider"]; + if (kind === "model" || kind === "provider") return [kind]; + throw new CliUsageError("--kind must be model, provider or all", FALLBACK_USAGE); +} + +async function put(body: Record, deps: RuntimeApiDeps): Promise { + return await runtimeRequest("/api/fallback", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, deps); +} + +async function status(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + rejectArgs(args, FALLBACK_USAGE); + const state = await runtimeRequest("/api/fallback", {}, deps); + printData(state, wantsJson, fallbackStatusLines(state)); +} + +async function setSwitch(argv: string[], value: boolean | null, deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + const kinds = takeKinds(args); + const scope = takeScope(args); + rejectArgs(args, FALLBACK_USAGE); + const state = await put({ writes: kinds.map(setting => ({ ...scope, setting, value })) }, deps); + printData(state, wantsJson, fallbackStatusLines(state)); +} + +async function local(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + const mode = args.shift(); + if (mode !== "on" && mode !== "off") throw new CliUsageError("fallback local needs on or off", FALLBACK_USAGE); + const device = takeOption(args, "--device"); + if (!device) throw new CliUsageError("fallback local needs --device ", FALLBACK_USAGE); + rejectArgs(args, FALLBACK_USAGE); + const state = await put({ scope: "device", key: device, setting: "localFallback", value: mode === "on" }, deps); + printData(state, wantsJson, fallbackStatusLines(state)); +} + +export async function handleFallbackCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { + return runCliAction(async () => { + const [sub, ...rest] = argv; + if (sub === undefined || sub === "status") await status(rest, deps); + else if (sub === "off") await setSwitch(rest, false, deps); + else if (sub === "on") await setSwitch(rest, true, deps); + else if (sub === "clear") await setSwitch(rest, null, deps); + else if (sub === "local") await local(rest, deps); + else throw new CliUsageError("unknown fallback command: " + sub, FALLBACK_USAGE); + }); +} diff --git a/src/cli/help.ts b/src/cli/help.ts index 779526d2496..5f5400088cf 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -81,6 +81,7 @@ Usage: ocx agent Subagents, injection, effort caps, and sidecars ocx message Codex queued messages, explicit SSH peers and foreground ownership ocx effort [sub] Inspect and configure reasoning effort caps and defaults + ocx fallback Turn model or provider fallback off per scope; per-device local fallback ocx observe Logs, usage, storage, memory, and debug data ocx inspect Effective config, catalog, analytics, pacing, client-config ocx route Routing features (combo, policy) diff --git a/src/cli/index.ts b/src/cli/index.ts index ebff2d17c25..4a83719a2cc 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -167,7 +167,7 @@ import { startClientRuntimeUnderOwnershipLease } from "./client-start-fence"; import { recoverStartStateUnderOwnershipLease } from "./start-owner-fence"; import { handledSignalExitCode } from "../lib/handled-signal-exit"; import { createLocalAttestationSecret } from "../lib/local-management-attestation"; -import { MEMORY_DRAIN_RESTART_MS, REPLACEMENT_READY_TIMEOUT_MS } from "../lib/system-restart-contract"; +import { MEMORY_DRAIN_RESTART_MS, REPLACEMENT_READY_TIMEOUT_MS, RESTART_REOBSERVE_RESERVE_MS } from "../lib/system-restart-contract"; import { startServiceLogging } from "../service/logging"; /** @@ -941,7 +941,6 @@ async function handleTrayProxyStart(existingIsSuccess = true): Promise const PROXY_RESTART_OBSERVE_MS = MEMORY_DRAIN_RESTART_MS + REPLACEMENT_READY_TIMEOUT_MS + 15_000; /** Reserve confirmation time within the shared restart deadline. */ -const RESTART_REOBSERVE_RESERVE_MS = 10_000; async function handleProxyRestart( startWhenStopped: (recoveringLiveRestart: boolean) => Promise, ): Promise { diff --git a/src/cli/link-apply-restart.ts b/src/cli/link-apply-restart.ts new file mode 100644 index 00000000000..7ef415f5585 --- /dev/null +++ b/src/cli/link-apply-restart.ts @@ -0,0 +1,72 @@ +/** + * After `ocx sync` applies the Home's Codex settings on a Remote Link Child, restart the Child's + * runtime so it started after the apply (OCX-6, P5). + * + * Idempotent: the record keeps its apply time while the settings digest is unchanged, so a runtime + * that already started after it is left alone, and a sync after a failed restart retries it. The + * restart is `ocx restart`'s own (`handleProxyRestart`); this module only decides whether to call + * it and never starts a runtime that was not running, except to recover one an accepted restart + * left absent. + */ +import type { HomeCodexSettingsRecord } from "../client/link-home"; +import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness"; +import type { ProxyRestartStartOutcome } from "./tray-proxy"; + +export type ApplyRestartOutcome = + | { kind: "current" } + | { kind: "not-running" } + | { kind: "connection-changed" } + | { kind: "restarted" } + | { kind: "failed" }; + +export interface ApplyRestartDeps { + findLive?: () => Promise; + /** Epoch ms the live client runtime reports as its start, or null when it reports none. */ + readStartedAt?: (live: LiveProxy) => Promise; + restart: (startWhenStopped: (recoveringLiveRestart: boolean) => Promise) => Promise; + recoveryStart: (recoveringLiveRestart: boolean) => Promise; + /** Still the connection this sync applied for (rechecked right before the restart). */ + connectionUnchanged: () => boolean; +} + +export async function readClientRuntimeStartedAt(live: LiveProxy, fetchImpl: typeof fetch = fetch): Promise { + try { + const response = await fetchImpl(`http://${probeHostname(live.hostname)}:${live.port}/healthz`, { + headers: { accept: "application/json" }, + redirect: "manual", + signal: AbortSignal.timeout(2_000), + }); + if (!response.ok) return null; + const body = await response.json() as { pid?: unknown; startedAt?: unknown } | null; + if (body?.pid !== live.pid || typeof body.startedAt !== "string") return null; + const startedAt = Date.parse(body.startedAt); + return Number.isFinite(startedAt) ? startedAt : null; + } catch { + return null; + } +} + +export async function restartRuntimeAfterApply(record: HomeCodexSettingsRecord, deps: ApplyRestartDeps): Promise { + const live = await (deps.findLive ?? (() => findLiveProxy()))(); + if (!live || live.role !== "client") return { kind: "not-running" }; + const appliedAt = Date.parse(record.appliedAt); + // A runtime that reports no start time predates OCX-6 and cannot prove it is current. + const startedAt = await (deps.readStartedAt ?? (target => readClientRuntimeStartedAt(target)))(live); + if (startedAt !== null && Number.isFinite(appliedAt) && startedAt >= appliedAt) return { kind: "current" }; + if (!deps.connectionUnchanged()) return { kind: "connection-changed" }; + const ok = await deps.restart(async recovering => recovering + ? deps.recoveryStart(true) + : { status: "skipped" }); + return ok ? { kind: "restarted" } : { kind: "failed" }; +} + +/** One line for each outcome, printed by `ocx sync`. */ +export function applyRestartMessage(outcome: ApplyRestartOutcome): string { + switch (outcome.kind) { + case "current": return "Client runtime already started after this apply; no restart needed."; + case "not-running": return "No client runtime is running; the applied settings take effect when it starts."; + case "connection-changed": return "The connection changed during sync; the client runtime was not restarted. Run 'ocx sync' again."; + case "restarted": return "Client runtime restarted after applying the Home's settings."; + case "failed": return "The client runtime restart did not complete; the previous runtime keeps serving. If a background service or the desktop app owns it, restart it there; otherwise retry with 'ocx restart' or 'ocx sync'."; + } +} diff --git a/src/cli/link-child.ts b/src/cli/link-child.ts index 5f7d19603bc..bdb53c267f2 100644 --- a/src/cli/link-child.ts +++ b/src/cli/link-child.ts @@ -74,6 +74,7 @@ export async function readLinkHomeJson( context: LinkChildContext, path: string, deps: LinkHomeDeps = {}, + init: { signal?: AbortSignal } = {}, ): Promise<{ status: number; body: unknown }> { const token = readServiceApiTokenState(); if (token.kind !== "present" || token.fingerprint !== context.connection.tokenFingerprint) { @@ -81,12 +82,15 @@ export async function readLinkHomeJson( } let response: Response; try { - response = await fetchLinkHomeManagement(context.connection, token.token, path.startsWith("/") ? path : `/${path}`, {}, deps); + response = await fetchLinkHomeManagement(context.connection, token.token, path.startsWith("/") ? path : `/${path}`, + init.signal ? { signal: init.signal } : {}, deps); } catch (error) { const detail = error instanceof Error && error.name === "LinkRelayAuthenticationError" ? error.message : "the link tunnel did not answer"; throw new Error(`Could not read ${context.homePhrase} over Remote Link (${context.linkId}): ${detail}.`); } - const result = await readBoundedResponseBytes(response, { maxBytes: RESPONSE_MAX_BYTES, signal: AbortSignal.timeout(30_000) }); + // A caller's deadline (the status report's 2 s) bounds the body read as well as the request. + const readSignal = init.signal ? AbortSignal.any([init.signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000); + const result = await readBoundedResponseBytes(response, { maxBytes: RESPONSE_MAX_BYTES, signal: readSignal }); if (result.oversized) throw new Error(`${context.homePhrase} returned a response larger than the CLI accepts.`); const text = new TextDecoder().decode(result.bytes); let body: unknown = null; diff --git a/src/cli/link-cleanup.ts b/src/cli/link-cleanup.ts new file mode 100644 index 00000000000..6d1d772581e --- /dev/null +++ b/src/cli/link-cleanup.ts @@ -0,0 +1,95 @@ +/** + * `ocx link cleanup`: find Home-initiated Remote Link Children that run Windows and explain how to + * move each one to Hub mode. It never revokes or changes anything. + * + * Windows Children are refused at enrollment, but a link enrolled before that refusal existed keeps + * its record, key and reverse tunnel. Its record carries no platform, so this command asks the + * Child over the same pinned SSH trust the tunnel uses (`uname -s` through `sh`) and classifies the + * answer with `detectRemotePlatform` (a localized cmd or PowerShell refusal gets the fixed %OS% probe). + * + * The recovery order matters. An ordinary `ocx link revoke` runs `ocx disconnect` on the Child + * through the POSIX ocx prelude, which exits 125 on Windows, so for these Children the revoke must + * be forced after the Child disconnected itself. + */ +import { linkKnownHostsPath, linkStorePath } from "../link/paths"; +import { detectRemotePlatform, REMOTE_UNAME_ARGV, type RemotePlatform } from "../link/remote-platform"; +import { buildExecArgv } from "../link/ssh-argv"; +import { createSshRunner, sshFailureHint, sshRunnerErrorHint, type SshRunner } from "../link/ssh-runner"; +import { readLinkStore, type LinkStore } from "../link/store"; +import { CliUsageError, printData, rejectArgs, takeJsonFlag, takeOption } from "./runtime-api"; + +const LINK_ID = /^lnk_[0-9a-f]{16}$/; +const CHECK_TIMEOUT_MS = 30_000; + +export interface LinkCleanupDeps { + readStore?: () => LinkStore; + sshRunner?: SshRunner; + knownHostsFile?: string; +} + +export interface LinkCleanupEntry { + linkId: string; + alias: string; + direction: LinkStore["links"][number]["direction"]; + platform: RemotePlatform | "not_checked"; + reason: string | null; + recovery: string[]; +} + +export function windowsChildRecovery(linkId: string, alias: string): string[] { + return [ + `On ${alias}, run 'ocx disconnect' and then 'ocx restart' so it stops using this link.`, + `On this Home, run 'ocx link revoke --link-id ${linkId} --force --yes'. A plain revoke cannot disconnect a Windows Child remotely.`, + `Then connect ${alias} in Hub mode: run 'ocx hub invite' on a Hub and the printed 'ocx connect' command on ${alias}.`, + ]; +} + +async function checkPlatform(runner: SshRunner, alias: string, knownHostsFile: string): Promise<{ platform: RemotePlatform; reason: string | null }> { + try { + const result = await runner.run(buildExecArgv({ alias, argv: REMOTE_UNAME_ARGV, knownHostsFile }), { timeoutMs: CHECK_TIMEOUT_MS }); + const platform = await detectRemotePlatform(runner, alias, knownHostsFile, result); + return { platform, reason: platform === "unknown" ? (sshFailureHint(result.stderr) ?? `ssh exited ${result.code}`) : null }; + } catch (error) { + return { platform: "unknown", reason: sshRunnerErrorHint(error) ?? "ssh could not run" }; + } +} + +function humanLines(entries: LinkCleanupEntry[]): string[] { + if (entries.length === 0) return ["No links are recorded on this Home.", "Nothing was revoked or changed."]; + const lines: string[] = []; + for (const entry of entries) { + const label = `${entry.linkId} (${entry.alias})`; + if (entry.platform === "windows") { + lines.push(`${label}: Windows Child. Remote Link does not support Windows Children; this link predates that refusal.`); + for (const [index, step] of entry.recovery.entries()) lines.push(` ${index + 1}. ${step}`); + } else if (entry.platform === "posix") lines.push(`${label}: macOS or Linux Child; nothing to clean up.`); + else if (entry.platform === "not_checked") lines.push(`${label}: Child-initiated; this Home has no SSH route to check it.`); + else lines.push(`${label}: platform could not be checked (${entry.reason ?? "unknown"}); retry when it is reachable.`); + } + lines.push("Nothing was revoked or changed."); + return lines; +} + +export async function runLinkCleanup(args: string[], deps: LinkCleanupDeps, usage: string): Promise { + const wantsJson = takeJsonFlag(args); + const linkId = takeOption(args, "--link-id"); + if (linkId !== undefined && !LINK_ID.test(linkId)) throw new CliUsageError("cleanup --link-id must be a valid link id", usage); + rejectArgs(args, usage); + const store = (deps.readStore ?? (() => readLinkStore(linkStorePath())))(); + const records = linkId === undefined ? store.links : store.links.filter(link => link.id === linkId); + if (linkId !== undefined && records.length === 0) throw new CliUsageError(`No link ${linkId} is recorded on this Home.`, usage); + const runner = deps.sshRunner ?? createSshRunner(); + const knownHostsFile = deps.knownHostsFile ?? linkKnownHostsPath(); + const entries: LinkCleanupEntry[] = []; + // One at a time: each check is a bounded SSH command, and order keeps the report stable. + for (const record of records) { + const checked = record.direction === "hub-initiated" + ? await checkPlatform(runner, record.alias, knownHostsFile) + : { platform: "not_checked" as const, reason: null }; + entries.push({ + linkId: record.id, alias: record.alias, direction: record.direction, ...checked, + recovery: checked.platform === "windows" ? windowsChildRecovery(record.id, record.alias) : [], + }); + } + printData({ links: entries, changed: false }, wantsJson, humanLines(entries)); +} diff --git a/src/cli/link-enrollment.ts b/src/cli/link-enrollment.ts index 6563fedf343..e00d4b02ad4 100644 --- a/src/cli/link-enrollment.ts +++ b/src/cli/link-enrollment.ts @@ -1,5 +1,6 @@ import { createInterface } from "node:readline/promises"; import { assertSshAlias } from "../link/ssh-argv"; +import { windowsChildRefusal } from "../link/remote-platform"; import { CliUsageError, RuntimeApiError, runtimeBaseUrl, rejectArgs, takeJsonFlag, takeOption, printData } from "./runtime-api"; import type { LinkCliDeps } from "./link"; @@ -83,6 +84,9 @@ export async function runLinkEnrollment(command: "probe" | "add", args: string[] catch (error) { if (error instanceof RuntimeApiError && record(error.body)) { const code = record(error.body.error) ? error.body.error.code : undefined; + // Refused at probe or confirm, before any key or tunnel: point at Hub mode, not at a phase. + // A residual response comes after key issuance; it keeps its Child recovery below. + if (code === "remote_platform_unsupported" && error.body.residual !== true) throw new Error(windowsChildRefusal(alias)); const phase = typeof code === "string" && Object.hasOwn(PHASES, code) ? PHASES[code] : undefined; const message = phase ? "Remote Link failed during " + phase : error.message; if (phase || error.body.residual === true) throw new Error(message + (error.body.residual === true ? ". " + RECOVERY : "")); diff --git a/src/cli/link.ts b/src/cli/link.ts index 79ce83f88fd..9d7d3b98044 100644 --- a/src/cli/link.ts +++ b/src/cli/link.ts @@ -2,7 +2,11 @@ import { clientLinkStatusIdentity } from "../client/link-status"; import { clientLinkTunnelStatus } from "../client/link-tunnel"; import { loadConfig } from "../config"; import { projectLinkStatus, type ClientLinkIdentity, type LinkStatusConfig, type LinkStatusDto } from "../link/status-projection"; +import { isEpochMs, type LinkChildParity } from "../link/child-parity"; +import { CODEX_SETTINGS_DIGEST_PATTERN } from "../link/codex-settings"; +import type { LinkLocalPeer, LinkPeerStatus } from "../link/compatibility"; import { runLinkEnrollment } from "./link-enrollment"; +import { runLinkCleanup, type LinkCleanupDeps } from "./link-cleanup"; import { assertSshAlias } from "../link/ssh-argv"; import { linkStorePath } from "../link/paths"; import { isLinkPort } from "../link/ports"; @@ -31,8 +35,10 @@ export const LINK_USAGE = `Usage: ocx link status [--json] ocx link revoke --link-id [--force --yes] [--json] ocx link reconnect --link-id [--json] + ocx link cleanup [--link-id ] [--json] -Forced revoke skips remote cleanup. Run ocx disconnect on the remote client.`; +Forced revoke skips remote cleanup. Run ocx disconnect on the remote client. +Cleanup finds Windows Children and explains how to move them to Hub mode; it changes nothing.`; type LinkState = "connecting" | "connected" | "reconnecting" | "failed" | "idle"; type ListenerState = "off" | "listening" | "failed"; @@ -40,7 +46,7 @@ type ListenerState = "off" | "listening" | "failed"; export interface LinkStatusPayload { role: "standalone" | "home" | "child"; listener: { state: ListenerState; port: number | null }; - links: Array<{ + links: Array; child: null | { alias: string; @@ -55,6 +62,7 @@ export interface LinkStatusPayload { since: string; reason: string | null; }; + local?: LinkLocalPeer; } export interface LinkIssuePayload { @@ -64,7 +72,7 @@ export interface LinkIssuePayload { listenerPort: number; } -export interface LinkCliDeps extends RuntimeApiDeps { +export interface LinkCliDeps extends RuntimeApiDeps, Omit { /** Test seam can shorten, never extend, the whole enrollment deadline. */ enrollmentTimeoutMs?: number; choosePort?: () => Promise; @@ -85,9 +93,14 @@ function isRecord(value: unknown): value is Record { } function assertExactKeys(value: Record, keys: readonly string[], label: string): void { - const expected = new Set(keys); + assertKeys(value, keys, [], label); +} + +/** Exact keys plus fields a newer proxy adds; an older proxy omits them. */ +function assertKeys(value: Record, keys: readonly string[], optional: readonly string[], label: string): void { + const allowed = new Set([...keys, ...optional]); const actual = Object.keys(value); - if (actual.length !== expected.size || actual.some(key => !expected.has(key))) { + if (keys.some(key => !(key in value)) || actual.some(key => !allowed.has(key))) { throw new Error(`invalid link API response: ${label} fields`); } } @@ -104,9 +117,42 @@ function validNullableString(value: unknown): value is string | null { return value === null || typeof value === "string"; } +function validStringList(value: unknown): value is string[] { + return Array.isArray(value) && value.every(validString); +} + +function validateLocal(value: unknown): LinkLocalPeer { + if (!isRecord(value)) throw new Error("invalid link API response: local"); + assertExactKeys(value, ["version", "relayAuth", "features"], "local"); + if (!validString(value.version) || !validString(value.relayAuth) || !validStringList(value.features)) { + throw new Error("invalid link API response: local fields"); + } + return { version: value.version, relayAuth: value.relayAuth, features: [...value.features] }; +} + +function validatePeer(value: unknown, index: number): LinkPeerStatus { + const fail = (): never => { throw new Error(`invalid link API response: link ${index} peer`); }; + if (!isRecord(value)) return fail(); + assertExactKeys(value, ["observedAt", "version", "versionRange", "relayAuth", "features", "compatibility", "reasons"], `link ${index} peer`); + const range = value.versionRange; + if (!validNullableString(value.observedAt) || !validNullableString(value.version) || !validNullableString(value.relayAuth) + || (value.features !== null && !validStringList(value.features)) || !validStringList(value.reasons) + || !["compatible", "degraded", "incompatible", "unknown"].includes(value.compatibility as string) + || (range !== null && (!isRecord(range) || !validNullableString(range.min) || !validString(range.below)))) fail(); + return { + observedAt: value.observedAt as string | null, + version: value.version as string | null, + versionRange: range === null ? null : { min: (range as Record).min as string | null, below: (range as Record).below as string }, + relayAuth: value.relayAuth as string | null, + features: value.features === null ? null : [...value.features as string[]], + compatibility: value.compatibility as LinkPeerStatus["compatibility"], + reasons: [...value.reasons as string[]], + }; +} + function validateStatus(value: unknown): LinkStatusPayload { if (!isRecord(value)) throw new Error("invalid link API response: status object"); - assertExactKeys(value, ["role", "listener", "links", "child"], "status"); + assertKeys(value, ["role", "listener", "links", "child"], ["local"], "status"); if (value.role !== "standalone" && value.role !== "home" && value.role !== "child") { throw new Error("invalid link API response: status role"); } @@ -121,7 +167,10 @@ function validateStatus(value: unknown): LinkStatusPayload { if (!Array.isArray(value.links)) throw new Error("invalid link API response: links"); const links = value.links.map((candidate, index) => { if (!isRecord(candidate)) throw new Error(`invalid link API response: link ${index}`); - assertExactKeys(candidate, ["id", "alias", "direction", "state", "since", "reason", "tunnelPort"], `link ${index}`); + // The OCX-6 parity fields are optional on the wire (an older runtime omits them) and always + // present in this output, null when absent. + assertKeys(candidate, ["id", "alias", "direction", "state", "since", "reason", "tunnelPort"], [...PARITY_KEYS, "peer"], `link ${index}`); + const parity = validateParity(candidate, index); if (typeof candidate.id !== "string" || !LINK_ID.test(candidate.id) || !validString(candidate.alias) || (candidate.direction !== "hub-initiated" && candidate.direction !== "client-initiated") || (candidate.state !== "connecting" && candidate.state !== "connected" && candidate.state !== "reconnecting" && candidate.state !== "failed" && candidate.state !== "idle") @@ -136,6 +185,8 @@ function validateStatus(value: unknown): LinkStatusPayload { since: candidate.since, reason: candidate.reason, tunnelPort: candidate.tunnelPort, + ...parity, + ...(candidate.peer !== undefined ? { peer: validatePeer(candidate.peer, index) } : {}), } as LinkStatusPayload["links"][number]; }); let child: LinkStatusPayload["child"] = null; @@ -159,6 +210,7 @@ function validateStatus(value: unknown): LinkStatusPayload { listener: { state: value.listener.state, port: value.listener.port }, links, child, + ...(value.local !== undefined ? { local: validateLocal(value.local) } : {}), }; } @@ -258,7 +310,8 @@ async function runStatus(args: string[], deps: LinkCliDeps): Promise { } throw error; } - const result = validateStatus(await linkRequest("/api/link/status", {}, { ...deps, baseUrl })); + // A proxy older than the compatibility report ignores the query and answers the exact K16 shape. + const result = validateStatus(await linkRequest("/api/link/status?compatibility=1", {}, { ...deps, baseUrl })); console.log(JSON.stringify(result)); } @@ -326,6 +379,28 @@ export async function runLinkCommand(rawArgs: string[], deps: LinkCliDeps = {}): else if (command === "status") await runStatus(args, deps); else if (command === "revoke") await runRevoke(args, deps); else if (command === "reconnect") await runReconnect(args, deps); - else throw new CliUsageError("link requires one of: probe, add, port, issue, status, revoke, reconnect", LINK_USAGE); + else if (command === "cleanup") await runLinkCleanup(args, deps, LINK_USAGE); + else throw new CliUsageError("link requires one of: probe, add, port, issue, status, revoke, reconnect, cleanup", LINK_USAGE); }); } + +const PARITY_KEYS = ["lastApplyAt", "runtimeStartedAt", "appliedCodexSettingsDigest", "reportedAt"] as const; + +function validateParity(candidate: Record, index: number): LinkChildParity { + const time = (key: "lastApplyAt" | "runtimeStartedAt" | "reportedAt"): number | null => { + const value = candidate[key]; + if (value === undefined || value === null) return null; + if (!isEpochMs(value)) throw new Error(`invalid link API response: link ${index} ${key}`); + return value; + }; + const digest = candidate.appliedCodexSettingsDigest; + if (digest !== undefined && digest !== null && (typeof digest !== "string" || !CODEX_SETTINGS_DIGEST_PATTERN.test(digest))) { + throw new Error(`invalid link API response: link ${index} appliedCodexSettingsDigest`); + } + return { + lastApplyAt: time("lastApplyAt"), + runtimeStartedAt: time("runtimeStartedAt"), + appliedCodexSettingsDigest: typeof digest === "string" ? digest : null, + reportedAt: time("reportedAt"), + }; +} diff --git a/src/cli/provider.ts b/src/cli/provider.ts index dd6769a5b37..e53bdf78c75 100644 --- a/src/cli/provider.ts +++ b/src/cli/provider.ts @@ -28,6 +28,8 @@ import type { RuntimeApiDeps } from "./runtime-api"; import { redactSecretArgs } from "./secret-args"; import { projectLocalSyncResult, type LocalSyncResult } from "./local-sync-result"; import { providerManagementConfigError } from "../server/auth-cors"; +import { readClientConnectionState, type ClientConnectionState } from "../client/state"; +import { SENSITIVE_KEY_PATTERN } from "../lib/redact"; export interface ProviderCommandDeps extends RuntimeApiDeps { syncModels?: typeof syncModelsToCodex; @@ -102,7 +104,46 @@ function validateAndSave(config: ReturnType): void { // provider list // --------------------------------------------------------------------------- -function handleList(args: string[]): void { +/** + * Does this device's own provider config serve requests? A device linked to a Home — a Remote + * Link Child or a Hub-mode client — routes through the Home, so its local rows are shadowed. + * A standalone device and a Hub serve their own rows. An unreadable or inconsistent client + * state is not a link, the same reading `readLinkChildContext` takes. + */ +export function localProvidersActive(read: () => ClientConnectionState = readClientConnectionState): boolean { + try { + return read().kind !== "connected"; + } catch { + return true; + } +} + +const SENSITIVE_QUERY_KEY = /key|token|secret|password|passwd|signature|^sig$|auth|credential/i; + +/** + * The baseUrl as `provider list` prints it. Validation already rejects embedded credentials; + * this also covers a hand-edited config, dropping userinfo and masking credential-shaped query + * values while keeping ordinary parameters such as `api-version`. + */ +export function providerListBaseUrl(baseUrl: string): string { + let parsed: URL; + try { parsed = new URL(baseUrl); } catch { return baseUrl; } + let changed = false; + if (parsed.username || parsed.password) { + parsed.username = ""; + parsed.password = ""; + changed = true; + } + for (const key of [...new Set(parsed.searchParams.keys())]) { + if (SENSITIVE_KEY_PATTERN.test(key) || SENSITIVE_QUERY_KEY.test(key)) { + parsed.searchParams.set(key, "redacted"); + changed = true; + } + } + return changed ? parsed.toString() : baseUrl; +} + +function handleList(args: string[], active: boolean = localProvidersActive()): void { const wantsJson = consumeFlag(args, "--json"); const wantsJsonl = consumeFlag(args, "--jsonl"); rejectUnknownArgs(args, "Usage: ocx provider list [--json|--jsonl]"); @@ -120,12 +161,13 @@ function handleList(args: string[]): void { return { name, adapter: prov.adapter, - baseUrl: prov.baseUrl, + baseUrl: providerListBaseUrl(prov.baseUrl), authMode: prov.authMode ?? "key", defaultModel: prov.defaultModel ?? null, isDefault: name === config.defaultProvider, source: registryEntry ? "registry" : "custom", models: prov.models ?? [], + active, }; }); diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 5dd435db752..70290fdbe53 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -150,7 +150,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ }, { name: "link", - usage: "ocx link ", + usage: "ocx link ", summary: "Allocate and manage a loopback remote home link.", details: [ "Port: ocx link port [--json]", @@ -158,6 +158,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ "Status: ocx link status [--json]", "Revoke: ocx link revoke --link-id [--force --yes] [--json]", "Reconnect: ocx link reconnect --link-id [--json]", + "Cleanup: ocx link cleanup [--link-id ] [--json]", "Issue, status, revoke, and reconnect use the running proxy's loopback management API and admin token.", ], }, @@ -434,6 +435,17 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ "Works both online (via live proxy API) and offline (modifies persisted config safely with atomic writes).", ], }, + { + name: "fallback", + usage: "ocx fallback [--kind model|provider|all] [--provider | --route | --device ] [--json]", + summary: "Turn model and provider fallback off or on per scope, and record per-device local fallback.", + details: [ + "A refused fallback answers fallback_refused with the status of the failure that triggered it, and names the command that allows it.", + "Precedence: a device set to off always refuses; otherwise route, then provider, then global; unset allows.", + "Rotation among the accounts or keys of one provider pool is not a fallback and is not governed here.", + "Live proxy only. On a Remote Link Child, status reads the Home; writes are refused. Run write commands on the Home.", + ], + }, { name: "agent", usage: "ocx agent ...", diff --git a/src/cli/restart-failure.ts b/src/cli/restart-failure.ts index d7d23c3d1ee..b73df5a3f22 100644 --- a/src/cli/restart-failure.ts +++ b/src/cli/restart-failure.ts @@ -16,6 +16,10 @@ export function reportRestartFailure(result: Extract( if (!linkChildReadable(method, path)) { throw new RuntimeApiError(linkChildRefusal(link, `${method} ${pathname}`), 503, null, "link_child_management_unavailable"); } - const { status, body } = await readLinkHomeJson(link, path, deps.linkHome); + const { status, body } = await readLinkHomeJson(link, path, deps.linkHome, init.signal ? { signal: init.signal } : {}); if (status < 200 || status >= 300) throw new RuntimeApiError(responseMessage(body, status), status, body); return body as T; } diff --git a/src/cli/status-codex-settings.ts b/src/cli/status-codex-settings.ts new file mode 100644 index 00000000000..9e5ace392b4 --- /dev/null +++ b/src/cli/status-codex-settings.ts @@ -0,0 +1,56 @@ +/** + * The OCX-6 fields of `ocx status --json`: which Codex settings this machine applied, when, and + * when its client runtime started. + * + * - `appliedCodexSettingsDigest` (any device): the digest of the values this machine applies. On a + * Remote Link Child that is its apply record (the Home's values its last sync wrote); on a Home or + * standalone install, its own projection, which is also exactly what it serves its Children. Null + * on a hub client and on a Child that has no apply record yet. + * - `lastApplyAt` (Child): epoch ms of the last successful apply of a different Home settings + * document; null elsewhere. + * - `runtimeStartedAt` (Child): epoch ms the live client runtime reports as its start; null when no + * client runtime answers. + */ +import type { LiveProxy } from "../server/proxy-liveness"; +import type { OcxConfig } from "../types"; + +export interface CliAppliedCodexSettingsStatus { + appliedCodexSettingsDigest: string | null; + lastApplyAt: number | null; + runtimeStartedAt: number | null; +} + +export interface AppliedCodexSettingsStatusDeps { + readStartedAt?: (live: LiveProxy) => Promise; +} + +export async function collectAppliedCodexSettingsStatus( + config: OcxConfig, + live: LiveProxy | null, + deps: AppliedCodexSettingsStatusDeps = {}, +): Promise { + const { codexSettingsDigest, projectHomeCodexSettings } = await import("../link/codex-settings"); + if (config.runtimeRole !== "client") { + return { + appliedCodexSettingsDigest: codexSettingsDigest(projectHomeCodexSettings(config, "0.0.0").settings), + lastApplyAt: null, + runtimeStartedAt: null, + }; + } + const linkId = config.client?.transport === "link" ? config.client.link?.linkId : undefined; + if (!linkId) return { appliedCodexSettingsDigest: null, lastApplyAt: null, runtimeStartedAt: null }; + const { readHomeCodexSettingsRecord } = await import("../client/link-home"); + const record = readHomeCodexSettingsRecord(linkId); + const appliedAt = record ? Date.parse(record.appliedAt) : Number.NaN; + let runtimeStartedAt: number | null = null; + if (live?.role === "client") { + const read = deps.readStartedAt ?? (async (target: LiveProxy) => + (await import("./link-apply-restart")).readClientRuntimeStartedAt(target)); + runtimeStartedAt = await read(live); + } + return { + appliedCodexSettingsDigest: record ? codexSettingsDigest(record.document.settings) : null, + lastApplyAt: Number.isFinite(appliedAt) ? appliedAt : null, + runtimeStartedAt, + }; +} diff --git a/src/cli/status-fallback.ts b/src/cli/status-fallback.ts new file mode 100644 index 00000000000..f7e7e142a32 --- /dev/null +++ b/src/cli/status-fallback.ts @@ -0,0 +1,29 @@ +/** + * `ocx status --json` `fallback` (OCX-11). The live proxy's `GET /api/fallback` when it answers + * (on a Remote Link Child that read is the Home's, over the link); otherwise the policy from this + * machine's config with no events, marked `source: "local"`. Never fails the status report. + */ +import type { OcxConfig } from "../types"; +import { buildFallbackState, type FallbackStateView } from "../routing/fallback-state"; +import { runtimeRequest, type RuntimeApiDeps } from "./runtime-api"; + +export type StatusFallback = (FallbackStateView | ReturnType) & { source: "live" | "home" | "local" }; + +const STATUS_FALLBACK_TIMEOUT_MS = 2_000; + +export async function collectStatusFallback( + config: Pick, + options: { live: boolean; linkChild: boolean; deps?: RuntimeApiDeps }, +): Promise { + if (options.live) { + try { + const state = await runtimeRequest("/api/fallback", { + signal: AbortSignal.timeout(STATUS_FALLBACK_TIMEOUT_MS), + }, options.deps ?? {}); + if (state && typeof state === "object" && state.schema === "ocx.fallback/1") { + return { ...state, source: options.linkChild ? "home" : "live" }; + } + } catch { /* an older or unreachable proxy: report the local policy instead */ } + } + return { ...buildFallbackState(options.linkChild ? {} : config), source: "local" }; +} diff --git a/src/cli/status-install.ts b/src/cli/status-install.ts new file mode 100644 index 00000000000..b41e2d7f618 --- /dev/null +++ b/src/cli/status-install.ts @@ -0,0 +1,52 @@ +import { detectInstallOwnership, type InstallOwnership } from "../update"; + +/** How this ocx is installed, from the updater's own ownership detector. */ +export type InstallMethod = "bun" | "npm" | "pnpm" | "mise" | "source" | "standalone" | "unknown"; + +/** + * `ocx status --json` `install`. Additive within `schemaVersion: 1`. + * + * The verdict is the one `ocx update` acts on (`src/update/install-detection.mjs`), read + * from the running package's own location — PATH is never consulted, because a machine can + * carry several package managers at once. `evidence` names what decided it without adding + * a path: the package layout, mise's backend record (tool alias and backend only), the + * compiled standalone binary, or nothing when detection itself failed. + */ +export type InstallDetectionStatus = { + method: InstallMethod; + evidence: { + basis: "package-layout" | "mise-backend-record" | "standalone-binary" | "unavailable"; + mise?: { tool: string; backend: string }; + error?: "metadata_unreadable" | "metadata_inconsistent" | "detection_failed"; + }; +}; + +export function installDetectionStatus( + detect: () => InstallOwnership = detectInstallOwnership, +): InstallDetectionStatus { + let ownership: InstallOwnership; + try { + ownership = detect(); + } catch { + return { method: "unknown", evidence: { basis: "unavailable", error: "detection_failed" } }; + } + switch (ownership.installer) { + case "standalone": + return { method: "standalone", evidence: { basis: "standalone-binary" } }; + case "mise": + if (ownership.owner) { + return { + method: "mise", + evidence: { basis: "mise-backend-record", mise: { tool: ownership.owner.tool, backend: ownership.owner.backend } }, + }; + } + return { method: "mise", evidence: { basis: "mise-backend-record", error: ownership.error ?? "metadata_unreadable" } }; + case "bun": + case "npm": + case "pnpm": + case "source": + return { method: ownership.installer, evidence: { basis: "package-layout" } }; + default: + return { method: "unknown", evidence: { basis: "unavailable", error: "detection_failed" } }; + } +} diff --git a/src/cli/status.ts b/src/cli/status.ts index 904b6c2ce34..b5722ac9dca 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -36,6 +36,8 @@ export type { ListenTarget } from "./status-probes"; import { checkProxyHealth, probeUncleanExitState, type ListenTarget } from "./status-probes"; import { LOCAL_MANAGEMENT_READ_PATHS } from "../lib/local-management-capability"; import { fetchBoundLocalManagementRead } from "../server/local-management-read-client"; +import { collectAppliedCodexSettingsStatus, type CliAppliedCodexSettingsStatus } from "./status-codex-settings"; +import { installDetectionStatus, type InstallDetectionStatus } from "./status-install"; /** * The state of the data-plane admission secret the SERVICE will use. State only -- never the value. @@ -118,6 +120,8 @@ export type CliRemoteHubStatus = { export type CliStatusJson = { mainAccountHardLock?: CodexMainAccountPolicyHealth; + /** OCX-11 fallback state; see src/cli/status-fallback.ts. */ + fallback?: import("./status-fallback").StatusFallback; schemaVersion: 1; /** * This machine's topology role, named rather than inferred (#4236). @@ -229,8 +233,10 @@ export type CliStatusJson = { */ versionSkew: VersionSkew; cliCommand: CliPathDiagnostics; + /** Installer ownership from the updater's detector (OCX-10); additive, `schemaVersion` stays 1. */ + install: InstallDetectionStatus; startupSource: "live" | "local" | "local-supervision-override"; -}; +} & CliAppliedCodexSettingsStatus; export type CliStatusView = { json: CliStatusJson; @@ -853,12 +859,22 @@ export async function collectStatus(options: { mainAccountPolicy?: boolean } = { const mainAccountHardLock = options.mainAccountPolicy && live ? (await (await import("../oauth/health")).fetchCodexHealthFromLiveProxy(undefined, async () => live)).mainAccountHardLock : undefined; + // Same `--json`-only gate as the hard-lock read above: the human report stays one probe. + const fallback = options.mainAccountPolicy + ? await (await import("./status-fallback")).collectStatusFallback(config, { + live: Boolean(live), linkChild: config.client?.transport === "link", + deps: { findLiveProxy: async () => live }, + }) + : undefined; const pidFile = readPid(); // Preserve an authoritative null from orphan/legacy liveness — do not restore pidFile. const pid = resolveStatusPid(live, pidFile); // No extra request: findLiveProxy's identity probe already parsed and validated the // healthz body, so the version came back with the liveness result. const versionSkew = computeVersionSkew(packageVersion(), live?.version); + // OCX-6: what this machine applied and, on a Child, when its runtime started. Additive and + // always present (null when unknown), so `schemaVersion` stays 1. + const appliedCodexSettings = await collectAppliedCodexSettingsStatus(config, live); const listen = live ? { port: live.port, @@ -1005,6 +1021,7 @@ export async function collectStatus(options: { mainAccountPolicy?: boolean } = { healthLabel: formatCliStatusHealthLabel(health.label, cliCommand, remoteHub.connected), json: { ...(mainAccountHardLock ? { mainAccountHardLock } : {}), + ...(fallback ? { fallback } : {}), schemaVersion: 1, runtimeRole: config.runtimeRole ?? "standalone", proxy: { @@ -1067,7 +1084,9 @@ export async function collectStatus(options: { mainAccountPolicy?: boolean } = { // print it under the wrong heading (#2701). versionSkew, cliCommand, + install: installDetectionStatus(), startupSource, + ...appliedCodexSettings, }, }; } diff --git a/src/cli/system-restart-client.ts b/src/cli/system-restart-client.ts index 5cffaf27238..be8241d5e35 100644 --- a/src/cli/system-restart-client.ts +++ b/src/cli/system-restart-client.ts @@ -1,4 +1,5 @@ import { readRuntimePort } from "../config/process-state"; +import { readBoundedResponseBytes } from "../lib/bounded-body"; import { LOCAL_ATTESTATION_CHALLENGE_HEADER, LOCAL_ATTESTATION_PROOF_HEADER, @@ -6,6 +7,8 @@ import { verifyLocalAttestationProof, } from "../lib/local-management-attestation"; import { + CLIENT_RESTART_REQUEST_MIN_REMAINING_MS, + RESTART_SUPERVISED_ERROR, SYSTEM_RESTART_CAPABILITY_HEADER, SYSTEM_RESTART_CAPABILITY_VERSION, SYSTEM_RESTART_EXPECTED_PID_HEADER, @@ -182,6 +185,13 @@ export async function requestBoundSystemRestart( ); if (!capability) return rejected("restart_capability_unavailable"); + // A connected client runtime keeps serving when its replacement fails, but its rollback takes + // up to CLIENT_RESTART_BUDGET_MS. Ask only while that still ends before this CLI stops observing, + // or the recovery start below could race the rollback for the port (OCX-6). + if (body.role === "client" && deadlineAt - now() < CLIENT_RESTART_REQUEST_MIN_REMAINING_MS) { + return rejected("restart_deadline_expired"); + } + const requestBudget = remaining(deadlineAt, now, SYSTEM_RESTART_REQUEST_TIMEOUT_MS); if (requestBudget <= 0) return rejected("restart_deadline_expired"); try { @@ -194,10 +204,31 @@ export async function requestBoundSystemRestart( }, signal: AbortSignal.timeout(requestBudget), }); - return response.ok ? { accepted: true } : rejected(`restart_request_http_${response.status}`); + if (response.ok) return { accepted: true }; + if (response.status === 409) { + const supervisor = await supervisedRefusal(response); + if (supervisor) return rejected(`${RESTART_SUPERVISED_ERROR}_${supervisor}`); + } + return rejected(`restart_request_http_${response.status}`); } catch { // The server may have accepted the restart before the response connection failed. // The coordinator observes the original PID for replacement instead of replaying. return uncertain("restart_request_outcome_unknown"); } } + +/** + * A client runtime under the desktop app or a service answers 409 `restart_supervised` and keeps + * serving (OCX-6). Read that small body and return the validated supervisor, or null. + */ +async function supervisedRefusal(response: Response): Promise<"service" | "desktop" | null> { + try { + const read = await readBoundedResponseBytes(response, { maxBytes: 1024, signal: AbortSignal.timeout(SYSTEM_RESTART_REQUEST_TIMEOUT_MS) }); + if (read.oversized) return null; + const body = JSON.parse(new TextDecoder().decode(read.bytes)) as { error?: { code?: unknown; supervisor?: unknown } } | null; + if (body?.error?.code !== RESTART_SUPERVISED_ERROR) return null; + return body.error.supervisor === "service" || body.error.supervisor === "desktop" ? body.error.supervisor : null; + } catch { + return null; + } +} diff --git a/src/cli/update-restart-child.ts b/src/cli/update-restart-child.ts index 24198a3e9f9..e2fcc37bc76 100644 --- a/src/cli/update-restart-child.ts +++ b/src/cli/update-restart-child.ts @@ -20,10 +20,11 @@ function parseMarker(raw: string): UpdateRestartChildMarker { || typeof value.home.serviceRecord.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.home.serviceRecord.digest)) throw new Error("update_restart_child_marker_invalid"); for (const directory of [value.home.config, value.home.codex]) { // Windows reports 64-bit NTFS file ids (MFT sequence in the high 16 bits) as doubles above - // 2^53. Parent and child read them through the same stat API, so any finite integer is a - // faithful identity token; assertUpdateRestartHome still compares path, dev and ino exactly. + // 2^53, where neighbouring ids collapse. dev and ino may be large finite integers; fileId + // carries the exact "dev:ino" that assertUpdateRestartHome actually relies on. if (!directory || typeof directory.path !== "string" || !directory.path - || !Number.isInteger(directory.dev) || !Number.isInteger(directory.ino)) { + || !Number.isInteger(directory.dev) || !Number.isInteger(directory.ino) + || typeof directory.fileId !== "string" || !/^\d{1,20}:\d{1,20}$/.test(directory.fileId)) { throw new Error("update_restart_child_marker_invalid"); } } diff --git a/src/cli/update-restart-home.ts b/src/cli/update-restart-home.ts index bf387b73fa9..842f2cbcb26 100644 --- a/src/cli/update-restart-home.ts +++ b/src/cli/update-restart-home.ts @@ -1,4 +1,4 @@ -import { realpathSync, statSync } from "node:fs"; +import { realpathSync, statSync, type BigIntStats } from "node:fs"; import { readClientConnectionState } from "../client/state"; import { readConfigDiagnostics } from "../config/diagnostics"; import { getConfigDir } from "../config/paths"; @@ -6,23 +6,34 @@ import { currentCodexHome } from "../service/state"; import { captureUpdateRestartServiceRecord, type UpdateRestartServiceRecord, type UpdateRestartServiceRecordDeps } from "./update-restart-service-record"; import { probeUpdateRestartSupervision, type UpdateRestartSupervisionDeps } from "./update-restart-supervision"; +/** + * `fileId` is the exact identity ("dev:ino" from a bigint stat). `dev` and `ino` are doubles and + * cannot be: an NTFS file id carries the MFT sequence in its high bits, so above 2^53 a replacement + * directory with a nearby MFT index can round to the same `ino` as the one it replaced. + */ +export interface UpdateRestartDirectory { path: string; dev: number; ino: number; fileId: string } + export interface UpdateRestartHome { - config: { path: string; dev: number; ino: number }; - codex: { path: string; dev: number; ino: number }; + config: UpdateRestartDirectory; + codex: UpdateRestartDirectory; revision: number; serviceRecord: UpdateRestartServiceRecord; } -function physicalDirectory(path: string): UpdateRestartHome["config"] { +export type UpdateRestartDirectoryStat = Pick; + +function physicalDirectory(path: string, stat: (path: string) => UpdateRestartDirectoryStat): UpdateRestartDirectory { const canonical = realpathSync.native(path); - const stat = statSync(canonical); - if (!stat.isDirectory()) throw new Error("update_restart_home_unverified"); - return { path: canonical, dev: stat.dev, ino: stat.ino }; + const observed = stat(canonical); + if (!observed.isDirectory()) throw new Error("update_restart_home_unverified"); + return { path: canonical, dev: Number(observed.dev), ino: Number(observed.ino), fileId: `${observed.dev}:${observed.ino}` }; } export interface UpdateRestartHomeDeps { record?: UpdateRestartServiceRecordDeps; supervision?: UpdateRestartSupervisionDeps; + /** Test seam for the directory identity source; production reads a bigint stat. */ + statDirectory?: (path: string) => UpdateRestartDirectoryStat; } /** Capture eligibility from the same bytes that supply the frozen fingerprint. */ @@ -32,7 +43,8 @@ export function readUpdateRestartHome(deps: UpdateRestartHomeDeps = {}): UpdateR if (owner.kind !== "none" || (state.kind === "state" && platform !== "darwin" && platform !== "linux")) { throw new Error("update_restart_owner_unverified"); } - return { config: physicalDirectory(getConfigDir()), codex: physicalDirectory(currentCodexHome()), revision: owner.revision, serviceRecord }; + const stat = deps.statDirectory ?? (path => statSync(path, { bigint: true })); + return { config: physicalDirectory(getConfigDir(), stat), codex: physicalDirectory(currentCodexHome(), stat), revision: owner.revision, serviceRecord }; } export function assertUpdateRestartHome(expected: UpdateRestartHome, deadlineAt = Date.now() + 2000, deps: UpdateRestartHomeDeps = {}): void { diff --git a/src/client/connect.ts b/src/client/connect.ts index 2d86ca5ae2f..a257666f1dc 100644 --- a/src/client/connect.ts +++ b/src/client/connect.ts @@ -78,9 +78,14 @@ import { overlayHomeCodexSettings } from "../link/codex-settings"; import { clearHomeCodexSettingsRecord, fetchHomeCodexSettings, + homeCodexSettingsReceipt, + readHomeCodexSettingsRecord, resolveHomeCodexSettings, recordHomeCodexSettings, + withLinkApply, + withdrawChangingHomeCodexSettingsRecord, writeHomeCodexSettingsRecord, + type HomeCodexSettingsRecord, type HomeCodexSettingsSource, type LinkHomeDeps, } from "./link-home"; @@ -122,6 +127,8 @@ export interface ClientConnectDeps { catalogCompatibility?: CatalogCompatibilityDeps; /** Link mode: how the Home's Codex settings are read (#6957). */ linkHome?: LinkHomeDeps; + /** Link mode: the apply lock's path (OCX-6); defaults to `/link/apply-lock.sqlite`. */ + linkApplyLockPath?: string; } export interface RotateClientOptions { @@ -549,9 +556,22 @@ function assertConnectingState(expectedTokenFingerprint?: string): void { } /** Enroll a client key, keeping its pending ownership visible until commit or rollback. */ +/** + * Connect this machine to a hub or, with `transport: "link"`, to a Remote Link Home. A link + * connect holds the apply lock throughout (OCX-6), because it applies the Home's Codex settings + * and records that apply exactly as `ocx sync` does. + */ export async function connectClient( options: ConnectOptions, deps: ClientConnectDeps = {}, +): Promise { + if ((options.transport ?? "hub") !== "link") return connectClientUnlocked(options, deps); + return withLinkApply(() => connectClientUnlocked(options, deps), deps.linkApplyLockPath); +} + +async function connectClientUnlocked( + options: ConnectOptions, + deps: ClientConnectDeps = {}, ): Promise { deps.signal?.throwIfAborted(); const rawFetch = deps.fetchImpl ?? fetch; @@ -755,11 +775,17 @@ export async function connectClient( commitClientConnection(connection); committed = true; }), deps.lifecycleLockDeps); - // Recorded only when the injection that applied it ran (not with --no-sync). + // Recorded only when the injection that applied it ran (not with --no-sync). The connection is + // already committed, so a failed record write must not reach the rollback below: it is a + // warning, and `ocx status` reports no recorded apply until `ocx sync` writes one. if (homeSettings?.kind === "fresh" && linkMetadata && injectionCommitted) { try { - writeHomeCodexSettingsRecord({ linkId: linkMetadata.linkId, fetchedAt: now, document: homeSettings.document }); - } catch { /* the next sync records it; the applied config is already committed */ } + writeHomeCodexSettingsRecord(homeCodexSettingsReceipt( + linkMetadata.linkId, homeSettings.document, now, (deps.now ?? (() => new Date()))().toISOString(), null, + )); + } catch { + console.warn("Warning: the Home's Codex settings were applied, but the apply record was not written; run 'ocx sync' to record it."); + } } return connection; } catch (error) { @@ -804,10 +830,34 @@ export async function connectClient( } } +export interface ConnectedSyncResult { + catalogWritten: boolean; + cacheSynced: boolean; + injected: boolean; + stale: boolean; + homeSettings?: HomeCodexSettingsSource; + /** The apply record this sync committed or kept (link Child with Home settings only). */ + appliedRecord?: HomeCodexSettingsRecord; + /** The Home's settings were injected but their record could not be written (OCX-6). */ + applyRecordFailed?: true; +} + +/** + * Refresh the catalog and Codex config from the hub or Home. Every sync holds the apply lock + * (OCX-6), so a sync that finds a link connection inside, whatever an earlier unlocked read said, + * never interleaves with another apply, and its record names what its own injection wrote. + */ export async function syncConnectedClient( + options: { restartCodex?: boolean } = {}, + deps: ClientConnectDeps = {}, +): Promise { + return withLinkApply(() => syncConnectedClientUnlocked(options, deps), deps.linkApplyLockPath); +} + +async function syncConnectedClientUnlocked( _options: { restartCodex?: boolean } = {}, deps: ClientConnectDeps = {}, -): Promise<{ catalogWritten: boolean; cacheSynced: boolean; injected: boolean; stale: boolean; homeSettings?: HomeCodexSettingsSource }> { +): Promise { if (siblingOfLivePort() !== null) throw new Error(siblingSkipMessage()); const initial = withClientLifecycleSync(() => withConfigMutationLockSync(() => { assertNoClientDisconnectPending(); @@ -853,12 +903,18 @@ export async function syncConnectedClient( }; let injected = false; let homeSettings: HomeCodexSettingsSource | undefined; + let appliedRecord: HomeCodexSettingsRecord | undefined; + let applyRecordFailed = false; if (next.selectedClients.includes("codex")) { let config = loadConfig(); // #6957: a link Child applies the Home's OpenCodex-owned Codex settings with the catalog. if (isLinkConnection(next)) { homeSettings = await resolveHomeCodexSettings(next, initial.token.token, deps.linkHome); if (homeSettings.source !== "local") config = overlayHomeCodexSettings(config, homeSettings.document.settings); + // About to write different settings: until their record commits, report no applied digest + // rather than the previous one (OCX-6). A failed injection then reads "unknown", never a + // digest of values Codex no longer has or never got. + if (homeSettings.source === "home" && next.link) withdrawChangingHomeCodexSettingsRecord(next.link.linkId, homeSettings.document); } const result = await injectCodexConfig(config.port, { ...config, syncResumeHistory: false }, { routingTarget: routingTarget(next.serverUrl, isLinkConnection(next) ? config.port : undefined, config), catalogPath: DEFAULT_CATALOG_PATH, @@ -866,14 +922,34 @@ export async function syncConnectedClient( }); if (!result.success || result.status === "skipped") throw new Error(result.message); injected = true; - if (homeSettings && next.link) recordHomeCodexSettings(next.link.linkId, homeSettings); + if (homeSettings && next.link) { + const linkId = next.link.linkId; + const settings = homeSettings; + try { + appliedRecord = withClientLifecycleSync(() => { + beforeClientWrite(); + return recordHomeCodexSettings(linkId, settings, deps.now) ?? readHomeCodexSettingsRecord(linkId) ?? undefined; + }, deps.lifecycleLockDeps); + } catch (error) { + // An ownership change is not a disk problem: report it as itself. + if (error instanceof Error && (error.message === "client_token_changed" || error.message === "client_connection_changed")) throw error; + // The injection committed, so the cache refresh below still runs; the caller reports this. + try { clearHomeCodexSettingsRecord(); } catch { /* status already reads no recorded apply or reports the write */ } + applyRecordFailed = true; + } + } } const cacheSynced = withClientLifecycleSync(() => { beforeClientWrite(); // Cache invalidation acquires K itself (N -> K -> C); never call it while C is held. return invalidateCodexModelsCache({ allowWhenDesiredDisabled: true }); }, deps.lifecycleLockDeps); - return { catalogWritten: downloaded !== undefined, cacheSynced, injected, stale, ...(homeSettings ? { homeSettings } : {}) }; + return { + catalogWritten: downloaded !== undefined, cacheSynced, injected, stale, + ...(homeSettings ? { homeSettings } : {}), + ...(appliedRecord ? { appliedRecord } : {}), + ...(applyRecordFailed ? { applyRecordFailed } : {}), + }; } /** diff --git a/src/client/home-unreachable.ts b/src/client/home-unreachable.ts new file mode 100644 index 00000000000..babd851b188 --- /dev/null +++ b/src/client/home-unreachable.ts @@ -0,0 +1,16 @@ +/** + * OCX-11: the stable answer a Remote Link Child or Hub client gives when its Home cannot be + * reached. The relay keeps its status, `error` string and retry semantics; `code` and + * `message` are added so callers (Codex, GOAT) read one contract. The client runtime has no + * provider dispatch, so nothing is ever served from this device's local providers. Whether GOAT + * may move the work to them is the Home's per-device `fallback.devices..localFallback`. + */ +export const HOME_UNREACHABLE_CODE = "home_unreachable"; + +export const HOME_UNREACHABLE_MESSAGE = "The Home is unreachable, and OpenCodex did not serve this request " + + "from this device's local providers. Retry once the Home is back. Allowing local fallback for this " + + "device is set on the Home: ocx fallback local on --device ."; + +export function homeUnreachableBody(error: string): { error: string; code: string; message: string } { + return { error, code: HOME_UNREACHABLE_CODE, message: HOME_UNREACHABLE_MESSAGE }; +} diff --git a/src/client/hub-relay.ts b/src/client/hub-relay.ts index 2bbaf0fa5e9..be42eb24ec4 100644 --- a/src/client/hub-relay.ts +++ b/src/client/hub-relay.ts @@ -1,4 +1,5 @@ import { stripMachineAuthHeaders } from "./machine-auth"; +import { homeUnreachableBody } from "./home-unreachable"; export interface HubRelayTarget { managementUrl: string; @@ -73,6 +74,11 @@ function relayError(status: number, error: string): Response { return Response.json({ error }, { status }); } +/** The Hub did not answer (OCX-11 `home_unreachable`); the 502 status is kept. */ +function hubUnreachable(): Response { + return Response.json(homeUnreachableBody("hub relay unavailable"), { status: 502 }); +} + function canonicalOrigin(value: string): string | null { try { const url = new URL(value); @@ -275,12 +281,12 @@ export async function relayHubManagementRequest( }); } catch { cleanup(); - return relayError(502, "hub relay unavailable"); + return hubUnreachable(); } if (signal.aborted) { cleanup(); try { await upstream.body?.cancel(); } catch { /* best effort */ } - return relayError(502, "hub relay unavailable"); + return hubUnreachable(); } if (upstream.status >= 300 && upstream.status < 400) { cleanup(); diff --git a/src/client/link-home.ts b/src/client/link-home.ts index a015bc98408..43daefe8028 100644 --- a/src/client/link-home.ts +++ b/src/client/link-home.ts @@ -13,9 +13,11 @@ import { readBoundedResponseBytes } from "../lib/bounded-body"; import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; import { hardenSecretDir } from "../lib/windows-secret-acl"; import { + codexSettingsDigest, parseHomeCodexSettingsDocument, type HomeCodexSettingsDocument, } from "../link/codex-settings"; +import { LINK_CHILD_PARITY_PATH, type LinkChildParityReport } from "../link/child-parity"; import { LINK_CODEX_SETTINGS_PATH, LINK_MANAGEMENT_PREFIX, @@ -23,10 +25,17 @@ import { } from "../link/management-routes"; import { linkDir } from "../link/paths"; import type { OcxClientConnectionConfig } from "../types"; +import { withClientLifecycle } from "./lifecycle-lock"; import { fetchBoundLinkRelay } from "./link-relay-transport"; import { readClientLinkState, type ClientLinkState } from "./link-state"; const RECORD_MAX_BYTES = 64 * 1024; + +function isIsoTimestamp(value: unknown): value is string { + if (typeof value !== "string" || value.length !== 24) return false; + const parsed = Date.parse(value); + return Number.isFinite(parsed) && new Date(parsed).toISOString() === value; +} const SETTINGS_MAX_BYTES = 64 * 1024; const READ_TIMEOUT_MS = 10_000; @@ -42,6 +51,11 @@ export type LinkHomeConnection = Pick; if (record.linkId !== linkId || typeof record.fetchedAt !== "string" || Number.isNaN(Date.parse(record.fetchedAt))) return null; const document = parseHomeCodexSettingsDocument(record.document); - return document ? { linkId, fetchedAt: record.fetchedAt, document } : null; + // Records written before OCX-6 carry no apply time; their read time is the closest one. + const appliedAt = isIsoTimestamp(record.appliedAt) ? record.appliedAt : record.fetchedAt; + return document ? { linkId, fetchedAt: record.fetchedAt, appliedAt, document } : null; } catch { return null; } @@ -89,6 +105,59 @@ export function clearHomeCodexSettingsRecord(path = homeCodexSettingsPath()): vo try { unlinkSync(path); } catch (error) { if (!isMissingPathError(error)) throw error; } } +/** + * The record an apply of `document` produces: the apply time is `appliedAt` unless the previous + * record is for the same link and the same settings digest, in which case it is kept. + */ +export function homeCodexSettingsReceipt( + linkId: string, + document: HomeCodexSettingsDocument, + fetchedAt: string, + appliedAt: string, + previous: HomeCodexSettingsRecord | null = readHomeCodexSettingsRecord(linkId), +): HomeCodexSettingsRecord { + const unchanged = previous !== null && previous.linkId === linkId + && codexSettingsDigest(previous.document.settings) === codexSettingsDigest(document.settings); + return { linkId, fetchedAt, appliedAt: unchanged ? previous.appliedAt : appliedAt, document }; +} + +export function linkApplyLockPath(): string { + return join(linkDir(), "apply-lock.sqlite"); +} + +/** + * Before an apply writes settings that differ from the recorded ones, withdraw the record, so a + * failed injection or record write reads as "no recorded apply" instead of the previous digest + * (OCX-6). Re-applying the same settings keeps it. Returns whether the record was withdrawn. + */ +export function withdrawChangingHomeCodexSettingsRecord(linkId: string, document: HomeCodexSettingsDocument): boolean { + const previous = readHomeCodexSettingsRecord(linkId); + if (!previous || codexSettingsDigest(previous.document.settings) === codexSettingsDigest(document.settings)) return false; + clearHomeCodexSettingsRecord(); + return true; +} + +/** + * Serialize applies of the Home's settings on this Child, from the Home read through the Codex + * injection to the record write, so a receipt always names the settings the injection wrote. + * Outermost in the client lock order (before N, L and C); a second apply fails fast. + */ +export async function withLinkApply(work: () => Promise, lockPath: string = linkApplyLockPath()): Promise { + const dir = linkDir(); + assertNotRealHomeUnderTest(join(dir, "..")); + // Only a refusal of the apply lock itself means "another apply"; an error from inside `work` + // (including a busy ordinary lifecycle lock) passes through as itself. + let entered = false; + try { + return await withClientLifecycle(() => { entered = true; return work(); }, { lockPath }); + } catch (error) { + if (!entered && error instanceof Error && error.message === "client_lifecycle_busy") { + throw new Error("another Home settings apply is in progress on this machine; retry when it finishes"); + } + throw error; + } +} + /** * The Home's name as this Child knows it: the SSH alias of a Child-initiated link, else the * host name the Home published with its Codex settings, else null ("its Home"). @@ -239,10 +308,54 @@ export async function resolveHomeCodexSettings( : { source: "local", reason: fetched.reason }; } -/** Record settings a successful injection applied. Best effort: the config is already written. */ -export function recordHomeCodexSettings(linkId: string, settings: HomeCodexSettingsSource): void { - if (settings.source !== "home") return; +/** + * Record settings a successful injection applied and return the committed record; null when the + * source was not a fresh Home read. A write failure throws: the caller reports it, because + * `ocx status` and the Home read this record as the proof of what was applied. + */ +export function recordHomeCodexSettings( + linkId: string, + settings: HomeCodexSettingsSource, + now: () => Date = () => new Date(), +): HomeCodexSettingsRecord | null { + if (settings.source !== "home") return null; + const record = homeCodexSettingsReceipt(linkId, settings.document, settings.fetchedAt, now().toISOString()); + writeHomeCodexSettingsRecord(record); + return record; +} + +/** + * Tell the Home what this Child last applied and when its runtime started (OCX-6). Resolves to + * `"accepted"`, `"unsupported"` for a Home that predates the route, or `"failed"`. + */ +export async function sendLinkChildParityReport( + connection: LinkHomeConnection, + admissionKey: string, + report: LinkChildParityReport, + deps: LinkHomeDeps = {}, +): Promise<"accepted" | "unsupported" | "failed"> { + let response: Response; try { - writeHomeCodexSettingsRecord({ linkId, fetchedAt: settings.fetchedAt, document: settings.document }); - } catch { /* the next sync records it */ } + const target = linkTarget(connection, admissionKey); + const destination = `http://127.0.0.1:${target.tunnelPort}${LINK_CHILD_PARITY_PATH}`; + const body = JSON.stringify(report); + const request: RequestInit = { + method: "POST", + headers: { + accept: "application/json", + authorization: `Bearer ${admissionKey}`, + "content-type": "application/json", + "content-length": String(Buffer.byteLength(body)), + }, + body, + redirect: "manual", + signal: AbortSignal.timeout(deps.timeoutMs ?? READ_TIMEOUT_MS), + }; + response = deps.fetchImpl ? await deps.fetchImpl(destination, request) : await fetchBoundLinkRelay(target, destination, request); + } catch { + return "failed"; + } + try { await response.body?.cancel(); } catch { /* best effort */ } + if (response.status === 404) return "unsupported"; + return response.status === 204 ? "accepted" : "failed"; } diff --git a/src/client/link-parity-reporter.ts b/src/client/link-parity-reporter.ts new file mode 100644 index 00000000000..ff4d4e1f437 --- /dev/null +++ b/src/client/link-parity-reporter.ts @@ -0,0 +1,107 @@ +/** + * The Child half of the OCX-6 report: tell the Home, over the link, when this Child last applied + * the Home's Codex settings, which digest it applied and when this runtime started. + * + * Runs in a linked client runtime. The first report goes out shortly after start (the tunnel may + * still be coming up, so failures back off: 2, 5, 15, 30 then 60 seconds), and then once a minute, + * so a Home that restarted and forgot the report learns it again within a minute. A Home that + * predates the route (404) is not asked again. Timers are unref'd and never keep the process alive. + */ +import { codexSettingsDigest } from "../link/codex-settings"; +import type { LinkChildParityReport } from "../link/child-parity"; +import type { OcxClientConnectionConfig } from "../types"; +import { readHomeCodexSettingsRecord, sendLinkChildParityReport, type HomeCodexSettingsRecord } from "./link-home"; + +export const LINK_PARITY_RETRY_MS = [2_000, 5_000, 15_000, 30_000, 60_000] as const; +export const LINK_PARITY_PERIOD_MS = 60_000; + +/** Process-wide, so a reporter rebuilt by a restart rollback keeps counting up (see `sequence`). */ +let reportSequence = 0; +function nextReportSequence(): number { + reportSequence += 1; + return reportSequence; +} + +export interface LinkParityReporter { + start(): void; + stop(): void; +} + +export interface LinkParityReporterDeps { + connection: OcxClientConnectionConfig; + linkKey: () => string | null; + /** ISO start time of this runtime. */ + runtimeStartedAt: string; + readRecord?: (linkId: string) => HomeCodexSettingsRecord | null; + send?: typeof sendLinkChildParityReport; + setTimer?: (callback: () => void, ms: number) => unknown; + clearTimer?: (timer: unknown) => void; +} + +/** The report for this Child's current record; null without link metadata. */ +export function linkChildParityReport( + connection: OcxClientConnectionConfig, + runtimeStartedAt: string, + record: HomeCodexSettingsRecord | null, + sequence: number = nextReportSequence(), +): LinkChildParityReport | null { + const linkId = connection.link?.linkId; + const started = Date.parse(runtimeStartedAt); + if (!linkId || !Number.isFinite(started)) return null; + const applied = record ? Date.parse(record.appliedAt) : Number.NaN; + return Number.isFinite(applied) + ? { linkId, lastApplyAt: applied, runtimeStartedAt: started, appliedCodexSettingsDigest: codexSettingsDigest(record!.document.settings), sequence } + : { linkId, lastApplyAt: null, runtimeStartedAt: started, appliedCodexSettingsDigest: null, sequence }; +} + +export function createLinkParityReporter(deps: LinkParityReporterDeps): LinkParityReporter { + const readRecord = deps.readRecord ?? readHomeCodexSettingsRecord; + const send = deps.send ?? sendLinkChildParityReport; + const setTimer = deps.setTimer ?? ((callback: () => void, ms: number) => { + const timer = setTimeout(callback, ms); + if (typeof timer === "object" && "unref" in timer) timer.unref(); + return timer; + }); + const clearTimer = deps.clearTimer ?? (timer => clearTimeout(timer as ReturnType)); + let timer: unknown; + let stopped = true; + let failures = 0; + + const schedule = (ms: number) => { + if (stopped) return; + timer = setTimer(() => { void tick(); }, ms); + }; + + const tick = async (): Promise => { + timer = undefined; + if (stopped) return; + const key = deps.linkKey(); + const linkId = deps.connection.link?.linkId; + let record: HomeCodexSettingsRecord | null = null; + try { record = linkId ? readRecord(linkId) : null; } catch { record = null; } + const report = linkChildParityReport(deps.connection, deps.runtimeStartedAt, record); + const outcome = key && report ? await send(deps.connection, key, report).catch(() => "failed" as const) : "failed"; + if (outcome === "unsupported") { stopped = true; return; } + if (outcome === "accepted") { + failures = 0; + schedule(LINK_PARITY_PERIOD_MS); + return; + } + failures = Math.min(failures + 1, LINK_PARITY_RETRY_MS.length - 1); + schedule(LINK_PARITY_RETRY_MS[failures]!); + }; + + return { + start() { + if (!stopped) return; + stopped = false; + failures = 0; + schedule(LINK_PARITY_RETRY_MS[0]); + }, + stop() { + stopped = true; + if (timer !== undefined) clearTimer(timer); + timer = undefined; + }, + }; +} diff --git a/src/client/link-relay-transport.ts b/src/client/link-relay-transport.ts index ce8e370d858..b43bf4da7b1 100644 --- a/src/client/link-relay-transport.ts +++ b/src/client/link-relay-transport.ts @@ -4,6 +4,9 @@ import { Readable } from "node:stream"; import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib"; import { serviceApiTokenFingerprint } from "../lib/service-secrets"; import { clearableDeadline } from "../lib/abort"; +import { packageVersion } from "../lib/package-version"; +import { linkPeerReportHeaders } from "../link/peer-report"; +import { buildPeerFeatures } from "../remote/features"; import { LINK_RELAY_AUTH_PATH, LINK_RELAY_AUTH_TIMEOUT_MS, LINK_RELAY_AUTH_VERSION, LINK_RELAY_SESSION_HEADER, linkRelayChallenge, linkRelayProofMatches, @@ -148,6 +151,8 @@ async function responseOnConnection(incoming: IncomingMessage, agent: SingleConn }, { highWaterMark: 0 }), { status, headers }); } +let peerReport: Record | undefined; + /** Authenticate and stream one request on the SAME socket; never fall back to an ordinary fetch. */ export async function fetchBoundLinkRelay(target: BoundLinkRelayTarget, destination: string, init: RequestInit): Promise { if (!target.linkId || !target.apiKeyId) throw new LinkRelayAuthenticationError(); @@ -160,7 +165,9 @@ export async function fetchBoundLinkRelay(target: BoundLinkRelayTarget, destinat const agent = new SingleConnectionAgent({ keepAlive: true, maxSockets: 1, maxFreeSockets: 1 }); const deadline = clearableDeadline(LINK_RELAY_AUTH_TIMEOUT_MS, init.signal ?? undefined); try { - const response = await requestOnConnection(probe, { method: "GET", signal: deadline.signal }, agent); + // The challenge also carries this Child's version and features for the Home's link status. + peerReport ??= linkPeerReportHeaders(packageVersion("0.0.0"), buildPeerFeatures()); + const response = await requestOnConnection(probe, { method: "GET", signal: deadline.signal, headers: peerReport }, agent); const socket = agent.socket; const proof = response.headers["x-opencodex-link-proof"]; if (response.statusCode !== 204 || typeof proof !== "string" || !linkRelayProofMatches(proof, challenge.expected)) { diff --git a/src/client/link-relay.ts b/src/client/link-relay.ts index f0d8942f161..d46e406cdca 100644 --- a/src/client/link-relay.ts +++ b/src/client/link-relay.ts @@ -1,4 +1,5 @@ import { fetchBoundLinkRelay, LinkRelayAuthenticationError } from "./link-relay-transport"; +import { homeUnreachableBody } from "./home-unreachable"; import { boundedRelayResponseStream, filterRelayHeaders, @@ -112,6 +113,29 @@ function jsonError(status: number, error: string, retry = false): Response { return Response.json({ error }, { status, headers }); } +/** A retryable 503 for a Home this Child cannot reach (OCX-11 `home_unreachable`). */ +function homeUnreachable(error: string): Response { + return Response.json(homeUnreachableBody(error), { + status: 503, + headers: { "Retry-After": String(LINK_RELAY_RETRY_AFTER_SECONDS) }, + }); +} + +/** + * The peer answered but the connection-bound relay authentication failed. That is not a Home + * outage (an unrecognized protocol needs an upgrade or a re-link), so it must never read as + * `home_unreachable`, which GOAT's device-local fallback keys on. The status and Retry-After it + * carried before OCX-11 are kept: a lost bound socket can succeed on a fresh connection. + */ +export const LINK_RELAY_AUTH_FAILED_CODE = "link_relay_auth_failed"; + +function relayAuthenticationFailed(error: string): Response { + return Response.json({ error, code: LINK_RELAY_AUTH_FAILED_CODE }, { + status: 503, + headers: { "Retry-After": String(LINK_RELAY_RETRY_AFTER_SECONDS) }, + }); +} + export function linkRelayDestination(url: URL, target: Pick): string { if (!isLinkPort(target.tunnelPort)) { throw new RangeError("invalid link tunnel port"); @@ -369,7 +393,7 @@ export async function relayLinkDataRequest( const tunnelUnavailable = async (): Promise => { cleanup(); try { await body?.cancel(); } catch { /* best effort */ } - return jsonError(503, "link tunnel unavailable", true); + return homeUnreachable("link tunnel unavailable"); }; const readyForFetch = async (): Promise => { if (!tunnel) return false; @@ -393,7 +417,7 @@ export async function relayLinkDataRequest( if (error instanceof LinkRelayAuthenticationError) { cleanup(); try { await body?.cancel(); } catch { /* an upload might already be drained */ } - return jsonError(503, error.message, true); + return relayAuthenticationFailed(error.message); } if (bodyOverflow) { cleanup(); @@ -408,7 +432,7 @@ export async function relayLinkDataRequest( if (relayAbort.signal.aborted) { cleanup(); try { await upstream.body?.cancel(); } catch { /* best effort */ } - return jsonError(503, "link tunnel unavailable", true); + return homeUnreachable("link tunnel unavailable"); } const sse = isSse(upstream.headers); diff --git a/src/client/machine-listener.ts b/src/client/machine-listener.ts index 5954f2b45d3..5009507d641 100644 --- a/src/client/machine-listener.ts +++ b/src/client/machine-listener.ts @@ -22,9 +22,23 @@ import { readClientLinkState, type ClientLinkState } from "./link-state"; import { projectClientLinkChild, type ClientLinkSidecarRead } from "./link-status"; import type { ClientLinkSupervisorStatus } from "./link-tunnel"; import { packageVersion } from "../lib/package-version"; +import { createLocalAttestationProof, LOCAL_ATTESTATION_CHALLENGE_HEADER, LOCAL_ATTESTATION_PROOF_HEADER } from "../lib/local-management-attestation"; +import { + CLIENT_RESTART_BUDGET_MS, + parseExpectedSystemRestartPid, + RESTART_SUPERVISED_ERROR, + SYSTEM_RESTART_CAPABILITY_HEADER, + SYSTEM_RESTART_CAPABILITY_VERSION, + SYSTEM_RESTART_EXPECTED_PID_HEADER, + SYSTEM_RESTART_METHOD, + SYSTEM_RESTART_NONCE_HEADER, + SYSTEM_RESTART_PATH, + verifySystemRestartCapability, +} from "../lib/system-restart-contract"; import { linkRouteAllowed } from "../link/routes"; import { linkHomeName, linkHomePhrase } from "./link-home"; import { relayLinkManagementRead } from "./link-management-relay"; +import type { ClientRestartAdmission } from "./runtime-restart"; const VERSION = packageVersion("0.0.0"); const GUI_SPA_PATHS = new Set([ @@ -51,6 +65,17 @@ export interface MachineListenerDeps { linkTunnel?: LinkTunnelGate; /** Link mode: relay seams (deadline clock and byte cap). */ linkRelay?: LinkIngress["relay"]; + /** + * The bound restart (OCX-6). With it, `/healthz` proves this process's identity to a challenge, + * advertises `restartCapability` and `startedAt`, and `POST /api/system/restart` accepts a + * capability signed with `attestationSecret` for this pid and port, and nothing else. + */ + restart?: { + attestationSecret: string; + /** ISO start time of this runtime's process. */ + startedAt: string; + request: () => Promise; + }; serve?: (options: Parameters[0]) => Server; } @@ -68,6 +93,8 @@ export function machineRouteAllowed(url: URL, req: Request, relayEnabled: boolea if (linkMode && linkRouteAllowed(url, req)) return true; const path = url.pathname; if (req.method === "GET" && (path === "/healthz" || path === "/readyz" || path === "/" || path === "/opencodex-session")) return true; + // The bound restart. Only a capability signed for this exact process admits it (see the handler). + if (req.method === SYSTEM_RESTART_METHOD && path === SYSTEM_RESTART_PATH) return true; if ((req.method === "GET" || req.method === "HEAD") && (path === "/api/machine/status" || path === "/api/machine/clients" || path === "/api/machine/shim")) return true; // The one link route a connected Child serves: its own read-only link status. if (linkMode && (req.method === "GET" || req.method === "HEAD") && path === "/api/link/status") return true; @@ -143,7 +170,20 @@ export function startMachineListener( } if (!machineRouteAllowed(url, req, relayEnabled, linkMode)) return json404(req); if (url.pathname === "/healthz" && req.method === "GET") { - return Response.json({ service: "opencodex", version: VERSION, role: "client", uptime: process.uptime(), pid: process.pid, port: server.port }); + const restart = deps.restart; + const response = Response.json({ + service: "opencodex", version: VERSION, role: "client", uptime: process.uptime(), pid: process.pid, port: server.port, + ...(restart ? { startedAt: restart.startedAt, restartCapability: SYSTEM_RESTART_CAPABILITY_VERSION, restartBudgetMs: CLIENT_RESTART_BUDGET_MS } : {}), + }); + const challenge = restart ? req.headers.get(LOCAL_ATTESTATION_CHALLENGE_HEADER) : null; + const proof = challenge && server.port + ? createLocalAttestationProof(restart!.attestationSecret, challenge, process.pid, server.port) + : null; + if (proof) response.headers.set(LOCAL_ATTESTATION_PROOF_HEADER, proof); + return response; + } + if (url.pathname === SYSTEM_RESTART_PATH && req.method === SYSTEM_RESTART_METHOD) { + return await handleBoundRestart(req, server, deps.restart); } if (url.pathname === "/readyz" && req.method === "GET") { return Response.json({ service: "opencodex", version: VERSION, role: "client", status: "ready", uptime: process.uptime(), pid: process.pid, port: server.port, protocolVersion: 1 }); @@ -232,3 +272,45 @@ export function startMachineListener( }, } as Parameters[0]); } + +/** + * `POST /api/system/restart` on the client listener (OCX-6). The only accepted principal is the + * process-bound restart capability `ocx restart` derives from this runtime's attestation secret: + * no dashboard session, admin token or link key reaches it, and a browser cannot mint it. + */ +async function handleBoundRestart( + req: Request, + server: Server, + restart: MachineListenerDeps["restart"], +): Promise { + const noStore = { "cache-control": "no-store" }; + if (!restart || !server.port) return json404(req); + const expected = parseExpectedSystemRestartPid(req.headers.get(SYSTEM_RESTART_EXPECTED_PID_HEADER)); + if (expected.kind !== "present" || expected.pid !== process.pid) { + return Response.json({ success: false, error: "Restart target identity changed." }, { status: 409, headers: noStore }); + } + const authorized = verifySystemRestartCapability( + restart.attestationSecret, + req.headers.get(SYSTEM_RESTART_NONCE_HEADER), + SYSTEM_RESTART_METHOD, + SYSTEM_RESTART_PATH, + process.pid, + server.port, + req.headers.get(SYSTEM_RESTART_CAPABILITY_HEADER), + ); + if (!authorized) return Response.json({ success: false, error: "restart capability required" }, { status: 401, headers: noStore }); + const admission = await restart.request(); + if (admission.kind === "supervised") { + return Response.json({ + success: false, + error: { code: RESTART_SUPERVISED_ERROR, supervisor: admission.supervisor }, + }, { status: 409, headers: noStore }); + } + return Response.json({ + success: true, + message: admission.alreadyRestarting ? "Restart already in progress." : "Restarting the client runtime.", + activeTurnCount: 0, + drainTimeoutMs: CLIENT_RESTART_BUDGET_MS, + alreadyDraining: admission.alreadyRestarting, + }, { status: 202, headers: noStore }); +} diff --git a/src/client/runtime-restart.ts b/src/client/runtime-restart.ts new file mode 100644 index 00000000000..f5ad4417908 --- /dev/null +++ b/src/client/runtime-restart.ts @@ -0,0 +1,127 @@ +/** + * The connected client runtime's answer to the bound `POST /api/system/restart` (OCX-6). + * + * It is the same restart `ocx restart` drives on a standalone proxy, with one difference the + * Remote Link parity work requires: a failed restart keeps the old runtime. That is only possible + * when this process starts its own replacement, so: + * + * - Under the desktop app or a viable service supervisor the restart is refused (409 + * `restart_supervised`) and the runtime keeps serving. The supervisor owns this process and its + * port; handing off to it means exiting before anything proves the replacement can start. + * - Otherwise the listener drains, a replacement `ocx start` is spawned and must answer within the + * budget. Success exits this process. A replacement that exited, or never answered and was then + * stopped, leaves this process to bind the port again, start a fresh tunnel supervisor and + * republish its records: the old runtime keeps serving and the CLI, still seeing its pid, + * reports the restart as failed. + * + * Everything runs inside {@link CLIENT_RESTART_BUDGET_MS} from acceptance. + */ +import { + CLIENT_RESTART_DRAIN_MS, + CLIENT_RESTART_READY_BY_MS, + isDesktopSupervised, +} from "../lib/system-restart-contract"; +import type { OcxClientConnectionConfig } from "../types"; +import { sameClientConnectionOwner, type readClientConnectionState } from "./state"; + +type ClientConnectionState = ReturnType; + +export type ClientRestartSupervisor = "service" | "desktop"; + +export type ClientRestartAdmission = + | { kind: "accepted"; alreadyRestarting: boolean } + | { kind: "supervised"; supervisor: ClientRestartSupervisor }; + +export interface ClientRestartIo { + /** Which supervisor owns this process, or null when it starts its own replacement. */ + supervisor(): Promise; + /** Stop the tunnel supervisor and the listener, giving in-flight requests up to `drainMs`. */ + stop(drainMs: number): Promise; + /** Start the replacement; resolves once it answers, rejects when none survives. */ + spawnReplacement(deadlineAt: number): Promise; + /** Serve again on this process: listener, fresh tunnel supervisor, records. */ + resume(): Promise; + exitProcess(code: number): void; + now?: () => number; + sleep?: (ms: number) => Promise; + warn?: (message: string) => void; +} + +export interface ClientRestartController { + request(): Promise; + /** Settles when the restart in flight (if any) has finished or rolled back. Tests only. */ + settled(): Promise; +} + +/** Stable, path-free label for a failure (an error message can carry a path). */ +function failureCode(error: unknown): string { + const code = error && typeof error === "object" && "code" in error ? (error as { code?: unknown }).code : undefined; + return typeof code === "string" && /^[A-Za-z0-9_]{1,64}$/.test(code) ? code : "failed"; +} + +export function createClientRestartController(io: ClientRestartIo): ClientRestartController { + const now = io.now ?? Date.now; + const sleep = io.sleep ?? Bun.sleep; + const warn = io.warn ?? ((message: string) => console.warn(message)); + let flight: Promise | null = null; + + const run = async (acceptedAt: number): Promise => { + // Let the 202 reach the CLI before the listener starts closing. + await sleep(200); + try { + await io.stop(CLIENT_RESTART_DRAIN_MS); + } catch (error) { + warn(`[client] restart: stopping the runtime failed (${failureCode(error)}); continuing`); + } + try { + await io.spawnReplacement(acceptedAt + CLIENT_RESTART_READY_BY_MS); + } catch (error) { + warn(`[client] restart: the replacement did not start (${failureCode(error)}); this runtime keeps serving`); + try { + await io.resume(); + } catch (resumeError) { + warn(`[client] restart: this runtime could not serve again (${failureCode(resumeError)}); run 'ocx start'`); + io.exitProcess(1); + } + return; + } + io.exitProcess(0); + }; + + return { + async request() { + const supervisor = await io.supervisor(); + if (supervisor) return { kind: "supervised", supervisor }; + if (flight) return { kind: "accepted", alreadyRestarting: true }; + flight = run(now()).finally(() => { flight = null; }); + return { kind: "accepted", alreadyRestarting: false }; + }, + async settled() { + await flight; + }, + }; +} + +/** Production supervision check: the desktop app, then a service that will really relaunch us. */ +export async function clientRestartSupervisor(): Promise { + if (isDesktopSupervised()) return "desktop"; + if (process.env.OCX_SERVICE !== "1") return null; + // Same viability test as the standalone restart: stale service assets would not relaunch. + const { isServiceViable } = await import("../service/orchestration"); + return isServiceViable() ? "service" : null; +} + +/** + * After a failed replacement: serve the same connection again only while it is still the durable + * one (same owner and link). Anything else, a disconnect or a different connection, goes to the + * recycle, which starts what the current state calls for instead of a stale client runtime. + */ +export function clientRestartRollbackAction( + served: OcxClientConnectionConfig, + current: ClientConnectionState, +): "resume" | "recycle" { + return current.kind === "connected" && sameClientConnectionOwner(current.value, served) + && current.value.link?.linkId === served.link?.linkId + ? "resume" + : "recycle"; +} diff --git a/src/client/runtime.ts b/src/client/runtime.ts index 9ade2340e33..cb12800eb14 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -9,6 +9,7 @@ import { createLocalAttestationSecret } from "../lib/local-management-attestatio import { loadServiceTokenFromFile, serviceApiTokenFingerprint } from "../lib/service-secrets"; import { handledSignalExitCode } from "../lib/handled-signal-exit"; import { + CLIENT_RESTART_REBIND_WAIT_MS, DESKTOP_RESTART_EXIT_CODE, DESKTOP_SUPERVISED_ENV, DESKTOP_SUPERVISED_PORT_WAIT_MS, @@ -23,12 +24,22 @@ import { createClientLinkSupervisor, type ClientLinkSupervisor } from "./link-tu import { clientLinkStatePath, isChildInitiatedLink, recordChildInitiatedLink } from "./link-state"; import { startMachineListener, type MachineListenerDeps } from "./machine-listener"; import { isLinkConnection, readClientConnectionState } from "./state"; +import { withClientLifecycleSync } from "./lifecycle-lock"; +import { clientRestartRollbackAction, clientRestartSupervisor, createClientRestartController, type ClientRestartController } from "./runtime-restart"; +import { createLinkParityReporter, type LinkParityReporter } from "./link-parity-reporter"; let activeServer: Server | null = null; let activePort: number | null = null; let activeSupervisor: ClientLinkSupervisor | null = null; +let activeReporter: LinkParityReporter | null = null; let recycleScheduled = false; +/** + * When this runtime's process started (OCX-6, P5): what `/healthz` reports as `startedAt` and the + * Child reports to its Home. A Home settings apply recorded after it means this runtime predates it. + */ +export const CLIENT_RUNTIME_STARTED_AT = new Date(Date.now() - process.uptime() * 1000).toISOString(); + /** * How long link mode waits for its configured port: the budget a hard-pinned `ocx start --port` * gives `reclaimListenPort` (`src/cli/index.ts`). @@ -53,6 +64,8 @@ export interface ClientRuntimeIo { /** Link-mode port budget; defaults to {@link LINK_PORT_WAIT_MS}. */ portWaitMs?: number; startListener?: typeof startMachineListener; + /** Which supervisor owns this process for a bound restart; tests replace the production check. */ + restartSupervisor?: () => Promise<"service" | "desktop" | null>; } export interface StandaloneRecycleIo { @@ -214,7 +227,7 @@ export async function bindClientListener( preferred: number; explicitPort: boolean; configuredPort: number; - } & Pick, + } & Pick, io: ClientRuntimeIo = {}, ): Promise<{ server: Server; port: number }> { const { linkMode } = request; @@ -254,6 +267,7 @@ export async function bindClientListener( ...(request.linkStatus ? { linkStatus: request.linkStatus } : {}), ...(request.linkKeySource ? { linkKeySource: request.linkKeySource } : {}), ...(request.linkTunnel ? { linkTunnel: request.linkTunnel } : {}), + ...(request.restart ? { restart: request.restart } : {}), }); return { server, port: server.port ?? port }; } catch (error) { @@ -286,33 +300,129 @@ export async function startClientRuntime( if (linkMode && state.value.link) { try { recordChildInitiatedLink(state.value.link.linkId); } catch { /* the sidecar check below still applies */ } } - const supervisor = linkMode && existsSync(clientLinkStatePath()) - ? createClientLinkSupervisor({ - onLinkEnded: () => scheduleStandaloneRecycle(state.value.tokenFingerprint), - linkKey, - }) - : null; + const supervised = linkMode && existsSync(clientLinkStatePath()); // A Child with no sidecar and no record of joining itself was connected by its Home over // `ssh -R`; that link keeps 2.67.0's unproven forward. A Child-initiated link that lost its // sidecar gets no gate at all, so the relay refuses it. - const homeInitiated = linkMode && !supervisor && !!state.value.link + const homeInitiated = linkMode && !supervised && !!state.value.link && !isChildInitiatedLink(state.value.link.linkId); - const { server, port: boundPort } = await bindClientListener({ - state: state.value, - linkMode, - preferred, - explicitPort: options.port !== undefined, - configuredPort: config.port, - ...(linkMode ? { linkStatus: () => supervisor?.status() ?? { kind: "stopped" as const }, linkKeySource: linkKey } : {}), - ...(supervisor ? { linkTunnel: supervisor } : homeInitiated ? { linkTunnel: HOME_INITIATED_LINK_TUNNEL } : {}), - }, io); - activeServer = server; - activePort = boundPort; - activeSupervisor = supervisor; - supervisor?.start(); + // One secret for the life of this process: a rollback republishes the same identity. + const attestationSecret = createLocalAttestationSecret(); + + // Bind, start a fresh tunnel supervisor and publish. Runs once at start and again when a failed + // restart rolls back (OCX-6); the stopped supervisor cannot be started twice, so each call builds one. + // The port this process actually serves on; a rollback binds exactly this one again. + let servedPort = preferred; + // Returns false only for a rollback whose connection changed while it was binding: the new + // listener is then closed unpublished and the caller recycles. + const serve = async (rollback?: { port: number; portWaitMs: number }): Promise => { + const supervisor = supervised + ? createClientLinkSupervisor({ + onLinkEnded: () => scheduleStandaloneRecycle(state.value.tokenFingerprint), + linkKey, + }) + : null; + const { server, port: boundPort } = await bindClientListener({ + state: state.value, + linkMode, + // A rollback takes back exactly the port it gave up: never the preferred one, never another. + preferred: rollback?.port ?? preferred, + explicitPort: rollback !== undefined || options.port !== undefined, + configuredPort: config.port, + restart: { attestationSecret, startedAt: CLIENT_RUNTIME_STARTED_AT, request: () => restartController.request() }, + ...(linkMode ? { linkStatus: () => activeSupervisor?.status() ?? { kind: "stopped" as const }, linkKeySource: linkKey } : {}), + ...(supervisor ? { linkTunnel: supervisor } : homeInitiated ? { linkTunnel: HOME_INITIATED_LINK_TUNNEL } : {}), + }, rollback === undefined ? io : { ...io, portWaitMs: rollback.portWaitMs }); + const publish = (): void => { + activeServer = server; + activePort = boundPort; + servedPort = boundPort; + activeSupervisor = supervisor; + supervisor?.start(); + writePid(process.pid); + writeRuntimePort(clientRuntimeRecord(process.pid, boundPort, attestationSecret)); + if (linkMode && state.value.link && linkKey) { + activeReporter = createLinkParityReporter({ connection: state.value, linkKey, runtimeStartedAt: CLIENT_RUNTIME_STARTED_AT }); + activeReporter.start(); + } + }; + if (!rollback) { + publish(); + return true; + } + // A rollback publishes under the client lifecycle lock, after rechecking the connection there, + // so a disconnect or connect that committed during the bind can never be served over. A lock + // held by such an operation counts as a change. + let published = false; + try { + published = withClientLifecycleSync(() => { + if (clientRestartRollbackAction(state.value, readClientConnectionState()) === "recycle") return false; + publish(); + return true; + }); + } catch { + published = false; + } + if (!published) { + try { await server.stop(true); } catch { /* never published */ } + } + return published; + }; + + // Stop everything this process serves, giving in-flight requests up to `drainMs`. + const stopServing = async (drainMs: number): Promise => { + activeReporter?.stop(); + activeReporter = null; + try { + await activeSupervisor?.stop(); + } catch (error) { + console.warn(`[client] link supervisor stop failed: ${error instanceof Error ? error.message : String(error)}`); + } + activeSupervisor = null; + const server = activeServer; + activeServer = null; + if (!server) return; + let timer: ReturnType | undefined; + try { + await Promise.race([ + Promise.resolve(server.stop(false)), + new Promise(resolve => { timer = setTimeout(resolve, drainMs); }), + ]); + } catch { /* closed below */ } finally { + if (timer) clearTimeout(timer); + } + try { await server.stop(true); } catch { /* already closed */ } + }; + + const restartController: ClientRestartController = createClientRestartController({ + supervisor: io.restartSupervisor ?? clientRestartSupervisor, + stop: stopServing, + spawnReplacement: async deadlineAt => { + const { replacementStartEnvironment } = await import("../server/management/system-restart"); + const { spawnReplacementStart } = await import("../server/restart-replacement"); + await spawnReplacementStart( + { port: activePort ?? config.port, waitForHealth: true, env: replacementStartEnvironment(true), notReady: "terminate" }, + { deadlineAt }, + ); + }, + resume: async () => { + // The connection may have changed while the replacement was starting (`ocx disconnect`, a + // new connect). Serving the old one again would be a stale runtime nothing recycles; hand + // off to the recycle, which starts whatever the durable state now calls for. + if (clientRestartRollbackAction(state.value, readClientConnectionState()) === "recycle") { + await recycleStandalone(state.value.tokenFingerprint); + return; + } + if (!(await serve({ port: servedPort, portWaitMs: CLIENT_RESTART_REBIND_WAIT_MS }))) { + await recycleStandalone(state.value.tokenFingerprint); + } + }, + exitProcess: code => { process.exit(code); }, + }); + installCrashGuards(); - writePid(process.pid); - writeRuntimePort(clientRuntimeRecord(process.pid, boundPort)); + // serve() publishes the pid and runtime record; the ownership lease is released only after it. + await serve(); options.afterPublish?.(); let shuttingDown = false; @@ -320,14 +430,16 @@ export async function startClientRuntime( if (shuttingDown) return; shuttingDown = true; void (async () => { + activeReporter?.stop(); + activeReporter = null; try { - await supervisor?.stop(); + await activeSupervisor?.stop(); } catch (error) { console.warn(`[client] link supervisor stop failed: ${error instanceof Error ? error.message : String(error)}`); } activeSupervisor = null; try { - server.stop(true); + activeServer?.stop(true); } catch (error) { console.warn(`[client] listener stop failed: ${error instanceof Error ? error.message : String(error)}`); } finally { diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index f804912e339..5afdd2230fd 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -98,6 +98,7 @@ import { getMainReserveAuthorization, isMainReserveAuthorizationLive, nativeUser import { UpstreamRetryEvidenceError } from "../lib/upstream-retry"; import { getEffectiveCodexAutoSwitchThreshold } from "./account-auto-switch"; import { CODEX_MAIN_DUPLICATE_ACTION, configuredCodexMainDuplicate } from "./collision-identity"; +import { AgentAssertionUnsupportedError, authorizationCredential, hasAgentAssertionAuthorization } from "../lib/authorization-scheme"; /** * A request-owned bearer cannot inspect the physical main credential for its plan, but cached @@ -701,7 +702,7 @@ async function authorizeReserveCredential( function selectedCodexToken(headers: Headers): { accessToken: string; chatgptAccountId: string } { return { - accessToken: headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim() ?? "", + accessToken: authorizationCredential(headers) ?? "", chatgptAccountId: headers.get("chatgpt-account-id") ?? "", }; } @@ -716,7 +717,7 @@ function selectedCodexToken(headers: Headers): { accessToken: string; chatgptAcc export function callerCodexWorkspaceAccountId(headers: Headers): string | undefined { const explicit = headers.get("chatgpt-account-id"); if (explicit) return explicit; - const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + const bearer = authorizationCredential(headers); return bearer ? extractAccountId(undefined, bearer) : undefined; } @@ -807,7 +808,7 @@ function assertMainAccountPolicy( /** No auth-file I/O: an unsigned claim alone never identifies a caller as stored main. */ function callerMatchesObservedMain(headers: Headers): boolean { - const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + const bearer = authorizationCredential(headers); if (!bearer) return false; const effectiveAccountId = headers.get("chatgpt-account-id") ?? extractAccountId(undefined, bearer); @@ -831,7 +832,7 @@ function sameCredentialMaterial(a: string, b: string): boolean { * cooldown — the safe direction. */ function callerIsCooledPoolAccount(headers: Headers, config: OcxConfig, accountId: string): boolean { - const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + const bearer = authorizationCredential(headers); if (!bearer) return true; const callerAccountId = headers.get("chatgpt-account-id") ?? extractAccountId(undefined, bearer); if (callerAccountId === undefined) return true; @@ -1767,7 +1768,7 @@ export class CodexMainSubstitutionUnavailableError extends Error { /** Dispatch identity comes only from an owned observation of the selected credential. */ function selectedMainQuotaDispatch(selected: Headers): MainQuotaDispatch | undefined { - const bearer = selected.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + const bearer = authorizationCredential(selected); if (!bearer) return undefined; const accountId = selected.get("chatgpt-account-id") ?? extractAccountId(undefined, bearer); return captureMainQuotaDispatch(bearer, accountId, captureConfigGeneration()); @@ -1792,6 +1793,8 @@ export function materializeCodexUpstreamAuth( ctx: CodexAuthContext, options: CodexAuthMaterializationOptions = {}, ): Headers { + // assertion_only must never be answered with a pool, stored-main, or other substituted Bearer. + if (hasAgentAssertionAuthorization(headers)) throw new AgentAssertionUnsupportedError(); if (ctx.kind === "main") ctx.mainQuotaDispatch = undefined; const selected = new Headers(); for (const name of FORWARD_HEADERS) { @@ -1814,7 +1817,7 @@ export function materializeCodexUpstreamAuth( } if (ctx.kind === "main" && options.substituteMainCredential !== true && !selected.has("chatgpt-account-id")) { - const bearer = selected.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + const bearer = authorizationCredential(selected); const accountId = bearer ? extractAccountId(undefined, bearer) : undefined; if (accountId) selected.set("chatgpt-account-id", accountId); } @@ -1892,6 +1895,7 @@ export async function materializeCodexUpstreamAuthAsync( ctx: CodexAuthContext, options: CodexAuthMaterializationOptions = {}, ): Promise { + if (hasAgentAssertionAuthorization(headers)) throw new AgentAssertionUnsupportedError(); if (ctx.kind === "main") ctx.mainQuotaDispatch = undefined; if (requiresReserveAuthorization(options.config, options.modelId, options.admission)) { return materializeReserveUpstreamAuth(headers, ctx, options); diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index f5d0a860f8e..bdab81c61e3 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -809,6 +809,10 @@ export function normalizeRoutedCatalogEntry( delete entry.multi_agent_version; delete entry.multi_agent_reasoning_effort; delete entry.use_responses_lite; + // Codex 0.164 sends positional `configuration_update` effort items only for rows declaring this + // and freezes the request-level effort for the context window. A routed provider never + // negotiated that contract, so a row cloned from a GPT-6 template must not inherit it. + delete entry.supports_reasoning_effort_updates; delete entry.supports_websockets; // Routed rows cloned from native templates must not inherit OpenAI-only experimental context // delivery. Codex reads the flag as "this model accepts experimental context history" and drives diff --git a/src/codex/desktop-compatibility/windows-key-protection.ts b/src/codex/desktop-compatibility/windows-key-protection.ts index fc1a11b54f6..a4df04267c3 100644 --- a/src/codex/desktop-compatibility/windows-key-protection.ts +++ b/src/codex/desktop-compatibility/windows-key-protection.ts @@ -10,17 +10,24 @@ const ENTROPY = new TextEncoder().encode(PURPOSE); // Only the PowerShell fallback is bounded by a clock. A cold Windows PowerShell that must also load // System.Security has been measured past 10s on a loaded machine (Windows CI run 38591626343). const DPAPI_TIMEOUT_MS = 20_000; +// Cmdlet-free on purpose. The first cmdlet a PowerShell session runs (Add-Type, ConvertFrom-Json) +// sends it through command discovery and module auto-loading, which without a module-analysis cache +// for the profile scans the modules on PSModulePath. The test runner gives every run a fresh profile +// and CI images carry large module sets, so that scan, not DPAPI, is what cold runs waited on. +// Assembly loading, string splitting and base64 are plain .NET calls outside command discovery. +// The request is "\n" on stdin. const SCRIPT = [ "$ErrorActionPreference='Stop'", - "Add-Type -AssemblyName System.Security", - "$r=[Console]::In.ReadToEnd()|ConvertFrom-Json", - "$data=[Convert]::FromBase64String($r.data)", + "[void][Reflection.Assembly]::Load('System.Security, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a')", + "$r=[Console]::In.ReadToEnd().Split([char]10)", + "if($r.Length -ne 2){throw 'Invalid request'}", + "$data=[Convert]::FromBase64String($r[1])", `if($data.Length -eq 0 -or $data.Length -gt ${MAX_INPUT}){throw 'Invalid data size'}`, `$entropy=[Text.Encoding]::UTF8.GetBytes('${PURPOSE}')`, "$scope=[Security.Cryptography.DataProtectionScope]::CurrentUser", - "if($r.operation -eq 'protect'){", + "if($r[0] -ceq 'protect'){", " $out=[Security.Cryptography.ProtectedData]::Protect($data,$entropy,$scope)", - "}elseif($r.operation -eq 'unprotect'){", + "}elseif($r[0] -ceq 'unprotect'){", " $out=[Security.Cryptography.ProtectedData]::Unprotect($data,$entropy,$scope)", "}else{throw 'Invalid operation'}", "[Console]::Out.Write([Convert]::ToBase64String($out))", @@ -126,7 +133,7 @@ function runPowerShellDpapi(operation: Operation, data: Uint8Array, timeoutMs = resolve(bytes); }); child.stdin?.on("error", () => { /* execFile's completion reports the failed child. */ }); - child.stdin?.end(JSON.stringify({ operation, data: Buffer.from(data).toString("base64") })); + child.stdin?.end(`${operation}\n${Buffer.from(data).toString("base64")}`); }); } @@ -163,3 +170,8 @@ export function powerShellAuthorityKeyProtectionForTests(timeoutMs: number): Aut export function usesNativeDpapiForTests(): boolean { return process.platform === "win32" && loadNativeDpapi() !== null; } + +/** Test-only: the PowerShell helper's script, so its cmdlet-free contract can be checked anywhere. */ +export function powerShellDpapiScriptForTests(): string { + return SCRIPT; +} diff --git a/src/codex/quota-history.ts b/src/codex/quota-history.ts index a792f89597c..099a7562fe2 100644 --- a/src/codex/quota-history.ts +++ b/src/codex/quota-history.ts @@ -92,6 +92,13 @@ export class CodexQuotaHistory { return { samples: structuredClone(bucket.samples.slice(-capped)), truncated: bucket.samples.length > capped }; } + /** The newest sample's origin, without pruning or clearing: a passive read changes nothing. */ + peekLatestSource(accountId: string, identity: string | undefined): QuotaHistorySample["source"] | undefined { + const bucket = this.accounts.get(accountId); + if (!identity || !bucket || bucket.identity !== identity) return undefined; + return bucket.samples.at(-1)?.source; + } + clear(accountId?: string): number { if (accountId === undefined) { const count = this.accounts.size; diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 334c8515801..0088c4968d6 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -1099,3 +1099,13 @@ export function getAccountQuotaHistory(accountId: string, limit: number = QUOTA_ return { observations: result.samples.map(({ credentialGeneration: _generation, ...sample }) => sample), truncated: result.truncated, retention: { maxObservations: QUOTA_HISTORY_LIMITS.perAccount, maxAgeDays: 30 } }; } + +/** + * Where the newest stored reading for a pool account came from (a WHAM usage body or upstream + * response headers), or undefined when no current history exists. Read-only: unlike + * `getAccountQuotaHistory` it never prunes or clears a mismatched bucket. + */ +export function latestCodexQuotaObservationSource(accountId: string): "wham" | "response-header" | undefined { + hydrateAccountQuotasFromDisk(); + return quotaHistory.peekLatestSource(accountId, poolQuotaHistoryIdentity(accountId)); +} diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index ed0d6c8b0b3..073903b6326 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -38,6 +38,7 @@ import { isCanonicalOpenAiForwardProvider, } from "../providers/openai-tiers"; import { routeModel, type RouteResult } from "../router"; +import { guardFallbackHop, type FallbackAdmission } from "../routing/fallback-policy"; import { sweepExpiredOnWrite } from "../lib/state-store-sweeper"; import { codexAccountNamespaceForModel } from "./account-namespace-match"; import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_MAIN_DRAIN_SENTINEL_MODELS } from "./catalog/native-models"; @@ -629,7 +630,8 @@ export function applySubagentModelFallback( modelEligibleAccountIdsForModel?: SubagentModelEligibleAccountIds, resolvedFallbackChain?: readonly string[] | null, restrictedRouteEligible?: SubagentFallbackRouteEligibility, -): { from?: string; to?: string; skipped?: string[] } | null { + admission?: FallbackAdmission, +): { from?: string; to?: string; skipped?: string[]; refusal?: Response } | null { if (!isThreadSpawnRequest(headers)) return null; const fallbackChain = resolvedFallbackChain === undefined ? resolveSubagentFallbackChain(parsed, config) @@ -653,10 +655,31 @@ export function applySubagentModelFallback( ? { from: parsed.modelId, to: parsed.modelId, skipped: selection.skipped } : null; const from = parsed.modelId; + // OCX-11: the rewrite is a proactive hop away from an unavailable primary. Refused, the spawn + // keeps its model and the caller answers with the refusal instead of sending anything. + const hop = guardFallbackHop(config, { + site: "subagent", + route: from, + from: fallbackTargetOf(config, from), + to: fallbackTargetOf(config, selection.model), + trigger: "unavailable", + admission, + now, + }); + if (!hop.ok) return { from, to: from, skipped: selection.skipped, refusal: hop.response }; + // The rewrite is this site's admission: the spawn now carries the fallback model. + hop.commit(); rewriteParsedModel(parsed, selection.model); return { from, to: selection.model, skipped: selection.skipped }; } +function fallbackTargetOf(config: OcxConfig, model: string): { provider: string; model: string } { + const route = tryRouteFallbackModel(config, model); + if (route) return { provider: route.providerName, model: route.modelId }; + const slash = model.indexOf("/"); + return slash > 0 ? { provider: model.slice(0, slash), model: model.slice(slash + 1) } : { provider: "", model }; +} + /** Resolve the effective fallback chain once for one logical spawn request. */ export function resolveSubagentFallbackChain( parsed: OcxParsedRequest, diff --git a/src/combos/index.ts b/src/combos/index.ts index 0b22c99d814..d1a7d275a6f 100644 --- a/src/combos/index.ts +++ b/src/combos/index.ts @@ -23,6 +23,7 @@ export { advanceComboAfterFailure, clearComboSelectionState, comboExclusionReasons, + comboTargetSkipTrigger, NoAvailableComboTargetsError, noteComboFailure, noteComboSuccess, diff --git a/src/combos/resolve.ts b/src/combos/resolve.ts index 6f05123717a..bf7bfe125f6 100644 --- a/src/combos/resolve.ts +++ b/src/combos/resolve.ts @@ -74,6 +74,18 @@ function targetProviderIsUsable(config: OcxConfig, target: OcxComboTarget, now: return providerExclusionReason(config, target, now) === undefined; } +/** + * OCX-11: the coarse fallback trigger for a target the picker passed over for a reason other + * than a cooldown (provider missing or disabled, cached quota spent, ineligible for the request). + */ +export function comboTargetSkipTrigger( + config: OcxConfig, + target: OcxComboTarget, + now = Date.now(), +): "quota_exhausted" | "unavailable" { + return providerExclusionReason(config, target, now) === "quota exhausted" ? "quota_exhausted" : "unavailable"; +} + /** * Why each target of a combo cannot be picked right now, for a zero-attempt refusal. Only the * configured provider/model and a coarse label are named, never account or request data. diff --git a/src/config/diagnostics.ts b/src/config/diagnostics.ts index 1b5ed47bb71..78515ce9977 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -11,6 +11,7 @@ import { nativeReasoningRetentionConfigError } from "./schema/native-reasoning-r import { desktopCompatibilityConfigError } from "./schema/desktop-compatibility"; import { blockedModelRedirectsError } from "./schema/blocked-model-redirects"; import { mainAccountHardLockFallbackError } from "./schema/main-account-hard-lock-fallback"; +import { fallbackPolicyError } from "./schema/fallback-policy"; import type { OcxConfig } from "../types"; import { anthropicAccountPoolConfigError, anthropicOAuthInstanceConfigError, anthropicSidecarConfigError } from "./schema/anthropic-account-pool"; import { configReasoningPinsConfigError } from "./provider-validation"; @@ -145,6 +146,7 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf if (blockedModelRedirectsError(rawParsed)) warnings.push("blockedModelRedirects ignored: expected a map of nonempty model keys to nonempty string targets"); if (nativeReasoningRetentionConfigError(rawParsed)) warnings.push("nativeReasoningRetention ignored: expected only optional boolean modelSwitch and accountSwitch fields; both remain off"); if (mainAccountHardLockFallbackError(rawParsed)) warnings.push("codexMainAccountHardLockFallback ignored: expected a nonblank model selector and an optional declared reasoningEffort"); + if (fallbackPolicyError(rawParsed)) warnings.push("fallback partly ignored: expected boolean model/provider switches, providers/routes/devices maps of them, and devices..localFallback; ignored leaves keep today's fallback behavior"); const quotaAutoRefreshWarning = degradedCodexQuotaAutoRefreshWarning(rawParsed, normalized); if (quotaAutoRefreshWarning) warnings.push(quotaAutoRefreshWarning); if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) { @@ -729,6 +731,9 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx const anthropicError = anthropicOAuthInstanceConfigError(value) ?? anthropicAccountPoolConfigError(value) ?? anthropicSidecarConfigError(value); if (anthropicError) return { ok: false, error: anthropicError }; const boundaryError = blockedModelRedirectsError(value) + // OCX-11: the read schema degrades a malformed fallback leaf to absent, which would silently + // drop a refusal on the next whole-config save; the write boundary rejects it instead. + ?? fallbackPolicyError(value) ?? nativeReasoningRetentionConfigError(value) ?? mainAccountHardLockFallbackError(value) ?? compactionRecoveryConfigError(value) ?? desktopCompatibilityConfigError(value) ?? configReasoningPinsConfigError(value) diff --git a/src/config/load-degrade.ts b/src/config/load-degrade.ts index 0dd593bf863..2fba9e30102 100644 --- a/src/config/load-degrade.ts +++ b/src/config/load-degrade.ts @@ -7,6 +7,7 @@ import { nativeReasoningRetentionConfigError } from "./schema/native-reasoning-r import { desktopCompatibilityConfigError } from "./schema/desktop-compatibility"; import { blockedModelRedirectsError } from "./schema/blocked-model-redirects"; import { mainAccountHardLockFallbackError } from "./schema/main-account-hard-lock-fallback"; +import { fallbackPolicyError } from "./schema/fallback-policy"; import { anthropicSidecarConfigError } from "./schema/anthropic-account-pool"; import { modelPinnedEffortsConfigError, @@ -142,6 +143,7 @@ export function warnDegradedTopLevelOptIns(rawParsed: unknown, validated: OcxCon if (desktopCompatibilityConfigError(rawParsed)) console.warn("Invalid desktopCompatibility startup preference ignored; no desktop compatibility service will start automatically."); if (blockedModelRedirectsError(rawParsed)) console.warn("⚠️ invalid blockedModelRedirects ignored; provider routing remains available"); if (mainAccountHardLockFallbackError(rawParsed)) console.warn("⚠️ invalid codexMainAccountHardLockFallback ignored; the main-account hard lock still refuses while blocked"); + if (fallbackPolicyError(rawParsed)) console.warn("⚠️ invalid fallback leaves ignored; those scopes keep today's fallback behavior"); const chatgptDesktop = chatgptDesktopConfigIssue(rawParsed); if (chatgptDesktop) console.warn(`⚠️ config.json ${chatgptDesktop} — the whole chatgptDesktop block is ignored, so the ChatGPT desktop integration reads as off`); warnDegradedStreamMode(rawParsed, validated); diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index ddf640a8a15..d4a561836f3 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -8,6 +8,7 @@ import { nativeReasoningRetentionSchema } from "./native-reasoning-retention"; import { desktopCompatibilitySchema } from "./desktop-compatibility"; import { blockedModelRedirectsSchema } from "./blocked-model-redirects"; import { mainAccountHardLockFallbackSchema } from "./main-account-hard-lock-fallback"; +import { fallbackPolicySchema } from "./fallback-policy"; import { agentTaskRecoverySchema, catalogAutoRefreshSchema, @@ -241,6 +242,8 @@ export const configSchema = z.object({ // A malformed fallback section disables only the reroute, never the lock's refusal // behavior; the management write boundary still rejects the bad value. codexMainAccountHardLockFallback: mainAccountHardLockFallbackSchema.optional().catch(undefined), + // OCX-11: a malformed section reads as absent, which keeps today's fallback behavior. + fallback: fallbackPolicySchema.optional().catch(undefined), // #2999: a malformed hand edit falls back to auto detection rather than rejecting the config. nativeMainRefresh: z.enum(["codex", "opencodex"]).optional().catch(undefined), // Future versions remain opaque through passthrough-compatible whole-config saves. diff --git a/src/config/schema/fallback-policy.ts b/src/config/schema/fallback-policy.ts new file mode 100644 index 00000000000..9dfc4f4e352 --- /dev/null +++ b/src/config/schema/fallback-policy.ts @@ -0,0 +1,40 @@ +import * as z from "zod/v4"; + +/** + * Read schema of `fallback` (OCX-11). A malformed leaf degrades to absent, which is today's + * behavior, and config diagnostics name it; the management write boundary rejects it instead. + * Map keys are checked by the resolver's normalization, so one bad key never drops its siblings. + */ +const switchValue = z.boolean().optional().catch(undefined); +const switchesSchema = z.object({ model: switchValue, provider: switchValue }); +const deviceSchema = switchesSchema.extend({ localFallback: switchValue }); + +export const fallbackPolicySchema = z.object({ + model: switchValue, + provider: switchValue, + providers: z.record(z.string(), switchesSchema.catch({})).optional().catch(undefined), + routes: z.record(z.string(), switchesSchema.catch({})).optional().catch(undefined), + devices: z.record(z.string(), deviceSchema.catch({})).optional().catch(undefined), +}); + +export const FALLBACK_DEVICE_ID = /^(?:lnk_[0-9a-f]{16}|[A-Za-z0-9][A-Za-z0-9_.:-]{0,255})$/; +export const FALLBACK_SELECTOR = /^[^\s]{1,256}$/; + +const strictSwitches = z.object({ model: z.boolean().optional(), provider: z.boolean().optional() }).strict(); +const strictPolicy = z.object({ + model: z.boolean().optional(), + provider: z.boolean().optional(), + providers: z.record(z.string().regex(FALLBACK_SELECTOR), strictSwitches).optional(), + routes: z.record(z.string().regex(FALLBACK_SELECTOR), strictSwitches).optional(), + devices: z.record(z.string().regex(FALLBACK_DEVICE_ID), strictSwitches.extend({ localFallback: z.boolean().optional() }).strict()).optional(), +}).strict(); + +/** Diagnostics text for a section the read schema would partly ignore, or undefined. */ +export function fallbackPolicyError(rawConfig: unknown): string | undefined { + if (!rawConfig || typeof rawConfig !== "object" || Array.isArray(rawConfig)) return undefined; + const raw = (rawConfig as Record).fallback; + if (raw === undefined) return undefined; + return strictPolicy.safeParse(raw).success + ? undefined + : "schema_invalid: fallback accepts boolean model/provider switches, providers/routes/devices maps of them, and devices..localFallback"; +} diff --git a/src/lib/authorization-scheme.ts b/src/lib/authorization-scheme.ts new file mode 100644 index 00000000000..eab24026622 --- /dev/null +++ b/src/lib/authorization-scheme.ts @@ -0,0 +1,95 @@ +/** + * One parser for the inbound `Authorization` scheme. + * + * Bearer reads in the proxy used `replace(/^Bearer\s+/i, "")`, which returns any other value + * unchanged. Codex 0.164 runtime agent instances (`features.runtime_agent_instances`) can send + * `Authorization: AgentAssertion `, and that replace handed the whole value on as an opaque + * bearer. An agent assertion is never a bearer: it is not an opencodex admission secret, it is not + * a ChatGPT credential, and it must never be forwarded as `Bearer`. + * + * The legacy read is preserved for every other value, including a scheme-less token, so this file + * changes nothing except the AgentAssertion case. + */ + +/** The companion header Codex sends in `dual_header` mode next to an ordinary Bearer. */ +export const AGENT_ASSERTION_HEADER = "x-openai-agent-assertion"; + +/** Stable error code for `Authorization: AgentAssertion`, which opencodex does not route. */ +export const AGENT_ASSERTION_UNSUPPORTED_CODE = "agent_assertion_unsupported"; + +export const AGENT_ASSERTION_UNSUPPORTED_MESSAGE = "Codex runtime agent assertion_only authentication " + + "(Authorization: AgentAssertion) is not supported through opencodex pooling or routing. " + + "Use dual_header mode, or turn off features.runtime_agent_instances."; + +export type AuthorizationScheme = + | { kind: "none" } + | { kind: "bearer"; token: string } + | { kind: "agent-assertion" } + | { kind: "other"; credential: string }; + +const BEARER_PREFIX = /^Bearer\s+/i; +// RFC 9110 auth schemes are case-insensitive; the scheme ends at whitespace or end of value. +const AGENT_ASSERTION_SCHEME = /^\s*AgentAssertion(?:\s|$)/i; + +/** Classify an Authorization value. The assertion's own bytes are deliberately not returned. */ +export function parseAuthorizationScheme(value: string | null | undefined): AuthorizationScheme { + if (value == null) return { kind: "none" }; + if (AGENT_ASSERTION_SCHEME.test(value)) return { kind: "agent-assertion" }; + if (BEARER_PREFIX.test(value)) { + const token = value.replace(BEARER_PREFIX, "").trim(); + return token ? { kind: "bearer", token } : { kind: "none" }; + } + const credential = value.trim(); + return credential ? { kind: "other", credential } : { kind: "none" }; +} + +/** + * The credential the legacy `replace(/^Bearer\s+/i, "").trim()` read produced, except that an + * AgentAssertion value yields nothing. Use this wherever that replace used to be. + */ +export function authorizationCredential(headers: Pick): string | undefined { + const parsed = parseAuthorizationScheme(headers.get("authorization")); + if (parsed.kind === "bearer") return parsed.token; + if (parsed.kind === "other") return parsed.credential; + return undefined; +} + +/** Whether the request authenticates with `Authorization: AgentAssertion` (assertion_only). */ +export function hasAgentAssertionAuthorization(headers: Pick): boolean { + return parseAuthorizationScheme(headers.get("authorization")).kind === "agent-assertion"; +} + +export class AgentAssertionUnsupportedError extends Error { + readonly code = AGENT_ASSERTION_UNSUPPORTED_CODE; + constructor() { + super(AGENT_ASSERTION_UNSUPPORTED_MESSAGE); + this.name = "AgentAssertionUnsupportedError"; + } +} + +/** The typed refusal. It never echoes the request's Authorization or assertion header. */ +export function agentAssertionUnsupportedResponse(): Response { + return Response.json({ + error: { + message: AGENT_ASSERTION_UNSUPPORTED_MESSAGE, + type: "invalid_request_error", + code: AGENT_ASSERTION_UNSUPPORTED_CODE, + }, + }, { status: 400, headers: { "cache-control": "no-store" } }); +} + +/** + * Carry the dual_header assertion onto first-party Direct headers, and only when the outbound + * Authorization is the caller's own Bearer, byte for byte. A pool account, a substituted main + * credential, or a stripped Authorization means the assertion would accompany a credential it was + * not sent with, so it is dropped instead. + */ +export function carryAgentAssertionForCallerBearer(target: Headers, caller: Pick): void { + target.delete(AGENT_ASSERTION_HEADER); + const assertion = caller.get(AGENT_ASSERTION_HEADER); + if (!assertion) return; + const callerAuth = parseAuthorizationScheme(caller.get("authorization")); + const outboundAuth = parseAuthorizationScheme(target.get("authorization")); + if (callerAuth.kind !== "bearer" || outboundAuth.kind !== "bearer" || callerAuth.token !== outboundAuth.token) return; + target.set(AGENT_ASSERTION_HEADER, assertion); +} diff --git a/src/lib/provider-client-headers.ts b/src/lib/provider-client-headers.ts index 600173860ec..37208a29598 100644 --- a/src/lib/provider-client-headers.ts +++ b/src/lib/provider-client-headers.ts @@ -21,6 +21,7 @@ const BLOCKED_FORWARDED_CLIENT_HEADERS = new Set([ "api-key", "x-goog-api-key", "x-oai-attestation", + "x-openai-agent-assertion", ]); const ALLOWED_FORWARDED_CLIENT_HEADERS = new Set([ diff --git a/src/lib/redact.ts b/src/lib/redact.ts index 76d80641b9b..fcde66a7361 100644 --- a/src/lib/redact.ts +++ b/src/lib/redact.ts @@ -7,7 +7,7 @@ export const REDACTED_SECRET = "[REDACTED]"; * credentials over an unsafe channel (e.g. plaintext non-loopback HTTP) rather than * re-deriving a narrower local list. */ -export const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|x-api-key|x-goog-api-key|x-amz-security-token|x-dsh-auth-token|copilot[-_]?session[-_]?token|session[-_]?token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|password|profile[-_]?arn|exa[-_]?api[-_]?key)$/i; +export const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|x-api-key|x-goog-api-key|x-amz-security-token|x-dsh-auth-token|x-openai-agent-assertion|copilot[-_]?session[-_]?token|session[-_]?token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|password|profile[-_]?arn|exa[-_]?api[-_]?key)$/i; /** * Colon-labelled credential headers echoed back inside an error body @@ -40,7 +40,7 @@ export const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cook // Every letter position also accepts \u0001, the placeholder the fold emits for // an unresolved HTML named reference: `authorⅈzation` is the label with one // character we cannot name, and that is still the label. -const CREDENTIAL_HEADER_LABEL_RAW = "copilot[_-]?session[_-]?token|session[_-]?token|sessionToken|x-api-key|x-goog-api-key|x-amz-security-token|x-dsh-auth-token|api[_-]?key|apiKey|exa[_-]?api[_-]?key|exaApiKey|access[_-]?token|accessToken|refresh[_-]?token|refreshToken|id[_-]?token|client[_-]?secret|clientSecret|authorization|proxy-authorization|cookie|set-cookie|password|secret|token"; +const CREDENTIAL_HEADER_LABEL_RAW = "copilot[_-]?session[_-]?token|session[_-]?token|sessionToken|x-openai-agent-assertion|x-api-key|x-goog-api-key|x-amz-security-token|x-dsh-auth-token|api[_-]?key|apiKey|exa[_-]?api[_-]?key|exaApiKey|access[_-]?token|accessToken|refresh[_-]?token|refreshToken|id[_-]?token|client[_-]?secret|clientSecret|authorization|proxy-authorization|cookie|set-cookie|password|secret|token"; const CREDENTIAL_HEADER_LABEL = CREDENTIAL_HEADER_LABEL_RAW .replace(/(? tree.path); } +/** + * The file names Bun's test runner discovers: `.test`, `_test`, `.spec` or `_spec` before a + * JS/TS extension. Under `bun test`, `Bun.main` names the test file being evaluated, in the + * main runner and in `--parallel` workers alike. Every production entrypoint — the CLI, the + * service daemon, the compiled sidecar (`/$bunfs/...`), a release script — has a different name. + */ +const BUN_TEST_FILE = /[._](?:test|spec)\.[cm]?[jt]sx?$/i; + +/** + * Whether this process runs a Bun test file as its entry module. + * + * This exists for the run that never loads `tests/preload.ts`. Bun resolves `bunfig.toml` from + * the CURRENT WORKING DIRECTORY, so `cd /tmp && bun test /tests/x.test.ts` skips the + * preload: HOME stays real, OPENCODEX_HOME stays unset, and before this check the guard stayed + * down, so an OAuth save landed in the developer's real `~/.opencodex/auth.json` (#7407). + * Bun exposes no runner flag, and NODE_ENV=test is both settable by users and overridable by the + * runner's caller, so the entry file's name is the one signal that production never carries. + * + * A file passed by explicit path under a non-test name (`bun test ./scratch.ts`) is not + * recognised; every repository suite is a `*.test.ts`, the only name `tests/test-layout.test.ts` admits. + * + * The converse also holds: `bun run ./something.test.ts` arms, because nothing distinguishes it + * from the runner. That is accepted. Requiring NODE_ENV=test as well would exempt every developer + * whose shell exports another NODE_ENV (`bun test` keeps an existing value), reopening #7407; + * the cost of the false positive is a refused real-home write from a script named like a test. + */ +export function isBunTestFileProcess(): boolean { + const main = (globalThis as { Bun?: { main?: unknown } }).Bun?.main; + const entry = typeof main === "string" ? main : process.argv[1]; + return typeof entry === "string" && BUN_TEST_FILE.test(basename(entry)); +} + +/** + * Armed when the preload (or a harness) set OCX_TEST_HOME_GUARD=1, or when this process runs a + * Bun test file and nobody set the flag. An explicit OCX_TEST_HOME_GUARD=0 is how a test case + * exercises the production branch of a guarded call, so it disarms even inside a test file. + */ export function isTestHomeGuardArmed(): boolean { - return process.env[GUARD_ENV] === "1"; + const flag = process.env[GUARD_ENV]; + if (flag === "1") return true; + if (flag === "0") return false; + return isBunTestFileProcess(); +} + +/** + * A test process that armed through its entry file publishes the flag, so the CLI and helper + * processes it spawns — whose own entry is not a test file — inherit the arming instead of + * writing the real home they also inherited. Runs at import, which in such a run happens before + * any test body. A production process never matches, so it never reaches the assignment. Any value + * but the explicit "0" opt-out is normalised, because an empty or stray value arms this process by + * name but would leave a non-test child, which cannot match by name, disarmed. + */ +if (process.env[GUARD_ENV] !== "0" && process.env[GUARD_ENV] !== "1" && isBunTestFileProcess()) { + process.env[GUARD_ENV] = "1"; +} + +/** + * Appended to a refusal when no preload ran, because the fix there is different: the test did + * not forget OPENCODEX_HOME, the whole run started from a directory without bunfig.toml. + */ +function missingPreloadHint(): string { + if (process.env.OCX_TEST_PRELOAD_PID !== undefined) return ""; + return " This test process did not load tests/preload.ts — Bun reads bunfig.toml from the " + + "current working directory — so nothing was sandboxed. Run bun test from the repository " + + "root, or pass --preload /tests/preload.ts."; } /** @@ -144,7 +208,8 @@ export function assertNotRealHomeUnderTest(dir: string): void { throw new Error( `refusing to write the real OpenCodex home (${PROTECTED_HOME}) from a test process. ` + "Point OPENCODEX_HOME at a temp directory for this test, or inject persistence " - + "instead of calling the global writer (see devlog 260730_codex_rs_upstream_v2_live_handoff/070).", + + "instead of calling the global writer (see devlog 260730_codex_rs_upstream_v2_live_handoff/070)." + + missingPreloadHint(), ); } @@ -177,7 +242,8 @@ export function assertNotRealCodexHomeUnderTest(dir: string): void { throw new Error( `refusing to write the real Codex home (${PROTECTED_CODEX_HOME}) from a test process. ` + "Point CODEX_HOME at a temp directory for this test before writing Codex files " - + "(auth.json, the catalog, models_cache.json, the journal or config.toml).", + + "(auth.json, the catalog, models_cache.json, the journal or config.toml)." + + missingPreloadHint(), ); } @@ -351,12 +417,13 @@ export function protectedRemovalReason(target: string): string | null { /** * Throw before a removal that would reach a protected tree. * - * Deliberately NOT gated on {@link isTestHomeGuardArmed}. Arming happens in `tests/preload.ts`, - * which Bun loads from the `bunfig.toml` it finds in the CURRENT WORKING DIRECTORY — so a run - * started outside the repository arms nothing, leaves OPENCODEX_HOME unset, and resolves the - * developer's real home. That unarmed run is precisely the one that caused the incident, so the - * refusal has to hold without it. Nothing in production calls this; the callers are test - * helpers, where the only cost of an unconditional check is a path comparison. + * Deliberately NOT gated on {@link isTestHomeGuardArmed}. `tests/preload.ts` is loaded from the + * `bunfig.toml` Bun finds in the CURRENT WORKING DIRECTORY, so a run started outside the + * repository leaves OPENCODEX_HOME unset and resolves the developer's real home. Such a run now + * arms through its test-file entry, but a helper launched as a plain script does not, and the + * incident was exactly this unsandboxed shape, so the refusal has to hold without arming. + * Nothing in production calls this; the callers are test helpers, where the only cost of an + * unconditional check is a path comparison. */ export function assertRemovalOutsideProtectedTrees(target: string): void { const reason = protectedRemovalReason(target); diff --git a/src/link/child-parity.ts b/src/link/child-parity.ts new file mode 100644 index 00000000000..220ffbc7f43 --- /dev/null +++ b/src/link/child-parity.ts @@ -0,0 +1,124 @@ +/** + * What a Remote Link Child tells its Home about the last settings apply (OCX-6). + * + * The Child knows when it last applied the Home's Codex settings, which digest it applied and when + * its runtime started; the Home knows none of it. The Child reports these facts over the link + * (`POST` to {@link LINK_CHILD_PARITY_PATH}, admitted by its link key) and the Home shows them on + * each link row of `GET /api/link/status` (`lastApplyAt`, `runtimeStartedAt`, + * `appliedCodexSettingsDigest`), so a peer can compare apply time with runtime start, and the + * applied digest with the Home's own, without reading the Child. + * + * Display only. A report never admits, routes or changes anything on the Home, carries no setting + * value, and lives in memory: a Home restart forgets it until the Child's next report (at most a + * minute later). + */ +import { CODEX_SETTINGS_DIGEST_PATTERN } from "./codex-settings"; +import { LINK_MANAGEMENT_PREFIX } from "./management-routes"; + +/** The one write a Child may send its Home over the link. */ +export const LINK_CHILD_PARITY_PATH = `${LINK_MANAGEMENT_PREFIX}/child-parity`; +/** A report is four short fields; anything larger is refused while it is still being read. */ +export const LINK_CHILD_PARITY_MAX_BYTES = 1024; + +const LINK_ID = /^lnk_[0-9a-f]{16}$/; +/** 2001-09-09 .. 2286-11-20: any epoch-millisecond time a real clock reports. */ +const MIN_EPOCH_MS = 1_000_000_000_000; +const MAX_EPOCH_MS = 9_999_999_999_999; + +export interface LinkChildParityReport { + linkId: string; + /** Epoch ms of the Child's last successful apply of a different Home settings document. */ + lastApplyAt: number | null; + /** Epoch ms of the Child runtime's process start. */ + runtimeStartedAt: number; + /** `appliedCodexSettingsDigest` of the applied settings; null before any apply. */ + appliedCodexSettingsDigest: string | null; + /** + * Increases with every report this runtime process sends. With `runtimeStartedAt` it orders + * reports, so a late POST from an older runtime or an earlier send never replaces a newer one. + */ + sequence: number; +} + +/** The per-link fields `GET /api/link/status` carries; every one is null until a report arrives. */ +export interface LinkChildParity { + lastApplyAt: number | null; + runtimeStartedAt: number | null; + appliedCodexSettingsDigest: string | null; + /** Epoch ms the Home received the report (the Home's clock; the other times are the Child's). */ + reportedAt: number | null; +} + +export const EMPTY_LINK_CHILD_PARITY: LinkChildParity = Object.freeze({ + lastApplyAt: null, runtimeStartedAt: null, appliedCodexSettingsDigest: null, reportedAt: null, +}); + +export function isEpochMs(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= MIN_EPOCH_MS && value <= MAX_EPOCH_MS; +} + +function exactKeys(value: unknown, keys: readonly string[]): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const actual = Object.keys(value); + return actual.length === keys.length && actual.every(key => keys.includes(key)); +} + +/** Strict: an unknown field, a non-integer time or a malformed digest rejects the report. */ +export function parseLinkChildParityReport(value: unknown): LinkChildParityReport | null { + if (!exactKeys(value, ["linkId", "lastApplyAt", "runtimeStartedAt", "appliedCodexSettingsDigest", "sequence"])) return null; + const { linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest: digest, sequence } = value; + if (typeof linkId !== "string" || !LINK_ID.test(linkId)) return null; + if (lastApplyAt !== null && !isEpochMs(lastApplyAt)) return null; + if (!isEpochMs(runtimeStartedAt)) return null; + if (digest !== null && (typeof digest !== "string" || !CODEX_SETTINGS_DIGEST_PATTERN.test(digest))) return null; + // An apply has both a time and a digest; before the first apply it has neither. + if ((lastApplyAt === null) !== (digest === null)) return null; + if (typeof sequence !== "number" || !Number.isSafeInteger(sequence) || sequence < 0) return null; + return { linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest: digest, sequence }; +} + +export interface LinkChildParityStore { + /** + * Keep the report for its link unless a newer one is already kept (by `runtimeStartedAt`, then + * `sequence`), dropping every entry whose link is not in `currentLinkIds`. True when kept. + */ + record(report: LinkChildParityReport, currentLinkIds: ReadonlySet): boolean; + get(linkId: string): LinkChildParity; + clear(): void; +} + +export function createLinkChildParityStore(now: () => number = Date.now): LinkChildParityStore { + const reports = new Map(); + return { + record(report, currentLinkIds) { + for (const id of reports.keys()) if (!currentLinkIds.has(id)) reports.delete(id); + if (!currentLinkIds.has(report.linkId)) return false; + const kept = reports.get(report.linkId); + if (kept && (kept.runtimeStartedAt! > report.runtimeStartedAt + || (kept.runtimeStartedAt === report.runtimeStartedAt && kept.sequence >= report.sequence))) return false; + reports.set(report.linkId, { + lastApplyAt: report.lastApplyAt, + runtimeStartedAt: report.runtimeStartedAt, + appliedCodexSettingsDigest: report.appliedCodexSettingsDigest, + reportedAt: now(), + sequence: report.sequence, + }); + return true; + }, + get(linkId) { + const kept = reports.get(linkId); + if (!kept) return { ...EMPTY_LINK_CHILD_PARITY }; + const { sequence: _sequence, ...parity } = kept; + return parity; + }, + clear() { reports.clear(); }, + }; +} + +/** The Home's reports: one process-wide store, bounded by the link store on every write. */ +export const homeLinkChildParity: LinkChildParityStore = createLinkChildParityStore(); + +/** Home link listener: is this the Child's report (exact path, POST, no query, no upgrade)? */ +export function linkChildParityRouteAllowed(url: URL, req: Request): boolean { + return url.pathname === LINK_CHILD_PARITY_PATH && req.method === "POST" && url.search === "" && !req.headers.has("upgrade"); +} diff --git a/src/link/codex-settings.ts b/src/link/codex-settings.ts index 8639e71cca8..ff96f34346d 100644 --- a/src/link/codex-settings.ts +++ b/src/link/codex-settings.ts @@ -23,6 +23,7 @@ * It rides on its own authenticated link read rather than inside the catalog because the * catalog bytes are written verbatim to `model_catalog_json`, which Codex parses itself. */ +import { createHash } from "node:crypto"; import { hostname } from "node:os"; import type { OcxConfig } from "../types"; @@ -153,3 +154,39 @@ export function describeHomeCodexSettings(settings: HomeCodexSettings): string[] `native sub-agent defaults: ${d ? `${d.model}${d.reasoningEffort ? ` (${d.reasoningEffort})` : ""}` : "not synced"}`, ]; } + +/** 64 lowercase hex digits: the only form a settings digest takes on the wire. */ +export const CODEX_SETTINGS_DIGEST_PATTERN = /^[0-9a-f]{64}$/; + +/** + * The exact bytes {@link codexSettingsDigest} hashes: compact UTF-8 JSON in a fixed key order, + * + * {"version":1,"settings":{"fastMode":…,"multiAgentMode":…,"webSearchSidecarEnabled":…, + * "subagentDefaults":null|{"model":…,"reasoningEffort":…}}} + * + * This is a published contract (GOAT's hub recomputes it byte for byte), so the order is written + * out here rather than left to a canonicalizer, and every value is copied by name: whatever key + * order or extra field the input object has, the output is the same. + */ +export function codexSettingsDigestPayload(settings: HomeCodexSettings): string { + const d = settings.subagentDefaults; + return JSON.stringify({ + version: HOME_CODEX_SETTINGS_VERSION, + settings: { + fastMode: settings.fastMode, + multiAgentMode: settings.multiAgentMode, + webSearchSidecarEnabled: settings.webSearchSidecarEnabled, + subagentDefaults: d ? { model: d.model, reasoningEffort: d.reasoningEffort } : null, + }, + }); +} + +/** + * `appliedCodexSettingsDigest` (OCX-6b): lowercase hex SHA-256 of + * {@link codexSettingsDigestPayload}. It covers the applied values only, never the Home's name or + * version, so a Home and a Child that applied the same values agree, and any value change differs. + * It carries no setting value, so it is safe wherever `ocx status --json` goes. + */ +export function codexSettingsDigest(settings: HomeCodexSettings): string { + return createHash("sha256").update(codexSettingsDigestPayload(settings), "utf8").digest("hex"); +} diff --git a/src/link/compatibility.ts b/src/link/compatibility.ts new file mode 100644 index 00000000000..b8689310903 --- /dev/null +++ b/src/link/compatibility.ts @@ -0,0 +1,119 @@ +/** + * Home and Child compatibility over a Remote Link. + * + * `LINK_PROTOCOL_RELEASES` is the published record of which release first spoke each part of the + * link protocol. It is history, so it cannot be derived at run time; `tests/clients/link-peer- + * compatibility.test.ts` holds every id to the live feature registry and every release to the + * package version. The verdict uses only what the link carries: the relay-auth version of a + * verified challenge and, from 2.91.0, the Child's own version and feature report + * (`src/link/peer-report.ts`). + */ +import { LINK_RELAY_AUTH_VERSION } from "./relay-auth"; +import { LINK_PEER_REPORT_VERSION, type LinkPeerEntry } from "./peer-report"; + +export interface LinkProtocolRelease { + /** The peer feature id, as `GET /readyz` lists it. */ + readonly id: string; + /** First release that speaks it on both sides of a link. */ + readonly since: string; + readonly summary: string; +} + +export const LINK_PEER_REPORT_FEATURE = `link-peer-report/${LINK_PEER_REPORT_VERSION}`; + +export const LINK_PROTOCOL_RELEASES: readonly LinkProtocolRelease[] = [ + { id: "relay-auth/2", since: "2.69.0", summary: "Connection-bound relay authentication before every relayed request." }, + { id: "link-management-relay", since: "2.84.0", summary: "Read-only Home management and Home Codex settings for the Child." }, + { id: LINK_PEER_REPORT_FEATURE, since: "2.91.0", summary: "The Child reports its version and features on the relay challenge." }, +]; + +export type LinkCompatibilityVerdict = "compatible" | "degraded" | "incompatible" | "unknown"; + +export interface LinkLocalPeer { + version: string; + relayAuth: string; + features: string[]; +} + +export interface LinkPeerStatus { + observedAt: string | null; + /** The Child's reported release, or null when it predates the report. */ + version: string | null; + /** For an unreported version: the oldest release the observed protocol implies, and the first that would report. */ + versionRange: { min: string | null; below: string } | null; + relayAuth: string | null; + features: string[] | null; + compatibility: LinkCompatibilityVerdict; + reasons: string[]; +} + +function releaseParts(version: string): number[] { + return version.split(/[-+]/, 1)[0]!.split(".").map(part => Number(part)); +} + +export function compareReleases(a: string, b: string): number { + const left = releaseParts(a), right = releaseParts(b); + for (let i = 0; i < 3; i++) { + const diff = (left[i] ?? 0) - (right[i] ?? 0); + if (diff !== 0) return Math.sign(diff); + } + return 0; +} + +function sinceOf(id: string): string | null { + return LINK_PROTOCOL_RELEASES.find(release => release.id === id)?.since ?? null; +} + +function relayAuthNumber(value: string): number { + return /^\d{1,4}$/.test(value) ? Number(value) : Number.NaN; +} + +export function linkPeerStatus(local: LinkLocalPeer, entry: LinkPeerEntry): LinkPeerStatus { + const { observed, refused } = entry; + if (refused && (!observed || observed.observedAt <= refused.refusedAt)) { + const newerChild = relayAuthNumber(refused.relayAuth) > relayAuthNumber(local.relayAuth); + return { + observedAt: refused.refusedAt, version: null, versionRange: null, relayAuth: refused.relayAuth, features: null, + compatibility: "incompatible", + reasons: [`The Child speaks relay-auth/${refused.relayAuth} and this Home speaks relay-auth/${local.relayAuth}, so relayed requests are refused. Upgrade the ${newerChild ? "Home" : "Child"}.`], + }; + } + if (!observed) { + return { + observedAt: null, version: null, versionRange: null, relayAuth: null, features: null, + compatibility: "unknown", reasons: ["No relayed request from this Child since the Home started."], + }; + } + const base = { observedAt: observed.observedAt, relayAuth: observed.relayAuth, features: observed.features }; + if (!observed.reported) { + return { + ...base, version: null, + versionRange: { min: sinceOf(`relay-auth/${observed.relayAuth}`), below: sinceOf(LINK_PEER_REPORT_FEATURE)! }, + compatibility: "compatible", + reasons: [`relay-auth/${observed.relayAuth} matches. The Child does not report its version; it predates ${LINK_PEER_REPORT_FEATURE}.`], + }; + } + if (observed.version === null || observed.features === null) { + // A reporting Child whose version or feature list was missing or rejected: the Home cannot tell what it uses. + return { + ...base, version: observed.version, versionRange: null, compatibility: "unknown", + reasons: [`relay-auth/${observed.relayAuth} matches, but the Child's ${observed.version === null && observed.features === null ? "version and feature list were" : observed.version === null ? "version was" : "feature list was"} missing or invalid, so its features cannot be checked.`], + }; + } + const childOnly = observed.features.filter(id => !local.features.includes(id)); + if (childOnly.length > 0) { + return { + ...base, version: observed.version, versionRange: null, compatibility: "degraded", + reasons: [`The Child (${observed.version}) uses ${childOnly.join(", ")}, which this Home (${local.version}) does not serve. Upgrade the Home.`], + }; + } + return { + ...base, version: observed.version, versionRange: null, compatibility: "compatible", + reasons: [compareReleases(observed.version, local.version) === 0 ? "Same release as this Home." + : `relay-auth/${observed.relayAuth} matches and this Home (${local.version}) serves every feature the Child (${observed.version}) uses.`], + }; +} + +export function localLinkPeer(version: string, features: readonly string[]): LinkLocalPeer { + return { version, relayAuth: LINK_RELAY_AUTH_VERSION, features: [...features] }; +} diff --git a/src/link/management-routes.ts b/src/link/management-routes.ts index 3ac88deaedd..98500320fca 100644 --- a/src/link/management-routes.ts +++ b/src/link/management-routes.ts @@ -29,6 +29,8 @@ export const LINK_MANAGEMENT_READ_PATHS: readonly string[] = [ "/api/sidecar-settings", "/api/v2", "/api/provider-quotas", + "/api/quota-pool", + "/api/fallback", "/api/codex-auth/active", "/api/codex-auth/accounts", "/api/oauth/accounts", diff --git a/src/link/peer-report.ts b/src/link/peer-report.ts new file mode 100644 index 00000000000..d46bc141303 --- /dev/null +++ b/src/link/peer-report.ts @@ -0,0 +1,100 @@ +/** + * What a Remote Link Child tells its Home about itself, and what the Home remembers of it. + * + * A Child sends its release version and its peer feature ids (`src/remote/features.ts`) as two + * headers on the credential-free relay-auth challenge it already sends before every relayed + * request (`src/client/link-relay-transport.ts`). The Home records them only after the caller + * proof verifies, so an observation is bound to the link key; it is still the Child's own claim + * and is used for display and compatibility verdicts, never for admission. + * + * A Home older than this report ignores the headers, and a Child older than it sends none: the + * Home then knows only the relay-auth version the challenge itself names. Observations live in + * the Home's memory and start empty after a restart; the next relayed request refills them. + */ +export const LINK_PEER_REPORT_VERSION = "1"; +export const LINK_PEER_VERSION_HEADER = "x-opencodex-link-peer-version"; +export const LINK_PEER_FEATURES_HEADER = "x-opencodex-link-peer-features"; + +const RELEASE_VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]{1,128})?$/; +const FEATURE_ID = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,79}$/; +const MAX_FEATURES = 64; +const MAX_LINKS = 256; + +export interface LinkPeerReport { + /** True when the challenge carried either report header, valid or not; false for a pre-report Child. */ + reported: boolean; + version: string | null; + features: string[] | null; +} + +/** Header values a Child sends. Invalid input yields no header rather than a malformed one. */ +export function linkPeerReportHeaders(version: string, features: readonly string[]): Record { + const headers: Record = {}; + if (RELEASE_VERSION.test(version)) headers[LINK_PEER_VERSION_HEADER] = version; + const ids = parseFeatureList(features.join(",")); + if (ids) headers[LINK_PEER_FEATURES_HEADER] = ids.join(","); + return headers; +} + +function parseFeatureList(value: string | null): string[] | null { + if (value === null || value === "") return null; + const ids = value.split(","); + if (ids.length > MAX_FEATURES || new Set(ids).size !== ids.length || ids.some(id => !FEATURE_ID.test(id))) return null; + return ids; +} + +/** Home side: read a report from challenge headers. Each field is dropped on its own when invalid. */ +export function parseLinkPeerReport(headers: Headers): LinkPeerReport { + const version = headers.get(LINK_PEER_VERSION_HEADER); + const features = headers.get(LINK_PEER_FEATURES_HEADER); + return { + reported: version !== null || features !== null, + version: version !== null && RELEASE_VERSION.test(version) ? version : null, + features: parseFeatureList(features), + }; +} + +export interface LinkPeerObservation extends LinkPeerReport { + /** The relay-auth version of the verified challenge. */ + relayAuth: string; + observedAt: string; +} + +/** A challenge for a known link and key that named a relay-auth version this Home does not speak. */ +export interface LinkPeerRefusal { + relayAuth: string; + refusedAt: string; +} + +export interface LinkPeerEntry { + observed: LinkPeerObservation | null; + refused: LinkPeerRefusal | null; +} + +export interface LinkPeerObservations { + observe(linkId: string, relayAuth: string, report: LinkPeerReport): void; + refuse(linkId: string, relayAuth: string): void; + get(linkId: string): LinkPeerEntry; +} + +export function createLinkPeerObservations(now: () => Date = () => new Date()): LinkPeerObservations { + const entries = new Map(); + const update = (linkId: string, change: Partial) => { + const current = entries.get(linkId) ?? { observed: null, refused: null }; + entries.delete(linkId); + entries.set(linkId, { ...current, ...change }); + // Only known link ids reach here; the cap bounds memory if the store churns for a long time. + if (entries.size > MAX_LINKS) entries.delete(entries.keys().next().value!); + }; + return { + observe(linkId, relayAuth, report) { + update(linkId, { observed: { relayAuth, ...report, observedAt: now().toISOString() }, refused: null }); + }, + refuse(linkId, relayAuth) { + update(linkId, { refused: { relayAuth, refusedAt: now().toISOString() } }); + }, + get(linkId) { + return entries.get(linkId) ?? { observed: null, refused: null }; + }, + }; +} diff --git a/src/link/remote-platform.ts b/src/link/remote-platform.ts new file mode 100644 index 00000000000..0f5ffb704d1 --- /dev/null +++ b/src/link/remote-platform.ts @@ -0,0 +1,78 @@ +/** + * Windows recognition for Remote Link Children. + * + * Remote Link drives its Child through a POSIX `sh` prelude, so a Windows Child is refused + * before any link key is issued or tunnel started. A Windows OpenSSH server shows up in one of + * three ways, depending on its default shell: + * - Git Bash, MSYS2 or Cygwin: `uname -s` reports MINGW*, MSYS*, CYGWIN* (or Windows*), and the + * ocx prelude exits with REMOTE_PLATFORM_UNSUPPORTED. + * - cmd.exe: a POSIX command such as `true` or `sh` is "not recognized as an internal or + * external command". + * - PowerShell: the same command "is not recognized as the name of a cmdlet". + * + * Hub mode (`ocx hub invite` on a Hub, `ocx connect` on the client) supports Windows clients, so + * every refusal points there. + */ +import { buildWindowsProbeArgv, REMOTE_COMMAND_NOT_FOUND, REMOTE_PLATFORM_UNSUPPORTED } from "./ssh-argv"; +import type { SshRunner, SshRunResult } from "./ssh-runner"; + +export type RemotePlatform = "windows" | "posix" | "unknown"; + +const WINDOWS_UNAME = /^(?:Windows|MSYS|MINGW|CYGWIN)/i; +const POSIX_UNAME = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/; +// Windows PowerShell says "the name of a cmdlet", PowerShell 7 "a name of a cmdlet". Localized +// installs say neither, which is what confirmWindowsShell is for. +const WINDOWS_SHELL_REFUSAL = /is not recognized as (?:an internal or external command|(?:the|a) name of a cmdlet)/i; +/** ssh's own failure status: the remote shell never ran, so there is nothing to ask. */ +const SSH_TRANSPORT_FAILURE = 255; + +/** Remote argv that prints the kernel name; under cmd or PowerShell, `sh` itself is refused. */ +export const REMOTE_UNAME_ARGV: readonly string[] = ["sh", "-c", "uname -s 2>/dev/null", "ocx-platform"]; + +/** Server-side refusal text, shared by the dashboard and the CLI's JSON consumers. */ +export const WINDOWS_CHILD_REFUSAL = "Remote Link Children require macOS or Linux; Windows is unsupported. Connect a Windows computer in Hub mode: run ocx hub invite on a Hub, then the printed ocx connect command on the Windows computer."; + +/** Does this output come from cmd.exe or PowerShell refusing a POSIX command? */ +export function isWindowsShellRefusal(output: string): boolean { + return WINDOWS_SHELL_REFUSAL.test(output); +} + +/** Classify one remote command result. Transport failures and odd output stay unknown. */ +export function classifyRemotePlatform(result: SshRunResult): RemotePlatform { + if (result.code === REMOTE_PLATFORM_UNSUPPORTED) return "windows"; + if (isWindowsShellRefusal(result.stderr) || isWindowsShellRefusal(result.stdout)) return "windows"; + if (result.code !== 0) return "unknown"; + const name = result.stdout.trim().split(/\r?\n/)[0] ?? ""; + if (WINDOWS_UNAME.test(name)) return "windows"; + return POSIX_UNAME.test(name) ? "posix" : "unknown"; +} + +/** + * Could this failure be a Windows shell whose refusal text is localized? Success, the prelude's own + * 125/127 answers and ssh transport failures are already explained, so they never cost another call. + */ +export function mayBeWindowsShell(result: SshRunResult): boolean { + return result.code !== 0 && result.code !== REMOTE_PLATFORM_UNSUPPORTED + && result.code !== REMOTE_COMMAND_NOT_FOUND && result.code !== SSH_TRANSPORT_FAILURE; +} + +/** Ask the host the fixed, locale-independent WINDOWS_OS_PROBE. Any failure answers false. */ +export async function confirmWindowsShell(runner: SshRunner, alias: string, knownHostsFile: string, timeoutMs = 30_000): Promise { + try { + const result = await runner.run(buildWindowsProbeArgv({ alias, knownHostsFile }), { timeoutMs }); + return result.code === 0 && result.stdout.trim().split(/\r?\n/)[0]?.trim() === "Windows_NT"; + } catch { return false; } +} + +/** classifyRemotePlatform, plus the locale-independent follow-up when the answer could be Windows. */ +export async function detectRemotePlatform(runner: SshRunner, alias: string, knownHostsFile: string, result: SshRunResult): Promise { + const platform = classifyRemotePlatform(result); + if (platform !== "unknown" || !mayBeWindowsShell(result)) return platform; + return await confirmWindowsShell(runner, alias, knownHostsFile) ? "windows" : "unknown"; +} + +/** The CLI sentence for a refused Windows Child. */ +export function windowsChildRefusal(alias: string): string { + return `${alias} is a Windows computer. Remote Link Children require macOS or Linux, so no link key was issued and no tunnel was started. ` + + `Connect it in Hub mode instead: run 'ocx hub invite' on a Hub, then run the printed 'ocx connect' command on ${alias}.`; +} diff --git a/src/link/ssh-argv.ts b/src/link/ssh-argv.ts index 2726236ded9..81db1bbe144 100644 --- a/src/link/ssh-argv.ts +++ b/src/link/ssh-argv.ts @@ -191,6 +191,25 @@ export function buildProbeArgv(options: ProbeArgvOptions): string[] { ]; } +/** + * Locale-independent Windows check for a host whose SSH shell is cmd.exe or PowerShell. Both run + * `cmd /c echo %OS%`, and cmd expands it to `Windows_NT` regardless of display language. The + * command is a fixed literal; a POSIX shell answers "command not found" and nothing else. + */ +export const WINDOWS_OS_PROBE = "cmd /c echo %OS%"; + +/** Run WINDOWS_OS_PROBE against an already trusted (pinned or just-probed) host key only. */ +export function buildWindowsProbeArgv(options: { alias: string; knownHostsFile: string }): string[] { + const alias = assertSshAlias(options.alias); + return [ + "ssh", "-T", + ...commonOptions(alias, options.knownHostsFile, "yes"), + "-o", "ClearAllForwardings=yes", + "--", alias, + WINDOWS_OS_PROBE, + ]; +} + /** Read the fingerprint of the host key recorded by a probe. */ export function buildFingerprintArgv(tempKnownHostsFile: string): string[] { return ["ssh-keygen", "-l", "-f", assertKnownHostsPath(tempKnownHostsFile)]; diff --git a/src/link/status-projection.ts b/src/link/status-projection.ts index 1a83a662f8f..c87e33bfec3 100644 --- a/src/link/status-projection.ts +++ b/src/link/status-projection.ts @@ -2,6 +2,9 @@ import type { LinkStore } from "./store"; import type { LinkTunnelStatus } from "./supervisor"; import type { TunnelState } from "./tunnel-state"; import { readCompensation, type CompensationStore } from "./compensation"; +import { EMPTY_LINK_CHILD_PARITY, type LinkChildParity } from "./child-parity"; +import { linkPeerStatus, type LinkLocalPeer, type LinkPeerStatus } from "./compatibility"; +import type { LinkPeerEntry } from "./peer-report"; import type { OcxClientConnectionConfig } from "../types"; export type LinkWireState = "connecting" | "connected" | "reconnecting" | "failed" | "idle"; @@ -9,7 +12,7 @@ export type LinkWireState = "connecting" | "connected" | "reconnecting" | "faile export interface LinkStatusDto { role: "standalone" | "home" | "child"; listener: { state: "off" | "listening" | "failed"; port: number | null }; - links: Array<{ + links: Array; child: null | { alias: string; @@ -24,6 +29,17 @@ export interface LinkStatusDto { since: string; reason: string | null; }; + /** This build's version and link protocol, the side every `peer` verdict compares against. */ + local?: LinkLocalPeer; +} + +/** Add this build's identity and, on a Home, each Child's observed peer status. */ +export function attachLinkPeers(dto: LinkStatusDto, local: LinkLocalPeer, observation: (linkId: string) => LinkPeerEntry): LinkStatusDto { + if (dto.role === "home") { + for (const link of dto.links) link.peer = linkPeerStatus(local, observation(link.id)); + } + dto.local = local; + return dto; } export interface ClientLinkIdentity { direction: "hub-initiated" | "client-initiated"; alias?: string } @@ -75,6 +91,8 @@ export function projectLinkStatus( compensation: CompensationStore = readCompensation(), clientChild: LinkStatusDto["child"] = null, identity: ClientLinkIdentity | null = null, + /** The Home's view of each Child's last apply (OCX-6); every field is null without a report. */ + parity: (linkId: string) => LinkChildParity = () => EMPTY_LINK_CHILD_PARITY, ): LinkStatusDto { const byId = new Map(supervisorStates.map(status => [status.linkId, status])); const links = store.links.map(record => ({ @@ -83,6 +101,7 @@ export function projectLinkStatus( direction: record.direction, ...stateDetails(record, byId.get(record.id), compensation), tunnelPort: record.tunnelPort, + ...parity(record.id), })); const childRecord = config.runtimeRole === "client" ? (config.client?.transport === "link" ? store.links.find(record => record.id === config.client?.link?.linkId) : store.links[0]) @@ -101,7 +120,10 @@ export function projectLinkStatus( : client?.link ? { alias: identity?.alias ?? client.serverUrl, state: "idle" as const, since: client.connectedAt, reason: null } : null); if (client?.link && child && identity) { const active = links.find(link => link.id === client.link!.linkId); - const projected = { id: client.link.linkId, direction: identity.direction, tunnelPort: client.link.tunnelPort, ...child, alias: identity.alias ?? child.alias }; + const projected = { + id: client.link.linkId, direction: identity.direction, tunnelPort: client.link.tunnelPort, ...child, alias: identity.alias ?? child.alias, + ...parity(client.link.linkId), + }; if (active) Object.assign(active, projected, compensation.entries[active.id] && childRecord ? stateDetails(childRecord, byId.get(active.id), compensation) : {}); else links.push(projected); } diff --git a/src/oauth/index.ts b/src/oauth/index.ts index ef793933a56..2c602150f0e 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -1672,7 +1672,7 @@ export async function runLogin( : existing.accountId && cred.accountId ? existing.accountId === cred.accountId : existing.email && cred.email - ? existing.email.toLowerCase() === cred.email.toLowerCase() + ? existing.email.trim().toLowerCase() === cred.email.trim().toLowerCase() : false; if (!identityMatches) { throw new OAuthReauthIdentityMismatchError(); diff --git a/src/oauth/kimi.ts b/src/oauth/kimi.ts index 1d2f346e09e..1189e9725c3 100644 --- a/src/oauth/kimi.ts +++ b/src/oauth/kimi.ts @@ -4,6 +4,7 @@ import * as os from "node:os"; import { join } from "node:path"; import { randomUUID } from "node:crypto"; import { recordOwnedConfigPath } from "../lib/config-ownership"; +import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; import { getConfigDir } from "../config"; import type { OAuthController, OAuthCredentials } from "./types"; @@ -107,6 +108,7 @@ function getDeviceId(): string { if ((e as { code?: string })?.code !== "ENOENT") throw e; } const id = randomUUID().replace(/-/g, ""); + assertNotRealHomeUnderTest(getConfigDir()); recordOwnedConfigPath(getConfigDir(), p); if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true }); writeFileSync(p, id + "\n", { mode: 0o600 }); diff --git a/src/oauth/nous.ts b/src/oauth/nous.ts index 21ae5211edf..3a1a222bb8e 100644 --- a/src/oauth/nous.ts +++ b/src/oauth/nous.ts @@ -35,10 +35,11 @@ */ import { createHash } from "node:crypto"; import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import type { OAuthController, OAuthCredentials } from "./types"; import { getAuthStorePath } from "./store"; import { atomicWriteFile, hardenConfigDir, hardenExistingSecret } from "../config"; +import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBytes } from "../lib/bounded-body"; export const NOUS_PORTAL_BASE_URL = "https://portal.nousresearch.com"; @@ -220,6 +221,8 @@ function readRefreshIntent(refreshToken: string): RefreshIntent | undefined { } function writeRefreshIntent(refreshToken: string, status: RefreshIntentStatus): void { + // Before the mkdir/chmod below, which would otherwise touch the real home first. + assertNotRealHomeUnderTest(dirname(getAuthStorePath())); const dir = refreshIntentDir(); // Hardened, owner-only directory + atomic (temp+rename) write. Throws on // failure so the caller can fail closed instead of refreshing blind. diff --git a/src/oauth/store.ts b/src/oauth/store.ts index ca84b1f947e..2bf86019a0e 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -203,6 +203,7 @@ export function peekOAuthRefreshIntent(provider: string, accountId: string): OAu } export function writeOAuthRefreshIntent(provider: string, accountId: string, generation: string, createdAt = Date.now(), flightId?: string): OAuthRefreshIntent { const dir = getConfigDir(); + assertNotRealHomeUnderTest(dir); if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); hardenConfigDir(); const intent: OAuthRefreshIntent = { @@ -1152,7 +1153,7 @@ export async function upsertCredentialByIdentity( return Boolean( safe.email && account.credential.email - && account.credential.email.toLowerCase() === safe.email.toLowerCase(), + && account.credential.email.trim().toLowerCase() === safe.email.trim().toLowerCase(), ); }; const existing = safe.accountId diff --git a/src/protocols/codecs/chat.ts b/src/protocols/codecs/chat.ts index eddef8404ec..c0933160ed5 100644 --- a/src/protocols/codecs/chat.ts +++ b/src/protocols/codecs/chat.ts @@ -6,12 +6,12 @@ * (`src/chat/inbound.ts`), including its validation and the fields it drops, which * `src/protocols/features.ts` declares. */ -import { chatCompletionsToResponsesBody, chatSettingsToResponsesBody } from "../../chat/inbound"; +import { chatCompletionsToResponsesBody, chatSettingsToResponsesBody, type ChatResponsesProjectionOptions } from "../../chat/inbound"; import { featuresFromChatBody } from "../features"; /** Project a Chat Completions body onto the internal Responses bridge body. */ -export function chatToResponsesBody(body: unknown): Record { - return chatCompletionsToResponsesBody(body); +export function chatToResponsesBody(body: unknown, options?: ChatResponsesProjectionOptions): Record { + return chatCompletionsToResponsesBody(body, options); } /** diff --git a/src/protocols/encoders/adapter-events.ts b/src/protocols/encoders/adapter-events.ts index cf240923bef..4fc36ce90e3 100644 --- a/src/protocols/encoders/adapter-events.ts +++ b/src/protocols/encoders/adapter-events.ts @@ -109,7 +109,13 @@ export interface ClientWireWriter { text(delta: string): void; messageDone(): void; reasoning(delta: string, itemId: string, channel: "summary" | "raw"): void; - reasoningDone(item: { itemId: string; signature?: string; redacted?: string[] }): void; + /** `hidden`: the signature covers thinking text the client was not shown (the bridge's `txt`). */ + reasoningDone(item: { itemId: string; signature?: string; redacted?: string[]; hidden?: true }): void; + /** + * An Anthropic progress update: `text` is shown as content and is also the signed thinking + * block's text. Optional — a writer without it renders the update as ordinary text. + */ + progressThinking?(text: string, signature: string): void; toolStart(call: ClientToolCall): void; toolArgsDelta(itemId: string, delta: string): void; toolArgsDone(itemId: string, args: string): void; @@ -331,6 +337,8 @@ export function encodeAdapterEventStream( let currentRawReasoning: { itemId: string } | null = null; let pendingSignature: string | undefined; let pendingRedacted: string[] = []; + // The bridge's hiddenThinking: non-empty thinking text suppressed by hideThinkingSummary. + let hiddenThinkingSeen = false; let hiddenRawBytes = 0; let pendingKiroRedacted: string | undefined; let currentToolCall: OpenToolCall | null = null; @@ -367,9 +375,11 @@ export function encodeAdapterEventStream( }; // hideThinkingSummary: a signed or redacted block still round-trips as an envelope-only item. const flushHiddenReasoningEnvelope = () => { + const hidden = hiddenThinkingSeen; + hiddenThinkingSeen = false; const envelope = takeReasoningEnvelope(); if (!envelope) return; - writer.reasoningDone({ itemId: `rs_${uuid()}`, ...envelope }); + writer.reasoningDone({ itemId: `rs_${uuid()}`, ...envelope, ...(hidden ? { hidden: true as const } : {}) }); }; const flushHiddenRawReasoning = () => { if (hiddenRawBytes === 0) return; @@ -570,6 +580,19 @@ export function encodeAdapterEventStream( break; } case "text_delta": { + if (event.anthropicThinkingSignature && writer.progressThinking) { + // The bridge's progress items: close everything, the carrier, then the visible copy. + if (currentMsg) closeCurrentMessage(); + if (currentReasoning) closeCurrentReasoning(); + if (currentRawReasoning) closeCurrentRawReasoning(); + flushHiddenRawReasoning(); + flushHiddenReasoningEnvelope(); + if (currentToolCall) closeCurrentToolCall(); + writer.progressThinking(event.text, event.anthropicThinkingSignature); + writer.text(event.text); + writer.messageDone(); + break; + } if (currentReasoning) closeCurrentReasoning(); if (currentRawReasoning) closeCurrentRawReasoning(); flushHiddenRawReasoning(); @@ -585,6 +608,7 @@ export function encodeAdapterEventStream( } case "thinking_delta": { if (options.hideThinkingSummary) { + if (event.thinking.length > 0) hiddenThinkingSeen = true; flushHiddenRawReasoning(); break; } diff --git a/src/protocols/encoders/chat.ts b/src/protocols/encoders/chat.ts index 1b636b03f1f..4df4ce71353 100644 --- a/src/protocols/encoders/chat.ts +++ b/src/protocols/encoders/chat.ts @@ -13,6 +13,7 @@ import type { AdapterEvent } from "../../types"; import type { TranslatorBudget } from "../../lib/translator-budget"; import { responsesUsage } from "../../bridge/internal"; import { + appendedUtf8Bytes, chatCompletionsErrorResponse, chatCompletionsFailedResponse, chatCompletionsIncompleteOutcome, @@ -32,6 +33,7 @@ import { type ClientWireWriter, } from "./adapter-events"; import type { RelayedEventObservation } from "../../usage/attempt-delivery"; +import { reasoningDetailEntries } from "../../chat/reasoning-details"; type Rec = Record; @@ -54,6 +56,41 @@ function createChatCompletionWriter(sink: ClientFrameSink, model: string): Clien const emittedToolIndexes = new Set(); let nextToolIndex = 0; let staged: { text: string; observation?: RelayedEventObservation }[] | undefined; + // #7429: the shown thinking text of the open reasoning item, which its signature pairs with. + const budget = sink.budget; + let reasoningItemId: string | undefined; + let reasoningText = ""; + let reasoningTextBytes = 0; + let nextReasoningDetailIndex = 0; + const releaseReasoningText = () => { + budget.releaseRetained(reasoningTextBytes, { kind: "reasoning" }); + reasoningItemId = undefined; + reasoningText = ""; + reasoningTextBytes = 0; + }; + const retainReasoningText = (itemId: string, delta: string) => { + if (reasoningItemId !== itemId) releaseReasoningText(); + reasoningItemId = itemId; + const nextBytes = appendedUtf8Bytes(reasoningText, reasoningTextBytes, delta); + const reservation = budget.reserveTransient(nextBytes, { kind: "reasoning" }); + try { + reasoningText += delta; + reservation.commitRetained(); + budget.releaseRetained(reasoningTextBytes, { kind: "reasoning" }); + reasoningTextBytes = nextBytes; + } catch (error) { + reservation.release(); + throw error; + } + }; + const emitReasoningDetails = (entries: Rec[]) => { + if (entries.length === 0) return; + nextReasoningDetailIndex += entries.length; + ensureRole(); + const frame = chunkBase(id, model, created); + frame.choices = [{ index: 0, delta: { reasoning_details: entries }, finish_reason: null }]; + emit(frame); + }; const emit = (payload: Rec | "[DONE]", observation?: RelayedEventObservation) => { if (failed) return; @@ -138,15 +175,30 @@ function createChatCompletionWriter(sink: ClientFrameSink, model: string): Clien emit(frame, { semanticBytes: Buffer.byteLength(delta) }); }, messageDone() { /* the converter reads nothing from a finished message */ }, - reasoning(delta) { + reasoning(delta, itemId, channel) { if (!delta) return; ensureRole(); // Many OpenAI-compatible clients accept reasoning_content; harmless if ignored. const frame = chunkBase(id, model, created); frame.choices = [{ index: 0, delta: { reasoning_content: delta }, finish_reason: null }]; emit(frame, { semanticBytes: Buffer.byteLength(delta) }); + // Only summary-channel thinking can carry a signature; raw reasoning never does. + if (channel === "summary") retainReasoningText(itemId, delta); + }, + reasoningDone({ itemId, signature, redacted, hidden }) { + // The bridge converter's output_item.done for a reasoning item (#7429), entry for entry. + const entries = reasoningDetailEntries({ + text: reasoningItemId === itemId ? reasoningText : "", + ...(signature ? { signature } : {}), + ...(redacted ? { redacted } : {}), + ...(hidden ? { hidden } : {}), + }, nextReasoningDetailIndex); + releaseReasoningText(); + emitReasoningDetails(entries); + }, + progressThinking(text, signature) { + emitReasoningDetails(reasoningDetailEntries({ text, signature }, nextReasoningDetailIndex)); }, - reasoningDone() { /* reasoning items carry nothing a Chat client reads */ }, toolStart(call) { // Custom and tool-search calls have no Chat representation; the converter skips them. if (call.kind !== "function") return; @@ -198,7 +250,7 @@ function createChatCompletionWriter(sink: ClientFrameSink, model: string): Clien type: "upstream_error", }); }, - dispose() { /* nothing retained beyond the driver's frames */ }, + dispose() { releaseReasoningText(); }, }; } diff --git a/src/providers/mistral-transport.ts b/src/providers/mistral-transport.ts index 3b5648cd9fc..6ad67ae15c6 100644 --- a/src/providers/mistral-transport.ts +++ b/src/providers/mistral-transport.ts @@ -8,6 +8,14 @@ import { RequestPacingQueueOverloadError } from "./request-pacing"; import type { OcxProviderTransport } from "./xai-transport"; const MAX_IDENTITIES = 128; +/** Queued waiters per credential+model identity; one hot identity never refuses another. */ +const MAX_WAITERS_PER_IDENTITY = 128; +/** + * Process-wide waiter bound. Model strings come from clients, so identities are not bounded by + * configuration; this keeps memory and pending requests finite. It is a multiple of the + * per-identity cap, so no single identity can fill it alone. + */ +const MAX_WAITERS_TOTAL = 4 * MAX_WAITERS_PER_IDENTITY; const DEFAULT_MAX_WAIT_MS = 60_000; // Existing retry wait budget, not an inferred rate window. const MINUTE_MS = 60 * 1000; // Unit named by the observed headers, never an account quota. type RecoveryState = { generation: number; probe?: { done: Promise; finish(): void } }; @@ -61,7 +69,14 @@ export class MistralBudgetPacer { private readonly recoveries = new Map(); /** Latest live deadline evicted at capacity; unknown identities wait it out instead of sending early. */ private evictedUntil = 0; - private waiting = 0; + /** + * Parked waiters keyed by identity. A request counts only once it is about to wait, so a held + * credential or model fills only its own queue and a request that can send now is never answered + * queue_full (HTTP 429). The map holds identities with live waiters only; the total is capped by + * MAX_WAITERS_TOTAL. + */ + private readonly waiting = new Map(); + private waitingTotal = 0; constructor( private readonly now: () => number = Date.now, @@ -103,9 +118,18 @@ export class MistralBudgetPacer { /** Wait within the admission budget and return a release callback for one recovery probe. */ async acquire(identity: string, signal?: AbortSignal, maxWaitMs = DEFAULT_MAX_WAIT_MS): Promise<(success?: boolean) => void> { signal?.throwIfAborted(); - if (this.waiting >= MAX_IDENTITIES) throw new RequestPacingQueueOverloadError("mistral", "queue_full", 60); - this.waiting++; const deadline = this.now() + maxWaitMs; + let parked = false; + const park = (): void => { + if (parked) return; + const queued = this.waiting.get(identity) ?? 0; + if (queued >= MAX_WAITERS_PER_IDENTITY || this.waitingTotal >= MAX_WAITERS_TOTAL) { + throw new RequestPacingQueueOverloadError("mistral", "queue_full", 60); + } + this.waiting.set(identity, queued + 1); + this.waitingTotal++; + parked = true; + }; try { for (;;) { signal?.throwIfAborted(); @@ -118,6 +142,7 @@ export class MistralBudgetPacer { throw new RequestPacingQueueOverloadError("mistral", "queue_expired", Math.ceil(delay / 1000)); } if (readyAt !== undefined && readyAt > this.now()) { + park(); await this.sleep(readyAt - this.now(), signal); continue; } @@ -126,6 +151,7 @@ export class MistralBudgetPacer { if (state.probe) { const remaining = deadline - this.now(); if (remaining <= 0) throw new RequestPacingQueueOverloadError("mistral", "queue_expired", 1); + park(); const local = new AbortController(); try { await Promise.race([state.probe.done, this.sleep(remaining, AbortSignal.any([local.signal, ...(signal ? [signal] : [])]))]); @@ -150,7 +176,14 @@ export class MistralBudgetPacer { probe.finish(); }; } - } finally { this.waiting--; } + } finally { + if (parked) { + this.waitingTotal--; + const left = (this.waiting.get(identity) ?? 1) - 1; + if (left > 0) this.waiting.set(identity, left); + else this.waiting.delete(identity); + } + } } } diff --git a/src/providers/quota/antigravity.ts b/src/providers/quota/antigravity.ts index e24f5f0d6da..0536d15f93c 100644 --- a/src/providers/quota/antigravity.ts +++ b/src/providers/quota/antigravity.ts @@ -8,6 +8,7 @@ import { asRecord, normalizePercent, normalizeResetAt, readQuotaJson, REQUEST_TI import { report, type ProviderQuotaReport } from "./report-cache"; import { quotaCredentialIdentity } from "./account-cache"; import type { ProviderQuota, ProviderQuotaWindow, QuotaFailureCode } from "../quota-types"; +import { httpQuotaFailure } from "./failure-codes"; export function antigravityQuotaDiagnosticIdentity(accountId: string, credential = getAccountCredential("google-antigravity", accountId)): string | undefined { return credential ? quotaCredentialIdentity("google-antigravity", accountId, credential, { @@ -275,13 +276,6 @@ function quotaTransportFailure(error: unknown): QuotaFailureCode { return "transport_error"; } -function quotaHttpFailure(status: number): QuotaFailureCode { - if (status >= 300 && status < 400) return "redirect_blocked"; - if (status === 401 || status === 403) return "access_denied"; - if (status === 429) return "rate_limited"; - return "upstream_error"; -} - function unavailableAntigravityQuota(failure: QuotaFailureCode): AntigravityQuotaProbeResult { return { kind: "unavailable", failure, legacy: { kind: "null" } }; } @@ -342,7 +336,7 @@ export async function probeAntigravityUsageQuota(accessToken: string, projectId: return unavailableAntigravityQuota(antigravityUnavailableFailure(summaryFailure, "redirect_blocked")); } if (!response.ok) { - return unavailableAntigravityQuota(antigravityUnavailableFailure(summaryFailure, quotaHttpFailure(response.status))); + return unavailableAntigravityQuota(antigravityUnavailableFailure(summaryFailure, httpQuotaFailure(response.status))); } const customWindows = antigravityWindowsFromModels(asRecord(await readQuotaJson(response))); if (!customWindows.length) { diff --git a/src/quota-pool/assemble.ts b/src/quota-pool/assemble.ts new file mode 100644 index 00000000000..419a2ea63eb --- /dev/null +++ b/src/quota-pool/assemble.ts @@ -0,0 +1,326 @@ +/** + * Assemble `ocx.quota-pool/1` (`GET /api/quota-pool`) from what this Home already holds. + * + * Strictly passive. Every source below is a cache another path filled; this module starts no + * probe, token refresh, quota refresh or upstream call and writes nothing. The whole assembly runs + * inside `runPassiveRead`, so even the shared readers it calls (the Codex account list, the OAuth + * store) take their passive branch: no WHAM probe, no native-main claim, no file hardening or + * malformed-store backup. Sources: + * + * - Codex pool accounts: the passive `listCodexAuthAccountsSnapshot` (stored `StoredAccountQuota` + * per account plus the cached main-account reading); + * - provider-level reports: `heldProviderQuotaReports`, the reports the Home last published + * (for the Codex pool provider this is OpenCodex's own pool aggregate, reported as `pooled`); + * - per-account OAuth quota (Anthropic instances, Kiro, Antigravity, xAI, Cursor, Kimi, Command + * Code, Devin, DSH, Meta): the last-good per-account cache; + * - Claude CLI: the usage-admission snapshot (5-hour, weekly and family windows, plus a parsed + * CLI limit refusal). + * + * An unknown window is omitted, never emitted as 0%. A window whose reset has passed is unknown + * again and is omitted too. Optional fields are left out instead of set to null. + */ +import type { OcxConfig, OcxProviderConfig } from "../types"; +import type { ProviderQuota, ProviderQuotaWindow } from "../providers/quota-types"; +import type { StoredAccountQuota } from "../codex/quota-types"; +import { isTerminalShortWindow, resetAtToMs } from "../codex/quota-types"; +import { runPassiveRead } from "../lib/passive-read"; +import { packageVersion } from "../lib/package-version"; +import { listCodexAuthAccountsSnapshot } from "../codex/auth-api"; +import { latestCodexQuotaObservationSource } from "../codex/quota"; +import { ACCOUNT_LOG_LABEL_RE, oauthAccountLogLabel } from "../codex/account-label"; +import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers-destination"; +import { hasCodexPoolProvider, heldProviderQuotaReports, type ProviderQuotaReport } from "../providers/quota/report-cache"; +import { + accountCacheKey, + accountQuotaCache, + getCachedProviderAccountQuota, + hasPassiveAccountQuota, + supportsPerAccountQuota, +} from "../providers/quota/account-cache"; +import { parseQuotaFailureCode } from "../providers/quota-types"; +import { getAccountSet } from "../oauth/store"; +import { peekClaudeUsageObservation } from "../adapters/claude-cli/usage-admission"; +import { + QUOTA_POOL_SCHEMA, + type QuotaPool, + type QuotaPoolAccount, + type QuotaPoolAccountState, + type QuotaPoolProvider, + type QuotaPoolRole, + type QuotaPoolSource, + type QuotaPoolWindow, +} from "./contract"; +import { quotaPoolAccountRef, readQuotaPoolIdentity, type QuotaPoolIdentity } from "./identity"; + +const VERSION = packageVersion("0.0.0"); +const SHORT_BURST_MAX_SECONDS = 6 * 3600; +const SHORT_DAILY_MAX_SECONDS = 36 * 3600; + +export interface QuotaPoolDeps { + now?: () => number; + /** Test seam for the Claude CLI admission file. */ + claudeUsageStatePath?: string; +} + +export type QuotaPoolResult = { ok: true; pool: QuotaPool } | { ok: false; error: "quota_pool_identity_unavailable" }; + +const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value); + +function iso(ms: number): string | undefined { + if (!finite(ms) || ms <= 0) return undefined; + const date = new Date(ms); + return Number.isFinite(date.getTime()) ? date.toISOString() : undefined; +} + +/** A used percent inside the contract's 0..100, or undefined when there is no reading. */ +function percent(value: unknown): number | undefined { + if (!finite(value) || value < 0) return undefined; + return Math.min(100, value); +} + +type WindowDraft = Omit & { resetMs?: number }; + +/** + * Finish a window, or drop it when it carries nothing (no percent, no counters) or its reset has + * already passed: an elapsed window is unknown now, not empty. + */ +function finish(draft: WindowDraft, source: QuotaPoolSource, observedMs: number, now: number): QuotaPoolWindow | null { + const observedAt = iso(observedMs); + if (!observedAt) return null; + const { resetMs, ...rest } = draft; + if (resetMs !== undefined && resetMs <= now) return null; + if (rest.usedPercent === undefined && rest.used === undefined && rest.limit === undefined && rest.exhausted === undefined) return null; + const resetsAt = resetMs !== undefined ? iso(resetMs) : undefined; + const exhausted = rest.exhausted ?? (rest.usedPercent !== undefined ? rest.usedPercent >= 100 : undefined); + const window: QuotaPoolWindow = { ...rest, source, observedAt }; + if (resetsAt) window.resetsAt = resetsAt; + if (exhausted !== undefined) window.exhausted = exhausted; + return window; +} + +function resetMs(value: unknown): number | undefined { + return finite(value) && value > 0 ? resetAtToMs(value) : undefined; +} + +function shortKind(seconds: number | undefined): Pick { + if (seconds === undefined || seconds <= SHORT_BURST_MAX_SECONDS) return { id: "5h", kind: "burst_5h", label: "5-hour" }; + if (seconds <= SHORT_DAILY_MAX_SECONDS) return { id: "daily", kind: "daily", label: "Daily" }; + return { id: "short", kind: "custom", label: "Short window" }; +} + +/** Codex `StoredAccountQuota`, mapped as the header and WHAM parsers stored it. */ +export function codexQuotaWindows(quota: StoredAccountQuota, source: QuotaPoolSource, now: number): QuotaPoolWindow[] { + const out: QuotaPoolWindow[] = []; + const push = (window: QuotaPoolWindow | null) => { if (window) out.push(window); }; + const shortPercent = percent(quota.shortPercent); + const shortSeconds = finite(quota.shortWindowSeconds) && quota.shortWindowSeconds > 0 ? quota.shortWindowSeconds : undefined; + if (shortPercent !== undefined) { + push(finish({ + ...shortKind(shortSeconds), + usedPercent: shortPercent, + ...(shortSeconds !== undefined ? { windowSeconds: shortSeconds } : {}), + resetMs: resetMs(quota.shortResetAt), + exhausted: isTerminalShortWindow(quota, now), + }, source, quota.shortObservedAt ?? quota.updatedAt, now)); + } + const weekly = percent(quota.weeklyPercent); + if (weekly !== undefined) { + push(finish({ id: "weekly", kind: "weekly", label: "Weekly", usedPercent: weekly, resetMs: resetMs(quota.weeklyResetAt) }, source, quota.updatedAt, now)); + } + const monthly = percent(quota.monthlyPercent); + if (monthly !== undefined) { + push(finish({ id: "monthly", kind: "monthly", label: "Monthly", usedPercent: monthly, resetMs: resetMs(quota.monthlyResetAt) }, source, quota.updatedAt, now)); + } + for (const custom of quota.customWindows ?? []) { + const used = percent(custom?.percent); + if (typeof custom?.label !== "string" || !custom.label.trim() || used === undefined) continue; + push(finish({ id: `custom:${custom.label}`, kind: "custom", label: custom.label, usedPercent: used, resetMs: resetMs(custom.resetAt) }, source, quota.updatedAt, now)); + } + return out; +} + +function customWindow(window: ProviderQuotaWindow, source: QuotaPoolSource, updatedAt: number, now: number): QuotaPoolWindow | null { + if (typeof window?.label !== "string" || !window.label.trim()) return null; + // An exact-model row (Antigravity diagnostics, CCA fallback) restates the pool its family + // window already reports and carries no proven scope; reporting it unscoped would let it gate + // every model. + if (window.modelId !== undefined && window.scope !== "model") return null; + const scoped = window.scope === "model"; + const passive = finite(window.passiveObservedAt); + return finish({ + id: scoped ? `family:${window.label.toLowerCase()}` : `custom:${window.label}`, + kind: "custom", + label: window.label, + ...(percent(window.percent) !== undefined ? { usedPercent: percent(window.percent) } : {}), + ...(scoped ? { scopeModel: window.label } : {}), + ...(window.rejected === true ? { exhausted: true } : {}), + resetMs: resetMs(window.resetAt), + }, passive ? "header" : source, passive ? window.passiveObservedAt! : updatedAt, now); +} + +/** A `ProviderQuota` (provider report or per-account cache row). */ +export function providerQuotaWindows(quota: ProviderQuota, source: QuotaPoolSource, now: number): QuotaPoolWindow[] { + const out: QuotaPoolWindow[] = []; + const push = (window: QuotaPoolWindow | null) => { if (window) out.push(window); }; + const at = quota.updatedAt; + const fixed = [ + ["5h", "burst_5h", "5-hour", quota.fiveHourPercent, quota.fiveHourResetAt], + ["weekly", "weekly", "Weekly", quota.weeklyPercent, quota.weeklyResetAt], + ["monthly", "monthly", "Monthly", quota.monthlyPercent, quota.monthlyResetAt], + ] as const; + for (const [id, kind, label, value, reset] of fixed) { + const used = percent(value); + if (used === undefined) continue; + push(finish({ id, kind, label, usedPercent: used, resetMs: resetMs(reset) }, source, at, now)); + } + const kiroUsed = finite(quota.kiroCreditsUsed) && quota.kiroCreditsUsed >= 0 ? quota.kiroCreditsUsed : undefined; + const kiroLimit = finite(quota.kiroCreditsLimit) && quota.kiroCreditsLimit > 0 ? quota.kiroCreditsLimit : undefined; + if (kiroUsed !== undefined || kiroLimit !== undefined) { + push(finish({ + id: "credits:kiro", kind: "credits", label: "Kiro credits", unit: "credits", + ...(kiroUsed !== undefined ? { used: kiroUsed } : {}), + ...(kiroLimit !== undefined ? { limit: kiroLimit } : {}), + ...(kiroUsed !== undefined && kiroLimit !== undefined ? { usedPercent: percent((kiroUsed / kiroLimit) * 100) } : {}), + }, source, at, now)); + } + const usd = quota.creditsUsd; + if (usd && finite(usd.used) && usd.used >= 0) { + const unlimited = usd.unlimited === true; + push(finish({ + id: "credits:usd", kind: "credits", label: "USD credits", unit: "usd", used: usd.used, + ...(!unlimited && finite(usd.limit) && usd.limit > 0 ? { limit: usd.limit } : {}), + ...(!unlimited && percent(usd.percent) !== undefined ? { usedPercent: percent(usd.percent) } : {}), + ...(unlimited ? { exhausted: false } : finite(usd.remaining) ? { exhausted: usd.remaining <= 0 } : {}), + }, source, at, now)); + } + for (const custom of quota.customWindows ?? []) push(customWindow(custom, source, at, now)); + return out; +} + +function accountState(input: { reauth: boolean; paused: boolean; windows: readonly QuotaPoolWindow[] }): QuotaPoolAccountState { + if (input.reauth) return "reauth"; + if (input.paused) return "paused"; + // A model-scoped window never gates the account as a whole. + if (input.windows.some(window => window.exhausted === true && window.scopeModel === undefined)) return "exhausted"; + return input.windows.length === 0 ? "unknown" : "available"; +} + +function nextResetAt(windows: readonly QuotaPoolWindow[]): string | undefined { + const resets = windows.filter(window => window.scopeModel === undefined && window.resetsAt).map(window => window.resetsAt!).sort(); + return resets[0]; +} + +function reportSource(report: ProviderQuotaReport): QuotaPoolSource { + if (report.aggregation) return "estimated"; + return report.observed === true ? "header" : "measured"; +} + +function codexAccounts(identity: QuotaPoolIdentity, accounts: Awaited>["accounts"], now: number): QuotaPoolAccount[] { + const provider = OPENAI_CODEX_PROVIDER_ID; + return accounts.map(dto => { + const source: QuotaPoolSource = latestCodexQuotaObservationSource(dto.id) === "response-header" ? "header" : "measured"; + const windows = dto.quota ? codexQuotaWindows(dto.quota as StoredAccountQuota, source, now) : []; + const account: QuotaPoolAccount = { + provider, + accountRef: quotaPoolAccountRef(identity, provider, dto.id), + // needsReauth only: a passive read leaves hasCredential false when it did not check, and an + // account without a credential already carries needsReauth. + state: accountState({ reauth: dto.needsReauth === true, paused: dto.paused, windows }), + windows, + }; + if (typeof dto.logLabel === "string" && ACCOUNT_LOG_LABEL_RE.test(dto.logLabel)) account.label = dto.logLabel; + if (typeof dto.plan === "string" && dto.plan.trim()) account.plan = dto.plan.trim(); + return account; + }); +} + +function oauthAccounts(identity: QuotaPoolIdentity, provider: string, now: number): QuotaPoolAccount[] { + const set = getAccountSet(provider); + if (!set) return []; + return set.accounts.map(row => { + const quota = getCachedProviderAccountQuota(provider, row.id); + const entry = accountQuotaCache.get(accountCacheKey(provider, row.id)); + const windows = quota ? providerQuotaWindows(quota, "measured", now) : []; + const account: QuotaPoolAccount = { + provider, + accountRef: quotaPoolAccountRef(identity, provider, row.id), + label: oauthAccountLogLabel(row.id, provider), + state: accountState({ reauth: row.needsReauth === true, paused: row.paused === true, windows }), + windows, + }; + const failure = entry?.quotaFailure && entry.quotaFailureIsCurrent?.() === true ? parseQuotaFailureCode(entry.quotaFailure) : undefined; + if (failure) account.failure = failure; + return account; + }); +} + +function claudeCliAccounts(identity: QuotaPoolIdentity, provider: string, deps: QuotaPoolDeps, now: number): QuotaPoolAccount[] { + const observation = peekClaudeUsageObservation(deps.claudeUsageStatePath ? { statePath: deps.claudeUsageStatePath } : {}); + if (!observation) return []; + const windows = observation.quota ? providerQuotaWindows(observation.quota, "measured", now) : []; + if (finite(observation.refusalUntil) && observation.refusalUntil > now) { + const refusal = finish({ id: "refusal", kind: "custom", label: "Claude CLI limit", exhausted: true, resetMs: observation.refusalUntil }, + "estimated", observation.checkedAt, now); + if (refusal) windows.push(refusal); + } + return [{ + provider, + accountRef: quotaPoolAccountRef(identity, provider, `claude-cli:${observation.identity}`), + state: accountState({ reauth: false, paused: false, windows }), + windows, + }]; +} + +function role(config: OcxConfig): QuotaPoolRole { + return config.runtimeRole === "hub" || config.runtimeRole === "client" ? config.runtimeRole : "standalone"; +} + +function enabled(provider: OcxProviderConfig | undefined): provider is OcxProviderConfig { + return !!provider && provider.disabled !== true; +} + +async function assemble(config: OcxConfig, identity: QuotaPoolIdentity, deps: QuotaPoolDeps): Promise { + const now = (deps.now ?? Date.now)(); + const names = Object.keys(config.providers).filter(name => enabled(config.providers[name])).sort(); + const reports = new Map(heldProviderQuotaReports(names).reports.map(report => [report.provider, report])); + const codexPool = hasCodexPoolProvider(config); + const codex = codexPool ? (await listCodexAuthAccountsSnapshot(config)).accounts : []; + const providers: QuotaPoolProvider[] = []; + for (const name of names) { + const provider = config.providers[name]!; + let accounts: QuotaPoolAccount[] = []; + let tracked = false; + if (codexPool && name === OPENAI_CODEX_PROVIDER_ID) { + tracked = true; + accounts = codexAccounts(identity, codex, now); + } else if (provider.adapter === "claude-cli") { + tracked = true; + accounts = claudeCliAccounts(identity, name, deps, now); + } else if (supportsPerAccountQuota(name) || hasPassiveAccountQuota(name)) { + tracked = true; + accounts = oauthAccounts(identity, name, now); + } + const report = reports.get(name); + const pooledWindows = report ? providerQuotaWindows(report.quota, reportSource(report), now) : []; + if (!tracked && !report) continue; + const pooled: QuotaPoolProvider["pooled"] = { windows: pooledWindows }; + const next = nextResetAt(pooledWindows); + if (next) pooled.nextResetAt = next; + providers.push({ provider: name, accounts, pooled }); + } + return { + schema: QUOTA_POOL_SCHEMA, + producer: { version: VERSION, role: role(config), homeId: identity.homeId }, + generatedAt: iso(now) ?? new Date().toISOString(), + providers, + }; +} + +/** The quota pool, or an identity error. Always a passive read, whoever asks. */ +export function readQuotaPool(config: OcxConfig, deps: QuotaPoolDeps = {}): Promise { + return runPassiveRead(async () => { + const identity = readQuotaPoolIdentity(); + if (!identity) return { ok: false, error: "quota_pool_identity_unavailable" } as const; + return { ok: true, pool: await assemble(config, identity, deps) } as const; + }); +} diff --git a/src/quota-pool/contract.ts b/src/quota-pool/contract.ts new file mode 100644 index 00000000000..88da277d0d2 --- /dev/null +++ b/src/quota-pool/contract.ts @@ -0,0 +1,58 @@ +/** + * Wire shape of `GET /api/quota-pool`: the GOAT contract `ocx.quota-pool/1`. + * + * GOAT owns the decoder (`packages/contracts/src/quotaPool.ts` in goat-si/goat); OpenCodex is + * the producer. Every optional field is OMITTED when OpenCodex does not know it. A missing + * window means unknown, never 0%, and no field is ever `null`. + */ + +export const QUOTA_POOL_SCHEMA = "ocx.quota-pool/1" as const; + +export type QuotaPoolWindowKind = "burst_5h" | "daily" | "weekly" | "monthly" | "credits" | "custom"; +/** measured: a usage probe body; header: response rate-limit headers; estimated: derived by OpenCodex. */ +export type QuotaPoolSource = "measured" | "header" | "estimated"; +export type QuotaPoolUnit = "requests" | "tokens" | "credits" | "usd"; +export type QuotaPoolAccountState = "available" | "exhausted" | "paused" | "reauth" | "unknown"; +export type QuotaPoolRole = "standalone" | "hub" | "client"; + +export interface QuotaPoolWindow { + id: string; + kind: QuotaPoolWindowKind; + label: string; + usedPercent?: number; + used?: number; + limit?: number; + unit?: QuotaPoolUnit; + resetsAt?: string; + windowSeconds?: number; + scopeModel?: string; + source: QuotaPoolSource; + observedAt: string; + exhausted?: boolean; +} + +export interface QuotaPoolAccount { + provider: string; + driver?: string; + /** Opaque, stable for this install, never an email or a raw account id (see identity.ts). */ + accountRef: string; + label?: string; + plan?: string; + state: QuotaPoolAccountState; + windows: QuotaPoolWindow[]; + /** A closed `QuotaFailureCode`; never upstream text. */ + failure?: string; +} + +export interface QuotaPoolProvider { + provider: string; + accounts: QuotaPoolAccount[]; + pooled: { windows: QuotaPoolWindow[]; headroom?: number; nextResetAt?: string }; +} + +export interface QuotaPool { + schema: typeof QUOTA_POOL_SCHEMA; + producer: { version: string; role: QuotaPoolRole; homeId: string }; + generatedAt: string; + providers: QuotaPoolProvider[]; +} diff --git a/src/quota-pool/identity.ts b/src/quota-pool/identity.ts new file mode 100644 index 00000000000..ea838ea652d --- /dev/null +++ b/src/quota-pool/identity.ts @@ -0,0 +1,203 @@ +/** + * The install identity behind `ocx.quota-pool/1`: the Home's `homeId` and the secret that keys + * every `accountRef`. + * + * Both live in `/quota-pool-identity.json` (owner-only). The server creates the + * file once, in the background after it starts listening (`ensureQuotaPoolIdentity`); the + * `/api/quota-pool` read only reads it, so the read path never writes. A read that finds no + * valid file reports the identity as unavailable instead of minting one. + * + * `accountRef` is `acct_` + base64url(HMAC-SHA256(secret, "ocx.quota-pool/1" NUL provider NUL + * accountId)) truncated to 128 bits. It is stable across restarts because the secret is + * persisted, distinct per provider, and not reversible: without the per-install secret a + * recipient cannot confirm a guessed email or account id against it, and it never contains + * either. Deleting the file rotates `homeId` and every `accountRef` together. + */ +import { createHmac, randomBytes, randomUUID } from "node:crypto"; +import { closeSync, constants as fsConstants, fsyncSync, linkSync, lstatSync, openSync, readFileSync, unlinkSync, writeSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { getConfigDir } from "../config/paths"; + +export const QUOTA_POOL_IDENTITY_FILE = "quota-pool-identity.json"; + +export interface QuotaPoolIdentity { + homeId: string; + secret: string; +} + +const HOME_ID = /^home_[0-9a-f]{32}$/; +const SECRET = /^[0-9a-f]{64}$/; +let memo: { path: string; identity: QuotaPoolIdentity } | null = null; + +export function quotaPoolIdentityPath(configDir = getConfigDir()): string { + return join(configDir, QUOTA_POOL_IDENTITY_FILE); +} + +function parseIdentity(text: string): QuotaPoolIdentity | null { + try { + const raw = JSON.parse(text) as Record; + if (raw?.version !== 1 || typeof raw.homeId !== "string" || typeof raw.secret !== "string") return null; + if (!HOME_ID.test(raw.homeId) || !SECRET.test(raw.secret)) return null; + return { homeId: raw.homeId, secret: raw.secret }; + } catch { + return null; + } +} + +/** Read-only: the persisted identity, or null when it is missing or malformed. Never writes. */ +export function readQuotaPoolIdentity(configDir = getConfigDir()): QuotaPoolIdentity | null { + const path = quotaPoolIdentityPath(configDir); + if (memo?.path === path) return memo.identity; + try { + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024) return null; + } catch { + return null; + } + let identity: QuotaPoolIdentity | null; + try { identity = parseIdentity(readFileSync(path, "utf8")); } catch { return null; } + if (identity) memo = { path, identity }; + return identity; +} + +const NOFOLLOW = process.platform === "win32" ? 0 : fsConstants.O_NOFOLLOW; +/** How long a loser waits for a concurrent winner's file to become readable before giving up. */ +const PUBLISHED_READ_ATTEMPTS = 50; +const PUBLISHED_READ_INTERVAL_MS = 10; + +/** Test seams for the publication windows; production never sets them. */ +export interface QuotaPoolIdentityTestHooks { + /** After the first read found no identity, before the existence check. */ + afterAbsentRead?: (path: string) => void; + /** After a file was exclusively created, before its bytes are written. */ + afterCreate?: (path: string) => void; + /** Replace `linkSync` (to force the in-place fallback). */ + link?: (existing: string, target: string) => void; + /** Override the bounded wait for a concurrent winner. */ + publishedReadAttempts?: number; +} +let hooks: QuotaPoolIdentityTestHooks = {}; + +export function setQuotaPoolIdentityHooksForTests(next: QuotaPoolIdentityTestHooks = {}): void { + hooks = next; +} + +/** Write a whole new owner-only file, flushed to disk; fails if anything (a symlink included) already exists. */ +function createExclusive(path: string, content: string): void { + const fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | NOFOLLOW, 0o600); + try { + hooks.afterCreate?.(path); + writeSync(fd, content); + fsyncSync(fd); + } finally { closeSync(fd); } +} + +function errorCode(error: unknown): string | undefined { + return (error as NodeJS.ErrnoException | null)?.code; +} + +/** + * Make the new directory entry durable too: fsyncing the file alone can lose the entry on power + * loss, and a lost entry would mint a new identity and rotate every accountRef on the next start. + * Best effort; Windows cannot open a directory for fsync. + */ +function syncParentDirectory(path: string): void { + if (process.platform === "win32") return; + try { + const fd = openSync(dirname(path), fsConstants.O_RDONLY); + try { fsyncSync(fd); } finally { closeSync(fd); } + } catch { /* the file itself is flushed; a directory fsync is an extra guarantee */ } +} + +function pause(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +/** + * Something already sits at the path: adopt it once it reads as a valid identity. + * + * A concurrent winner's file is complete the moment it appears when it was hard-linked, but the + * in-place fallback creates the file before writing it, so a loser can briefly see it empty. The + * wait is bounded; an invalid result is never returned or memoized. A symlink, or a file that stays + * invalid past the bound, is refused and left alone. + */ +function adoptPublishedIdentity(configDir: string, path: string): QuotaPoolIdentity { + const attempts = hooks.publishedReadAttempts ?? PUBLISHED_READ_ATTEMPTS; + for (let attempt = 0; attempt < attempts; attempt++) { + let stat; + try { stat = lstatSync(path); } catch (error) { + if (errorCode(error) !== "ENOENT") throw error; + stat = null; + } + if (stat && (stat.isSymbolicLink() || !stat.isFile())) break; + const identity = readQuotaPoolIdentity(configDir); + if (identity) return identity; + if (attempt + 1 < attempts) pause(PUBLISHED_READ_INTERVAL_MS); + } + throw new Error("quota-pool identity file is not a valid regular file; remove it to rotate the identity"); +} + +/** + * Create the identity once. Called off the request path (server startup); idempotent. + * + * Publication is exclusive and never follows a link: the secret is written and flushed to a + * private temp file, then hard-linked into place, which fails if anything already sits at the path. + * Where hard links are unavailable it is created in place exclusively. Two servers starting on one + * fresh home therefore cannot each keep a different identity: a loser, including one that finds + * the winner's file appear between its own checks, re-reads within a bound and adopts the winner's. + * A symlink or a persistently malformed file at the path is refused and left alone (the read keeps + * reporting the identity as unavailable until an operator removes it); it is never overwritten. + */ +export function ensureQuotaPoolIdentity(configDir = getConfigDir()): QuotaPoolIdentity { + const existing = readQuotaPoolIdentity(configDir); + if (existing) return existing; + const path = quotaPoolIdentityPath(configDir); + hooks.afterAbsentRead?.(path); + let present = true; + try { lstatSync(path); } catch (error) { if (errorCode(error) !== "ENOENT") throw error; present = false; } + if (present) return adoptPublishedIdentity(configDir, path); + const identity: QuotaPoolIdentity = { + homeId: `home_${randomUUID().replaceAll("-", "")}`, + secret: randomBytes(32).toString("hex"), + }; + const content = `${JSON.stringify({ version: 1, ...identity })}\n`; + const temp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; + let published = false; + try { + createExclusive(temp, content); + try { + (hooks.link ?? linkSync)(temp, path); + published = true; + } catch (error) { + const code = errorCode(error); + if (code === "EEXIST") return adoptPublishedIdentity(configDir, path); + // No hard links on this filesystem: exclusive create in place is still never a replacement. + if (code !== "EPERM" && code !== "ENOTSUP" && code !== "EXDEV" && code !== "ENOSYS") throw error; + } + } finally { + try { unlinkSync(temp); } catch { /* best effort */ } + } + if (!published) { + try { + createExclusive(path, content); + } catch (error) { + if (errorCode(error) !== "EEXIST") throw error; + return adoptPublishedIdentity(configDir, path); + } + } + syncParentDirectory(path); + memo = { path, identity }; + return identity; +} + +/** The opaque account reference: keyed by the install secret, never the raw id or an email. */ +export function quotaPoolAccountRef(identity: QuotaPoolIdentity, provider: string, accountId: string): string { + const digest = createHmac("sha256", Buffer.from(identity.secret, "hex")) + .update(`ocx.quota-pool/1\0${provider}\0${accountId}`) + .digest(); + return `acct_${digest.subarray(0, 16).toString("base64url")}`; +} + +export function resetQuotaPoolIdentityMemoForTests(): void { + memo = null; +} diff --git a/src/remote/features.ts b/src/remote/features.ts new file mode 100644 index 00000000000..25d9d8e08a8 --- /dev/null +++ b/src/remote/features.ts @@ -0,0 +1,74 @@ +/** + * The one registry of peer-visible features this build serves, advertised in `GET /readyz` + * `features` so a Hub, a Child or GOAT decides compatibility by feature instead of parsing versions. + * + * Every entry reads the module that owns the capability; nothing here restates a fact by hand: + * + * - `quota-pool/1` when the management route registry declares `GET /api/quota-pool`. The registry + * is reconciled against dispatch source (`tests/server/management-route-registry.test.ts`), so the + * feature appears exactly when the route is reachable. + * - `fallback-policy/1` when the registry declares `GET /api/fallback` (OCX-11). GOAT reads a Home + * without it as "fallback state unknown". + * - `link-management-relay` when the Remote Link management read table is non-empty. + * - `served-by-header` when the served-by emitter says responses carry the header. + * - `relay-auth/` with the connection-bound link relay auth version. + * - `link-peer-report/` with the version of the report a Child sends on the relay challenge and a + * Home records for `ocx link status` (`src/link/peer-report.ts`). + * + * The ids are static strings. They carry no account, credential or configuration data, which is + * what keeps the unauthenticated `/readyz` body safe. `parseRemoteReadyMetadata` accepts at most + * 64 unique ids of at most 80 characters, and `tests/server/readyz-features.test.ts` holds the + * full registry to that. + */ +import { LINK_MANAGEMENT_READ_PATHS } from "../link/management-routes"; +import { LINK_PEER_REPORT_VERSION } from "../link/peer-report"; +import { LINK_RELAY_AUTH_VERSION } from "../link/relay-auth"; +import { MANAGEMENT_ROUTES, type ManagementRoute } from "../server/management/route-registry"; +import { SERVED_BY_HEADER_EMITTED } from "./served-by"; + +export const QUOTA_POOL_PATH = "/api/quota-pool"; +export const FALLBACK_STATE_PATH = "/api/fallback"; + +/** The owning sources the registry reads. Tests pass their own to drive presence and absence. */ +export interface PeerFeatureSources { + readonly managementRoutes: readonly Pick[]; + readonly linkManagementReadPaths: readonly string[]; + readonly servedByHeaderEmitted: boolean; + readonly relayAuthVersion: string; + readonly linkPeerReportVersion: string; +} + +/** One feature: its id when the sources say it is served, otherwise null. */ +export type PeerFeature = (sources: PeerFeatureSources) => string | null; + +/** Ordered; `/readyz` lists the served ids in this order. */ +export const PEER_FEATURES: readonly PeerFeature[] = [ + s => s.managementRoutes.some(route => route.method === "GET" && route.path === QUOTA_POOL_PATH) ? "quota-pool/1" : null, + s => s.managementRoutes.some(route => route.method === "GET" && route.path === FALLBACK_STATE_PATH) ? "fallback-policy/1" : null, + s => s.linkManagementReadPaths.length > 0 ? "link-management-relay" : null, + s => s.servedByHeaderEmitted ? "served-by-header" : null, + s => `relay-auth/${s.relayAuthVersion}`, + s => `link-peer-report/${s.linkPeerReportVersion}`, +]; + +export function currentPeerFeatureSources(): PeerFeatureSources { + return { + managementRoutes: MANAGEMENT_ROUTES, + linkManagementReadPaths: LINK_MANAGEMENT_READ_PATHS, + servedByHeaderEmitted: SERVED_BY_HEADER_EMITTED, + relayAuthVersion: LINK_RELAY_AUTH_VERSION, + linkPeerReportVersion: LINK_PEER_REPORT_VERSION, + }; +} + +export function servedPeerFeatures(sources: PeerFeatureSources = currentPeerFeatureSources()): string[] { + return PEER_FEATURES.map(feature => feature(sources)).filter((id): id is string => id !== null); +} + +let buildFeatures: readonly string[] | undefined; + +/** This build's served features. The sources are build constants, so the list is computed once. */ +export function buildPeerFeatures(): string[] { + buildFeatures ??= Object.freeze(servedPeerFeatures()); + return [...buildFeatures]; +} diff --git a/src/remote/protocol.ts b/src/remote/protocol.ts index dc4cf359af2..fe76f9bff21 100644 --- a/src/remote/protocol.ts +++ b/src/remote/protocol.ts @@ -1,4 +1,5 @@ import type { OcxConfig } from "../types/config"; +import { buildPeerFeatures } from "./features"; export const REMOTE_HUB_PROTOCOL = 1; export const MINIMUM_REMOTE_CLIENT_PROTOCOL = 1; @@ -53,6 +54,7 @@ export function readyProtocolMetadata(config: OcxConfig, req: Request): RemoteRe protocol: REMOTE_HUB_PROTOCOL, minimumClientProtocol: MINIMUM_REMOTE_CLIENT_PROTOCOL, managementUrl, + features: buildPeerFeatures(), }; } diff --git a/src/remote/served-by.ts b/src/remote/served-by.ts new file mode 100644 index 00000000000..22b37dc396c --- /dev/null +++ b/src/remote/served-by.ts @@ -0,0 +1,57 @@ +/** + * The served-by response header, the Home instance id it carries, and whether this build sends it. + * + * Every `GET /v1/models` response the server answers itself names the serving Home in + * `x-opencodex-served-by`, and `GET /healthz` publishes the same value as `instanceId`, so a peer + * (GOAT's parity check P4) can prove which Home answered without reading logs. A Remote Link Child + * never answers `/v1/models` itself: its relay copies the Home's response headers, so the value a + * Child returns is the Home's. A Hub-mode client's Codex calls the Hub's data listener directly, so + * the Hub stamps its own response. + * + * The instance id is the install's `homeId` from `src/quota-pool/identity.ts`: a random + * `home_<32 hex>` minted once per install, persisted owner-only, and stable across restarts. It is + * never a hostname and is not derived from one. Until the identity exists (it is created in the + * background after the listener starts) or when its file is unreadable, the header and the + * `/healthz` field are omitted; an empty or malformed value is never sent. + * + * `SERVED_BY_HEADER_EMITTED` tells `/readyz` to advertise `served-by-header` + * (`src/remote/features.ts`); `tests/server/readyz-features.test.ts` fails if the advertisement + * and live responses disagree. + */ +import { readQuotaPoolIdentity } from "../quota-pool/identity"; + +export const SERVED_BY_HEADER = "x-opencodex-served-by"; + +/** Whether responses from this build carry {@link SERVED_BY_HEADER}. */ +export const SERVED_BY_HEADER_EMITTED: boolean = true; + +const INSTANCE_ID = /^home_[0-9a-f]{32}$/; + +/** This Home's opaque instance id, or undefined while the install identity is unavailable. */ +export function homeInstanceId(): string | undefined { + let homeId: unknown; + try { homeId = readQuotaPoolIdentity()?.homeId; } catch { return undefined; } + return typeof homeId === "string" && INSTANCE_ID.test(homeId) ? homeId : undefined; +} + +/** Stamp {@link SERVED_BY_HEADER} with this Home's instance id; leaves the response alone without one. */ +export function withServedBy(response: Response, instanceId = homeInstanceId()): Response { + if (!instanceId) return response; + try { + response.headers.set(SERVED_BY_HEADER, instanceId); + return response; + } catch { + const headers = new Headers(response.headers); + headers.set(SERVED_BY_HEADER, instanceId); + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); + } +} + +/** + * The server's last step for every answered request: `GET /v1/models` names the serving Home, + * whichever branch produced the response. `pathname` is the dispatch path the router matched. + */ +export function stampServedBy(method: string, pathname: string, response: Response): Response { + if (!response || method !== "GET" || pathname !== "/v1/models") return response; + return withServedBy(response); +} diff --git a/src/responses/parser.ts b/src/responses/parser.ts index e18ee9f1cbe..10bd953eb5e 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -329,6 +329,13 @@ export function parseRequest( continue; } + if (effectiveType === "configuration_update") { + // Codex 0.164 positional reasoning-effort control. It is not a turn, so a translated route + // drops it without touching pending reasoning; the native passthrough keeps it verbatim in + // _rawBody. Mapping its effort onto the routed request is deliberately not done (#6895). + continue; + } + if (isCompactionItemType(effectiveType)) { // A stored summary from a previous compaction. Decode our ocx1/ocx2 envelopes into plain text so // the routed model keeps the compacted context; real OpenAI-encrypted blobs degrade to a note. diff --git a/src/responses/reasoning-envelope.ts b/src/responses/reasoning-envelope.ts index edfc7e464d7..628bd82838a 100644 --- a/src/responses/reasoning-envelope.ts +++ b/src/responses/reasoning-envelope.ts @@ -121,6 +121,18 @@ export function isProviderIssuedThinkingSignature( } } +/** + * Whether a value has the spelling of a signature Anthropic issued. An allow-list on top of + * `isProviderIssuedThinkingSignature`: Anthropic's signature is base64, so item ids and the + * parser's serialized placeholder never pass. Shared by the adapter, which forwards only such a + * signature, and the Chat `reasoning_details` carrier, which accepts and emits only such a one. + */ +export function isLikelyRealAnthropicThinkingSignature(signature: string | undefined): signature is string { + if (typeof signature !== "string" || signature.length < 16) return false; + if (/^(fc|call|msg|rs|resp|reasoning|item|ws|tool|func|function)[-_]/i.test(signature)) return false; + return /^[A-Za-z0-9+/_=-]+$/.test(signature); +} + /** Decode an ocxr1 envelope; returns null for native (OpenAI-encrypted) blobs or garbage. */ export function decodeReasoningEnvelope(encryptedContent: string, budget?: TranslatorBudget): ReasoningEnvelope | null { if (!encryptedContent.startsWith(OCX_REASONING_PREFIX)) return null; diff --git a/src/routing/fallback-policy.ts b/src/routing/fallback-policy.ts new file mode 100644 index 00000000000..95877d50729 --- /dev/null +++ b/src/routing/fallback-policy.ts @@ -0,0 +1,290 @@ +/** + * OCX-11 fallback policy: one resolver, one guard, one record. + * + * A fallback is a hop that serves a request from a different model or a different provider than + * the one chosen or requested. Rotation among the accounts or keys of one provider pool for the + * same model is intra-pool resilience and never passes through here (see + * `structure/fallback-policy.md`). Every hop site calls {@link guardFallbackHop} at the point it + * would switch targets; nothing else reads `config.fallback`. + * + * Precedence: a device scope set to false refuses regardless; otherwise route, then provider, + * then global decide, most specific first; absence allows (today's behavior). + * + * Privacy: events carry public selectors, coarse trigger codes and device ids only. No request + * body, credential, account id, label or email reaches the ring or the refusal body. + */ +import type { OcxConfig } from "../types"; +import type { OcxFallbackConfig, OcxFallbackKind, OcxFallbackSwitches } from "../types/fallback"; +import { FALLBACK_DEVICE_ID, FALLBACK_SELECTOR } from "../config/schema/fallback-policy"; + +export type FallbackScope = "device" | "route" | "provider" | "global" | "default"; +export type FallbackSite = "combo" | "subagent" | "hard-lock" | "routing-profile"; +export type FallbackTrigger = + | "rate_limited" | "quota_exhausted" | "auth_refused" | "upstream_5xx" | "upstream_error" + | "cooldown" | "unavailable" | "hard_lock"; + +export interface FallbackTarget { provider: string; model: string } + +export interface FallbackDecision { + allowed: boolean; + scope: FallbackScope; + scopeKey?: string; +} + +export interface FallbackEvent { + at: string; + site: FallbackSite; + route: string; + kind: OcxFallbackKind; + from: string; + to: string; + trigger: FallbackTrigger; + outcome: "taken" | "refused"; + scope: FallbackScope; + scopeKey?: string; + deviceId?: string; +} + +/** The admission slice this module reads; a configured key is the only device identity. */ +export type FallbackAdmission = { kind: string; keyId?: string } | null | undefined; + +const RING_SIZE = 20; +const ring: FallbackEvent[] = []; + +function switches(value: unknown): OcxFallbackSwitches | undefined { + return value && typeof value === "object" && !Array.isArray(value) ? value as OcxFallbackSwitches : undefined; +} + +function policyOf(config: Pick | undefined): OcxFallbackConfig | undefined { + const value = config?.fallback; + return value && typeof value === "object" && !Array.isArray(value) ? value : undefined; +} + +function entry(map: Record | undefined, key: string, pattern: RegExp): OcxFallbackSwitches | undefined { + if (!map || typeof map !== "object" || !pattern.test(key) || !Object.hasOwn(map, key)) return undefined; + return switches(map[key]); +} + +export function selector(target: FallbackTarget): string { + return target.provider + "/" + target.model; +} + +/** The kind of a hop, or null when the target does not change. */ +export function fallbackHopKind(from: FallbackTarget, to: FallbackTarget): OcxFallbackKind | null { + if (from.provider !== to.provider) return "provider"; + return from.model === to.model ? null : "model"; +} + +/** Resolve one hop against the configured scopes. Pure; device ids are passed in. */ +export function resolveFallbackPolicy( + config: Pick | undefined, + query: { kind: OcxFallbackKind; routes?: readonly string[]; provider?: string; deviceIds?: readonly string[] }, +): FallbackDecision { + const policy = policyOf(config); + if (!policy) return { allowed: true, scope: "default" }; + for (const id of query.deviceIds ?? []) { + if (entry(policy.devices, id, FALLBACK_DEVICE_ID)?.[query.kind] === false) { + return { allowed: false, scope: "device", scopeKey: id }; + } + } + for (const route of query.routes ?? []) { + const value = entry(policy.routes, route, FALLBACK_SELECTOR)?.[query.kind]; + if (typeof value === "boolean") return { allowed: value, scope: "route", scopeKey: route }; + } + if (query.provider) { + const value = entry(policy.providers, query.provider, FALLBACK_SELECTOR)?.[query.kind]; + if (typeof value === "boolean") return { allowed: value, scope: "provider", scopeKey: query.provider }; + } + const global = policy[query.kind]; + if (typeof global === "boolean") return { allowed: global, scope: "global" }; + return { allowed: true, scope: "default" }; +} + +/** + * Core-owned slot (AGENTS.md "optional subsystems stay off the core path"): the link listener + * lifecycle registers how a data key id maps to its Remote Link id, so this request-path module + * never imports link state. With nothing registered a key id is its own device id (a Hub client). + */ +export type FallbackLinkResolver = (keyId: string) => string | undefined; +let linkResolver: FallbackLinkResolver | undefined; + +/** Register the key-to-link resolver; returns a disposer that clears it if still current. */ +export function registerFallbackLinkResolver(resolver: FallbackLinkResolver): () => void { + linkResolver = resolver; + linkCache = undefined; + return () => { + if (linkResolver === resolver) linkResolver = undefined; + linkCache = undefined; + }; +} + +let linkCache: { at: number; byKey: Map } | undefined; +const LINK_CACHE_MS = 5_000; + +/** Test seam: forget the cached link-key map. */ +export function resetFallbackLinkCacheForTests(): void { + linkCache = undefined; +} + +function linkIdForKey(keyId: string, now: number): string | undefined { + if (!linkResolver) return undefined; + if (!linkCache || now - linkCache.at > LINK_CACHE_MS) linkCache = { at: now, byKey: new Map() }; + if (!linkCache.byKey.has(keyId)) { + let linkId: string | undefined; + try { linkId = linkResolver(keyId); } catch { /* unreadable store: the key id alone still identifies the device */ } + linkCache.byKey.set(keyId, linkId); + } + return linkCache.byKey.get(keyId); +} + +/** + * Device ids for a request: the Remote Link id its data key belongs to, then the key id itself + * (a Hub client). The registered link resolver runs only when a device override exists. + */ +export function fallbackDeviceIds(config: Pick, admission: FallbackAdmission, now = Date.now()): string[] { + const devices = policyOf(config)?.devices; + if (!devices || Object.keys(devices).length === 0) return []; + if (!admission || admission.kind !== "configured" || typeof admission.keyId !== "string") return []; + const linkId = linkIdForKey(admission.keyId, now); + return linkId ? [linkId, admission.keyId] : [admission.keyId]; +} + +/** A coarse trigger code for a failed upstream status. */ +export function fallbackTriggerForStatus(status: number): FallbackTrigger { + if (status === 429) return "rate_limited"; + if (status === 402) return "quota_exhausted"; + if (status === 401 || status === 403) return "auth_refused"; + if (status >= 500) return "upstream_5xx"; + return "upstream_error"; +} + +function record(event: FallbackEvent): void { + ring.push(event); + if (ring.length > RING_SIZE) ring.splice(0, ring.length - RING_SIZE); +} + +export function recentFallbackEvents(): FallbackEvent[] { + return ring.map(event => ({ ...event })); +} + +export function clearFallbackEventsForTests(): void { + ring.length = 0; +} + +export interface FallbackHop { + site: FallbackSite; + /** The public selector the client asked for (combo/, policy/, a model). */ + route: string; + /** Further selectors that name the same route, such as a resolved alias. */ + aliases?: readonly string[]; + from: FallbackTarget; + to: FallbackTarget; + trigger: FallbackTrigger; + admission?: FallbackAdmission; + /** Status of the failure that triggered the hop; absent for a proactive hop. */ + failureStatus?: number; + retryAfter?: string | null; + now?: number; +} + +export type FallbackGuardResult = + | { + ok: true; + kind: OcxFallbackKind | null; + /** + * Record the hop as taken. A site calls it once the fallback dispatch is admitted (send + * budget reserved, candidate not locally skipped), so the ring never claims a target that was + * never contacted. Idempotent; a no-op for a same-target retry. + */ + commit: () => void; + } + | { ok: false; kind: OcxFallbackKind; decision: FallbackDecision; response: Response }; + +const NO_COMMIT = (): void => {}; + +/** + * Check one hop. A refusal is recorded at once; an allowed hop is recorded only through the + * returned `commit`. A same-target retry is not a hop and always passes unrecorded. + */ +export function guardFallbackHop(config: OcxConfig, hop: FallbackHop): FallbackGuardResult { + const kind = fallbackHopKind(hop.from, hop.to); + if (kind === null) return { ok: true, kind: null, commit: NO_COMMIT }; + const now = hop.now ?? Date.now(); + const deviceIds = fallbackDeviceIds(config, hop.admission, now); + const routes = [hop.route, ...(hop.aliases ?? [])].filter((value, index, all) => value && all.indexOf(value) === index); + const decision = resolveFallbackPolicy(config, { kind, routes, provider: hop.from.provider, deviceIds }); + const event: FallbackEvent = { + at: new Date(now).toISOString(), + site: hop.site, + route: hop.route, + kind, + from: selector(hop.from), + to: selector(hop.to), + trigger: hop.trigger, + outcome: decision.allowed ? "taken" : "refused", + scope: decision.scope, + ...(decision.scopeKey !== undefined ? { scopeKey: decision.scopeKey } : {}), + ...(deviceIds[0] !== undefined ? { deviceId: deviceIds[0] } : {}), + }; + if (decision.allowed) { + let committed = false; + return { ok: true, kind, commit: () => { if (!committed) { committed = true; record(event); } } }; + } + record(event); + return { ok: false, kind, decision, response: fallbackRefusedResponse(event, hop) }; +} + +function scopeFlag(event: FallbackEvent): string { + switch (event.scope) { + case "device": return " --device " + event.scopeKey; + case "route": return " --route " + event.scopeKey; + case "provider": return " --provider " + event.scopeKey; + default: return ""; + } +} + +function scopePhrase(event: FallbackEvent): string { + return event.scope === "global" ? "globally" : "for " + event.scope + " " + event.scopeKey; +} + +/** The stable `fallback_refused` error. Status follows the triggering failure. */ +export function fallbackRefusedResponse(event: FallbackEvent, hop: Pick): Response { + const status = typeof hop.failureStatus === "number" && hop.failureStatus >= 400 && hop.failureStatus <= 599 + ? hop.failureStatus + : event.trigger === "cooldown" || event.trigger === "quota_exhausted" || event.trigger === "rate_limited" + || event.trigger === "hard_lock" ? 429 : 503; + const kindWord = event.kind === "provider" ? "Provider" : "Model"; + const message = event.route + ": " + event.from + " failed (" + event.trigger + "). " + kindWord + + " fallback to " + event.to + " is off " + scopePhrase(event) + ", so OpenCodex did not send the request there." + + " To allow it: ocx fallback on --kind " + event.kind + scopeFlag(event); + const headers = new Headers({ "x-opencodex-fallback": "refused", "cache-control": "no-store" }); + if (hop.retryAfter) headers.set("retry-after", hop.retryAfter); + return Response.json({ + error: { + message, + type: "fallback_refused", + code: "fallback_refused", + fallback: { + kind: event.kind, + scope: event.scope, + ...(event.scopeKey !== undefined ? { scopeKey: event.scopeKey } : {}), + from: event.from, + to: event.to, + trigger: event.trigger, + }, + }, + }, { status, headers }); +} + +/** Whether a response is this module's refusal. */ +export function isFallbackRefusal(response: Response): boolean { + return response.headers.get("x-opencodex-fallback") === "refused"; +} + +/** Carries a refusal out of a site that cannot return a Response itself. */ +export class FallbackRefusedError extends Error { + constructor(readonly response: Response) { + super("fallback_refused"); + this.name = "FallbackRefusedError"; + } +} diff --git a/src/routing/fallback-state.ts b/src/routing/fallback-state.ts new file mode 100644 index 00000000000..58d91f86844 --- /dev/null +++ b/src/routing/fallback-state.ts @@ -0,0 +1,179 @@ +/** + * OCX-11 fallback state: the `ocx.fallback/1` read model and the one scoped write. + * + * Off the request path: the management route, `ocx fallback` and `ocx status --json` use it. + * The request path only resolves (`src/routing/fallback-policy.ts`). + */ +import type { OcxConfig } from "../types"; +import type { OcxFallbackConfig, OcxFallbackDeviceConfig, OcxFallbackSwitches } from "../types/fallback"; +import { FALLBACK_DEVICE_ID, FALLBACK_SELECTOR } from "../config/schema/fallback-policy"; +import type { LinkRecord } from "../link/store"; +import { recentFallbackEvents, type FallbackEvent } from "./fallback-policy"; + +export const FALLBACK_STATE_SCHEMA = "ocx.fallback/1"; + +export interface FallbackDeviceView { + id: string; + kind: "link" | "key"; + alias?: string; + /** Permission for GOAT to move this device's work to its own providers while the Home is down. */ + localFallback: boolean; + /** false: refused for this device whatever the other scopes say; null: not set. */ + model: boolean | null; + provider: boolean | null; +} + +export interface FallbackStateView { + schema: typeof FALLBACK_STATE_SCHEMA; + /** Effective global switches; absence reads true, today's behavior. */ + global: { model: boolean; provider: boolean }; + providers: Record; + routes: Record; + devices: FallbackDeviceView[]; + last: FallbackEvent | null; + recent: FallbackEvent[]; +} + +function cleanSwitches(value: unknown): OcxFallbackSwitches { + const out: OcxFallbackSwitches = {}; + if (!value || typeof value !== "object" || Array.isArray(value)) return out; + const raw = value as Record; + if (typeof raw.model === "boolean") out.model = raw.model; + if (typeof raw.provider === "boolean") out.provider = raw.provider; + return out; +} + +function cleanMap(value: unknown, pattern: RegExp): Record { + const out: Record = {}; + if (!value || typeof value !== "object" || Array.isArray(value)) return out; + for (const [key, entry] of Object.entries(value as Record)) { + if (!pattern.test(key)) continue; + const switches = cleanSwitches(entry); + if (Object.keys(switches).length > 0) out[key] = switches; + } + return out; +} + +/** + * Build the read model. `passive` (a Remote Link Child's read through the management relay) + * drops link aliases and per-event device ids, so a Child learns its own row by link id only. + */ +export function buildFallbackState( + config: Pick, + options: { links?: readonly Pick[]; passive?: boolean } = {}, +): FallbackStateView { + const policy: OcxFallbackConfig = config.fallback ?? {}; + const devicesRaw = policy.devices && typeof policy.devices === "object" ? policy.devices : {}; + const rows = new Map(); + const deviceRow = (id: string, kind: "link" | "key", alias?: string): FallbackDeviceView => { + const raw = (Object.hasOwn(devicesRaw, id) ? devicesRaw[id] : undefined) as OcxFallbackDeviceConfig | undefined; + return { + id, + kind, + ...(alias && !options.passive ? { alias } : {}), + localFallback: raw?.localFallback === true, + model: typeof raw?.model === "boolean" ? raw.model : null, + provider: typeof raw?.provider === "boolean" ? raw.provider : null, + }; + }; + for (const link of options.links ?? []) rows.set(link.id, deviceRow(link.id, "link", link.alias)); + for (const id of Object.keys(devicesRaw)) { + if (!FALLBACK_DEVICE_ID.test(id) || rows.has(id)) continue; + rows.set(id, deviceRow(id, id.startsWith("lnk_") ? "link" : "key")); + } + const recent = recentFallbackEvents().map(event => { + if (!options.passive || event.deviceId === undefined) return event; + const { deviceId: _omitted, ...rest } = event; + return rest; + }); + return { + schema: FALLBACK_STATE_SCHEMA, + global: { model: policy.model !== false, provider: policy.provider !== false }, + providers: cleanMap(policy.providers, FALLBACK_SELECTOR), + routes: cleanMap(policy.routes, FALLBACK_SELECTOR), + devices: [...rows.values()], + last: recent.at(-1) ?? null, + recent, + }; +} + +export type FallbackWriteScope = "global" | "provider" | "route" | "device"; +export type FallbackWriteSetting = "model" | "provider" | "localFallback"; + +export interface FallbackWrite { + scope: FallbackWriteScope; + key?: string; + setting: FallbackWriteSetting; + /** true or false sets the leaf; null removes it so the scope inherits again. */ + value: boolean | null; +} + +/** Validate a write body; the error text is operator-facing. */ +export function parseFallbackWrite(body: unknown): { ok: true; write: FallbackWrite } | { ok: false; error: string } { + if (!body || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: "expected a JSON object" }; + const raw = body as Record; + const scope = raw.scope; + const setting = raw.setting; + const value = raw.value; + if (scope !== "global" && scope !== "provider" && scope !== "route" && scope !== "device") { + return { ok: false, error: "scope must be global, provider, route or device" }; + } + if (setting !== "model" && setting !== "provider" && setting !== "localFallback") { + return { ok: false, error: "setting must be model, provider or localFallback" }; + } + if (value !== true && value !== false && value !== null) return { ok: false, error: "value must be true, false or null" }; + if (setting === "localFallback" && scope !== "device") return { ok: false, error: "localFallback is set per device" }; + if (scope === "global") { + if (raw.key !== undefined) return { ok: false, error: "a global write takes no key" }; + return { ok: true, write: { scope, setting, value } }; + } + const key = raw.key; + const pattern = scope === "device" ? FALLBACK_DEVICE_ID : FALLBACK_SELECTOR; + if (typeof key !== "string" || !pattern.test(key)) return { ok: false, error: scope + " key is missing or malformed" }; + return { ok: true, write: { scope, key, setting, value } }; +} + +/** Up to 8 writes applied in one config mutation: `{ writes: [...] }`, or a single write. */ +export const MAX_FALLBACK_WRITES = 8; + +export function parseFallbackWrites(body: unknown): { ok: true; writes: FallbackWrite[] } | { ok: false; error: string } { + const list = body && typeof body === "object" && !Array.isArray(body) && Object.hasOwn(body, "writes") + ? (body as { writes: unknown }).writes + : [body]; + if (!Array.isArray(list) || list.length === 0 || list.length > MAX_FALLBACK_WRITES) { + return { ok: false, error: "writes must hold 1 to " + MAX_FALLBACK_WRITES + " entries" }; + } + const writes: FallbackWrite[] = []; + for (const entry of list) { + const parsed = parseFallbackWrite(entry); + if (!parsed.ok) return parsed; + writes.push(parsed.write); + } + return { ok: true, writes }; +} + +function setLeaf(target: Record, setting: string, value: boolean | null): void { + if (value === null) delete target[setting]; + else target[setting] = value; +} + +/** Apply one write to a config in place; reports whether anything changed. */ +export function applyFallbackWrite(config: OcxConfig, write: FallbackWrite): boolean { + const before = JSON.stringify(config.fallback ?? null); + const policy: Record = { ...(config.fallback ?? {}) }; + if (write.scope === "global") { + setLeaf(policy, write.setting, write.value); + } else { + const mapName = write.scope === "provider" ? "providers" : write.scope === "route" ? "routes" : "devices"; + const map: Record> = { ...((policy[mapName] as Record> | undefined) ?? {}) }; + const entry = { ...(Object.hasOwn(map, write.key!) ? map[write.key!] : {}) }; + setLeaf(entry, write.setting, write.value); + if (Object.keys(entry).length > 0) map[write.key!] = entry; + else delete map[write.key!]; + if (Object.keys(map).length > 0) policy[mapName] = map; + else delete policy[mapName]; + } + if (Object.keys(policy).length > 0) config.fallback = policy as OcxFallbackConfig; + else delete config.fallback; + return JSON.stringify(config.fallback ?? null) !== before; +} diff --git a/src/server/audio-client.ts b/src/server/audio-client.ts index 9c89e4a166a..9070a510c95 100644 --- a/src/server/audio-client.ts +++ b/src/server/audio-client.ts @@ -4,6 +4,7 @@ import type { OcxConfig } from "../types"; import { captureExplicitOpenAiCallerAuth, selectOpenAiImagesProvider } from "../providers/openai-sidecar"; import { isProxyAdmissionSecret, type DataPlaneAdmission, type DataPlaneAdmissionOptions } from "./auth-cors"; import { resolveAudioAdmission } from "./audio-upstream"; +import { authorizationCredential } from "../lib/authorization-scheme"; export const AUDIO_WEBSOCKET_PROTOCOL = "opencodex-audio"; const KEY_PROTOCOL_PREFIX = "opencodex-key."; @@ -61,7 +62,7 @@ export function resolveAudioClient( : null; } const credential = admission.source === "dedicated" ? headers.get("x-opencodex-api-key") - : admission.source === "x-api-key" ? headers.get("x-api-key") : headers.get("authorization")?.replace(/^Bearer\s+/i, ""); + : admission.source === "x-api-key" ? headers.get("x-api-key") : authorizationCredential(headers); const owner = admission.kind === "configured" ? JSON.stringify(["configured", admission.keyId]) : JSON.stringify(["environment", createHash("sha256").update(credential?.trim() ?? "").digest("hex")]); diff --git a/src/server/audio-dictation.ts b/src/server/audio-dictation.ts index 87c2fe55d1e..ad0c70ddac3 100644 --- a/src/server/audio-dictation.ts +++ b/src/server/audio-dictation.ts @@ -4,6 +4,7 @@ import type { OcxConfig } from "../types"; import type { AudioClient } from "./audio-client"; import { resolveAudioUpstream, TRANSCRIPTION_MODEL, type AudioUpstream } from "./audio-upstream"; import type { RequestLogContext } from "./request-log"; +import { authorizationCredential } from "../lib/authorization-scheme"; export const DICTATION_SESSION_MAX_MS = 300_000; const DICTATION_FRAME_MAX_BYTES = 64 * 1024; @@ -74,7 +75,7 @@ export async function resolveDictationSocket( return formatErrorResponse(400, "invalid_request_error", "Streaming dictation requires a connected ChatGPT account"); } const headers = new Headers(relay.headers); - const token = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + const token = authorizationCredential(headers); if (!token || /[\s,]/.test(token)) { relay.release(); return formatErrorResponse(401, "authentication_error", "Dictation account authentication unavailable"); diff --git a/src/server/audio-upstream.ts b/src/server/audio-upstream.ts index 01e9b802375..5eee3620aed 100644 --- a/src/server/audio-upstream.ts +++ b/src/server/audio-upstream.ts @@ -24,6 +24,7 @@ import { } from "./auth-cors"; import { codexAccountSelectionForTurn } from "./lifecycle"; import type { RequestLogContext } from "./request-log"; +import { authorizationCredential } from "../lib/authorization-scheme"; export const TRANSCRIPTION_MODEL = "gpt-4o-transcribe"; export const LIVE_AUDIO_MODEL = "gpt-live-1-codex"; @@ -77,7 +78,7 @@ export async function resolveAudioUpstream( if (candidates.keyed?.providerName !== options.providerName) delete candidates.keyed; } const headers = new Headers(incoming); - const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + const bearer = authorizationCredential(headers); if (bearer && isProxyAdmissionSecret(bearer, config)) { headers.delete("authorization"); headers.delete("chatgpt-account-id"); diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index d9ca58b20aa..43dd633d11b 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -47,6 +47,7 @@ import { modelAutoCompactTokenLimitsConfigError } from "../providers/auto-compac import { vercelGatewayRoutingConfigError } from "../providers/vercel-gateway-routing"; import { googleVertexLocationConfigError } from "../providers/google-vertex-location"; import { xaiResponsesOptInState } from "../providers/xai-responses-opt-in"; +import { authorizationCredential } from "../lib/authorization-scheme"; let _corsOrigin = "http://localhost:10100"; // Listener identity is process-local. Never infer this trust from forwarded headers: the @@ -501,7 +502,7 @@ export function resolveContextPrincipal(req: Request, config: OcxConfig, admissi if (named) return named; if (admission?.kind !== "loopback") return undefined; const dedicated = req.headers.get("x-opencodex-api-key")?.trim(); - const bearer = req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + const bearer = authorizationCredential(req.headers); const apiKey = req.headers.get("x-api-key")?.trim(); for (const [token, source] of [[dedicated, "dedicated"], [bearer, "bearer"], [apiKey, "x-api-key"]] as const) { if (!token) continue; @@ -600,13 +601,13 @@ export class ForwardAdmissionCredentialError extends Error { } export function validateForwardAdmissionCredential(headers: Headers, config: OcxConfig): void { - const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + const bearer = authorizationCredential(headers); if (bearer && isProxyAdmissionSecret(bearer, config)) throw new ForwardAdmissionCredentialError(); } /** Whether Authorization carries a caller-owned native Codex credential safe to forward. */ export function hasForwardableCodexBearer(headers: Headers, config: OcxConfig): boolean { - const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + const bearer = authorizationCredential(headers); const accountId = headers.get("chatgpt-account-id")?.trim() || (bearer ? extractAccountId(undefined, bearer) : undefined); return !!bearer && !!accountId && !isProxyAdmissionSecret(bearer, config); @@ -621,7 +622,7 @@ export function resolveApiAuth(req: Request, config: RequestPolicyView): DataPla if (!isApiAuthRequired(config)) return { kind: "loopback", source: "loopback" }; const dedicated = req.headers.get("x-opencodex-api-key")?.trim(); if (dedicated) return resolveDataPlaneAdmissionSecret(dedicated, config, "dedicated", { linkIngress: config.linkIngress?.allowedKeyIds }); - const bearer = req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + const bearer = authorizationCredential(req.headers); if (bearer) return resolveDataPlaneAdmissionSecret(bearer, config, "bearer", { linkIngress: config.linkIngress?.allowedKeyIds }); // Anthropic-SDK clients (Claude Code with ANTHROPIC_API_KEY) authenticate via x-api-key. const apiKey = req.headers.get("x-api-key")?.trim(); @@ -653,7 +654,7 @@ export function resolveResponsesApiAuth(req: Request, config: RequestPolicyView) // safe because the upstream credential is then SUBSTITUTED rather than forwarded -- see // materializeCodexUpstreamAuth. A bearer that is NOT our secret stays unadmitted here and // remains Codex Direct passthrough, so the two bearer domains still never mix. - const bearer = req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + const bearer = authorizationCredential(req.headers); if (bearer) return resolveDataPlaneAdmissionSecret(bearer, config, "bearer", { linkIngress: config.linkIngress?.allowedKeyIds }); // `x-api-key` is deliberately NOT accepted on this transport. return null; diff --git a/src/server/background-lifecycle.ts b/src/server/background-lifecycle.ts index 8a81af070fa..8f51e2c9fcf 100644 --- a/src/server/background-lifecycle.ts +++ b/src/server/background-lifecycle.ts @@ -32,6 +32,7 @@ import { startMemoryWatchdog, type MemoryWatchdog, } from "./memory-watchdog"; +import { ensureQuotaPoolIdentity } from "../quota-pool/identity"; type PolicyApply = (policy: StorageCleanupPolicy) => void; @@ -204,6 +205,9 @@ export function acquireServerBackgroundLifecycle( if (cleanupInProgress) { throw new Error("server background lifecycle cleanup is still in progress"); } + // The quota-pool identity (homeId and the accountRef key) is minted here, per server and off + // the request path, so GET /api/quota-pool only ever reads it. + try { ensureQuotaPoolIdentity(); } catch { /* the read reports quota_pool_identity_unavailable */ } const owner: LeaseOwner = { token: Symbol("server-background-lifecycle"), diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 870647414a6..9d1b2dfac1e 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -81,6 +81,7 @@ import { createNativeChatComboSource, handleNativeChatCompletions, nativeChatDec import { upstreamWireForAdapter, type ProtocolReasonCode } from "../protocols/contract"; import { createProtocolEnvelope } from "../protocols/envelope"; import { featuresFromChatBody } from "../protocols/features"; +import { credentialDomainFor } from "../protocols/opaque-state"; import { checkRepresentable, unrepresentableMessage } from "../protocols/guard"; import { requestPathForLane } from "../protocols/path"; import { resolveProtocolSettings } from "../protocols/settings"; @@ -344,7 +345,13 @@ async function handleChatCompletionsWithBudget( directConversation = chatConversationToIr(chatBody, chatBody.model as string); internalBody = chatSettingsBody(chatBody); } else { - internalBody = chatToResponsesBody(chatBody); + internalBody = chatToResponsesBody(chatBody, { + // #7429: only a route that is and stays first-party Anthropic may receive signed thinking + // a Chat client returns; a combo, policy or rewritten route resolves its target later. + acceptAnthropicReasoningSignatures: settledRoute !== null && !routeMayChangeCredentialDomain + && settledRoute.provider.adapter === "anthropic" + && credentialDomainFor(settledRoute.provider)?.firstPartyAnthropic === true, + }); } if (effortRow) { internalBody.reasoning = { diff --git a/src/server/gui-session.ts b/src/server/gui-session.ts index daaf5a865f3..3d68695a9e3 100644 --- a/src/server/gui-session.ts +++ b/src/server/gui-session.ts @@ -1,6 +1,7 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import type { OcxConfig } from "../types"; import { canonicalGuiBrowserOrigin, standaloneGuiPairingOrigin } from "../lib/gui-pair-capability"; +import { authorizationCredential } from "../lib/authorization-scheme"; import { isAllowedManagementOrigin, isApiAuthRequired, @@ -409,7 +410,7 @@ function isPairingTransportPermitted(origin: string): boolean { function requestCredential(req: Request): string | null { return req.headers.get("x-opencodex-api-key")?.trim() - || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim() + || authorizationCredential(req.headers) || null; } diff --git a/src/server/index/link-listener.ts b/src/server/index/link-listener.ts index 118a68f79e3..4f6cdf2ff65 100644 --- a/src/server/index/link-listener.ts +++ b/src/server/index/link-listener.ts @@ -1,4 +1,5 @@ import { createLinkRelaySessions } from "./link-relay-sessions"; +import { createLinkPeerObservations, type LinkPeerEntry } from "../../link/peer-report"; import type { Server } from "bun"; import { emptyLinkStore, @@ -9,6 +10,7 @@ import { import { linkStorePath } from "../../link/paths"; import { linkRouteAllowed } from "../../link/routes"; import { withConfigMutationLockSync } from "../../config/mutation-lock"; +import { registerFallbackLinkResolver } from "../../routing/fallback-policy"; export const LINK_INGRESS_HOSTNAME = "opencodex-link.invalid"; @@ -51,6 +53,8 @@ export interface LinkListenerLifecycle { linkAdmissionKeyIds(): ReadonlySet; onAuthenticatedCatalog(listener: (apiKeyId: string) => void): () => void; notifyAuthenticatedCatalog(apiKeyId: string): void; + /** What this Home last learned of a Child over the link; empty until it relays a request. */ + peerObservation(linkId: string): LinkPeerEntry; status(): LinkListenerStatus; close(): Promise; stop(): Promise; @@ -76,6 +80,7 @@ export function createLinkListenerLifecycle(deps: LinkListenerDeps = {}): Lin let stopped = false; const authenticatedCatalogListeners = new Set<(apiKeyId: string) => void>(); let lifecycleStatus: LinkListenerStatus = { state: "off", port: null, reason: null }; + const peers = createLinkPeerObservations(); const setStatus = (state: LinkListenerStatus["state"], port: number | null, reason: string | null): void => { lifecycleStatus = { state, port, reason }; @@ -89,6 +94,9 @@ export function createLinkListenerLifecycle(deps: LinkListenerDeps = {}): Lin const readStoreForAdmission = (): LinkStore => { try { return readStore(storePath); } catch { return emptyLinkStore(); } }; + // OCX-11: the fallback policy resolves a link data key to its link id through this slot. + const releaseFallbackLinkResolver = registerFallbackLinkResolver(keyId => + readStoreForAdmission().links.find(link => link.apiKeyId === keyId)?.id); const bindIfNeeded = (): void => { if (stopped || listener || !startContext) return; @@ -104,7 +112,7 @@ export function createLinkListenerLifecycle(deps: LinkListenerDeps = {}): Lin return; } const requestedPort = store.listenerPort ?? 0; - const sessions = createLinkRelaySessions({ fingerprints: (keyId, linkId) => { + const sessions = createLinkRelaySessions({ peers, fingerprints: (keyId, linkId) => { if (!readStoreForAdmission().links.some(link => link.id === linkId && link.apiKeyId === keyId)) return []; return startContext?.keyFingerprints?.(keyId) ?? []; } }); @@ -214,12 +222,14 @@ export function createLinkListenerLifecycle(deps: LinkListenerDeps = {}): Lin notifyAuthenticatedCatalog(apiKeyId) { for (const listener of authenticatedCatalogListeners) listener(apiKeyId); }, + peerObservation: linkId => peers.get(linkId), status() { return { ...lifecycleStatus }; }, close, async stop() { stopped = true; + releaseFallbackLinkResolver(); // Process shutdown is deliberately forceful, unlike last-link removal. Every // authenticated socket is closed, and the client transport cannot reconnect it. relaySessions?.abortReservations(); diff --git a/src/server/index/link-management.ts b/src/server/index/link-management.ts index 2dece895c61..3a7423d6ecf 100644 --- a/src/server/index/link-management.ts +++ b/src/server/index/link-management.ts @@ -14,22 +14,69 @@ import type { ManagementSessionControl } from "../management-auth"; import { runPassiveRead } from "../../lib/passive-read"; import { LINK_CODEX_SETTINGS_PATH, linkManagementRouteAllowed, linkManagementTarget } from "../../link/management-routes"; import { projectHomeCodexSettings } from "../../link/codex-settings"; +import { homeLinkChildParity, LINK_CHILD_PARITY_MAX_BYTES, LINK_CHILD_PARITY_PATH, parseLinkChildParityReport } from "../../link/child-parity"; +import { linkStorePath } from "../../link/paths"; +import { readLinkStore } from "../../link/store"; +import { readBoundedResponseBytes } from "../../lib/bounded-body"; import { packageVersion } from "../../lib/package-version"; const VERSION = packageVersion("0.0.0"); const KEPT_RESPONSE_HEADERS = ["content-type", "content-language", "etag", "last-modified"]; +const REPORT_READ_TIMEOUT_MS = 5_000; function notFound(): Response { return Response.json({ error: "not_found" }, { status: 404, headers: { "cache-control": "no-store" } }); } +function refused(status: number, code: string): Response { + return Response.json({ error: code }, { status, headers: { "cache-control": "no-store" } }); +} + +/** + * `POST` {@link LINK_CHILD_PARITY_PATH}: the Child's apply report (OCX-6). The one write the link + * accepts, handled here, before and apart from the read-only table, so it never widens what a + * read may do. It is admitted only for the link the admitting key belongs to, reads at most + * {@link LINK_CHILD_PARITY_MAX_BYTES} and changes nothing but the in-memory report. + */ +async function handleChildParityReport(req: Request, url: URL, admittedKeyId: string | undefined): Promise { + if (req.method !== "POST" || url.search !== "" || req.headers.has("upgrade")) return notFound(); + if (!admittedKeyId) return refused(403, "link_key_required"); + const declared = Number(req.headers.get("content-length") ?? "0"); + if (Number.isFinite(declared) && declared > LINK_CHILD_PARITY_MAX_BYTES) return refused(413, "report_too_large"); + let bytes: Uint8Array; + try { + const read = await readBoundedResponseBytes(new Response(req.body), { + maxBytes: LINK_CHILD_PARITY_MAX_BYTES, + signal: AbortSignal.timeout(REPORT_READ_TIMEOUT_MS), + }); + if (read.oversized) return refused(413, "report_too_large"); + bytes = read.bytes; + } catch { + return refused(400, "invalid_report"); + } + let report: ReturnType; + try { + report = parseLinkChildParityReport(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes))); + } catch { + report = null; + } + if (!report) return refused(400, "invalid_report"); + let links: ReturnType["links"]; + try { links = readLinkStore(linkStorePath()).links; } catch { return refused(503, "link_store_unavailable"); } + if (!links.some(link => link.id === report.linkId && link.apiKeyId === admittedKeyId)) return refused(403, "link_mismatch"); + homeLinkChildParity.record(report, new Set(links.map(link => link.id))); + return new Response(null, { status: 204, headers: { "cache-control": "no-store" } }); +} + export async function handleLinkManagementRequest( req: Request, url: URL, config: OcxConfig, deps: ManagementApiDeps, sessionControl?: ManagementSessionControl, + admittedKeyId?: string, ): Promise { + if (url.pathname === LINK_CHILD_PARITY_PATH) return handleChildParityReport(req, url, admittedKeyId); if (!linkManagementRouteAllowed(url, req)) return notFound(); if (url.pathname === LINK_CODEX_SETTINGS_PATH) { return Response.json(projectHomeCodexSettings(config, VERSION), { headers: { "cache-control": "no-store" } }); diff --git a/src/server/index/link-relay-sessions.ts b/src/server/index/link-relay-sessions.ts index 2ae0cb33cf0..166558384c0 100644 --- a/src/server/index/link-relay-sessions.ts +++ b/src/server/index/link-relay-sessions.ts @@ -3,6 +3,7 @@ import { LINK_RELAY_AUTH_PATH, LINK_RELAY_AUTH_TIMEOUT_MS, LINK_RELAY_AUTH_VERSION, LINK_RELAY_SESSION_HEADER, linkRelayProof, linkRelayProofMatches, } from "../../link/relay-auth"; +import { parseLinkPeerReport, type LinkPeerObservations } from "../../link/peer-report"; interface Session { peer: string; @@ -18,6 +19,8 @@ export interface LinkRelaySessionOptions { fingerprints: (keyId: string, linkId: string) => readonly string[]; /** Tests can use a bounded shorter reservation; production uses five seconds. */ reservationMs?: number; + /** Where a verified challenge records the Child's report, and a known link's version mismatch. */ + peers?: LinkPeerObservations; } /** One book per bound listener. A pending proof reserves the bind before the data request arrives. */ @@ -64,14 +67,20 @@ export function createLinkRelaySessions(options: LinkRelaySessionOptions) { if (closing || req.method !== "GET" || sessions.size >= 256 || req.headers.has("upgrade") || req.headers.has("authorization") || req.headers.has("x-opencodex-api-key")) return refuse(); const q = url.searchParams; - if (q.get("version") !== LINK_RELAY_AUTH_VERSION || [...q.keys()].length !== 5 - || ["version", "key", "link", "nonce", "proof"].some(name => q.getAll(name).length !== 1)) return refuse(); + if ([...q.keys()].length !== 5 || ["version", "key", "link", "nonce", "proof"].some(name => q.getAll(name).length !== 1)) return refuse(); const keyId = q.get("key")!, linkId = q.get("link")!, nonce = q.get("nonce")!; + if (q.get("version") !== LINK_RELAY_AUTH_VERSION) { + // Unverifiable, but only for a link and key this Home issued: status names the mismatch. + const version = q.get("version")!; + if (/^[1-9]\d{0,3}$/.test(version) && options.fingerprints(keyId, linkId).length > 0) options.peers?.refuse(linkId, version); + return refuse(); + } const peer = peerOf(req, server as Server); if (!peer || sessions.has(nonce)) return refuse(); const fingerprint = options.fingerprints(keyId, linkId).find(candidate => linkRelayProofMatches(q.get("proof"), linkRelayProof(candidate, "caller", keyId, linkId, nonce))); if (!fingerprint) return refuse(); + options.peers?.observe(linkId, LINK_RELAY_AUTH_VERSION, parseLinkPeerReport(req.headers)); const release = acquire(); const timer = setTimeout(() => { consume(nonce)?.release(); }, reservationMs); sessions.set(nonce, { peer, keyId, linkId, fingerprint, release, timer, expiresAt: Date.now() + reservationMs }); diff --git a/src/server/index/optional-listeners.ts b/src/server/index/optional-listeners.ts index 9513354216d..22685617b1b 100644 --- a/src/server/index/optional-listeners.ts +++ b/src/server/index/optional-listeners.ts @@ -15,6 +15,7 @@ import { } from "./link-listener"; import { createLinkSupervisor, type LinkSupervisor } from "../../link/supervisor"; import { linkManagementRouteAllowed } from "../../link/management-routes"; +import { linkChildParityRouteAllowed } from "../../link/child-parity"; import { saveConfigPreservingClaudeCode } from "../../config/live-reconcile"; import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; import { startJevQuotaActivation } from "../../combos/jev-quota-activation"; @@ -38,6 +39,7 @@ export interface OptionalListenerSet { linkAdmissionKeyIds(): ReadonlySet; onAuthenticatedCatalog(listener: (apiKeyId: string) => void): () => void; notifyAuthenticatedCatalog(apiKeyId: string): void; + peerObservation(linkId: string): import("../../link/peer-report").LinkPeerEntry; status(): LinkListenerStatus; linkStatus(): LinkListenerStatus; linkSupervisor(): LinkSupervisor; @@ -83,11 +85,14 @@ export function createOptionalListenerSet(linkDeps: LinkListenerDeps = {}): O }, // The Home's link listener admits the data plane plus the read-only management relay // (#6957). The Child's own ingress keeps the data-only table, so a local caller on the Child - // cannot reach the Home's management reads without the Child's dashboard session. - linkRouteAllowed: (url, req) => linkRouteAllowed(url, req) || linkManagementRouteAllowed(url, req), + // cannot reach the Home's management reads without the Child's dashboard session. The one + // write is the Child's apply report (OCX-6), which changes only an in-memory status field. + linkRouteAllowed: (url, req) => linkRouteAllowed(url, req) || linkManagementRouteAllowed(url, req) + || linkChildParityRouteAllowed(url, req), linkAdmissionKeyIds: () => linkListener.linkAdmissionKeyIds(), onAuthenticatedCatalog: listener => linkListener.onAuthenticatedCatalog(listener), notifyAuthenticatedCatalog: apiKeyId => linkListener.notifyAuthenticatedCatalog(apiKeyId), + peerObservation: linkId => linkListener.peerObservation(linkId), status: () => linkListener.status(), linkStatus: () => linkListener.status(), linkSupervisor: () => supervisor, diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index 728c1fffe12..c680d02e2cb 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -204,11 +204,13 @@ import type { ReadinessGate } from "../readiness"; import type { WorkflowRefusalLog } from "../workflow-refusal"; import { readyProtocolMetadata } from "../../remote/protocol"; +import { homeInstanceId, stampServedBy } from "../../remote/served-by"; import { modelCapabilityFields } from "../models-capabilities"; import { createWebsocketHandler } from "./websocket-handler"; import { reasoningArchiveErrorResponse } from "../responses/reasoning-archive-error"; import { withGrokSessionIdentity } from "../../grok/session-identity"; import { withCallerSessionIdentity } from "../caller-session-identity"; +import { agentAssertionUnsupportedResponse, hasAgentAssertionAuthorization } from "../../lib/authorization-scheme"; export type ServerIngress = "public" | "unauthenticated-loopback" | "hub-management" | "claude-intercept" | "hub-link"; @@ -370,6 +372,10 @@ export function createServeOptions(ctx: ServeOptionsContext) { : ingress === "unauthenticated-loopback" || ingress === "claude-intercept" ? loopbackPolicy() : config; + // Codex runtime agent assertion_only (Authorization: AgentAssertion) is refused before + // admission or routing: it is never a bearer, never an admission secret, and no pool or + // routed credential may stand in for it. + if (hasAgentAssertionAuthorization(req.headers)) return withCors(agentAssertionUnsupportedResponse(), req, policy); if (ingress === "hub-link") { const admission = resolveApiAuth(req, policy); if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); @@ -380,7 +386,10 @@ export function createServeOptions(ctx: ServeOptionsContext) { // the shared table in src/link/management-routes.ts; never reachable on another ingress. if (isLinkManagementPath(requestUrl.pathname)) { const { handleLinkManagementRequest } = await import("./link-management"); - return withCors(await handleLinkManagementRequest(req, requestUrl, config, requestManagementApiDeps, managementSessionControl), req, policy); + return withCors(await handleLinkManagementRequest( + req, requestUrl, config, requestManagementApiDeps, managementSessionControl, + admission.kind === "configured" ? admission.keyId : undefined, + ), req, policy); } } const url = requestUrl; @@ -408,11 +417,13 @@ export function createServeOptions(ctx: ServeOptionsContext) { const message = "OpenCodex package files changed while this proxy was running; it restarts on its own, or run 'ocx restart' ('ocx service restart' for a background service)."; const fencedPort = ctx.boundPort ?? requestServer.port ?? listenPort; const fencedHealth = url.pathname === "/healthz"; + const fencedInstanceId = fencedHealth ? homeInstanceId() : undefined; const response = fencedHealth || readyzPath !== undefined ? jsonResponse({ status: "restart_required", service: "opencodex", version: VERSION, + ...(fencedInstanceId ? { instanceId: fencedInstanceId } : {}), uptime: process.uptime(), pid: process.pid, port: fencedPort, @@ -623,10 +634,13 @@ export function createServeOptions(ctx: ServeOptionsContext) { if (url.pathname === "/healthz" && req.method === "GET") { // service/pid/port let CLI liveness reject foreign 200s and verify pid identity. const healthPort = ctx.server.port ?? listenPort; + // Opaque Home instance id (src/remote/served-by.ts); omitted until the identity exists. + const instanceId = homeInstanceId(); const response = jsonResponse({ status: "ok", service: "opencodex", version: VERSION, + ...(instanceId ? { instanceId } : {}), uptime: process.uptime(), pid: process.pid, port: healthPort, @@ -1977,5 +1991,8 @@ export function createServeOptions(ctx: ServeOptionsContext) { }, websocket: createWebsocketHandler(ctx, requestMetrics), } as const; - return serveOptions; + const routeFetch = serveOptions.fetch; + // x-opencodex-served-by on GET /v1/models, whichever branch answered (src/remote/served-by.ts). + return { ...serveOptions, fetch: async (req: Request, requestServer: Server): Promise => + stampServedBy(req.method, codexCompatibleUrl(req.url).pathname, await routeFetch(req, requestServer)) } as const; } diff --git a/src/server/live.ts b/src/server/live.ts index a4e48348341..41d14925168 100644 --- a/src/server/live.ts +++ b/src/server/live.ts @@ -88,6 +88,10 @@ export const LIVE_SIDEBAND_API_ROOT = "https://api.openai.com/v1"; * upstream the caller was addressing, so there is nothing to scope it away from. That is not * true of the images sidecar, which strips it deliberately and keeps doing so. * + * `voice-session-id` is the client-generated voice session id Codex 0.164 forwards on WebSocket, + * WebRTC and existing-call transports (openai/codex#53784). It is a separate identity from the + * realtime session id carried in `x-session-id`, so neither may stand in for the other. + * * Every name here is relayed only when the caller sent it. Nothing on this list is invented, * which is what keeps a caller that omits one byte-identical upstream. Auth headers * (`authorization`, `chatgpt-account-id`) stay proxy-owned and are never taken from this list. @@ -100,6 +104,7 @@ export const LIVE_CLIENT_PROTOCOL_HEADERS = [ "originator", "x-oai-attestation", "x-codex-turn-metadata", + "voice-session-id", ] as const; /** diff --git a/src/server/management-api.ts b/src/server/management-api.ts index f6076920a48..e4baeac4d71 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -161,6 +161,20 @@ async function handleLowQuotaRoutesOnDemand(ctx: ManagementContext): Promise { + if (!pathInManagementNamespace(ctx.url.pathname, "/api/quota-pool", false)) return null; + const { handleQuotaPoolRoutes } = await import("./management/quota-pool-routes"); + return handleQuotaPoolRoutes(ctx); +} + +/** Lazy: the OCX-11 fallback state view and its write live off the dashboard's common path. */ +async function handleFallbackRoutesOnDemand(ctx: ManagementContext): Promise { + if (!pathInManagementNamespace(ctx.url.pathname, "/api/fallback", false)) return null; + const { handleFallbackRoutes } = await import("./management/fallback-routes"); + return handleFallbackRoutes(ctx); +} + /** * Lazy like the Lab and routing-profile handlers, and for the same recorded reason: this file is * mounted for every dashboard request, so a static import would put the workflow-budget ledger @@ -351,6 +365,8 @@ export async function handleManagementAPI( ?? (await handleRequestHistoryRoutes(ctx)) ?? (await handleQuotaResetRoutesOnDemand(ctx)) ?? (await handleLowQuotaRoutesOnDemand(ctx)) + ?? (await handleQuotaPoolRoutesOnDemand(ctx)) + ?? (await handleFallbackRoutesOnDemand(ctx)) ?? (await handleWorkflowBudgetRoutesOnDemand(ctx)) ?? (await handleCodexAgentRoleRoutesOnDemand(ctx)) ?? (await handleProtocolRoutesOnDemand(ctx)) diff --git a/src/server/management-auth.ts b/src/server/management-auth.ts index 19104ef1bf8..53e8cb10c04 100644 --- a/src/server/management-auth.ts +++ b/src/server/management-auth.ts @@ -73,6 +73,7 @@ import { } from "./gui-session"; import { hasLocalDesktopSnapshotCapability } from "./local-desktop-snapshot-auth"; import { hasLocalAccountSwitchCapability } from "./local-account-switch-auth"; +import { authorizationCredential } from "../lib/authorization-scheme"; export type { GuiSessionBootstrap, GuiSessionRequestContext } from "./gui-session"; const LOCAL_READ_REPLAY_LIMIT = 256; @@ -545,7 +546,7 @@ function hasGuiPairCapability( function requestManagementCredential(req: Request): string | null { return req.headers.get("x-opencodex-api-key")?.trim() - || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim() + || authorizationCredential(req.headers) || null; } diff --git a/src/server/management/context.ts b/src/server/management/context.ts index 2f90fe47c17..b12ec76f27b 100644 --- a/src/server/management/context.ts +++ b/src/server/management/context.ts @@ -156,7 +156,7 @@ export interface ManagementApiDeps { codexPromptPaths?: CodexPromptPaths; /** Link seams are getters so the optional listener and supervisor are singletons. */ linkSupervisor?: () => LinkSupervisor; - linkListener?: () => Pick, "ensureStarted" | "status" | "close" | "onAuthenticatedCatalog"> & Partial, "ensureClaudeIntercept" | "claudeInterceptOutcome">>; + linkListener?: () => Pick, "ensureStarted" | "status" | "close" | "onAuthenticatedCatalog"> & Partial, "peerObservation">> & Partial, "ensureClaudeIntercept" | "claudeInterceptOutcome">>; readLinkStore?: () => LinkStore; writeLinkStore?: (store: LinkStore) => void; linkKnownHostsPath?: () => string; diff --git a/src/server/management/fallback-routes.ts b/src/server/management/fallback-routes.ts new file mode 100644 index 00000000000..480b6a657ac --- /dev/null +++ b/src/server/management/fallback-routes.ts @@ -0,0 +1,75 @@ +/** + * `GET|PUT /api/fallback`: the OCX-11 fallback state (`ocx.fallback/1`) and its one scoped write. + * + * GET is a passive read. A Remote Link Child reaches it through the read-only link relay + * (`LINK_MANAGEMENT_READ_PATHS`); there it drops link aliases (`buildFallbackState` passive). PUT + * runs on the Home only: a Child's relay and CLI refuse writes before sending. + */ +import type { ManagementContext } from "./context"; +import { jsonResponse } from "../auth-cors"; +import { mutatePersistedConfig } from "../../config"; +import { isPassiveRead } from "../../lib/passive-read"; +import { readLinkStore, type LinkRecord } from "../../link/store"; +import { linkStorePath } from "../../link/paths"; +import { applyFallbackWrite, buildFallbackState, parseFallbackWrites } from "../../routing/fallback-state"; +import { managementBodyTooLargeResponse, readManagementJsonBody } from "./body"; + +function links(ctx: ManagementContext): LinkRecord[] { + try { + return (ctx.deps.readLinkStore?.() ?? readLinkStore(linkStorePath())).links; + } catch { + return []; + } +} + +function view(ctx: ManagementContext): Response { + const response = jsonResponse(buildFallbackState(ctx.config, { links: links(ctx), passive: isPassiveRead() }), 200, ctx.req, ctx.config); + response.headers.set("cache-control", "no-store"); + return response; +} + +export async function handleFallbackRoutes(ctx: ManagementContext): Promise { + const { req, url, config } = ctx; + if (url.pathname === "/api/fallback" && req.method === "GET") return view(ctx); + if (url.pathname === "/api/fallback" && req.method === "PUT") return await writeFallback(ctx); + return null; +} + +async function writeFallback(ctx: ManagementContext): Promise { + const { req, config } = ctx; + let body: unknown; + try { + body = await readManagementJsonBody(req); + } catch (error) { + return managementBodyTooLargeResponse(error, req, config) + ?? jsonResponse({ error: "invalid JSON body", code: "invalid_request" }, 400, req, config); + } + const parsed = parseFallbackWrites(body); + if (!parsed.ok) return jsonResponse({ error: parsed.error, code: "invalid_request" }, 400, req, config); + const known = links(ctx); + for (const write of parsed.writes) { + if (write.scope !== "device") continue; + // A link alias names its link; otherwise the key must be a known link id or data key id. + const byAlias = known.find(link => link.alias === write.key); + if (byAlias) write.key = byAlias.id; + // Clearing stays possible for a device that is gone (a revoked link, a deleted data key): + // its entry still lists in the state, and clearing a missing entry is a no-op. + if (write.value === null) continue; + const exists = known.some(link => link.id === write.key) + || (config.apiKeys ?? []).some(entry => entry.id === write.key); + if (!exists) { + return jsonResponse({ error: "unknown device: use a link id or alias from ocx link status, or a Hub data key id", code: "unknown_device" }, 404, req, config); + } + } + const outcome = mutatePersistedConfig(persisted => { + let changed = false; + for (const write of parsed.writes) changed = applyFallbackWrite(persisted, write) || changed; + return { changed, value: persisted.fallback === undefined ? undefined : structuredClone(persisted.fallback) }; + }); + if (outcome.status === "unavailable") { + return jsonResponse({ error: "config.json is " + outcome.reason + "; fallback settings were not saved", code: "config_unavailable" }, 409, req, config); + } + if (outcome.value === undefined) delete config.fallback; + else config.fallback = outcome.value; + return view(ctx); +} diff --git a/src/server/management/link-enrollment.ts b/src/server/management/link-enrollment.ts index 32b8967449f..78e6ff941ae 100644 --- a/src/server/management/link-enrollment.ts +++ b/src/server/management/link-enrollment.ts @@ -3,6 +3,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { assertSshAlias, buildExecArgv, buildFingerprintArgv, buildProbeArgv, REMOTE_COMMAND_NOT_FOUND, REMOTE_PLATFORM_UNSUPPORTED, remoteOcxArgv } from "../../link/ssh-argv"; import { parseFingerprintLine } from "../../link/fingerprint"; +import { detectRemotePlatform, isWindowsShellRefusal, WINDOWS_CHILD_REFUSAL } from "../../link/remote-platform"; +import type { SshRunner } from "../../link/ssh-runner"; import { LINK_CATALOG_MAX_BYTES, LINK_RELAY_VERIFY_MS, isChildHealth, linkCommandBudget, linkRestartBudget, parseChildHttp, remoteChildHttpArgv, REMOTE_CURL_PREFLIGHT } from "../../link/remote-probe"; import { newLinkId, type LinkStore } from "../../link/store"; import { SshRunnerError, boundHint, sshFailureHint, sshRunnerErrorHint, type SshRunResult } from "../../link/ssh-runner"; @@ -24,11 +26,23 @@ async function serialized(state: LinkRouteState, alias: string, action: () => Pr } function remoteFailure(result: SshRunResult, code: string, message: string): Response | null { - if (result.code === REMOTE_PLATFORM_UNSUPPORTED) return fail("remote_platform_unsupported", "Remote Link Children require macOS or Linux; Windows is unsupported.", 409); + if (result.code === REMOTE_PLATFORM_UNSUPPORTED || (result.code !== 0 && isWindowsShellRefusal(result.stderr))) return windowsRefusal(); if (result.code === REMOTE_COMMAND_NOT_FOUND) return fail("remote_ocx_missing", "ocx was not found on the remote host.", 502, sshFailureHint(result.stderr)); return result.code === 0 ? null : fail(code, message, 502, sshFailureHint(result.stderr)); } +function windowsRefusal(): Response { + return fail("remote_platform_unsupported", WINDOWS_CHILD_REFUSAL, 409); +} + +/** + * Pre-issuance Windows gate for a failed remote command: exit 125, the English cmd/PowerShell + * refusal, or (for localized shells) the fixed %OS% probe against the same host key. + */ +async function windowsChild(runner: SshRunner, alias: string, knownHostsFile: string, result: SshRunResult): Promise { + return result.code !== 0 && await detectRemotePlatform(runner, alias, knownHostsFile, result) === "windows"; +} + export async function probe(ctx: ManagementContext, state: LinkRouteState): Promise { const value = await readManagementJsonBodyOr(ctx.req, null); const body = exactBody(value, ["alias"]) ?? exactBody(value, ["alias", "includeVersion"]); @@ -44,7 +58,8 @@ export async function probe(ctx: ManagementContext, state: LinkRouteState): Prom const started = now(); const result = await runner.run(buildProbeArgv({ alias, tempKnownHostsFile: path }), { timeoutMs: 30_000 }); const latency = Math.max(0, now() - started); - if (result.code !== 0) return fail("probe_failed", "SSH host probing failed.", 502, sshFailureHint(result.stderr)); + // cmd.exe and PowerShell refuse the POSIX `true` the probe runs; say Windows, not "probe failed". + if (result.code !== 0) return await windowsChild(runner, alias, path, result) ? windowsRefusal() : fail("probe_failed", "SSH host probing failed.", 502, sshFailureHint(result.stderr)); const fp = await runner.run(buildFingerprintArgv(path), { timeoutMs: 10_000 }); if (fp.code !== 0) return fail("fingerprint_failed", "The SSH host fingerprint could not be read.", 502, sshFailureHint(fp.stderr)); const parsed = parseFingerprintLine(fp.stdout.trim()); @@ -53,6 +68,7 @@ export async function probe(ctx: ManagementContext, state: LinkRouteState): Prom let ocxVersion: string | undefined; if (body.includeVersion === true) { const versionResult = await runner.run(buildExecArgv({ alias, argv: remoteOcxArgv(["--version"]), knownHostsFile: path }), { timeoutMs: 30_000 }); + if (await windowsChild(runner, alias, path, versionResult)) return windowsRefusal(); const denied = remoteFailure(versionResult, "version_probe_failed", "The remote ocx version could not be read."); if (denied) return denied; const parsedVersion = parseVersion(versionResult.stdout); @@ -84,7 +100,9 @@ export async function confirmHost(ctx: ManagementContext, state: LinkRouteState) writeFileSync(path, pending.knownHostLine + "\n", { mode: 0o600 }); try { const started = now(); - const result = await runnerFor(ctx).run(buildExecArgv({ alias, argv: remoteOcxArgv(["--version"]), knownHostsFile: path }), { timeoutMs: 30_000 }); + const runner = runnerFor(ctx); + const result = await runner.run(buildExecArgv({ alias, argv: remoteOcxArgv(["--version"]), knownHostsFile: path }), { timeoutMs: 30_000 }); + if (await windowsChild(runner, alias, path, result)) return windowsRefusal(); const denied = remoteFailure(result, "version_probe_failed", "The remote ocx version could not be confirmed."); if (denied) return denied; const version = parseVersion(result.stdout); diff --git a/src/server/management/link-route-state.ts b/src/server/management/link-route-state.ts index e052cef410e..0474f56c94f 100644 --- a/src/server/management/link-route-state.ts +++ b/src/server/management/link-route-state.ts @@ -27,6 +27,8 @@ export interface LinkRouteListener { status(): { state: "off" | "listening" | "failed"; port: number | null; reason: string | null }; close(): Promise; onAuthenticatedCatalog(listener: (apiKeyId: string) => void): () => void; + /** Optional so route fakes stay small; without it every link reports an unknown peer. */ + peerObservation?(linkId: string): import("../../link/peer-report").LinkPeerEntry; } export interface LinkRouteState { diff --git a/src/server/management/link-routes.ts b/src/server/management/link-routes.ts index cb03aef342c..7baa25d2dbe 100644 --- a/src/server/management/link-routes.ts +++ b/src/server/management/link-routes.ts @@ -6,7 +6,11 @@ import { clearCompensationFailed, compensationPath, readCompensation } from "../ import { loadHostCandidates } from "../../link/ssh-config"; import { newLinkId, type LinkStore } from "../../link/store"; import { sshFailureHint, sshRunnerErrorHint } from "../../link/ssh-runner"; -import { projectLinkStatus, type LinkStatusDto } from "../../link/status-projection"; +import { attachLinkPeers, projectLinkStatus, type LinkStatusDto } from "../../link/status-projection"; +import { homeLinkChildParity } from "../../link/child-parity"; +import { localLinkPeer } from "../../link/compatibility"; +import { packageVersion } from "../../lib/package-version"; +import { buildPeerFeatures } from "../../remote/features"; import { isLinkPort } from "../../link/ports"; import { joinHome, type ClientLinkJoinDeps } from "../../client/link-join"; import { clientLinkStatusIdentity } from "../../client/link-status"; @@ -268,6 +272,7 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L store, state.supervisor.status(), listenerStatus, ctx.config, readCompensation(), ctx.config.runtimeRole === "client" ? clientLinkTunnelStatus() : null, ctx.config.runtimeRole === "client" && ctx.config.client?.link ? clientLinkStatusIdentity(ctx.config.client.link.linkId) : null, + linkId => homeLinkChildParity.get(linkId), ); for (const link of dto.links) { const failure = state.compensationFailures?.get(link.id); @@ -277,6 +282,12 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L const failure = state.compensationFailures?.get(store.links[0].id); if (failure) Object.assign(dto.child, { state: "failed" as const, since: failure.since, reason: failure.reason }); } + // Opt-in: an older `ocx link status` validates the document key by key and would reject the + // added fields, so only a caller that asks for compatibility gets `local` and `peer`. + if (url.searchParams.get("compatibility") === "1") { + attachLinkPeers(dto, localLinkPeer(packageVersion("0.0.0"), buildPeerFeatures()), + linkId => state.listener.peerObservation?.(linkId) ?? { observed: null, refused: null }); + } // A dashboard session also learns why it cannot join as a Child. The admin-token answer stays // the exact K16 document that `ocx link status` validates key by key. const denial = ctx.principal === "gui-session" ? joinDenied(ctx) : null; diff --git a/src/server/management/quota-pool-routes.ts b/src/server/management/quota-pool-routes.ts new file mode 100644 index 00000000000..8904a4604fb --- /dev/null +++ b/src/server/management/quota-pool-routes.ts @@ -0,0 +1,24 @@ +/** + * `GET /api/quota-pool`: the `ocx.quota-pool/1` read GOAT consumes (see src/quota-pool/). + * + * Same management authorization as the neighbouring reads; a Remote Link Child reaches it through + * the read-only link relay (`LINK_MANAGEMENT_READ_PATHS`) and a Hub client through its hub relay. + * The read is always passive: there is no refresh form, so nothing here can make the Home probe. + */ +import type { ManagementContext } from "./context"; +import { jsonResponse } from "../auth-cors"; + +export async function handleQuotaPoolRoutes(ctx: ManagementContext): Promise { + const { req, url, config } = ctx; + // Any query (including a stray refresh=1) is ignored: the read has no active form. + if (url.pathname === "/api/quota-pool" && req.method === "GET") { + const { readQuotaPool } = await import("../../quota-pool/assemble"); + const result = await readQuotaPool(config); + const response = result.ok + ? jsonResponse(result.pool, 200, req, config) + : jsonResponse({ error: result.error }, 503, req, config); + response.headers.set("cache-control", "no-store"); + return response; + } + return null; +} diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 168d7c7ea6c..5e11e8f6e32 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -370,6 +370,9 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "GET", path: "/api/provider-context-caps", module: "server/management/provider-routes", mutates: false }, { method: "GET", path: "/api/provider-presets", module: "server/management/provider-routes", mutates: false }, { method: "GET", path: "/api/provider-quotas", module: "server/management/provider-routes", mutates: false }, + { method: "GET", path: "/api/quota-pool", module: "server/management/quota-pool-routes", mutates: false, exempt: { reason: "deferred-verb", why: "The ocx.quota-pool/1 read is a machine contract GOAT consumes over management and the link relay; the CLI shows the same caches through ocx provider quota and ocx account.", owner: "ocx-1 quota pool", ownerDoc: "structure/quota-pool.md" } }, + { method: "GET", path: "/api/fallback", module: "server/management/fallback-routes", mutates: false }, + { method: "PUT", path: "/api/fallback", module: "server/management/fallback-routes", mutates: true }, { method: "GET", path: "/api/provider-request-pacing", module: "server/management/provider-key-pacing-routes", mutates: false }, { method: "GET", path: "/api/providers", module: "server/management/provider-routes", mutates: false }, { method: "PATCH", path: "/api/providers", module: "server/management/provider-routes", mutates: true }, diff --git a/src/server/responses/agent-task-recovery.ts b/src/server/responses/agent-task-recovery.ts index d170e9436f5..159ed7abd03 100644 --- a/src/server/responses/agent-task-recovery.ts +++ b/src/server/responses/agent-task-recovery.ts @@ -104,7 +104,7 @@ function findEnvelope(input: unknown): AgentEnvelope | null { const type = input[itemIndex] && typeof input[itemIndex] === "object" ? (input[itemIndex] as { type?: unknown }).type : undefined; - if (type !== "compaction_trigger" && type !== "additional_tools") break; + if (type !== "compaction_trigger" && type !== "additional_tools" && type !== "configuration_update") break; itemIndex -= 1; } diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 32263104a72..169ce00a0a6 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -135,7 +135,7 @@ import type { DataPlaneAdmission } from "../auth-cors"; import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar"; import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint } from "../../providers/openai-tiers"; import { buildResponsesTransport, copyResponsesClientMetadata } from "../../adapters/openai-responses/transport"; -import { scrubOcxCompactionItems, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough } from "../../adapters/openai-responses/request-strips"; +import { scrubOcxCompactionItems, stripCanonicalOnlyInputItems, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough } from "../../adapters/openai-responses/request-strips"; import { dropResponsesReasoningInputItems } from "../../adapters/openai-responses/reasoning"; import { requiresPlaintextReasoningReplay } from "../../adapters/openai-responses/passthrough"; import { destinationDecodesNativeCompactionBlob, isOpenAiOperatedResponsesDestination } from "../../providers/openai-tiers-destination"; @@ -1155,7 +1155,7 @@ export async function handleResponsesCompact( replayBody = stripInternalChatMessageMetadataPassthrough(replayBody) as typeof replayBody; } return isOpenAiOperatedResponsesDestination(sendProvider) - ? replayBody : stripCanonicalOnlyTopLevelFields(replayBody); + ? replayBody : stripCanonicalOnlyInputItems(stripCanonicalOnlyTopLevelFields(replayBody)); }; const doFetch = (upstreamRecovery?: UpstreamSendRecovery) => fetchWithHeaderTimeout( compactUrl, diff --git a/src/server/responses/core-auth.ts b/src/server/responses/core-auth.ts index e9d49346a05..2b60fe00814 100644 --- a/src/server/responses/core-auth.ts +++ b/src/server/responses/core-auth.ts @@ -46,6 +46,7 @@ import { forceRefreshMainAccountToken } from "../../codex/main-account"; import { rebindPoolCreditPolicy } from "../../codex/pool-credit-policy"; import { safeCallerSessionId } from "../caller-session-identity"; import { copyResponsesClientMetadata } from "../../adapters/openai-responses/transport"; +import { AgentAssertionUnsupportedError, agentAssertionUnsupportedResponse, authorizationCredential, carryAgentAssertionForCallerBearer, hasAgentAssertionAuthorization } from "../../lib/authorization-scheme"; /** Normalize caller aliases only on native egress; preserve ingress and fallback identity. */ export function withClaudeNativeSession(headers: Headers, provider: OcxProviderConfig, sessionId?: string): Headers { @@ -205,6 +206,9 @@ export async function resolveResponsesCodexAuth( retainAccountForUploadedFiles = false, ): Promise { let authCtx: CodexAuthContext | undefined; + // Ingress already refuses assertion_only; a later internal hop must not reach a pool or + // routed credential with it either, because that would be a silent credential swap. + if (hasAgentAssertionAuthorization(req.headers)) return { ok: false, response: agentAssertionUnsupportedResponse() }; try { let authInputHeaders = codexRouteCredentialDomainHeaders( req, @@ -332,7 +336,7 @@ export async function resolveResponsesCodexAuth( } else { // Even adapters that ignore caller auth must not retain a proxy secret for // a later internal hop or a future transport change. - const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + const bearer = authorizationCredential(headers); if (bearer && isProxyAdmissionSecret(bearer, config)) { headers.delete("authorization"); headers.delete("chatgpt-account-id"); @@ -343,6 +347,11 @@ export async function resolveResponsesCodexAuth( if (options.compactionRecoveryKind === "compaction-v1") { copyResponsesClientMetadata(headers, authInputHeaders, route.provider); } + // dual_header: the assertion is first-party only and travels only beside the caller's own + // unchanged Bearer, compared with the ORIGINAL request (injected credentials do not count). + if (isCanonicalOpenAiForwardProvider(route.provider) && authCtx?.kind === "main" && !substituteMainCredential) { + carryAgentAssertionForCallerBearer(headers, req.headers); + } return { ok: true, authCtx, @@ -364,6 +373,7 @@ export async function resolveResponsesCodexAuth( if (err instanceof ForwardAdmissionCredentialError) { return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) }; } + if (err instanceof AgentAssertionUnsupportedError) return { ok: false, response: agentAssertionUnsupportedResponse() }; const response = mapCodexAuthContextErrorToResponse(err, { accountSelector: route.codexAccountNamespace, now: Date.now(), diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index 64e8d7943dc..15237c60bdb 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -1,6 +1,7 @@ import { effectiveAzureOutputBudget } from "../../adapters/azure"; import { resolveWireProtocolOverride } from "../adapter-resolve"; -import type { CodexAccountModelRefusal } from "../../combos/failover"; +import { comboCooldownRetryAfterSeconds, type CodexAccountModelRefusal } from "../../combos/failover"; +import { fallbackTriggerForStatus, guardFallbackHop } from "../../routing/fallback-policy"; import { isCodexReasoningEffort, isDeclaredReasoningEffort, @@ -32,6 +33,7 @@ import { isComboTargetInCooldown, snapshotComboQuotaCooldowns, comboExclusionReasons, + comboTargetSkipTrigger, noteComboSuccess, comboFailureDecision, advanceComboAfterFailure, @@ -124,6 +126,20 @@ import { clientWireOf } from "../inference/client-wire"; */ export const COMBO_TARGET_BASE_SENDS = CODEX_TEXT_GUARDED_BUDGET_POLICY.baseSendAllowance; +/** + * OCX-11: where a combo target physically dispatches, after provider aliases and + * blockedModelRedirects. A target that does not resolve keeps its configured selector; its + * dispatch fails on its own. + */ +function resolvedComboDestination(config: OcxConfig, target: { provider: string; model: string }): { provider: string; model: string } { + try { + const route = routeConcreteModel(config, `${target.provider}/${target.model}`); + return { provider: route.providerName, model: route.modelId }; + } catch { + return { provider: target.provider, model: target.model }; + } +} + function cooledPoolAccountLabel(config: OcxConfig, providerName: string, modelId: string, label: string | undefined): string | undefined { if (!label) return undefined; const instance = configuredAnthropicInstance(config, providerName); @@ -638,6 +654,30 @@ export async function executeComboResponses( ? clientCancelledResponse() : comboUnavailable(comboId, undefined, comboExclusionReasons(config, comboId, targetEligible)); } + // OCX-11: an ordered failover combo that starts on a later target than its first one, for any + // reason (cooling down, provider disabled, cached quota spent, ineligible for this request), + // makes a proactive hop, so the fallback policy decides before anything is sent. Both ends are + // the resolved destinations, after provider aliases and blockedModelRedirects. + const primaryTarget = combo.targets[0]; + // An allowed hop is recorded only once its dispatch is admitted below. + let pendingHopCommit: (() => void) | undefined; + if (!toolLoopPin && (combo.strategy ?? "failover") === "failover" && primaryTarget + && targetKey(primaryTarget) !== targetKey(pick.target)) { + const primaryCooling = isComboTargetInCooldown(comboId, primaryTarget, initialNow); + const proactive = guardFallbackHop(config, { + site: "combo", + route: `combo/${comboId}`, + aliases: [requestedModel], + from: resolvedComboDestination(config, primaryTarget), + to: resolvedComboDestination(config, pick.target), + trigger: primaryCooling ? "cooldown" : comboTargetSkipTrigger(config, primaryTarget, initialNow), + admission: options.admission, + retryAfter: primaryCooling ? comboCooldownRetryAfterSeconds(comboId, initialNow) ?? null : null, + now: initialNow, + }); + if (!proactive.ok) return proactive.response; + pendingHopCommit = proactive.commit; + } let jevDecision: JevDecision | undefined; if (combo.strategy === "jev" && !toolLoopPin) { const choices = eligibleJevComboChoices(config, comboId, targetEligible, Date.now()); @@ -760,6 +800,9 @@ export async function executeComboResponses( // no-more-targets branch; a budget refusal ends the ladder one iteration later, where that // iteration's own `childLog` is already out of scope. let lastFailedChildLog: RequestLogContext | undefined; + // The target behind `lastFailure`, so the next dispatch can tell a hop from a same-target retry. + let lastFailedTarget: { provider: string; model: string } | undefined; + let lastFailedRetryAfter: string | undefined; // The exhausted-combo mapping below runs outside the loop, where `failure.upstreamCode` // is gone, so carry the loop's own classification decision instead of re-deriving a // weaker one from the status alone (#4149). @@ -812,7 +855,29 @@ export async function executeComboResponses( while (pick) { if (options.abortSignal?.aborted) return clientCancelledResponse(); const firstComboTarget = comboTargetsDispatched === 0; + // The physical destination after provider aliases and blockedModelRedirects: hops are + // classified by where the request really goes, not by the selector the combo names. const targetRoute = routeConcreteModel(config, `${pick.target.provider}/${pick.target.model}`); + // OCX-11: every later dispatch after a failure is a hop when the target changed. One check + // here covers the ordinary failover pick and the recovery picks alike. + if (!firstComboTarget && lastFailure && lastFailedTarget) { + const hop = guardFallbackHop(config, { + site: "combo", + route: `combo/${comboId}`, + aliases: [requestedModel], + from: lastFailedTarget, + to: { provider: targetRoute.providerName, model: targetRoute.modelId }, + trigger: fallbackTriggerForStatus(lastFailure.status), + admission: options.admission, + failureStatus: lastFailure.status, + retryAfter: lastFailedRetryAfter ?? null, + }); + if (!hop.ok) { + if (lastFailedChildLog) adoptFailedChildLog(lastFailedChildLog); + return hop.response; + } + pendingHopCommit = hop.commit; + } // The inherited spend tracker observes this parent log, before the child has its own label. logCtx.spendPoolId = targetRoute.providerName; // Combo prepays before its child normalizes the route; the parent tracker must @@ -851,6 +916,9 @@ export async function executeComboResponses( // an earlier one already recorded one. return exhaustedFailure(); } + // Admitted: only now is a guarded hop to this target recorded as taken. + pendingHopCommit?.(); + pendingHopCommit = undefined; const initialSend = hopDecision?.allowed ? { permit: hopDecision.permit, producerOwned: false } : undefined; try { comboTargetsDispatched += 1; @@ -1147,6 +1215,9 @@ export async function executeComboResponses( quotaFailedChildLog = childLog; } lastFailedChildLog = childLog; + // The failed child's resolved destination, so the next hop compares physical targets. + lastFailedTarget = { provider: targetRoute.providerName, model: targetRoute.modelId }; + lastFailedRetryAfter = failure.retryAfter; // A replacement that answers 200 is unmarked, and its zero-output failure only exists once // preflight has rebuilt the stream as a fresh Response. A spent grant never hops: a status the // client would resend becomes the refusal, and anything else reaches the client as it is. diff --git a/src/server/responses/encrypted-payload.ts b/src/server/responses/encrypted-payload.ts index 2ff158e4732..1788c0733fa 100644 --- a/src/server/responses/encrypted-payload.ts +++ b/src/server/responses/encrypted-payload.ts @@ -265,13 +265,13 @@ export function hasUnreadableEncryptedAgentTask(input: unknown): boolean { // codex-rs appends one agent_message (any of the four codex-rs message types) at the // current input tail. Historical // agent messages may be adjacent in full-history bodies; they must not poison the - // later task. compaction_trigger/additional_tools are trailing metadata rather than - // a newer user turn. + // later task. compaction_trigger/additional_tools/configuration_update are trailing + // metadata rather than a newer user turn; Codex may record an effort update after the task. let index = input.length - 1; while (index >= 0) { const item = input[index]; const type = item && typeof item === "object" ? (item as { type?: unknown }).type : undefined; - if (type !== "compaction_trigger" && type !== "additional_tools") break; + if (type !== "compaction_trigger" && type !== "additional_tools" && type !== "configuration_update") break; index -= 1; } const item = input[index]; diff --git a/src/server/responses/main-hard-lock-routing.ts b/src/server/responses/main-hard-lock-routing.ts index 09f129a01be..f44674765d3 100644 --- a/src/server/responses/main-hard-lock-routing.ts +++ b/src/server/responses/main-hard-lock-routing.ts @@ -1,8 +1,8 @@ import type { OcxConfig } from "../../types"; import { resolveComboId } from "../../combos"; -import { isBareOpenAiFamilyModel, previewRouteModel } from "../../router"; +import { isBareOpenAiFamilyModel, previewRouteModel, routeConcreteModel } from "../../router"; import { resolveChosenTarget } from "./shadow-target-availability"; -import { getRoutingProfile, resolvePolicyProfileId } from "../../routing/profile"; +import { getRoutingProfile, resolvePolicyProfileId, type NormalizedRoutingProfile } from "../../routing/profile"; import { NATIVE_RESERVE_MODEL } from "../../codex/catalog/native-models"; import { getMainAccountHardLockStatus, @@ -12,6 +12,7 @@ import { codexQuotaScopeForModel } from "../../codex/routing/health-store"; import { getEligiblePoolAccounts } from "../../codex/routing/selection"; import { OPENAI_CODEX_PROVIDER_ID, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { providerCodexAccountMode } from "../../providers/registry"; +import { FallbackRefusedError, guardFallbackHop, type FallbackAdmission, type FallbackTarget } from "../../routing/fallback-policy"; /** Resolves to a synchronous ownership proof, or null when the caller does not own the locked main. */ export type MainHardLockCallerCheck = (sourceModel: string) => Promise<(() => boolean) | null>; @@ -81,6 +82,7 @@ export async function applyMainHardLockFallbackOverride( config: OcxConfig, now?: number, callerCheck?: MainHardLockCallerCheck, + admission?: FallbackAdmission, ): Promise { const parsed = rawModelOf(body); if (!parsed) return null; @@ -120,6 +122,21 @@ export async function applyMainHardLockFallbackOverride( } } + // OCX-11: the reroute is a proactive model hop; refused, the request is answered with the + // fallback refusal and the body is left untouched. + const hop = guardFallbackHop(config, { + site: "hard-lock", + route: parsed.model, + from: { provider: OPENAI_CODEX_PROVIDER_ID, model: parsed.model }, + to: hardLockTargetOf(config, parsed.model, fallback.model), + trigger: "hard_lock", + admission, + ...(now !== undefined ? { now } : {}), + }); + if (!hop.ok) throw new FallbackRefusedError(hop.response); + // The rewrite is this site's admission: the body now names the fallback model. + hop.commit(); + parsed.raw.model = fallback.model; if (fallback.reasoningEffort !== undefined) { const reasoning = parsed.raw.reasoning; @@ -132,3 +149,38 @@ export async function applyMainHardLockFallbackOverride( } return { sourceModel: parsed.model, targetModel: fallback.model }; } + +/** + * The hop's destination for the fallback policy, read from config only. A routing profile picks + * its candidate from request-history evidence, and evaluating it here would open that index under + * OPENCODEX_HOME just to classify the hop (on Windows the open handle then outlives the request's + * home); the profile's own dispatch still evaluates it. Its declared candidates are classified + * instead, as the hop the profile could take: a candidate on another provider makes it a provider + * hop. Combos, account namespaces and concrete targets resolve from config alone. + */ +function hardLockTargetOf(config: OcxConfig, sourceModel: string, model: string): FallbackTarget { + const policyId = resolvePolicyProfileId(config, model); + const profile = policyId ? getRoutingProfile(config, policyId) : undefined; + if (profile) return profileHopTarget(config, sourceModel, profile); + try { + const route = previewRouteModel(config, model); + return { provider: route.providerName, model: route.modelId }; + } catch { + const slash = model.indexOf("/"); + return slash > 0 ? { provider: model.slice(0, slash), model: model.slice(slash + 1) } : { provider: "", model }; + } +} + +function profileHopTarget(config: OcxConfig, sourceModel: string, profile: NormalizedRoutingProfile): FallbackTarget { + const targets = profile.candidates.map((candidate): FallbackTarget => { + try { + const route = routeConcreteModel(config, candidate.provider + "/" + candidate.model); + return { provider: route.providerName, model: route.modelId }; + } catch { + return { provider: candidate.provider, model: candidate.model }; + } + }); + return targets.find(target => target.provider !== OPENAI_CODEX_PROVIDER_ID) + ?? targets.find(target => target.model !== sourceModel) + ?? { provider: OPENAI_CODEX_PROVIDER_ID, model: sourceModel }; +} diff --git a/src/server/responses/policy-fallback.ts b/src/server/responses/policy-fallback.ts index 7f9286e8ef6..1803719223b 100644 --- a/src/server/responses/policy-fallback.ts +++ b/src/server/responses/policy-fallback.ts @@ -13,6 +13,7 @@ import { resolvePolicyProfileId } from "../../routing/profile"; import { parseSyntheticRowId } from "../fast-row"; import { routeConcreteModel } from "../../router"; import { isPolicyCandidateRefusal, policyWireDestination, type PolicyRequestScope } from "./policy-request-scope"; +import { fallbackTriggerForStatus, guardFallbackHop } from "../../routing/fallback-policy"; type CoreHandler = typeof handleResponsesCore; type CoreOptions = Parameters[3]; @@ -25,6 +26,23 @@ function candidateKey(candidate: Pick return `${candidate.provider}\u0000${candidate.model}`; } +/** + * OCX-11: the destination a candidate physically dispatches to, after provider aliases and + * blockedModelRedirects, so the fallback guard compares where requests really go. A candidate + * that does not resolve keeps its own selector. + */ +function resolvedPolicyTarget( + config: OcxConfig, + candidate: Pick, +): { provider: string; model: string } { + try { + const routed = routeConcreteModel(config, `${candidate.provider}/${candidate.model}`); + return { provider: routed.providerName, model: routed.modelId }; + } catch { + return { provider: candidate.provider, model: candidate.model }; + } +} + function staysWithinPolicyAfterRedirect( config: OcxConfig, policyEligibility: ReadonlySet, @@ -219,12 +237,32 @@ export async function handleResponsesWithPolicyFallback( policyScope.triedDestinations.add(policyScope.preparedDestination ?? policyWireDestination({ providerName: initialTrace.selected.provider, modelId: initialTrace.selected.model, })); + let current = resolvedPolicyTarget(config, initialTrace.selected); + const policyRoute = `policy/${initialTrace.profile!.id}`; while (!storedPool401ReplayDispatched && await shouldHopPolicyCandidate(response, req.signal)) { if (req.signal.aborted) return response; const next = rankPolicyFallbackCandidates(initialTrace, tried) .find(candidate => staysWithinPolicyAfterRedirect(config, policyEligibility, candidate, policyScope.triedDestinations)); if (!next) return response; + // OCX-11: the next candidate is a hop; the fallback policy decides before it is sent, on the + // redirected destination the guard above already validated. + const nextTarget = resolvedPolicyTarget(config, next); + const hop = guardFallbackHop(config, { + site: "routing-profile", + route: policyRoute, + aliases: [initialRequestedModel ?? initialTrace.requestedModel], + from: current, + to: nextTarget, + trigger: fallbackTriggerForStatus(response.status), + admission: options.admission, + failureStatus: response.status, + retryAfter: response.headers.get("retry-after"), + }); + if (!hop.ok) { + try { await response.body?.cancel(); } catch { /* the refusal replaces this body */ } + return hop.response; + } tried.add(candidateKey(next)); // Retain the failed response's log owner until a candidate actually replaces it. This is @@ -243,7 +281,12 @@ export async function handleResponsesWithPolicyFallback( if (!Object.hasOwn(failureLog, key)) Reflect.deleteProperty(logCtx, key); } Object.assign(logCtx, failureLog); - } else response = nextResponse; + } else { + // Dispatched: only a candidate that was not locally skipped counts as a taken hop. + hop.commit(); + response = nextResponse; + current = nextTarget; + } if (policyScope.preparedDestination) policyScope.triedDestinations.add(policyScope.preparedDestination); } catch (error) { const overload = requestPacingOverloadResponse(error); diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 6b33dd11f41..721bcebee11 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -160,6 +160,7 @@ import { import { acquireUpstreamHostAdmission } from "../../codex/upstream-host-health"; import { applyCompactionRoutingOverride, compactionRoutingKeepsProviderIdentity } from "./compaction-routing"; import { applyMainHardLockFallbackOverride } from "./main-hard-lock-routing"; +import { FallbackRefusedError } from "../../routing/fallback-policy"; import { codexAuthContextLogLabel } from "../../codex/account-label"; import { conversationStateBindingFromAuth, @@ -367,10 +368,11 @@ export async function prepareResponsesRequest( && !shadowCallIntercepted && !shadowCallMatches && !memoryModelApplies && !threadSpawn && inboundWire === "responses") { try { options.mainHardLockFallbackOverride = await applyMainHardLockFallbackOverride( - body, config, undefined, mainHardLockFallbackCallerCheck(req, config, options), + body, config, undefined, mainHardLockFallbackCallerCheck(req, config, options), options.admission, ); } catch (error) { if (options.abortSignal?.aborted || req.signal.aborted) return clientCancelledResponse(); + if (error instanceof FallbackRefusedError) return error.response; const mapped = mapCodexAuthContextErrorToResponse(error, { now: Date.now() }); if (mapped) return mapped; throw error; @@ -1005,7 +1007,9 @@ export async function prepareResponsesRequest( subagentFallbackModelEligibleAccountIdsForModel, fallbackChain, candidateRoute => canPassThroughEncryptedV2AgentTask(candidateRoute, inboundWire), + options.admission, ); + if (fallback?.refusal) return fallback.refusal; if (fallback) { (logCtx as unknown as Record).subagentModelFallbackFrom = fallback.from; (logCtx as unknown as Record).subagentModelFallbackTo = fallback.to; @@ -1219,11 +1223,15 @@ export async function prepareResponsesRequest( recoverySelectionOptions, subagentFallbackAccountPreview, subagentFallbackModelEligibleAccountIdsForModel, + undefined, + undefined, + options.admission, ); } finally { recoverySelectionAdmission?.release(); } })(); + if (fallback?.refusal) return fallback.refusal; if (fallback) { (logCtx as unknown as Record).subagentModelFallbackFrom = fallback.from; (logCtx as unknown as Record).subagentModelFallbackTo = fallback.to; diff --git a/src/server/restart-replacement.ts b/src/server/restart-replacement.ts index 0fd002fcf84..2359b8fa939 100644 --- a/src/server/restart-replacement.ts +++ b/src/server/restart-replacement.ts @@ -214,6 +214,9 @@ export interface ReplacementStartIo extends ReplacementReadinessIo { logPath?: string | null; parentPid?: number; retryDelayMs?: number; + /** Exit waits for a terminated not-ready replacement; tests shorten them. */ + termWaitMs?: number; + killWaitMs?: number; } export interface ReplacementStartRequest { @@ -227,10 +230,19 @@ export interface ReplacementStartRequest { waitForHealth: boolean; /** The replacement's environment, already prepared by the caller; the parent marker is added here. */ env: NodeJS.ProcessEnv; + /** + * What a replacement still unready at the deadline means. `handoff` (the default, every + * standalone restart) hands off to it anyway, because the parent is about to exit and release + * what it may be waiting for. `terminate` is for a parent that keeps serving when the + * replacement fails (the client runtime): the replacement is stopped, its exit is observed, and + * the call rejects with `not_ready`, so nothing that may still bind the port survives. + */ + notReady?: "handoff" | "terminate"; } type AttemptOutcome = - | { kind: "spawned" | "ready" | "not-ready"; pid: number | undefined } + | { kind: "spawned" | "ready"; pid: number | undefined } + | { kind: "not-ready"; pid: number | undefined; terminate: () => Promise } | { kind: "early-exit"; pid: number | undefined; code: number | null; signal: NodeJS.Signals | null }; function pinnedPort(port: number | undefined): number | undefined { @@ -314,7 +326,11 @@ function runReplacementAttempt( void waitForReplacementReady(child.pid, parentPid, expectedPort, { ...io, cancelled: () => settled }).then( // Never kill a live ordinary start at its valid reclaim boundary. Parent exit is the // final resource release the child may still be waiting for. - ready => { settle({ kind: ready ? "ready" : "not-ready", pid: child.pid }); }, + ready => { + settle(ready + ? { kind: "ready", pid: child.pid } + : { kind: "not-ready", pid: child.pid, terminate: () => terminateReplacement(child, io) }); + }, err => { settle({ error: err }); }, ); }; @@ -369,6 +385,11 @@ export async function spawnReplacementStart( continue; } if (outcome.kind === "not-ready") { + if (request.notReady === "terminate") { + const exited = await outcome.terminate(); + log?.note(`replacement pid ${pid} did not answer within the readiness window; ${exited ? "stopped it" : "it did not exit after SIGKILL"}`); + throw handoffError(exited ? "not_ready" : "not_ready_unstopped"); + } console.warn("Restart replacement is still starting after the readiness window; handing off to it anyway"); log?.note(`replacement pid ${pid} is still starting after the readiness window; handing off anyway`); } else { @@ -380,3 +401,30 @@ export async function spawnReplacementStart( log?.close(); } } + +/** Exit-observation budgets for {@link terminateReplacement}. */ +export const REPLACEMENT_TERM_WAIT_MS = 3_000; +export const REPLACEMENT_KILL_WAIT_MS = 2_000; + +function waitForChildExit(child: ChildProcess, ms: number, sleep: (ms: number) => Promise): Promise { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true); + return new Promise(resolve => { + let done = false; + const finish = (exited: boolean) => { if (!done) { done = true; child.off("exit", onExit); resolve(exited); } }; + const onExit = () => finish(true); + child.once("exit", onExit); + void sleep(ms).then(() => finish(child.exitCode !== null || child.signalCode !== null)); + }); +} + +/** + * Stop a replacement that never answered: SIGTERM, wait for its exit, then SIGKILL and wait again. + * True only once the exit was observed; a kill call alone is not proof the port is free. + */ +async function terminateReplacement(child: ChildProcess, io: ReplacementStartIo): Promise { + const sleep = io.sleep ?? Bun.sleep; + try { child.kill("SIGTERM"); } catch { /* exited between the check and the signal */ } + if (await waitForChildExit(child, io.termWaitMs ?? REPLACEMENT_TERM_WAIT_MS, sleep)) return true; + try { child.kill("SIGKILL"); } catch { /* exited between the check and the signal */ } + return waitForChildExit(child, io.killWaitMs ?? REPLACEMENT_KILL_WAIT_MS, sleep); +} diff --git a/src/server/ws-bridge.ts b/src/server/ws-bridge.ts index 4009a48c1d7..6a10afe729e 100644 --- a/src/server/ws-bridge.ts +++ b/src/server/ws-bridge.ts @@ -12,6 +12,7 @@ import { sseDataPayload as parseSseBlock } from "./sse-payload-rewrite"; import { safeResponseHeaders } from "./safe-response-headers"; import type { AudioSocketTarget } from "./audio-dictation"; import type { CodexWebSocketActivity } from "../codex/websocket-activity"; +import { AGENT_ASSERTION_HEADER } from "../lib/authorization-scheme"; export { safeResponseHeaders } from "./safe-response-headers"; @@ -131,6 +132,10 @@ export function selectForwardHeaders( if (codexOverride) { selected.set("authorization", `Bearer ${codexOverride.accessToken}`); selected.set("chatgpt-account-id", codexOverride.chatgptAccountId); + } else { + // Kept for the WS turn pipeline, which drops it again everywhere except first-party Direct. + const assertion = headers.get(AGENT_ASSERTION_HEADER); + if (assertion) selected.set(AGENT_ASSERTION_HEADER, assertion); } return selected; } diff --git a/src/service/cli.ts b/src/service/cli.ts index 9b07622e367..3a1c198055d 100644 --- a/src/service/cli.ts +++ b/src/service/cli.ts @@ -11,6 +11,7 @@ import { statusWinswRaw, type WinswStatus } from "../lib/winsw"; import { withWindowsServiceMutationLock } from "../lib/windows-service-mutation-lock"; import { maybeShowStarPrompt } from "../cli/star-prompt"; import { serviceStatusReport } from "./diagnostics"; +import { collectServiceStatusJson } from "./status-json"; import { assertServiceAuthEnvironment } from "./auth-preflight"; import { assertServiceEnvironmentMatchesInstall } from "./guards"; import { resolveServiceListenPort } from "./definition"; @@ -47,10 +48,14 @@ export function normalizeServiceSubcommand(sub?: string): string { return sub ?? "install"; } +const SERVICE_JSON_STATUS_ONLY = "--json applies to `ocx service status` only."; + export interface ParsedServiceArgs { sub: string; backend: ServiceBackend | null; invalid: string[]; + /** `--json`: accepted for `status` only; planServiceCommand rejects it elsewhere. */ + json: boolean; } export type ServiceInstallationState = "installed" | "absent" | "unknown"; @@ -140,6 +145,9 @@ export function planServiceCommand( if (parsed.invalid.length > 0) { return { ok: false, message: `Unknown service option: ${parsed.invalid.join(" ")}` }; } + if (parsed.json && parsed.sub !== "status") { + return { ok: false, message: SERVICE_JSON_STATUS_ONLY }; + } if (parsed.backend && parsed.sub !== "install") { return { ok: false, message: "--native/--scheduler apply to `ocx service install` only; other subcommands use the installed backend." }; } @@ -174,9 +182,11 @@ export function planServiceCommand( export function parseServiceArgs(args: string[]): ParsedServiceArgs { let sub: string | undefined; let backend: ServiceBackend | null = null; + let json = false; const invalid: string[] = []; for (const arg of args) { - if (arg === "--native") { + if (arg === "--json") json = true; + else if (arg === "--native") { if (backend === "scheduler") { invalid.push("--native (conflicts with --scheduler)"); continue; } backend = "native"; } @@ -188,7 +198,7 @@ export function parseServiceArgs(args: string[]): ParsedServiceArgs { else if (sub === undefined) sub = arg; else invalid.push(arg); } - return { sub: normalizeServiceSubcommand(sub), backend, invalid }; + return { sub: normalizeServiceSubcommand(sub), backend, invalid, json }; } /** Remove the service credential only when no client connection can own it. */ @@ -243,6 +253,12 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise { try { return sh(`systemctl --user is-enabled ${TASK}`) === "enabled"; } catch { return false; } })(); const running = installed && (() => { try { return sh(`systemctl --user is-active ${TASK}`) === "active"; } catch { return false; } })(); const stale = installed && bakedServicePathsDiagnostic() !== null; - const viable = installed && enabled && running && !stale; - const summary = !installed ? `not installed (${diagnostics})` - : stale ? `installed, but stale (systemd user; ${diagnostics})` - : viable ? `installed, enabled and running (systemd user; ${diagnostics})` - : `installed, but ${!enabled ? "disabled" : "not running"} (systemd user; ${diagnostics})`; - return { supported: true, installed, enabled, running, viable, startable: installed && !stale, stale, conflict: false, backend: "systemd", summary }; + return deriveSystemdServiceDiagnostic({ installed, enabled, running, stale, diagnostics }); } return { supported: false, installed: false, enabled: false, running: false, viable: false, startable: false, stale: false, conflict: false, backend: null, summary: `unsupported on ${process.platform}` }; } +export interface SystemdServiceDiagnosticInputs { + installed: boolean; + enabled: boolean; + running: boolean; + stale: boolean; + diagnostics: string; +} + +/** The systemd user-unit verdict, pure so its states are testable without a user bus. */ +export function deriveSystemdServiceDiagnostic(inputs: SystemdServiceDiagnosticInputs): ServiceDiagnostic { + const { installed, enabled, running, stale, diagnostics } = inputs; + const viable = installed && enabled && running && !stale; + const summary = !installed ? `not installed (${diagnostics})` + : stale ? `installed, but stale (systemd user; ${diagnostics})` + : viable ? `installed, enabled and running (systemd user; ${diagnostics})` + : `installed, but ${!enabled ? "disabled" : "not running"} (systemd user; ${diagnostics})`; + return { supported: true, installed, enabled, running, viable, startable: installed && !stale, stale, conflict: false, backend: "systemd", summary }; +} + export function serviceStatusSummary(): string { return diagnoseService().summary; } diff --git a/src/service/status-json.ts b/src/service/status-json.ts new file mode 100644 index 00000000000..6248e373da7 --- /dev/null +++ b/src/service/status-json.ts @@ -0,0 +1,116 @@ +import { diagnoseService, type ServiceDiagnostic } from "./diagnostics"; +import { desktopLoginStatusSummary, diagnoseDesktopStartup } from "./desktop-startup"; +import { confirmServiceServing } from "./health"; +import { ownershipMutationLeaseStatusLine } from "./ownership-mutation-lease.mjs"; +import { LABEL, TASK, serviceLogPath, serviceStatePaths } from "./state"; +import { WINSW_SERVICE_ID } from "../lib/winsw"; +import { runPassiveRead } from "../lib/passive-read"; + +/** The service manager holding the registration; `none` when nothing is registered. */ +export type ServiceStatusBackend = "launchd" | "systemd" | "schtasks" | "winsw" | "none"; + +/** + * `ocx service status --json`. Additive within `schemaVersion: 1`. + * + * `running` is the service manager's own verdict — the value `ocx status --json` already + * reports as `startup.serviceRunning` — and `serving` says separately whether a proxy + * answers on the installed port, because a loaded job that never bound is the failure the + * text report exists to show. + */ +export interface ServiceStatusJson { + schemaVersion: 1; + platform: NodeJS.Platform; + supported: boolean; + backend: ServiceStatusBackend; + label: string | null; + installed: boolean; + enabled: boolean; + running: boolean; + viable: boolean; + stale: boolean; + conflict: boolean; + serving: { probed: boolean; ok: boolean; port: number | null }; + summary: string; + logPath: string; + ownershipLease: string | null; + desktopLogin: string | null; +} + +export interface ServiceStatusJsonDeps { + platform?: NodeJS.Platform; + diagnose?: () => ServiceDiagnostic; + serving?: () => Promise<{ ok: boolean; port: number }>; + logPath?: () => string; + leaseLine?: () => string | null; + desktopLogin?: () => string | null; +} + +export function serviceStatusBackend(diag: ServiceDiagnostic): ServiceStatusBackend { + if (!diag.supported || !diag.installed) return "none"; + switch (diag.backend) { + case "launchd": return "launchd"; + case "systemd": return "systemd"; + case "scheduler": return "schtasks"; + case "native": return "winsw"; + default: return "none"; + } +} + +const BACKEND_LABEL: Record = { + launchd: LABEL, + systemd: `${TASK}.service`, + schtasks: TASK, + winsw: WINSW_SERVICE_ID, + none: null, +}; + +/** + * Passive by construction: it performs the reads `ocx service status` already performs + * (manager diagnosis, the loopback serving probe only when a registration exists, the + * ownership-lease and Desktop login lines) and nothing else. The caller answers before + * `platformOps()` is resolved, so no install, start, stop or repair path is reachable. + * The whole collection runs in a passive-read scope: the serving probe resolves the port + * through loadConfig(), which outside that scope hardens config modes and backs up a + * malformed config — writes an inventory read must not make. + */ +export function collectServiceStatusJson(deps: ServiceStatusJsonDeps = {}): Promise { + return runPassiveRead(() => collectPassively(deps)); +} + +async function collectPassively(deps: ServiceStatusJsonDeps): Promise { + const diag = (deps.diagnose ?? diagnoseService)(); + const backend = serviceStatusBackend(diag); + let serving: ServiceStatusJson["serving"] = { probed: false, ok: false, port: null }; + if (diag.installed) { + // A probe that throws (config load, port resolution) must not cost the caller the whole + // document; it is reported as probed, not serving, with no port. + try { + const result = await (deps.serving ?? (() => confirmServiceServing({ timeoutMs: 1_500 })))(); + serving = { probed: true, ok: result.ok, port: result.port }; + } catch { + serving = { probed: true, ok: false, port: null }; + } + } + const desktopLogin = deps.desktopLogin ?? (() => { + const desktop = diagnoseDesktopStartup(); + return desktop ? desktopLoginStatusSummary(desktop) : null; + }); + return { + schemaVersion: 1, + platform: deps.platform ?? process.platform, + supported: diag.supported, + backend, + label: BACKEND_LABEL[backend], + installed: diag.installed, + enabled: diag.enabled, + running: diag.running, + viable: diag.viable, + stale: diag.stale, + conflict: diag.conflict, + serving, + summary: diag.summary, + logPath: (deps.logPath ?? serviceLogPath)(), + ownershipLease: (deps.leaseLine ?? (() => ownershipMutationLeaseStatusLine(serviceStatePaths())))() ?? null, + desktopLogin: desktopLogin(), + }; +} diff --git a/src/types/config.ts b/src/types/config.ts index 955522cf1fe..8be911294df 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -2,7 +2,9 @@ import type { OcxProviderConfig } from "./provider"; import type { CodexAccount } from "./accounts"; import type { AnthropicAccountPoolConfig, OcxAccountPoolRotationStrategy } from "./anthropic-account-pool"; import type { AnthropicInstanceId } from "../providers/anthropic-instance-id"; +import type { OcxFallbackConfig } from "./fallback"; export type { AnthropicAccountPoolConfig, AnthropicModelRoute, OcxAccountPoolRotationStrategy, OcxAccountPoolQuotaWindow } from "./anthropic-account-pool"; +export type { OcxFallbackConfig, OcxFallbackDeviceConfig, OcxFallbackKind, OcxFallbackSwitches } from "./fallback"; /** Public inference API exposure. Responses and Chat Completions are always served. */ export interface OcxApiSurfacesConfig { @@ -1361,6 +1363,11 @@ export interface OcxConfig { tokenGuardian?: OcxTokenGuardianConfig; /** Additional exact origins allowed for CORS (e.g. HTTPS or chrome-extension://). Loopback origins are always allowed. */ corsAllowOrigins?: string[]; + /** + * OCX-11 fallback off: refuse model or provider fallback per scope, and record per-device local + * fallback permission. Absent keeps every chain as it was. See src/routing/fallback-policy.ts. + */ + fallback?: OcxFallbackConfig; } export type OcxComboStrategy = "failover" | "round-robin" | "random" | "least-used" | "reset-window" | "jev"; diff --git a/src/types/fallback.ts b/src/types/fallback.ts new file mode 100644 index 00000000000..dcd3d92b390 --- /dev/null +++ b/src/types/fallback.ts @@ -0,0 +1,38 @@ +/** + * OCX-11 fallback policy (`fallback` in config.json). Every leaf is optional and absence keeps + * today's behavior: model and provider fallback allowed, local fallback not granted. + * + * Contract and precedence: `src/routing/fallback-policy.ts` and `structure/fallback-policy.md`. + */ + +/** A fallback kind this policy governs. Intra-pool account or key rotation is not one of them. */ +export type OcxFallbackKind = "model" | "provider"; + +export interface OcxFallbackSwitches { + /** false refuses a hop to another model of the same provider. */ + model?: boolean; + /** false refuses a hop to another provider. */ + provider?: boolean; +} + +export interface OcxFallbackDeviceConfig extends OcxFallbackSwitches { + /** + * Permission only: true tells GOAT it may move this device's work to the device's own + * providers while the Home is unreachable. OpenCodex itself never serves a Child or Hub client + * from local providers; absent or false means no permission. + */ + localFallback?: boolean; +} + +export interface OcxFallbackConfig extends OcxFallbackSwitches { + /** + * Keyed by the provider a hop leaves: the resolved provider of the target that just failed or + * is known unavailable. In combo [a, b, c] with providers.b.provider false, a to b is allowed and + * b to c is refused. + */ + providers?: Record; + /** Keyed by an exact public selector: combo/, policy/, provider/model or an alias. */ + routes?: Record; + /** Keyed by a Remote Link id (lnk_…) or a Hub-client data key id. Read on the Home. */ + devices?: Record; +} diff --git a/structure/INDEX.md b/structure/INDEX.md index cf769362195..3e3623af6ad 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -61,7 +61,9 @@ The wire surfaces a client actually talks to. | [`data-planes/images.md`](data-planes/images.md) | Standalone image generation/edit relay and local hosted Responses image display. | | [`data-planes/search.md`](data-planes/search.md) | Hosted search relay and exact account selectors. | | [`data-planes/inbound-compat.md`](data-planes/inbound-compat.md) | Chat Completions inbound, Anthropic-shaped clients, and JSON-upstream streaming clients. | +| [`data-planes/chat-reasoning-details.md`](data-planes/chat-reasoning-details.md) | The reasoning_details carrier that lets Chat Completions clients round-trip signed Anthropic thinking across tool steps (#7429). | | [`data-planes/protocol-paths.md`](data-planes/protocol-paths.md) | Shared protocol vocabulary, declared feature dispositions, the ingress-by-upstream baseline, plan/trace shapes, and protocol settings. | +| [`fallback-policy.md`](fallback-policy.md) | OCX-11 fallback off: what is a model or provider fallback (and what intra-pool rotation is not), scope precedence, the guarded hop sites, the fallback_refused and home_unreachable contracts, and the ocx.fallback/1 state. | | [`remote-workspace.md`](remote-workspace.md) | Opt-in workspace identity, executor grants, runtime adapters, management and dashboard integration. | | [`remote-link.md`](remote-link.md) | SSH machine-link building blocks: OpenSSH argument policy, ssh_config candidates, tunnel lifecycle reducer, the private link store, and standalone pairing-grant delivery. | @@ -109,6 +111,7 @@ The dashboard, the management API, and third-party client config ownership. | [`gui-and-management-api.md`](gui-and-management-api.md) | Dashboard serving, authentication boundaries, /api/* ownership, and startup safety. | | [`provider-completion-diagnostic.md`](provider-completion-diagnostic.md) | Explicit single-model completion test: consent, applicability gate, send-time authority fingerprint, bounded streaming and metadata comparison. | | [`dashboard-and-usage.md`](dashboard-and-usage.md) | Dashboard page contracts, usage accounting and request metrics, and per-surface management settings. | +| [`quota-pool.md`](quota-pool.md) | The passive GET /api/quota-pool read GOAT consumes (ocx.quota-pool/1): sources, window mapping, absent-not-zero, and the install identity behind homeId and accountRef. | | [`pricing.md`](pricing.md) | User price policies, prompt-length rate selection, persistence and estimate cache invalidation. | | [`clients/integrations.md`](clients/integrations.md) | Third-party client config ownership, snapshots, refresh, disable, and restore. | | [`clients/codex-desktop.md`](clients/codex-desktop.md) | Windows package activation, restart context, and protected compatibility authority persistence. | @@ -159,7 +162,7 @@ A source area can be described by more than one doc, because these docs are orga | `desktop/ui/cli.html` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | | `desktop/ui/cli.js` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | | `docs-site/` | [`ops/docs-and-release.md`](ops/docs-and-release.md) | -| `gui/` | [`overview.md`](overview.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`provider-completion-diagnostic.md`](provider-completion-diagnostic.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`design-methodology.md`](design-methodology.md)
[`companion.md`](companion.md) | +| `gui/` | [`overview.md`](overview.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`provider-completion-diagnostic.md`](provider-completion-diagnostic.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`fallback-policy.md`](fallback-policy.md)
[`design-methodology.md`](design-methodology.md)
[`companion.md`](companion.md) | | `gui/src/account-pause-display.ts` | [`account-auto-pause.md`](account-auto-pause.md) | | `gui/src/components/ModelPriceDialog.tsx` | [`pricing.md`](pricing.md) | | `gui/src/hooks/useProviderAccountPools.ts` | [`account-auto-pause.md`](account-auto-pause.md) | @@ -179,16 +182,22 @@ A source area can be described by more than one doc, because these docs are orga | `src/bridge.ts` | [`transports/responses.md`](transports/responses.md) | | `src/bridge/` | [`transports/responses.md`](transports/responses.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md) | | `src/chat/` | [`runtime.md`](runtime.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/chat-compat.md`](providers/chat-compat.md) | +| `src/chat/reasoning-details.ts` | [`data-planes/chat-reasoning-details.md`](data-planes/chat-reasoning-details.md) | | `src/chatgpt/` | [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | | `src/claude/` | [`runtime.md`](runtime.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`clients/claude-desktop-config-reads.md`](clients/claude-desktop-config-reads.md) | | `src/cli.ts` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | -| `src/cli/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`remote-messaging.md`](remote-messaging.md)
[`local-messaging.md`](local-messaging.md)
[`cli-management.md`](cli-management.md)
[`provider-completion-diagnostic.md`](provider-completion-diagnostic.md)
[`clients/integrations.md`](clients/integrations.md)
[`clients/codex-desktop.md`](clients/codex-desktop.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `src/cli/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`remote-messaging.md`](remote-messaging.md)
[`local-messaging.md`](local-messaging.md)
[`cli-management.md`](cli-management.md)
[`provider-completion-diagnostic.md`](provider-completion-diagnostic.md)
[`fallback-policy.md`](fallback-policy.md)
[`clients/integrations.md`](clients/integrations.md)
[`clients/codex-desktop.md`](clients/codex-desktop.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/cli/account-preservation.ts` | [`providers/openai-signed-in-preservation.md`](providers/openai-signed-in-preservation.md) | | `src/cli/codex-provider.ts` | [`codex-provider-routing.md`](codex-provider-routing.md) | +| `src/cli/link-apply-restart.ts` | [`remote-link.md`](remote-link.md) | | `src/cli/models-runtime.ts` | [`pricing.md`](pricing.md) | -| `src/client/` | [`runtime.md`](runtime.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md) | +| `src/cli/status-codex-settings.ts` | [`remote-link.md`](remote-link.md) | +| `src/client/` | [`runtime.md`](runtime.md)
[`fallback-policy.md`](fallback-policy.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md) | +| `src/client/link-home.ts` | [`remote-link.md`](remote-link.md) | +| `src/client/link-parity-reporter.ts` | [`remote-link.md`](remote-link.md) | +| `src/client/runtime-restart.ts` | [`remote-link.md`](remote-link.md) | | `src/clients/` | [`clients/integrations.md`](clients/integrations.md)
[`clients/qoder.md`](clients/qoder.md) | -| `src/codex/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`codex-home.md`](codex-home.md)
[`codex-prompts.md`](codex-prompts.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`subagent-capacity.md`](subagent-capacity.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`providers/openai-tiers.md`](providers/openai-tiers.md)
[`providers/openai-accounts.md`](providers/openai-accounts.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`clients/codex-desktop.md`](clients/codex-desktop.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `src/codex/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`codex-home.md`](codex-home.md)
[`codex-prompts.md`](codex-prompts.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`subagent-capacity.md`](subagent-capacity.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`providers/openai-tiers.md`](providers/openai-tiers.md)
[`providers/openai-accounts.md`](providers/openai-accounts.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`fallback-policy.md`](fallback-policy.md)
[`clients/codex-desktop.md`](clients/codex-desktop.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/codex/account-pause.ts` | [`account-auto-pause.md`](account-auto-pause.md) | | `src/codex/account-preservation.ts` | [`providers/openai-signed-in-preservation.md`](providers/openai-signed-in-preservation.md) | | `src/codex/app-server-processes.ts` | [`codex-cli-update.md`](codex-cli-update.md) | @@ -204,11 +213,11 @@ A source area can be described by more than one doc, because these docs are orga | `src/codex/provider-routing.ts` | [`codex-provider-routing.md`](codex-provider-routing.md) | | `src/codex/runtime.ts` | [`catalog-effort-clamp.md`](catalog-effort-clamp.md) | | `src/codex/signed-in-identity.ts` | [`providers/openai-signed-in-preservation.md`](providers/openai-signed-in-preservation.md) | -| `src/combos/` | [`runtime.md`](runtime.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/jev-decision.md`](providers/jev-decision.md) | +| `src/combos/` | [`runtime.md`](runtime.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/jev-decision.md`](providers/jev-decision.md)
[`fallback-policy.md`](fallback-policy.md) | | `src/companion/` | [`overview.md`](overview.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`companion.md`](companion.md) | | `src/compatibility/` | [`runtime.md`](runtime.md)
[`adapters/compatibility-contracts.md`](adapters/compatibility-contracts.md) | | `src/config.ts` | [`overview.md`](overview.md)
[`runtime.md`](runtime.md)
[`config.md`](config.md)
[`providers/openai-accounts.md`](providers/openai-accounts.md) | -| `src/config/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md) | +| `src/config/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`fallback-policy.md`](fallback-policy.md) | | `src/config/account-auto-pause-revisions.ts` | [`account-auto-pause.md`](account-auto-pause.md) | | `src/config/live-reconcile.ts` | [`pricing.md`](pricing.md) | | `src/config/persisted-mutation.ts` | [`runtime.md`](runtime.md) | @@ -249,23 +258,27 @@ A source area can be described by more than one doc, because these docs are orga | `src/providers/quota/anthropic-account-quota.ts` | [`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md) | | `src/providers/quota/dsh-account.ts` | [`providers/dsh-account.md`](providers/dsh-account.md) | | `src/providers/request-pacing.ts` | [`providers/anthropic-key-billing.md`](providers/anthropic-key-billing.md) | +| `src/quota-pool/` | [`quota-pool.md`](quota-pool.md) | | `src/quota/` | [`dashboard-and-usage.md`](dashboard-and-usage.md) | | `src/reasoning-effort.ts` | [`runtime.md`](runtime.md) | | `src/remote-control/` | [`remote-workspace.md`](remote-workspace.md) | -| `src/remote/` | [`runtime.md`](runtime.md) | +| `src/remote/` | [`runtime.md`](runtime.md)
[`fallback-policy.md`](fallback-policy.md) | | `src/responses/` | [`runtime.md`](runtime.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses.md`](transports/responses.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/kiro.md`](providers/kiro.md)
[`providers/xai-grok.md`](providers/xai-grok.md)
[`providers/chat-compat.md`](providers/chat-compat.md) | | `src/responses/reasoning-replay-cache.ts` | [`transports/anthropic-thinking-recovery.md`](transports/anthropic-thinking-recovery.md) | | `src/router.ts` | [`runtime.md`](runtime.md) | -| `src/routing/` | [`catalog.md`](catalog.md) | +| `src/routing/` | [`catalog.md`](catalog.md)
[`fallback-policy.md`](fallback-policy.md) | | `src/routing/history/` | [`dashboard-and-usage.md`](dashboard-and-usage.md) | -| `src/server/` | [`providers/generic-account-pool.md`](providers/generic-account-pool.md)
[`runtime.md`](runtime.md)
[`codex-prompts.md`](codex-prompts.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses.md`](transports/responses.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`transports/responses-combo-boundaries.md`](transports/responses-combo-boundaries.md)
[`transports/policy-fallback.md`](transports/policy-fallback.md)
[`transports/streaming-health.md`](transports/streaming-health.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/images.md`](data-planes/images.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/jev-decision.md`](providers/jev-decision.md)
[`providers/xai-grok.md`](providers/xai-grok.md)
[`adapters/registry.md`](adapters/registry.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`provider-completion-diagnostic.md`](provider-completion-diagnostic.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | +| `src/server/` | [`providers/generic-account-pool.md`](providers/generic-account-pool.md)
[`runtime.md`](runtime.md)
[`codex-prompts.md`](codex-prompts.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses.md`](transports/responses.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`transports/responses-combo-boundaries.md`](transports/responses-combo-boundaries.md)
[`transports/policy-fallback.md`](transports/policy-fallback.md)
[`transports/streaming-health.md`](transports/streaming-health.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/images.md`](data-planes/images.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/jev-decision.md`](providers/jev-decision.md)
[`providers/xai-grok.md`](providers/xai-grok.md)
[`adapters/registry.md`](adapters/registry.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`provider-completion-diagnostic.md`](provider-completion-diagnostic.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`fallback-policy.md`](fallback-policy.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | | `src/server/gui-pair-delivery.ts` | [`remote-link.md`](remote-link.md) | | `src/server/index.ts` | [`adapters/compatibility-lab.md`](adapters/compatibility-lab.md) | +| `src/server/index/link-management.ts` | [`remote-link.md`](remote-link.md) | | `src/server/inference/advisor-diagnostics.ts` | [`advisor.md`](advisor.md) | | `src/server/management/` | [`providers/oauth-code-display-login.md`](providers/oauth-code-display-login.md) | | `src/server/management/anthropic-pool-settings.ts` | [`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md) | | `src/server/management/companion-routes.ts` | [`desktop-shell.md`](desktop-shell.md) | +| `src/server/management/link-routes.ts` | [`remote-link.md`](remote-link.md) | | `src/server/management/model-routes.ts` | [`pricing.md`](pricing.md) | +| `src/server/management/quota-pool-routes.ts` | [`quota-pool.md`](quota-pool.md) | | `src/server/responses/account-auto-pause-recovery.ts` | [`account-auto-pause.md`](account-auto-pause.md) | | `src/server/responses/advisor-plan-slot.ts` | [`advisor.md`](advisor.md) | | `src/server/responses/advisor-slot.ts` | [`advisor.md`](advisor.md) | @@ -291,7 +304,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/storage/` | [`runtime.md`](runtime.md) | | `src/tray/` | [`runtime.md`](runtime.md) | | `src/types.ts` | [`runtime.md`](runtime.md)
[`config.md`](config.md) | -| `src/types/` | [`providers/generic-account-pool.md`](providers/generic-account-pool.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`providers-and-adapters.md`](providers-and-adapters.md) | +| `src/types/` | [`providers/generic-account-pool.md`](providers/generic-account-pool.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`fallback-policy.md`](fallback-policy.md) | | `src/types/account-auto-pause.ts` | [`account-auto-pause.md`](account-auto-pause.md) | | `src/types/provider.ts` | [`pricing.md`](pricing.md) | | `src/update/` | [`runtime.md`](runtime.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | diff --git a/structure/clients/codex-desktop.md b/structure/clients/codex-desktop.md index 06772631f73..b78c427be7c 100644 --- a/structure/clients/codex-desktop.md +++ b/structure/clients/codex-desktop.md @@ -69,12 +69,14 @@ registration refuse it, while exact trust removal and deliberate renewal remain `CryptProtectData`/`CryptUnprotectData` in-process on Windows x64, loading `crypt32.dll` from the `GetSystemDirectoryW` path and clearing unprotected output before `LocalFree`. Windows ARM64, where Bun FFI is unavailable, keeps the trusted PowerShell helper with -bounded stdin/stdout. Both write the same blob format; neither puts secrets on a command +bounded stdin/stdout. The helper uses only .NET calls and never a cmdlet, because the first +cmdlet in a session pays module discovery, which a cold profile turns into the whole budget. +Both write the same blob format; neither puts secrets on a command line or falls back to plaintext. CurrentUser protects against other OS identities, not another process using the same credentials. `tests/clients/desktop-compatibility-authority.test.ts` covers persistence, refusal, -a real Windows DPAPI round trip with synthetic data, and a PowerShell-written key opening -in-process. +a real Windows DPAPI round trip with synthetic data, a PowerShell-written key opening +in-process, and the helper's cmdlet-free script on every platform. ## Certificate setup API diff --git a/structure/data-planes/chat-reasoning-details.md b/structure/data-planes/chat-reasoning-details.md new file mode 100644 index 00000000000..a0743f29f1f --- /dev/null +++ b/structure/data-planes/chat-reasoning-details.md @@ -0,0 +1,32 @@ +# Anthropic Thinking Signatures on the Chat Wire (#7429) + +The one exception to the rule in [inbound-compat.md](inbound-compat.md) that opaque reasoning +state never crosses a Chat Completions boundary. + +A Chat client routed to Claude echoed `reasoning_content` only, so every tool step reached +Anthropic without the signed thinking block it requires and the adapter dropped the reasoning. +`src/chat/reasoning-details.ts` owns the carrier, OpenRouter's `reasoning_details` shape that +Hermes and pi already round-trip: `{type:"reasoning.text", text, signature, format: +"anthropic-claude-v1", index}` for a signed block and `{type:"reasoning.encrypted", data, …}` +for a `redacted_thinking` block, redacted entries first as the adapter replays them. + +Outbound, the bridge converter (`src/chat/outbound.ts`, stream and JSON) builds the entries from +each reasoning item's `ocxr1` envelope and the direct writer (`src/protocols/encoders/chat.ts`) +from `reasoningDone` and the optional `progressThinking` hook; the parity suite holds them +frame-equal. Only a value with Anthropic's signature spelling is emitted. A signature over text +the client never saw (hidden thinking: `txt` without `upd`, or the driver's `hidden` flag) is +withheld and only redacted entries go out. An Opus progress update (`txt` with `upd`) is +shown as `content` and also returned as a signed entry over that same text. + +Inbound, `chatCompletionsToResponsesBody` reads the entries only when the ingress sets +`acceptAnthropicReasoningSignatures`: the settled route is one `anthropic` adapter destination +that is first-party (`credentialDomainFor(...).firstPartyAnthropic`) and cannot change +credential domain (no combo, policy or credential rewrite). Every entry must be well formed or +the whole array is ignored and the plaintext projection above applies unchanged; a client that +sends no `reasoning_details` gets a byte-identical body. Accepted entries become reasoning +items carrying the same `ocxr1` envelope the Responses wire uses, so the serving-identity +strip, the rejection memo and thinking recovery all apply. A progress update's visible copy in +`content` is replayed as sent: no marker distinguishes it from answer text, so nothing is cut. +Validation is structural only: an edited `text` or a well-formed altered signature is forwarded +and Anthropic refuses it. No signature is computed, cached server side, or synthesized; the +chat-ir placeholder still never passes the adapter's signature check. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index eb85a03718a..121dd38b79a 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -482,7 +482,9 @@ projection as a `reasoning` input item emitted immediately before its assistant message, matching the parser's buffer-and-prepend adjacency. Only representable plaintext crosses: no signature, encrypted payload or provider item id is reconstructed, because those attest to content this proxy never received. Opaque -reasoning replay across a Chat boundary remains unimplemented by design. +reasoning replay across a Chat boundary has exactly one exception: Anthropic thinking +signatures carried in `reasoning_details` (#7429), documented in +[chat-reasoning-details.md](chat-reasoning-details.md). `presence_penalty` and `frequency_penalty` are carried too; per-model `noPenaltyModels` opt-outs still apply at the adapter. @@ -561,3 +563,16 @@ Missing identity, a restarted/expired/evicted cache, and another route receive o placeholder; they never recover another conversation's raw text. SSE capture retains at most 256 KiB and 64 calls within the translator budget and releases on terminal/error/cancellation. Unfinished tool streams cannot seed replay. This leaves the native wire and response fields intact. + +## Codex runtime agent assertions + +Authorization scheme parsing lives in `src/lib/authorization-scheme.ts`, and every former +`replace(/^Bearer\s+/i, "")` read uses it. Codex 0.164 runtime agent assertions add a value that is +neither an admission secret nor a caller credential. `Authorization: AgentAssertion` (assertion_only) +is never a bearer, never admitted, and never forwarded: the request fetch handler refuses it with +`agent_assertion_unsupported` before admission or routing, and Responses auth plus +`materializeCodexUpstreamAuth` refuse it again so no pool or substituted credential can stand in for +it. The dual_header `x-openai-agent-assertion` is outside `FORWARD_HEADERS` and blocked from +`forwardClientHeaders`. It is carried only for canonical-forward Direct when the outbound Bearer is +the caller's original one, and is dropped under a pool override. Coverage: +`tests/server/agent-assertion-auth.test.ts` and `tests/responses/agent-assertion-routes.test.ts`. diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index 6b682b88ded..16e940dd4ed 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -223,7 +223,7 @@ argument gating, the integral-float repair, every terminal with its usage and du the wire-silence heartbeat and stall watchdog, pull-based stepping, cancellation) and calls one `ClientWireWriter` method wherever the bridge would emit a frame a client converter reads. `chat.ts` (`encodeChatCompletionSse`, `foldChatCompletion`) and `messages.ts` -(`encodeAnthropicMessageSse`, `foldAnthropicMessage`) are the writers. They reuse the +(`encodeAnthropicMessageSse`, `foldAnthropicMessage`) are the writers; the Chat one also emits `reasoning_details` ([#7429](chat-reasoning-details.md)). They reuse the converters' own helpers, exported from `src/chat/outbound.ts` and `src/claude/outbound.ts` (ids, chunk and frame builders, usage mapping, `chatCompletionsStreamErrorPayload`, `chatCompletionsFailedResponse`, `chatCompletionsIncompleteOutcome`, diff --git a/structure/fallback-policy.md b/structure/fallback-policy.md new file mode 100644 index 00000000000..437f7f04668 --- /dev/null +++ b/structure/fallback-policy.md @@ -0,0 +1,113 @@ +# Fallback Policy + +OCX-11. One resolver decides whether a request may be served from a different **model** or a +different **provider** than the one chosen or requested, and every site that would make such a hop +asks it first. Absent configuration allows every hop, which is the behavior before this policy existed. + +## What is and is not a fallback + +A hop's kind is decided by what changes between the target that failed (or is known unavailable) and +the next one: provider differs → `provider`; same provider, different model → `model`; same target +→ not a hop. `fallbackHopKind` in `src/routing/fallback-policy.ts` is the only classifier. + +Rotation among the accounts or API keys of one provider pool for the same model is intra-pool +resilience and is not governed here: Codex pool account switches (`src/codex/routing/selection.ts`), +Anthropic OAuth rotation (`src/oauth/anthropic-routing.ts`), generic OAuth rotation +(`src/oauth/generic-account-failover.ts`) and API-key pool rotation (`src/providers/key-failover.ts`). +The rule recorded in `src/types/config.ts` that reactive 429 rotation cannot be switched off stands. +Same-target retries (`src/lib/upstream-retry.ts`), the Antigravity direct lane's per-request bridge +fallback and transport fallbacks keep their target and are not hops. Claude Code +`classifierFallbacks` (`src/claude/inbound-model-options.ts`) is a static mapping with no +availability check, so it never hops. + +## Configuration and precedence + +`fallback` in config.json (`src/types/fallback.ts`, read schema `src/config/schema/fallback-policy.ts`): +global `model`/`provider` booleans, `providers.`, `routes.` and `devices.` maps of +the same switches, and `devices..localFallback`. A malformed leaf reads as absent; diagnostics and +the load warning name the section. A whole-config write does not degrade: `validateConfigCandidate` +(`src/config/diagnostics.ts`) rejects a malformed `fallback` section through `fallbackPolicyError`, so a +save cannot silently drop a refusal. Map keys are validated by the resolver, so one bad key never drops its +siblings. + +`resolveFallbackPolicy`: a device entry set to `false` for the kind refuses; a device `true` never +reopens a hop. Otherwise the first route selector with a boolean decides, then the provider the hop +starts from, then the global switch, then the default (allowed). Route selectors are the public +selector the client asked for plus the site's aliases (`combo/` and the requested model, +`policy/` and the requested model). + +Device identity comes from admission only: a `configured` admission's key id, plus the Remote Link +id whose `apiKeyId` matches it in the link store. The store is read only when `devices` is non-empty +and is cached for five seconds. Loopback and unkeyed requests have no device scope. + +## Sites + +| Site | Hop point | Reactive status | Proactive | +|---|---|---|---| +| Combos, `src/server/responses/core-combo.ts` | top of the dispatch loop, when a later dispatch follows a failure on a different target; and before the first send of a `failover` combo that starts on a later target than its first, for any reason | the failure's status and `Retry-After` | `cooldown` or `quota_exhausted`, 429; otherwise `unavailable`, 503 (`comboTargetSkipTrigger`, `src/combos/resolve.ts`) | +| Routing profiles, `src/server/responses/policy-fallback.ts` | before the next ranked candidate runs | the failure's status and `Retry-After` | — | +| Subagent model fallback, `src/codex/subagent-model-fallback.ts` | `applySubagentModelFallback`, before the spawn's model is rewritten; the caller in `src/server/responses/request-prepare.ts` returns the refusal | — | `unavailable`, 503 | +| Main-account hard-lock fallback, `src/server/responses/main-hard-lock-routing.ts` | before the body is rewritten; `FallbackRefusedError` carries the refusal to `request-prepare.ts` | — | `hard_lock`, 429 | + +A refused hop never contacts the fallback target and leaves the request body untouched. + +The combo and routing-profile sites classify a hop by resolved destinations: both ends pass through +`routeConcreteModel`, so provider aliases and `blockedModelRedirects` are applied, and the end a hop +leaves is the failed child's resolved provider and model. Two selectors under one provider that redirect +to different providers make a provider hop; a routing profile's `current` advances to the redirected +candidate. + +The hard-lock site classifies its one proactive hop from config alone, before anything is sent: a +combo, account namespace or concrete target through `previewRouteModel`, and a routing profile from +its declared candidates (any candidate on another provider makes a provider hop) without running the +evaluator, which would open `routing-history.sqlite` under `OPENCODEX_HOME`. The profile's own +dispatch still evaluates it. + +## Refusal contract + +`fallbackRefusedResponse`: `error.type` and `error.code` `fallback_refused`, `error.fallback` with +`kind`, `scope`, `scopeKey`, `from`, `to` and `trigger`, a message naming the `ocx fallback on` command +that allows the hop, and the header `x-opencodex-fallback: refused`. `from`/`to` are `provider/model` +selectors. + +## Hub down + +A Remote Link Child and a Hub client run the relay-only client runtime (`src/client/runtime.ts`), which +has no provider dispatch; `tests/clients/client-home-unreachable.test.ts` proves there is no load-time +path from it to Responses dispatch, the router or adapter resolution. When the Home cannot be reached, +the Child's data relay (`src/client/link-relay.ts`) answers a retryable 503 and the Hub client's +management relay (`src/client/hub-relay.ts`) a 502; both bodies keep their legacy `error` string and add +`code: "home_unreachable"` and the message from `src/client/home-unreachable.ts`. That code means the +connection is unavailable and nothing else: a failed connection-bound relay authentication answers +`code: "link_relay_auth_failed"`, so an upgrade or re-link problem never reads as Home down to GOAT. +A tunnel that drops after forwarding started also answers `home_unreachable`, and the Home may have +received that request. +`devices..localFallback` is a permission GOAT reads (goat-si/goat ADR-0021 §1.6, "local fallback +opt-in per device"); OpenCodex never acts on it and keeps answering `home_unreachable`. + +## State and surfaces + +Every guarded hop, taken or refused, is recorded in a 20-entry in-memory ring (`recentFallbackEvents`). +A refusal is recorded when it is decided; an allowed hop only when its site calls `commit()` after the +dispatch is admitted (the combo send-budget reservation passed, a profile candidate was not locally +skipped), so the ring never names a target that was not contacted. Each event carries +time, site, route, kind, from, to, trigger, outcome, deciding scope and the device id. No body, key, +account id, label or email. `src/routing/fallback-state.ts` builds the `ocx.fallback/1` view and applies +the one scoped write. + +- `GET|PUT /api/fallback` (`src/server/management/fallback-routes.ts`, loaded on demand). The view lists + every link from the store and every configured device. A passive read (a Remote Link Child through + `LINK_MANAGEMENT_READ_PATHS`) omits link aliases and event device ids. PUT validates a device key + against the link store (an alias maps to its link id) and `apiKeys`, persists through + `mutatePersistedConfig` and mirrors the result into the live config. +- `GET /readyz` advertises `fallback-policy/1` (`src/remote/features.ts`); GOAT reads its absence as unknown. +- `ocx fallback` (`src/cli/fallback.ts`) and `ocx status --json` `fallback` (`src/cli/status-fallback.ts`: + the live view, the Home's over the link on a Child, or the local policy marked `source: "local"`). +- Dashboard: `gui/src/components/FallbackPanel.tsx` on the Dashboard overview (switches, overrides, last hop) and on + Remote Link (per-device local fallback and chains, read-only on a Child). + +Coverage: `tests/routing/fallback-policy.test.ts` (precedence matrix, degrade, device identity, contract, +privacy), `tests/responses/responses-combo-fallback-off.test.ts`, `tests/routing/routing-policy-fallback-off.test.ts`, +`tests/routing/subagent-model-fallback-off.test.ts`, `tests/codex-integration/main-account-hard-lock-fallback-off.test.ts`, +`tests/server/fallback-routes.test.ts`, `tests/clients/client-home-unreachable.test.ts`, `tests/cli/cli-fallback.test.ts` +and `gui/tests/fallback-panel.test.tsx`. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 47615cad546..e2daf09969d 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -290,7 +290,7 @@ per-request first-party callback reads that live object; a failed write leaves i | Diagnostics/sync | `src/server/management/config-routes.ts` — `GET /api/diagnostics/project-config` reports project-level Codex config that bypasses managed routing; `POST /api/sync` re-runs catalog/config sync. The diagnostic reports the bypass; it does not rewrite the project file. | | Sidecar/shadow-call settings | `src/server/management/config-routes.ts` — `GET/PUT /api/sidecar-settings` and `GET/PUT /api/shadow-call-settings`, and `GET/PUT /api/auto-review-settings`. The auto-review settings route validates the global reviewer selector, persists its enabled/model fields with rollback before publication, then converges the catalog and returns its disposition; the Models panel keeps the server echo and displays a pending-sync notice whenever no commit is reported. Sidecar PUT accepts model and backend (web-search union: openai/anthropic/xai/gemini/exa; xAI is live through stored Grok OAuth, while Gemini/Exa remain inert until their executors ship) plus validated `webSearch.xSearch`, optional `webSearch.exaApiKey` (write/clear only — never echoed by GET or the PUT response; redact.ts strips it from logs), `webSearch.reasoning`, `vision.reasoning`, `vision.enabled`, `vision.maxDescriptionsPerTurn`, and `vision.timeoutMs`; the read and PUT-response payload reports model, backend, reasoning, enabled, the vision per-turn limit, and timeout. An effective `vision.enabled` change persists before one bounded catalog-only convergence updates the managed catalog and Codex model cache; the PUT response returns `catalogRefreshPending` for incomplete work. Identical effective values and unrelated partial writes do not converge, and no result claims client reload. `timeoutMs` is validated against the runtime integer bounds in `src/vision/timeout-bounds.ts`. Provider/OAuth credentials live in their stores; `exaApiKey` is the one sidecar-owned secret and follows the write-only contract above. Both shadow-call responses also report the resolved `sourceModels` — the prefixes the runtime actually intercepts (`src/lib/shadow-call.ts`, default `gpt-6-luna` and `gpt-5.6-luna`; the retired `gpt-5.4-mini` stays available as an explicit `sourceModels` entry for 0.144.x clients), so no client hard-codes a helper slug that a Codex release can invalidate. PUT refuses a qualified target that only the router's default-provider fallback accepts. Provider disable and delete report the target they leave behind as `dependentShadowIntercept` (`shadowInterceptProviderDependency` in `src/server/management/shadow-call-validation.ts`), and at request time an unresolvable target returns `409 intercept_target_unavailable` before any send (`src/server/responses/shadow-target-availability.ts`); it never falls back to the native source model or the default provider. | | Storage | `src/server/management/logs-usage-routes.ts` — `GET /api/storage`, `POST /api/storage/cleanup/preview` and `/api/storage/cleanup`, `GET /api/storage/trash`, `POST /api/storage/trash/restore`, and `GET/PUT /api/storage/cleanup-policy` plus `POST /api/storage/cleanup-policy/run`. `GET /api/storage/cleanup-policy/test-stream` and `GET /api/storage/trash/restore/test-stream` exist for progress-stream testing. Cleanup takes an explicit `mode`: `quarantine` moves to trash and is restorable, `permanent` is not. The caller must name the mode — there is no default that silently deletes. | -| Provider quotas and tests | `src/server/management/provider-routes.ts` — `GET /api/provider-quotas`, `POST /api/providers/test`, `POST /api/providers/test-completion` (explicit single-model completion diagnostic, [contract](provider-completion-diagnostic.md)), `GET/PUT /api/provider-context-caps`, `GET /api/provider-presets`. A quota read may be served from cache or force-refreshed; absent quota data is reported as unknown rather than as a measured zero. | +| Provider quotas and tests | `src/server/management/provider-routes.ts` — `GET /api/provider-quotas`, `POST /api/providers/test`, `POST /api/providers/test-completion` (explicit single-model completion diagnostic, [contract](provider-completion-diagnostic.md)), `GET/PUT /api/provider-context-caps`, `GET /api/provider-presets`. A quota read may be served from cache or force-refreshed; absent quota data is reported as unknown rather than as a measured zero. `GET /api/quota-pool` (`src/server/management/quota-pool-routes.ts`, loaded on demand) is the always-passive `ocx.quota-pool/1` read GOAT consumes; see [quota pool](quota-pool.md). | | Models and visibility | `src/server/management/model-routes.ts` — `GET /api/models`, `PUT /api/disabled-models`, `PUT /api/model-visibility`, `PUT /api/selected-models`, `GET/POST /api/custom-models`, `PUT /api/model-settings`. Visibility writes trigger catalog sync through the owning server path. `PUT /api/model-settings` edits one routed row's capability axes in place — context window, declared input modalities, reasoning ladder and its default — through the provider-level per-model maps the runtime already reads, so the row keeps its discovery provenance instead of being replaced by a custom model. Every field is optional and `null` clears the declaration back to the registry/catalog answer; display name is deliberately absent because `PUT /api/providers/{provider}/model-display-names` already owns it. | | Effort and fallback | `src/server/management/agent-settings-routes.ts` — `GET/PUT /api/effort-caps`, `/api/subagent-models`, `/api/subagent-model-fallback`. Caps clamp; they do not reject. | | Grok and Claude integrations | `src/server/management/agent-settings-routes.ts` — `GET /api/grok`, `PUT /api/grok/selection`, `POST /api/grok/apply`, `GET/PUT /api/claude-desktop`, `POST /api/claude-desktop/apply` (`mode`: `gateway` default for new installs, `first-party` opt-in, or legacy shapes), `GET /api/claude-desktop/status` (`mode`, `riskWarning`, `firstParty`), `GET/PUT /api/claude-code`. Gateway apply writes an external app's profile, so its status probe must read the same resolved path it writes (see [`responses.md`](transports/responses.md)); first-party apply writes only the Claude Code proxy env, see [`clients/claude-desktop.md`](clients/claude-desktop.md#desktop-modes-gateway-and-first-party). `gui/src/pages/ClaudeDesktop.tsx` renders the mode selector and sends the chosen `mode` with apply. It leads with Default model and Quick task model, which set the `opus` and `haiku` family defaults through `gui/src/pages/claude-desktop-roles.ts` (same move rules as the family lanes, which sit under a folded Advanced disclosure); the profile wire shape is unchanged. `gui/src/pages/ClaudeCode.tsx` is one page with a single Save bar; its Save body (`gui/src/pages/claude-code-save.ts`) omits `enabled`, which only the immediate connection switch writes. `PUT /api/claude-code` re-runs macOS system-env reconciliation (`src/server/system-env.ts`) whenever the body carries `systemEnv`, `authMode`, a model slot or a lever field: keys opencodex tracks as injected are refreshed or unset once the config stops producing them, and a launchd value the user set before injection is never touched. `gui/src/pages/claude-code-tier-defaults.tsx` edits `tierModels` through the Save bar's dirty/poll/revert contract. `GET /api/claude-code/model-defaults` previews local user defaults; `POST` accepts exactly a fingerprint bound to the full settings document and saved tiers (`src/claude/model-default-settings.ts`). Changed settings/tiers return 409 and existing empty/corrupt documents are refused. Apply stages private bytes, captures the actual shared file into `model-default-backups/apply-*/settings.json` under the Claude config directory, validates that capture, then publishes with an exclusive hard link. A competing destination is never replaced, even on rollback; there is a brief missing-file interval, and unsupported cross-volume/link operations have no overwrite fallback. Failed restoration returns `settings_recovery_required` with the private recovery path shown as an alert. Selected aliases receive paired `_MODEL_NAME` labels via `claudeTierDisplayName` in `src/claude/context-windows.ts`. Preview includes label additions/updates and uses an empty `after` string for removal. An existing label is managed only when it exactly matches the generated name for its current alias (an identical manually entered label is indistinguishable); custom, empty and non-string labels are preserved. Unset tiers retain both their settings model IDs and display labels; a matching generated label is removed only when an explicitly selected tier is replaced with a non-alias; no client restart, connection credential change or Desktop family edit occurs. Covered by `tests/claude-integration/claude-model-default-labels.test.ts`, `tests/claude-integration/claude-intercept-settings.test.ts`, `tests/server/model-settings-management-api.test.ts`, `gui/tests/claude-code-save.test.ts` and `gui/tests/claude-code-background-helper.test.tsx`. | diff --git a/structure/manifest.json b/structure/manifest.json index aa34393fbf4..c91007a8169 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -404,6 +404,15 @@ "src/server/" ] }, + { + "path": "data-planes/chat-reasoning-details.md", + "tier": 3, + "title": "Anthropic Thinking Signatures on the Chat Wire", + "scope": "The reasoning_details carrier that lets Chat Completions clients round-trip signed Anthropic thinking across tool steps (#7429).", + "documents": [ + "src/chat/reasoning-details.ts" + ] + }, { "path": "data-planes/protocol-paths.md", "tier": 3, @@ -714,6 +723,34 @@ "src/usage/" ] }, + { + "path": "quota-pool.md", + "tier": 5, + "title": "Quota Pool Producer", + "scope": "The passive GET /api/quota-pool read GOAT consumes (ocx.quota-pool/1): sources, window mapping, absent-not-zero, and the install identity behind homeId and accountRef.", + "documents": [ + "src/quota-pool/", + "src/server/management/quota-pool-routes.ts" + ] + }, + { + "path": "fallback-policy.md", + "tier": 3, + "title": "Fallback Policy", + "scope": "OCX-11 fallback off: what is a model or provider fallback (and what intra-pool rotation is not), scope precedence, the guarded hop sites, the fallback_refused and home_unreachable contracts, and the ocx.fallback/1 state.", + "documents": [ + "src/routing/", + "src/types/", + "src/config/", + "src/client/", + "src/server/", + "src/cli/", + "src/codex/", + "src/combos/", + "src/remote/", + "gui/" + ] + }, { "path": "pricing.md", "tier": 5, @@ -924,6 +961,13 @@ "scope": "SSH machine-link building blocks: OpenSSH argument policy, ssh_config candidates, tunnel lifecycle reducer, the private link store, and standalone pairing-grant delivery.", "documents": [ "src/link/", + "src/client/link-home.ts", + "src/client/link-parity-reporter.ts", + "src/client/runtime-restart.ts", + "src/cli/link-apply-restart.ts", + "src/cli/status-codex-settings.ts", + "src/server/index/link-management.ts", + "src/server/management/link-routes.ts", "src/lib/gui-pair-intent.ts", "src/lib/windows-owner-acl.ts", "src/server/gui-pair-delivery.ts" diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index e82d02ff5f4..7182595e80a 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -58,7 +58,7 @@ without running handlers is not covered by this forwarding mechanism. systemd unit. State and ownership resolve from those captured bytes. Each present regular file contributes SHA-256 content and bigint device, inode, size, nanosecond mtime/ctime, mode, uid and gid. The opened descriptor must be regular and match device/inode before any read. Each record is limited to 1 MiB, read in bounded chunks with growth beyond that limit refused. Descriptor stats bracket the read; a final pathname stat must agree. Symlinks, inconsistent reads, malformed state, unreadable candidates and membership drift -refuse. Canonical directory aliases remain equivalent. Lock, PID and runtime records are excluded. +refuse. Homes compare canonical path plus exact bigint `dev:ino` (`fileId`); rounded doubles merge NTFS ids above 2^53. Aliases stay equivalent. Lock, PID and runtime records are excluded. `src/cli/update-restart.ts` requires known versions and a detached POSIX target with PID-1 parent, no ownership claim and no client/sibling role. An installed record without a claim @@ -574,7 +574,7 @@ waits for its replacement to answer, exits 1 when it never did, and falls back t when the listener recorded none. A supervised process (`OCX_SERVICE=1`) never spawns and exits 1 for its supervisor. Coverage: `tests/server/restart-replacement.test.ts`, `tests/cli/cli-restart-handoff.test.ts`, `tests/server/system-restart.test.ts` and -`tests/clients/client-runtime.test.ts`. +`tests/clients/client-runtime.test.ts`. The client runtime's bound restart (OCX-6) keeps serving when its replacement fails, so it alone passes `notReady: "terminate"`: an unready replacement is stopped and `not_ready` returns only after its exit is observed (`tests/server/restart-replacement-not-ready.test.ts`; contract in [Remote Link](../remote-link.md)). A process the desktop app spawned spawns no replacement at all. The app sets `OCX_DESKTOP_SUPERVISED=1` on its sidecar; `handleStart` consumes it with the other start markers and diff --git a/structure/ops/test-sandbox-cleanup.md b/structure/ops/test-sandbox-cleanup.md index 42c7025507d..882147d6d1c 100644 --- a/structure/ops/test-sandbox-cleanup.md +++ b/structure/ops/test-sandbox-cleanup.md @@ -52,6 +52,16 @@ protected roots resolve at check time and compare case-insensitively on macOS an directory is also a protected removal tree. `tests/claude-integration/claude-config-home-isolation.test.ts` pins each layer. +The write guard arms without the preload too. Bun reads `bunfig.toml` from the current working +directory, so `bun test` started outside the repository loads no preload and keeps the real HOME. +`isTestHomeGuardArmed()` therefore also arms when the entry module (`Bun.main`) has a Bun +test-file name, and publishes `OCX_TEST_HOME_GUARD=1` so the processes it spawns inherit the arming. No +production entrypoint has such a name, and `NODE_ENV` is never consulted. An explicit +`OCX_TEST_HOME_GUARD=0` disarms, which is how a case exercises a production branch. Such a run +refuses OAuth, Codex account-pool, refresh-intent, `auth.json` and config writes instead of +sandboxing them; `tests/ci-workflows/test-home-guard-unpreloaded.test.ts` drives each one from a +cwd without `bunfig.toml` against a fake real home, and checks that a plain `bun` process still persists (#7407). + `tests/helpers/test-sandbox-cleanup.ts` exposes case-scoped lifecycle ownership: cancellation starts listener stops while owned asynchronous work settles, and repeated close/stop calls share one promise. After teardown starts only the lifecycle's own abort reason is absorbed; any other diff --git a/structure/providers/mimo-bare-calls.md b/structure/providers/mimo-bare-calls.md index 6b53209afbe..14ce8b1654c 100644 --- a/structure/providers/mimo-bare-calls.md +++ b/structure/providers/mimo-bare-calls.md @@ -27,7 +27,7 @@ in order at streaming bounds, but eligible candidate inputs survive in a separat Only detached candidate data is retained, never trailing prose: at most 128 calls, 4 MiB total and a conservative 2 MiB per-record cap. Exceeding a cap disables the entire synthetic batch; candidate traversal stops at the 129th header, including after earlier candidates were released. -disposal releases all bytes. Already released markup cannot be retracted. Buffered recovery +Disposal releases all bytes. Already released markup cannot be retracted. Buffered recovery shares eligibility and byte/count bounds without the streaming text-release limit. When a size bound releases an unresolved outer block, standalone recovery is disabled for that response so later inner headers cannot become calls. A nested header also makes later apparent diff --git a/structure/quota-pool.md b/structure/quota-pool.md new file mode 100644 index 00000000000..a9ec956f7e5 --- /dev/null +++ b/structure/quota-pool.md @@ -0,0 +1,87 @@ +# Quota Pool Producer + +`GET /api/quota-pool` serves the GOAT contract `ocx.quota-pool/1`: one provider-neutral view of +every account quota this Home already holds. GOAT owns the decoder +(`packages/contracts/src/quotaPool.ts` in goat-si/goat); OpenCodex is the producer, and +`tests/helpers/quota-pool-schema.ts` mirrors the contract strictly (strict objects, no `null`). + +## Surfaces + +- Home: `GET /api/quota-pool` on the management API, under the same management authorization as + the neighbouring reads (`src/server/management/quota-pool-routes.ts`, loaded on demand by + `src/server/management-api.ts`). Only GET is routed. Any query, including `refresh=1`, is ignored: + the read has no active form. +- Remote Link Child: `/api/quota-pool` is in `LINK_MANAGEMENT_READ_PATHS`, so the Child's dashboard + reaches it at `/api/machine/hub-relay/api/quota-pool`; the Home serves it through + `/v1/link-management` with the link key. The shared refresh-key rule still refuses a link read + that carries `refresh`, `reload`, `force` or `quota`. +- Hub client: the hub relay forwards `/api/machine/hub-relay/api/*` to the hub's management API + with the caller's management credential, so the same path reaches the hub's read. + +## Passive by construction + +`readQuotaPool` (`src/quota-pool/assemble.ts`) runs the whole assembly inside `runPassiveRead`, for +every caller, and reads only caches other paths filled: + +- Codex pool accounts: the passive `listCodexAuthAccountsSnapshot` (stored `StoredAccountQuota` per + account and the cached main-account reading), with no WHAM probe, token refresh or native-main + claim. Each account's window `source` is `header` when its newest quota-history sample came from + response headers and `measured` otherwise (`latestCodexQuotaObservationSource`, a read that never + prunes or clears history). +- Provider reports: `heldProviderQuotaReports`, the reports the Home last published. They become + the provider's `pooled` windows; an aggregated Codex pool report is `estimated`, an in-band + observed report is `header`, any other report is `measured`. +- Per-account OAuth quota (Anthropic instances, Kiro, Antigravity, xAI, Cursor, Kimi, Command Code, + Devin, DSH, Meta): the last-good per-account cache. A current `QuotaFailureCode` becomes + `failure`. +- Claude CLI: `peekClaudeUsageObservation` in `src/adapters/claude-cli/usage-admission.ts`, which + reads the admission snapshot from memory or its file without resolving the sign-in. A parsed CLI + limit refusal is a `custom` window `refusal` with `exhausted: true` and source `estimated`. + +`tests/server/quota-pool-relay.test.ts` reads the route on the Home, over the link relay and over +the hub relay against a pool account with an expired token, and fails on any fetch, process spawn or +file write inside the read and on any change to the Home's files. + +## Window mapping + +Kinds come from the fields the existing parsers stored (`parseUpstreamQuotaHeaders`, +`parseUsageQuota`): the Codex short window is `burst_5h` up to 6 hours (`daily` up to 36 hours, +`custom` beyond) and carries `windowSeconds` only when upstream stated it; provider `fiveHour*` is +`burst_5h`; weekly and monthly map by name; Kiro plan credits and USD credits are `credits` windows +with `unit`. A window an upstream proved to be model-scoped (`scope: "model"`) carries +`scopeModel` and never makes the whole account `exhausted`; an exact-model diagnostic row without +that proof is dropped, because unscoped it would gate every model. `rejected` evidence becomes +`exhausted: true`. + +Absent is unknown: a window with no reading is omitted, never reported as 0%, and a window whose +reset has passed is omitted too. Optional fields are left out instead of set to `null`. Account +`state` is `reauth`, then `paused`, then `exhausted` (any unscoped exhausted window), then +`unknown` (no windows), else `available`. `pooled.nextResetAt` is the earliest unscoped reset among +the pooled windows. + +## Identity + +`src/quota-pool/identity.ts` keeps `/quota-pool-identity.json` (owner-only): +`homeId` (`home_` + 32 hex, the Home's stable instance id, not a hostname) and a 256-bit secret. +The server creates it in `acquireServerBackgroundLifecycle` before it serves; the read only reads it +and answers 503 `quota_pool_identity_unavailable` instead of creating it. Creation is exclusive and +never follows a link: the secret is written and flushed to a private temp file and hard-linked into +place (an in-place exclusive create where hard links are unavailable), so two servers starting on +one fresh home adopt the same identity. A loser that finds a file at the path, including one that +appears between its own absence check and existence check, re-reads it for up to 500 ms and adopts +it once it is valid; an empty or partial file is never returned or memoized. A symlink, or a file +that stays malformed past that bound, is refused and left untouched rather than overwritten. + +`homeId` is also the Home instance id other peers see: `GET /healthz` publishes it as `instanceId` +and `GET /v1/models` names it in `x-opencodex-served-by` (`src/remote/served-by.ts`, see +[Runtime](runtime.md)). It is random and carries nothing derivable from the machine; the secret is +never published. + +`accountRef` is `acct_` + base64url of the first 128 bits of HMAC-SHA256(secret, +`ocx.quota-pool/1` NUL provider NUL accountId). It is stable across restarts, distinct per provider, +and never contains an email, a raw account id or a token; without the secret a recipient cannot +confirm a guessed identity against it. Deleting the file rotates `homeId` and every reference +together when the proxy next starts; a running proxy keeps the identity it already read (and keeps +publishing that `homeId` as its instance id). `label` is the account's existing log label (`main`, `p`, `o`), never an +email or alias. `producer.role` is `runtimeRole` (`standalone` when unset) and `producer.version` +is the package version. `driver` and `pooled.headroom` are not produced yet. diff --git a/structure/remote-link.md b/structure/remote-link.md index ed0509bee6e..69daf34d44c 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -4,7 +4,9 @@ `src/link/ssh-argv.ts` builds every OpenSSH argument vector. All commands run with BatchMode and trust only the link known_hosts file, keyed by `HostKeyAlias=`: the global file is disabled with `GlobalKnownHostsFile=none`, and `KnownHostsCommand=none`, `VerifyHostKeyDNS=no` and `CheckHostIP=no` shut out every other source of host-key trust. Command-line options take precedence over `~/.ssh/config`, so a user config cannot re-enable them. `HashKnownHosts=no` preserves alias-prefixed records in these private files without changing the user's normal known_hosts file. Tunnel and exec commands use `StrictHostKeyChecking=yes`; only the probe uses `accept-new`, against an empty temporary file, so an offered key can be shown before it is trusted. The known_hosts path must be absolute and free of ssh expansion syntax. Forwards bind 127.0.0.1 on both ends, and aliases that could be parsed as options are refused. -Every remote `ocx` call goes through `remoteOcxArgv`, a quoted POSIX `sh -c` script. It tries executable Desktop and system candidates before generic PATH shims: `$HOME/.opencodex-desktop/bin/ocx`, `/Applications/OpenCodex.app/Contents/MacOS/ocx`, `/usr/bin/ocx`, then inherited PATH plus Bun/local/Homebrew directories. Each candidate must successfully report `--version`; an unrunnable stale shim is skipped. Arguments remain quoted once. Windows/MSYS/Cygwin detection exits 125 (`remote_platform_unsupported`); exhaustion exits 127 (`remote_ocx_missing`). A Windows SSH shell without POSIX sh is unsupported as well, though it may fail before this detection runs. Coverage: `tests/clients/link-ssh-argv-discovery.test.ts`. +Every remote `ocx` call goes through `remoteOcxArgv`, a quoted POSIX `sh -c` script. It tries executable Desktop and system candidates before generic PATH shims: `$HOME/.opencodex-desktop/bin/ocx`, `/Applications/OpenCodex.app/Contents/MacOS/ocx`, `/usr/bin/ocx`, then inherited PATH plus Bun/local/Homebrew directories. Each candidate must successfully report `--version`; an unrunnable stale shim is skipped. Arguments remain quoted once. Windows/MSYS/Cygwin detection exits 125 (`remote_platform_unsupported`); exhaustion exits 127 (`remote_ocx_missing`). Coverage: `tests/clients/link-ssh-argv-discovery.test.ts`. + +`src/link/remote-platform.ts` recognises a Windows Child from one remote result: exit 125, a MINGW/MSYS/CYGWIN/Windows `uname -s`, or the cmd.exe or PowerShell "is not recognized" refusal of a POSIX command when the SSH shell has no POSIX sh. Any other non-transport failure (not 0, 125, 127 or 255) gets one locale-independent follow-up, the fixed `cmd /c echo %OS%` (`WINDOWS_OS_PROBE`) against the same strict host key, which prints `Windows_NT` under cmd and PowerShell in any display language. Enrollment maps all three to `remote_platform_unsupported` at probe or host confirmation, before host trust, key issuance or a tunnel, and the refusal names Hub mode (`ocx hub invite`, `ocx connect`). `ocx link cleanup` (`src/cli/link-cleanup.ts`) applies the same classifier to existing Home-initiated records over their pinned SSH trust and only reports: a Windows Child gets the recovery order disconnect on the Child, forced revoke on the Home, then Hub mode, because an ordinary revoke's remote `ocx disconnect` goes through the POSIX prelude that exits 125. Coverage: `tests/cli/cli-link-cleanup.test.ts`, `tests/server/link-enrollment.test.ts`. `src/link/ssh-config.ts` lists host candidates from `~/.ssh/config`. Arguments are split with the rules of OpenSSH's `argv_split`. Pattern hosts, `Match` blocks and aliases that fail the alias check produce no candidates, and only top-level `Include` directives are followed, because an include inside a `Host` or `Match` block is conditional. A candidate is an offer, not trust. @@ -54,11 +56,11 @@ A connected Child answers `GET` and `HEAD /api/link/status` on its own listener `src/link/ssh-runner.ts` runs every OpenSSH and `ssh-keygen` argv without a shell and caps captured output. On POSIX it spawns them with the inherited PATH plus `/opt/homebrew/bin`, `/usr/local/bin`, `$HOME/.bun/bin` and `$HOME/.local/bin` appended once, so a ProxyCommand helper resolves inside a desktop sidecar that inherited a minimal PATH; Windows keeps the inherited environment. `src/link/supervisor.ts` keeps one `ssh -R` child per hub-initiated link, drives it with the tunnel reducer, coalesces reloads without dropping a later request, reconciles unowned `link:` API keys at startup, and stops children before the hub-link listener on shutdown. Automatic startup begins the supervisor only after the hub-link listener owns its socket; a bind failure must never leave a reverse forward targeting the persisted port. It reaps a leftover tunnel only when Linux `/proc//cmdline` matches the recorded argv exactly; on other platforms a leftover is reported, never killed. `src/link/status-projection.ts` builds the status document the dashboard and `ocx link status` read, including persisted compensation failures, and Home enrollment verifies a fresh post-restart key-authenticated catalog as described below. -Applying a link probes the host key into a temporary file, waits for the operator to confirm the fingerprint, issues a data key, records the link, starts the tunnel and runs the client's `ocx connect --link --key-stdin` over SSH with the key on standard input. A failed step revokes the new key first and removes the record only after revocation succeeds; if revocation fails the `src/link/` state persists a `compensation_failed` marker, and status reports the failed compensation after restart. A listener that is not listening fails the request instead of handing out a key. Removing a link stops its tunnel, disconnects the client, revokes the key and deletes the record; a failed client disconnect restarts the tunnel and keeps the record and key unless removal is forced. `src/cli/link.ts` provides `ocx link probe|add|port|issue|revoke|status|reconnect`. `ocx link revoke` is idempotent: a `404 link_not_found` answer exits 0, because removal revokes the key before it deletes the record, so a missing record means the key is already gone. A 404 without that code still fails. +Applying a link probes the host key into a temporary file, waits for the operator to confirm the fingerprint, issues a data key, records the link, starts the tunnel and runs the client's `ocx connect --link --key-stdin` over SSH with the key on standard input. A failed step revokes the new key first and removes the record only after revocation succeeds; if revocation fails the `src/link/` state persists a `compensation_failed` marker, and status reports the failed compensation after restart. A listener that is not listening fails the request instead of handing out a key. Removing a link stops its tunnel, disconnects the client, revokes the key and deletes the record; a failed client disconnect restarts the tunnel and keeps the record and key unless removal is forced. `src/cli/link.ts` provides `ocx link probe|add|port|issue|revoke|status|reconnect|cleanup`; `cleanup` is local and read-only (see `src/cli/link-cleanup.ts` above). `ocx link revoke` is idempotent: a `404 link_not_found` answer exits 0, because removal revokes the key before it deletes the record, so a missing record means the key is already gone. A 404 without that code still fails. ## Client link transport -The Child relay requires a positive `connected()` verdict from `src/client/link-tunnel.ts` before every fetch. Before the first keyed readiness probe and after a tunnel restart, the supervisor asynchronously proves the exact `127.0.0.1:` LISTEN socket belongs to its SSH child; an adopted process also needs matching pidfile argv and start time. `src/server/port-reclaim.ts` uses Linux `/proc/net/tcp{,6}` plus the expected PID's `/proc//fd` socket symlinks without external tools, macOS `lsof` and Windows `netstat` with bounded asynchronous execution. Every key-bearing probe and relayed request makes a fresh bounded asynchronous owner lookup; an adopted process also has its current argv and start time rechecked on every admission. An unreadable or timed-out identity denies only that admission and is retried; a confirmed mismatch releases the adopted PID without signalling it and lets the next tick start a fresh tunnel. An unknown socket-owner lookup likewise denies the current admission. A missing supervisor, or a failed or stopped tunnel, returns a retryable 503 without sending the link key or body to the persisted loopback port. A Home-initiated Child is the exception: the Home owns that link's `ssh -R` forward, so the Child has no sidecar, no supervisor and no SSH process whose socket it could prove. A join writes `/link/child-initiated.json` (the link id, mode 0600) beside the sidecar and removes it with the sidecar, so a Child-initiated link that lost its sidecar is recognised (`isChildInitiatedLink` in `src/client/link-state.ts`; an unreadable marker counts as present) and gets no gate. Only a link-mode runtime with neither the sidecar nor a marker for its link id is treated as Home-initiated: `src/client/runtime.ts` passes it `HOME_INITIATED_LINK_TUNNEL` (`src/client/link-relay.ts`), which omits local process ownership proof and reconnect holding but still uses the connection-bound relay authentication below. A refused connection answers 503. A held request rechecks the verdict after its wait and before each retry. +The Child relay requires a positive `connected()` verdict from `src/client/link-tunnel.ts` before every fetch. Before the first keyed readiness probe and after a tunnel restart, the supervisor asynchronously proves the exact `127.0.0.1:` LISTEN socket belongs to its SSH child; an adopted process also needs matching pidfile argv and start time. `src/server/port-reclaim.ts` uses Linux `/proc/net/tcp{,6}` plus the expected PID's `/proc//fd` socket symlinks without external tools, macOS `lsof` and Windows `netstat` with bounded asynchronous execution. Every key-bearing probe and relayed request makes a fresh bounded asynchronous owner lookup; an adopted process also has its current argv and start time rechecked on every admission. An unreadable or timed-out identity denies only that admission and is retried; a confirmed mismatch releases the adopted PID without signalling it and lets the next tick start a fresh tunnel. An unknown socket-owner lookup likewise denies the current admission. A missing supervisor, or a failed or stopped tunnel, returns a retryable 503 without sending the link key or body to the persisted loopback port; its body carries `code: "home_unreachable"` under the [fallback policy](fallback-policy.md#hub-down). A Home-initiated Child is the exception: the Home owns that link's `ssh -R` forward, so the Child has no sidecar, no supervisor and no SSH process whose socket it could prove. A join writes `/link/child-initiated.json` (the link id, mode 0600) beside the sidecar and removes it with the sidecar, so a Child-initiated link that lost its sidecar is recognised (`isChildInitiatedLink` in `src/client/link-state.ts`; an unreadable marker counts as present) and gets no gate. Only a link-mode runtime with neither the sidecar nor a marker for its link id is treated as Home-initiated: `src/client/runtime.ts` passes it `HOME_INITIATED_LINK_TUNNEL` (`src/client/link-relay.ts`), which omits local process ownership proof and reconnect holding but still uses the connection-bound relay authentication below. A refused connection answers 503. A held request rechecks the verdict after its wait and before each retry. The listener can change between an ownership check and the TCP connect. The data relay now authenticates that physical connection before sending the key or body (see [Connection-bound relay authentication](#connection-bound-relay-authentication)); this does not change the separate readiness/enrollment probes. A private Unix-socket forward could additionally remove competing TCP listeners from those probe paths. @@ -78,7 +80,16 @@ Enrollment cancellation in `src/client/connect.ts` reaches actual network reques ## Connection-bound relay authentication -The production relay's complete-send boundary is `src/client/link-relay-transport.ts`. A dedicated HTTP agent opens exactly one TCP socket. Before any admission header or body is sent, a credential-free, five-second challenge proves the stored link/key identity using direction-separated HMACs from `src/link/relay-auth.ts`. The data request uses that same authenticated socket, with a one-use session nonce; a closed socket cannot be silently replaced. A peer lacking this protocol is refused with an upgrade/re-link error, never retried through an unauthenticated send. Home-initiated reverse tunnels use the same check. `LinkRelayDeps.fetchImpl` is a trusted complete-send test seam, not a user setting; production supplies no substitute. Bun's Node HTTP compatibility is confined to this leaf so streamed request caps, backpressure, credential replacement, decompression, response status, caller cancellation and SSE limits remain owned by the existing relay. +The production relay's complete-send boundary is `src/client/link-relay-transport.ts`. A dedicated HTTP agent opens exactly one TCP socket. Before any admission header or body is sent, a credential-free, five-second challenge proves the stored link/key identity using direction-separated HMACs from `src/link/relay-auth.ts`. The data request uses that same authenticated socket, with a one-use session nonce; a closed socket cannot be silently replaced. A peer lacking this protocol is refused with an upgrade/re-link error, never retried through an unauthenticated send. That refusal is a 503 with `code: "link_relay_auth_failed"` (`src/client/link-relay.ts`), never `home_unreachable`: the peer answered, so it is not a Home outage and must not open device-local fallback ([fallback policy](fallback-policy.md#hub-down)). Home-initiated reverse tunnels use the same check. `LinkRelayDeps.fetchImpl` is a trusted complete-send test seam, not a user setting; production supplies no substitute. Bun's Node HTTP compatibility is confined to this leaf so streamed request caps, backpressure, credential replacement, decompression, response status, caller cancellation and SSE limits remain owned by the existing relay. +A Home advertises `LINK_RELAY_AUTH_VERSION` as `relay-auth/`, and the management relay as `link-management-relay`, in its `/readyz` `features` ([Runtime](runtime.md), `src/remote/features.ts`), so a peer can detect a relay-auth mismatch without comparing release versions. + +The relay copies the Home's response headers except `content-encoding`, `content-length` and hop-by-hop headers, so the Home's `x-opencodex-served-by` on `GET /v1/models` reaches the Child's caller unchanged and the Child never stamps its own; `tests/server/served-by-header.test.ts` relays through the Home's real link listener and through a fake Home naming another id ([Runtime](runtime.md)). + +### Home and Child version report + +The Home learns each Child's version only from what the link already carries; it opens no SSH session for it. `src/link/peer-report.ts` owns the report. `fetchBoundLinkRelay` adds `x-opencodex-link-peer-version` (the package version) and `x-opencodex-link-peer-features` (the Child build's `buildPeerFeatures()`) to the credential-free relay-auth challenge. A Home older than the report ignores both headers, and a Child older than 2.91.0 sends neither. `src/server/index/link-relay-sessions.ts` records the report into the listener lifecycle's in-memory `LinkPeerObservations` only after the caller proof verifies, so an observation is bound to the link key; it is the Child's own claim, used for display and never for admission. A challenge for a link and key pair this Home issued that names another relay-auth version cannot be verified and is refused as before, but it is recorded as a refusal so status can name the mismatch. Observations are bounded to 256 links and start empty after a Home restart. + +`src/link/compatibility.ts` publishes `LINK_PROTOCOL_RELEASES`, the first release of each link protocol part (`relay-auth/2` 2.69.0, `link-management-relay` 2.84.0, `link-peer-report/1` 2.91.0), and `linkPeerStatus` turns an observation into `compatible`, `degraded` (the Child reports a feature the Home does not serve), `incompatible` (a refused relay-auth version newer than the last verified challenge) or `unknown` (nothing observed, or a reporting Child whose version or feature list was missing or rejected). Only a Child that sent neither report header gets `versionRange` from its relay-auth release up to the report's release. `GET /api/link/status?compatibility=1` adds `local` and, on a Home, a `peer` block on every link (`attachLinkPeers` in `src/link/status-projection.ts`); without the query the document stays the exact K16 shape an older `ocx link status` validates key by key. The CLI sends the query and accepts either shape. Home `/readyz` advertises the report as `link-peer-report/`. Coverage: `tests/clients/link-peer-compatibility.test.ts` (verdicts, header parsing, a real Home lifecycle against a current and a pre-report Child challenge, the CLI round trip) and `tests/server/link-management-routes.test.ts` (the opt-in query). `src/server/index/link-relay-sessions.ts` owns a per-listener reservation book. A valid caller proof reserves its peer socket for five seconds (at most 256 outstanding reservations); the data request must consume that nonce on the same peer address/port while its link and credential remain valid. Current and unexpired pending-rotation fingerprints use the same expiry rule as ordinary API-key admission, and the server signs with the fingerprint that matched. The nonce is not an alternative data-plane credential: dispatch still applies its ordinary key and route policy. Removal, rotation expiry, abort and commit are observed anew at dispatch. Replays and a different socket are refused. @@ -112,7 +123,7 @@ Client-side status supplies initiation identity from `src/client/link-status.ts` A link Child relays every model request to its Home, so everything the Home's proxy applies per request (effort caps, the sub-agent roster and fallback, the injection prompt, sidecar and compaction routing, the catalog) already follows the Home. Two things did not: the Codex settings the injector writes into the Child's own `config.toml`, and every management read. Both now cross the link, and nothing else does. -`src/link/management-routes.ts` is the shared, default-deny table. A Home management path is readable over the link only when it is listed in `LINK_MANAGEMENT_READ_PATHS` (the reads the dashboard polls except the model list, the header quota strip, and the account, usage, catalog and preset reads the CLI makes; combos and routing profiles stay out until their projections are reviewed for decision-endpoint configuration), the method is GET or HEAD, and the query carries no `refresh`, `reload`, `force` or `quota` key, which would make the Home refresh credentials or probe upstream quota. `/api/models`, `/api/model-presets`, `/api/subagent-models` and `/api/subagent-model-fallback` are absent because they run live provider discovery and can persist an initial model selection; the Child's Codex picker already shows the Home's catalog, and the preset preview only decides a write a Child cannot make. `/api/injection-model` stays readable because the Child's dashboard and `ocx effort` show the Home's delegation model and effort from it, Every link read is a passive read (`runPassiveRead` in `src/lib/passive-read.ts`, entered by `src/server/index/link-management.ts`): it shows what the Home already knows and does not make the Home act on providers, accounts, processes or files. Inside it `fetchAllModels` and the management model rows return no routed models, so `/api/injection-model` and `/api/sidecar-settings` list native options plus the persisted picks; `fetchProviderQuotaReports` returns the reports the Home holds, without a probe, token refresh, account snapshot or publication into routing evidence; the Codex account list uses the cached main-account info and pool quota, without a token refresh, WHAM probe or native-main claim; the startup-health diagnostic, the Codex runtime version probes and the bundled-catalog refresh (which runs the Codex binary and persists the runtime) are not started, and a native-main fence raised for unknown service ownership is reported as it stands instead of re-inspected (a successful re-inspection activates the native-main lifecycle and its recovery); `hardenConfigDir`, `hardenExistingSecret` and `backupInvalidConfig` do nothing, so reading a credential store neither tightens its permissions nor backs up a malformed copy; and memory-budget enforcement triggered by a read (the usage caches publish and enforce) demotes only stores registered as `passiveEvictable` in `src/lib/app-owned-memory-stores.ts`, whose eviction only releases memory (today the two usage caches), because demoting other stores can spill a continuation to disk or rewrite a replay snapshot; the rest of the budget is enforced by the Home's own next enforcement. The Home's own dashboard and traffic keep those caches current. `tests/server/link-management-relay.test.ts` reads every allowlisted path, GET and HEAD, against a Home with a pending provider, a quota-probed provider, a canonical OpenAI provider, a pool account with an expired token, a malformed OAuth store and a native-main ownership fence. Inside a passive read it records calls to `fetch`; `child_process` `execFile`, `execFileSync`, `spawn`, `spawnSync`, `exec` and `execSync`; `Bun.spawn`, `Bun.spawnSync` and `Bun.write`; the `node:fs` writers `writeFileSync`, `appendFileSync`, `copyFileSync`, `renameSync`, `chmodSync`, `mkdirSync`, `rmSync`, `unlinkSync`, `writeSync`, `ftruncateSync`, `truncateSync`, `linkSync`, `symlinkSync`, `utimesSync` and `openSync` for writing; and the matching `node:fs/promises` writers, and asserts there are none. Writes through other APIs (callback `fs`, file handles, Bun file writers, SQLite) are caught only as file changes: the test compares the size, mtime and mode of every file under the Home's config and Codex homes before and after the reads, along with the config file bytes and the live config, and checks that the ownership fence was not re-probed. A separate case reads `/api/usage` against a ledger row, a persisted Antigravity replay session, an undeclared cache and a continuation store far over the memory budget, waits past the replay snapshot debounce, and checks that enforcement ran without demoting any of them, while the Home's own enforcement demotes all three. The same reads made by the Home then back up the store, probe the quota and the account, re-probe ownership and decide the pending provider, which proves the fixture is live. Work a read schedules for later is still attributed to the read when it runs; the test waits past the responses snapshot debounce (2 s) and checks again after the Home reads, but it cannot observe work delayed beyond that. `/api/logs` and `/api/diagnostics/project-config` are deliberately absent: the first carries request detail, the second describes files on the Home. The Home's hub-link listener admits `/v1/link-management/` and `/v1/link-management/codex-settings` (`linkManagementRouteAllowed`) in addition to the data-plane table; the Child's own data-plane ingress keeps the data-plane table, so a local caller on the Child cannot reach these reads without the Child's dashboard session. +`src/link/management-routes.ts` is the shared, default-deny table. A Home management path is readable over the link only when it is listed in `LINK_MANAGEMENT_READ_PATHS` (the reads the dashboard polls except the model list, the header quota strip, the passive `/api/quota-pool` read GOAT consumes ([quota pool](quota-pool.md)), and the account, usage, catalog and preset reads the CLI makes; combos and routing profiles stay out until their projections are reviewed for decision-endpoint configuration), the method is GET or HEAD, and the query carries no `refresh`, `reload`, `force` or `quota` key, which would make the Home refresh credentials or probe upstream quota. `/api/models`, `/api/model-presets`, `/api/subagent-models` and `/api/subagent-model-fallback` are absent because they run live provider discovery and can persist an initial model selection; the Child's Codex picker already shows the Home's catalog, and the preset preview only decides a write a Child cannot make. `/api/injection-model` stays readable because the Child's dashboard and `ocx effort` show the Home's delegation model and effort from it, Every link read is a passive read (`runPassiveRead` in `src/lib/passive-read.ts`, entered by `src/server/index/link-management.ts`): it shows what the Home already knows and does not make the Home act on providers, accounts, processes or files. Inside it `fetchAllModels` and the management model rows return no routed models, so `/api/injection-model` and `/api/sidecar-settings` list native options plus the persisted picks; `fetchProviderQuotaReports` returns the reports the Home holds, without a probe, token refresh, account snapshot or publication into routing evidence; the Codex account list uses the cached main-account info and pool quota, without a token refresh, WHAM probe or native-main claim; the startup-health diagnostic, the Codex runtime version probes and the bundled-catalog refresh (which runs the Codex binary and persists the runtime) are not started, and a native-main fence raised for unknown service ownership is reported as it stands instead of re-inspected (a successful re-inspection activates the native-main lifecycle and its recovery); `hardenConfigDir`, `hardenExistingSecret` and `backupInvalidConfig` do nothing, so reading a credential store neither tightens its permissions nor backs up a malformed copy; and memory-budget enforcement triggered by a read (the usage caches publish and enforce) demotes only stores registered as `passiveEvictable` in `src/lib/app-owned-memory-stores.ts`, whose eviction only releases memory (today the two usage caches), because demoting other stores can spill a continuation to disk or rewrite a replay snapshot; the rest of the budget is enforced by the Home's own next enforcement. The Home's own dashboard and traffic keep those caches current. `tests/server/link-management-relay.test.ts` reads every allowlisted path, GET and HEAD, against a Home with a pending provider, a quota-probed provider, a canonical OpenAI provider, a pool account with an expired token, a malformed OAuth store and a native-main ownership fence. Inside a passive read it records calls to `fetch`; `child_process` `execFile`, `execFileSync`, `spawn`, `spawnSync`, `exec` and `execSync`; `Bun.spawn`, `Bun.spawnSync` and `Bun.write`; the `node:fs` writers `writeFileSync`, `appendFileSync`, `copyFileSync`, `renameSync`, `chmodSync`, `mkdirSync`, `rmSync`, `unlinkSync`, `writeSync`, `ftruncateSync`, `truncateSync`, `linkSync`, `symlinkSync`, `utimesSync` and `openSync` for writing; and the matching `node:fs/promises` writers, and asserts there are none. Writes through other APIs (callback `fs`, file handles, Bun file writers, SQLite) are caught only as file changes: the test compares the size, mtime and mode of every file under the Home's config and Codex homes before and after the reads, along with the config file bytes and the live config, and checks that the ownership fence was not re-probed. A separate case reads `/api/usage` against a ledger row, a persisted Antigravity replay session, an undeclared cache and a continuation store far over the memory budget, waits past the replay snapshot debounce, and checks that enforcement ran without demoting any of them, while the Home's own enforcement demotes all three. The same reads made by the Home then back up the store, probe the quota and the account, re-probe ownership and decide the pending provider, which proves the fixture is live. Work a read schedules for later is still attributed to the read when it runs; the test waits past the responses snapshot debounce (2 s) and checks again after the Home reads, but it cannot observe work delayed beyond that. `/api/logs` and `/api/diagnostics/project-config` are deliberately absent: the first carries request detail, the second describes files on the Home. The Home's hub-link listener admits `/v1/link-management/` and `/v1/link-management/codex-settings` (`linkManagementRouteAllowed`) in addition to the data-plane table; the Child's own data-plane ingress keeps the data-plane table, so a local caller on the Child cannot reach these reads without the Child's dashboard session. On the Home, `src/server/index/link-management.ts` runs after the link-key admission in `src/server/index/serve-options.ts`. It builds a fresh loopback request with no caller header, cookie, Origin or credential and no principal, so no consent- or capability-gated branch is reachable, calls the management handler, and returns only content headers with `Cache-Control: no-store`. The relayed projections are the dashboard's own: provider and key-pool rows report presence and masked identities, never a credential value. @@ -123,3 +134,11 @@ On the Child, the dashboard's shared plane is the listener's `/api/machine/hub-r `src/link/codex-settings.ts` owns the Codex settings document. It carries exactly the OpenCodex config fields the injection plan reads that are policy rather than listener facts: `fastMode`, `multiAgentMode`, `webSearchSidecar.enabled`, and the effective managed sub-agent defaults (`syncCodexSubagentDefaults` with `injectionModel` and `injectionEffort`). Root `model_reasoning_effort` and the `desktop.*` picker keys are Codex app settings OpenCodex writes on no machine, so they are not part of it. Port, hostname, the Desktop authless and client compaction routing forms, and resume history stay the Child's because they describe how the Child's Codex reaches the Child. The document rides on its own read rather than in the catalog because the catalog bytes are written verbatim to `model_catalog_json`. The parse is strict. `syncConnectedClient` and link-mode connect in `src/client/connect.ts` overlay it on the Child's config before injection. A fresh read is recorded in `/link/home-codex-settings.json` (0600, keyed by link id, removed on disconnect) only after the injection that applies it succeeds, because `ocx status` reads the record as applied; connect with `--no-sync` records nothing. A failed read reuses that record, and a Home without the route leaves the Child's local values and says so. `ocx sync` and `ocx status` list the applied values; the record's host name is also how a Home-initiated Child, which has no sidecar alias, names its Home. Coverage: `tests/server/link-management-relay.test.ts` (the real hub-link listener: admission, refusals, masked projections, the settings document), `tests/clients/client-machine-listener.test.ts` (the Child relay and session bootstrap), `tests/clients/link-home-settings.test.ts` (parse, overlay, injection and record fallback) and `tests/cli/cli-link-child.test.ts` (the CLI reads, refusals and Home wording). + +`src/link/codex-settings.ts` also owns `appliedCodexSettingsDigest` (OCX-6): lowercase hex SHA-256 of compact JSON written in a fixed key order (`version`, then `settings` with `fastMode`, `multiAgentMode`, `webSearchSidecarEnabled`, `subagentDefaults` as `null` or `{model, reasoningEffort}`). It is a published contract that GOAT recomputes byte for byte, so it is serialized by name, not by a canonicalizer, and `tests/clients/link-codex-settings-digest.test.ts` pins two golden digests computed outside the code. The Child's record carries `appliedAt`, stamped when an apply writes a different digest and kept when the same digest is re-applied; a record from before OCX-6 reads its `fetchedAt`. Before an apply writes different settings, `withdrawChangingHomeCodexSettingsRecord` removes the old record, so a failed injection or record write reports no digest instead of the previous one. A Home reports the digest of the document it serves right now, which is what P6 compares a Child against. A failed record write is an error on `ocx sync` and a warning after an enrollment connect has committed (the committed connection is never rolled back for it). Every apply, `ocx sync` and the link branch of `connect`, holds the apply lock (`withLinkApply`: the lifecycle lock primitive on its own path under `/link/`, outermost, `busy_timeout = 0`) from the Home read through injection to the record write, so a record always names what its own injection wrote. L cannot be held across injection because injection takes N and the order is N -> L -> C. Coverage: `tests/clients/link-apply-record.test.ts`. + +`ocx status --json` reports `appliedCodexSettingsDigest` on every device (a Child's record, or a Home's own projection, which is what it serves), and on a Child `lastApplyAt` and `runtimeStartedAt` as epoch ms (`src/cli/status-codex-settings.ts`). The client runtime's `/healthz` reports `startedAt`. After `ocx sync` records an apply, `src/cli/link-apply-restart.ts` restarts the client runtime through `handleProxyRestart`, the `ocx restart` path, only when the live client runtime started before `appliedAt` or reports no start; its start callback starts nothing unless an accepted restart left no runtime, which takes the ordinary recovery start. + +The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. + +The Home learns a Child's apply from the Child (`src/link/child-parity.ts`, `src/client/link-parity-reporter.ts`). The linked client runtime POSTs `{linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest, sequence}` to `/v1/link-management/child-parity` through the connection-bound relay, shortly after start with backoff and then once a minute; a 404 stops it. On the Home the hub-link listener admits exactly that POST beside the read table, and `src/server/index/link-management.ts` handles it before the passive-read dispatch: the admitting key must own the named link, the body is capped at 1 KiB and 5 s, the parse is strict, and the only effect is an in-memory entry pruned to current links. Entries are ordered by `runtimeStartedAt`, then by `sequence` (process-wide, so a rollback keeps counting), and an older report is ignored, so a late POST from a replaced runtime or an earlier send never moves the row backward. `GET /api/link/status` and `ocx link status --json` carry `lastApplyAt`, `runtimeStartedAt`, `appliedCodexSettingsDigest` and `reportedAt` (epoch ms) on every link row, null without a report; the CLI accepts their absence from an older runtime. Coverage: `tests/server/link-child-parity.test.ts`, `tests/clients/link-parity-reporter.test.ts`. diff --git a/structure/runtime.md b/structure/runtime.md index aedb847931a..5103c022457 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -61,6 +61,8 @@ The [CLI management contract](cli-management.md#cli-readiness-diagnostics) owns `ocx stop --json` is a reporting layer over the unchanged stop path. `src/cli/index.ts` threads a `StopRunRecord` through the existing receipt, drain, respawn-verification and restore flow, and `src/cli/stop-report.ts` maps the recorded facts plus the signals that already pick the exit code into one versioned document (`schema: "ocx-stop/1"`). With `--json` the human lines print on stderr and stdout carries only that document; exit codes 0/1/79/80 cross the process boundary unchanged. The desktop's opt-in guarded stop adds exact approved PID, endpoint, home, CLI version and compatibility-token expectations; plain `ocx stop` retains its ordinary behavior. Under the ownership mutation lease, `src/cli/stop-approval.ts` re-resolves them and `src/service/guarded-manager-target.ts` binds any installed manager's process tree to the approved PID before a stop action. A pre-action mismatch returns `approval-changed` without stopping, and the summary carries `detail` naming the single guard fact that failed — live PID/port/hostname/CLI drift, an unverifiable ownership claim, a blocked takeover, or the compatibility fingerprint — so a transient re-probe can be told from genuine drift in the caller's log. Immediately before any manager stop or direct PID signal, the CLI rechecks the same manager identity or proven absence; a change also returns `approval-changed`. A non-OpenCodex process replacing the OS job in the remaining instant before the manager command is a residual same-user risk outside the ownership lease. The guarded path stops the bound manager, or the approved PID directly when managers are proven absent, then uses a five-second shared deadline to verify PID exit, port availability and definitive endpoint absence. Only after settlement does a read-only manager probe require launchd `not-loaded` in both domains or systemd `inactive` with `MainPID=0`; the state is checked again before a success summary. Timeout, active or unreadable manager state returns terminal `manager-still-active`, even if the endpoint briefly refuses. Windows managers bind through `src/service/windows-guarded-manager.ts`: a Task Scheduler backend must prove a recognized OpenCodex registration against the bounded current-user SID/name lookup, a running task, and a wrapper ancestor carrying this home's canonical paths — presence and running state are read root-folder-scoped because every `/tn` operation resolves a bare name there and nowhere else, so a same-named task inside a task folder is a different registration neither the probe nor the stop can reach; the registered definition itself is read only on the running path, and a state or registration read that cannot answer re-probes presence so a task deleted between probes — which can respawn nothing — reads absent while a merely unreadable one stays unknown; an unreadable current identity leaves the manager unknown; a WinSW backend must prove this installation's binary and SCM service PID ancestry; either marks `childNeedsSeparateStop`, so the approved PID receives its own graceful signal only after a successful manager stop because `schtasks /end` does not reliably cascade to the wrapper's child (#764); a failed or unknown manager stop leaves the child untouched. Post-stop Windows inactivity means the task not running, no surviving wrapper process, and no started WinSW — never mere registration presence; a running-state read that cannot answer re-probes presence the same way, so a task deleted between probes reports inactive while a persisting one stays unknown. Every Windows manager command a verdict or stop depends on — schtasks, sc.exe, WinSW verbs — runs under a bounded `execFileSync` timeout so a wedged Task Scheduler or Service Control Manager cannot hang the guarded stop; a killed command classifies through the same fail-closed paths a nonzero exit would (the interactive `install /p` password prompt is the one deliberate exception). A `wscript.exe`, `cscript.exe` or `cmd.exe` whose command line CIM cannot read makes the state unprovable only when it is tied to the proxy: pre-stop, as an ancestor of the approved PID; post-stop, as the former bound manager process or a descendant of it. Unrelated unreadable shells, which a non-admin query returns for other users' and elevated processes, are not evidence either way. An unprovable Windows manager still blocks guarded takeover. The token checks snapshot consistency, not whether a person approved the desktop prompt. `src/cli/stop-restore.ts` owns the shared Codex/Grok restoration and its history-only, history-deferred, and other failure categories. +`ocx service status --json` (`src/service/status-json.ts`) is the passive machine form of the service report, read by peers such as GOAT's device inventory. `src/service/cli.ts` accepts `--json` for `status` only (`planServiceCommand` refuses it on every other verb before any installation probe) and answers it before `platformOps()` resolves, so no install, start, stop, repair or migration path is reachable; `skipsCodexShimAutoRestore` exempts `service status --json` so the CLI preflight repairs no Codex shim first, the collection runs inside `runPassiveRead` so the serving probe's config load neither hardens modes nor backs up a malformed file, and `--json` on any other verb is refused before the Desktop check and the Windows mutation lock; an unsupported platform or Docker gets a document instead of the text path's exit. The document reuses exactly the reads the text report makes — `diagnoseService()` (launchd `print`, systemd observation verbs, Task Scheduler `/query`, SCM/WinSW status) and, only when a registration exists, the 1.5-second loopback `confirmServiceServing` probe — plus the ownership-lease and Desktop login lines. `running` is the manager's verdict (the value `startup.serviceRunning` carries in `ocx status --json`); `serving` reports separately whether a proxy answers on the installed port. `backend` is `launchd`, `systemd`, `schtasks`, `winsw`, or `none` when nothing is registered. The systemd branch of `diagnoseService` derives through the pure `deriveSystemdServiceDiagnostic`, matching the launchd and Windows derivations. `tests/service/service-status-json.test.ts` snapshots each platform branch and records every subprocess entry point on darwin, linux and win32 against a read-only allowlist. `ocx status --json` carries an additive `install` verdict (`src/cli/status-install.ts`) mapped from the updater's own `detectInstallOwnership()`: `method` is `bun`, `npm`, `pnpm`, `mise`, `source`, `standalone` or `unknown` (detector failure), and `evidence.basis` names the package layout, the mise backend record (tool alias and backend only) or the standalone binary without printing a path (`tests/cli/status-install-method.test.ts`). + ## Native main reauth JSON output `src/cli/account-main.ts` emits one JSON object to stdout when `ocx account main reauth --device --no-wait --json` succeeds. The human-readable `follow up:` line is emitted only without `--json`; `flowId` remains available for status polling. `tests/cli/cli-native-profile.test.ts` parses the complete captured stdout and preserves coverage of the human follow-up. @@ -177,6 +179,8 @@ until shutdown. Normal shutdown restores native Codex; a sibling instance beside `OCX_SERVICE=1`, so managed restarts do not repeatedly restore/reinject; explicit service stop and uninstall still restore. `src/service/cli.ts` removes the service token on uninstall only when persisted client state is disconnected and no pending connect marker owns the newly issued key. `src/client/connect.ts` publishes that fingerprint marker before the key, then clears it with the connection commit or rollback under the client lifecycle and config mutation locks. Connected, invalid, or mismatched client state retains an existing token. A valid pending marker retains only its matching fingerprint; an older marker does not own a replacement service key. An absent token is reported as absent; unsafe, malformed, or unreadable markers and lock, state-read, or deletion failures leave cleanup unverified. `src/cli/tray-proxy.ts` coordinates `ocx restart` and tray restart. A second start requires proof the prior attempt never launched or its child exited, plus confirmed absence under a fresh bounded discovery window; a missing health response alone is insufficient. After an accepted restart, polling continues through uncertain observations until its deadline and requires a new runtime PID on the same port. If the old proxy then disappears, explicit recovery starts it regardless of the Codex autostart preference; a skipped recovery fails rather than reporting success. The Windows tray startup-health probe in `src/tray/windows-tray.ps1` requests termination at its 30-second deadline without waiting on the UI thread. It retains the process through later ticks until actual exit and both redirected readers settle; replacements cannot start while that reference is held. Launch setup failures follow the same cleanup. Tray shutdown alone waits at most 500 ms before disposal. + +`ocx restart` also restarts a connected client runtime, which serves the same bound `POST /api/system/restart` from its client listener; the CLI sends that request only while the client's 90-second restart budget still fits before replacement observation ends (`CLIENT_RESTART_REQUEST_MIN_REMAINING_MS`), and reports a supervised client's `409 restart_supervised` as "not performed, runtime kept". See [Remote Link](remote-link.md) for the rollback. The package-tree integrity fence for live package replacement follows the [update transaction contract](ops/docs-and-release.md#package-tree-integrity-fence). After client journal reconciliation, `ocx ensure` in `src/cli/dispatch.ts` treats validated connected-client state as a successful no-op without local startup or a remote health probe; invalid or mismatched state still fails, and disconnected state keeps local ensure. `tests/cli/cli-ensure-client-state.test.ts` pins this dispatch contract so the Codex shim does not warn merely because its proxy is delegated to a hub. Startup journal reconciliation, including replacement after restart/drain, follows the [Codex-home recovery contract](codex-home.md#codex-home): already-original recovery is silent, incomplete recovery retains its journal and warns without claiming restoration, and only complete recovery that rewrites or removes an artifact reports a restore; restart admission, drain deadlines and replacement ownership are unchanged. A busy preferred port is never resolved by starting somewhere else. Both questions a start asks @@ -347,7 +351,7 @@ config snapshot and the bounded service-token observation needed for its `_remot it does not import the connect command, inspect catalog readiness, acquire lifecycle locks, or run config/secret ACL hardening. -`src/remote/protocol.ts` owns pure interval/feature negotiation. `src/remote/hub-state.ts` owns the `GET|HEAD /v1/hub-state` contract, its caps, and the parser both sides share. `src/client/hub-client.ts` owns bounded, schema-validated remote catalog consumption, hub-state reads, and key-id probes; `src/client/hub-state.ts` owns the resolution and the owner-stamped 0600 cache, and a failed read reports "unavailable" rather than degrading to the client's own local provider and login state. `src/client/hub-relay.ts` is a fixed-authority management relay with URL, header, body, redirect, and stream bounds. The public data listener remains the direct client→hub path; the loopback management ingress never serves data-plane routes. A client with `transport: "link"` reaches its hub through an SSH tunnel instead; see [Remote Link](remote-link.md). A client with a link sidecar owns its SSH tunnel; see [Remote Link](remote-link.md). For Remote Link, `src/cli/status.ts` asks the identity-checked local Child for `/v1/hub-state` without any credential header; the Child's connection-bound relay owns Home authentication. Hub Homes retain their bounded parsed projection and owner-bound cache. `src/client/hub-client.ts` distinguishes a bounded nested `hub_state_not_a_hub` 404 from legacy route absence; `src/client/hub-state.ts` reports role absence without presenting obsolete cached Hub state. `src/client/link-status.ts` reads matching sidecar/marker evidence for initiation identity; ambiguous data does not claim a Home-owned tunnel. These contracts are covered in `tests/cli/cli-status-hub-state.test.ts` and `tests/clients/link-status-projection.test.ts`. +`src/remote/protocol.ts` owns pure interval/feature negotiation. `src/remote/features.ts` is the one registry of peer features `GET /readyz` lists in `features`, and each entry reads its owning source: `quota-pool/1` when `src/server/management/route-registry.ts` declares `GET /api/quota-pool`, `link-management-relay` when `src/link/management-routes.ts` has read paths, `served-by-header` when the emitter descriptor `src/remote/served-by.ts` says responses carry `x-opencodex-served-by`, `relay-auth/` from `src/link/relay-auth.ts`, and `link-peer-report/` from `src/link/peer-report.ts`. `src/remote/served-by.ts` also owns the value: the Home instance id is the install's `homeId` from `src/quota-pool/identity.ts` (random, persisted, never a hostname), published on `GET /healthz` (including its package-tree-fenced 503 body) as `instanceId` and stamped on every `GET /v1/models` response by the wrapper at the end of `createServeOptions` (`src/server/index/serve-options.ts`), whichever branch answered. While the identity is unavailable both are omitted rather than sent empty. A link Child's relay passes the Home's header through and a Hub-mode client's Codex reads the Hub data listener directly, so the header always names the serving Home; `tests/server/served-by-header.test.ts` covers standalone, Hub, link-relayed, restart-stable, opaque and unavailable-identity cases. The ids are fixed strings with no account or configuration data; `tests/server/readyz-features.test.ts` checks presence and absence per source, the peer parser, and the served-by advertisement against live responses. The Child machine listener answers its own `/readyz` without `features`. `src/remote/hub-state.ts` owns the `GET|HEAD /v1/hub-state` contract, its caps, and the parser both sides share. `src/client/hub-client.ts` owns bounded, schema-validated remote catalog consumption, hub-state reads, and key-id probes; `src/client/hub-state.ts` owns the resolution and the owner-stamped 0600 cache, and a failed read reports "unavailable" rather than degrading to the client's own local provider and login state. `src/client/hub-relay.ts` is a fixed-authority management relay with URL, header, body, redirect, and stream bounds. The public data listener remains the direct client→hub path; the loopback management ingress never serves data-plane routes. A client with `transport: "link"` reaches its hub through an SSH tunnel instead; see [Remote Link](remote-link.md). A client with a link sidecar owns its SSH tunnel; see [Remote Link](remote-link.md). For Remote Link, `src/cli/status.ts` asks the identity-checked local Child for `/v1/hub-state` without any credential header; the Child's connection-bound relay owns Home authentication. Hub Homes retain their bounded parsed projection and owner-bound cache. `src/client/hub-client.ts` distinguishes a bounded nested `hub_state_not_a_hub` 404 from legacy route absence; `src/client/hub-state.ts` reports role absence without presenting obsolete cached Hub state. `src/client/link-status.ts` reads matching sidecar/marker evidence for initiation identity; ambiguous data does not claim a Home-owned tunnel. These contracts are covered in `tests/cli/cli-status-hub-state.test.ts` and `tests/clients/link-status-projection.test.ts`. ### Remote Hub status credential binding @@ -497,7 +501,7 @@ A definite context-window overflow is the fourth request-local verdict. A hetero This is also why the classifier cannot duplicate visible output. Native byte streams reach combo classification only through `preflightComboStreamResponse`, which commits the child on any text, tool call or unknown event and synthesizes a failure envelope only for a zero-output terminal. A `runTurn` adapter has the equivalent boundary in `preflightAdapterEvents`: when the first meaningful event is an undeclared tool call and no replay-unsafe heartbeat recorded a side effect, `src/server/responses/run-turn-execution.ts` checks it against the exact current request catalog and projects the existing fail-closed refusal as a pre-commit 502 so failover can continue without changing the catalog. Any earlier text, tool call, control boundary, unknown event or replay-unsafe heartbeat commits that child, so a turn whose output the client may have seen or whose side effect may have run is never replayed. -Regression coverage: `tests/responses/responses-forward-prompt-envelope.test.ts`, `tests/routing/router-combo-failover-classification.test.ts`, `tests/routing/routing-policy-fallback.test.ts`, `tests/helpers/combo-context-overflow-cases.ts`, and `tests/server/server-combo-failover-e2e.test.ts`. +Regression coverage: `tests/responses/responses-forward-prompt-envelope.test.ts`, `tests/routing/router-combo-failover-classification.test.ts`, `tests/routing/routing-policy-fallback.test.ts`, `tests/helpers/combo-context-overflow-cases.ts`, and `tests/server/server-combo-failover-e2e.test.ts`. Every combo, routing-profile, subagent and hard-lock hop to another model or provider first passes the [fallback policy](fallback-policy.md#sites). `src/combos/failover.ts` uses a 10-minute fallback for a spent account usage window (codes `usage_limit_exceeded`, `usage_limit_reached`, `1308`, or `usage limit reached` / `usage limit has been reached` / `token-plan quota has been exhausted` prose, including HTTP 502) and for provider-scoped credential or billing failure codes such as `invalid_api_key` and `insufficient_quota`. This duration does not change failure classification or cooldown scope; upstream retry/reset signals and configured durations retain precedence. It caps explicit upstream `Retry-After` target cooldowns at 24 hours while reset-derived, configured, and fallback cooldowns remain capped at 10 minutes. `src/combos/resolve.ts` omits a target cooldown for a 429 only when the failed child identifies one account in an Anthropic OAuth or canonical Codex pool and that account has a live pool cooldown, or identifies one account in a generic OAuth pool that still has an eligible sibling. `src/server/responses/core-combo.ts` checks the pool's health record before passing that attribution; for a generic OAuth pool it first hands the account (matched through its `accountLogLabel`) to `rotateGenericOAuthAccountOn429`, which cools it under the pool's own Retry-After policy even when the per-request account budget ended rotation before that account was recorded, and attributes the failure only when the rotator names a sibling. Unknown accounts, ambiguous labels, a lone or fully cooled generic OAuth pool, API-key providers, and other failures keep their ordinary target cooldown. An API-key pool needs no attribution: its 429 rotation walks every key inside the attempt, so a 429 reaches the combo only once every key is cooled. Regression coverage: `tests/oauth/generic-oauth-combo-account-cooldown.test.ts`. A 402 is the provider-scoped twin for an Anthropic OAuth pool: the pool rotates only on 401/403/429, so when the failed child identifies one pool account and the failure carries the exact credit-balance refusal (`isAnthropicBillingFailure`), `core-combo.ts` records that account's refusal through the pool's `recordAnthropicAccountRefusal` (the same entitlement backoff a 403 credit refusal gets) and `resolve.ts` omits every target cooldown only while the pool still has an eligible account; a lone account, an exhausted pool, an unidentified account, or any other 402 keeps the provider-scoped cooldown. Regression coverage: `tests/adapters/anthropic/anthropic-combo-account-cooldown.test.ts`. The Anthropic OAuth pool's own all-accounts-cooled 429 is the one unattributed exception: `isAnthropicPoolLocalRefusal` in `core-combo.ts` requires the `anthropic` provider with `authMode: "oauth"`, status 429, no identified failed account, no eligible pool account and a live pool retry-after. The target then still cools, but for the local fallback instead of the restated per-account `Retry-After`, so a resumed or newly added account is not ignored until a 24-hour target cooldown expires. An upstream 429 on an API-key provider or on an identified pool account keeps its full `Retry-After`. Regression coverage: `tests/routing/always-on-429-failover.test.ts`. Cooldown entries in `src/combos/failover.ts` retain the failure status. The quota-only snapshot query records target/expiry pairs for unexpired 429/402 cooldowns using only cooldown map lookups. Its config-aware wrapper in `src/combos/resolve.ts` applies the picker's provider availability and cached quota checks before dispatch; request eligibility runs lazily only when exhaustion considers a 503, with throwing checks treated as ineligible. Responses exhaustion uses this evidence under the [failover contract](transports/responses-failover.md); ordinary cooldown selection and timing stay unchanged. A zero-attempt `combo_unavailable` names why each target was skipped. `comboExclusionReasons` in `src/combos/resolve.ts` derives one coarse label per target from the picker's own checks, in picker order: `provider missing`, `provider disabled`, `quota exhausted`, `cooldown s`, then `ineligible for this request` when the caller's eligibility predicate refuses it or throws. `NoAvailableComboTargetsError` and `comboUnavailable` append them to the message; the label carries only the configured provider/model, never account, credential or request data. Coverage: `tests/responses/responses-combo-exhausted-error.test.ts`. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 805c208809d..c003eac87b0 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -197,7 +197,7 @@ This table is not an unknown-parameter sanitizer, and the distinction is the poi client is observed to send, so an unrecognized top-level key reaches the wire untouched rather than being deleted on the theory that the destination would have rejected it. `codex_output_schema` is deliberately absent for that reason: in codex-rs it is the `name` of the JSON-schema `text.format` -object, not a top-level key, so listing it would remove a field this client never sends. +object, not a top-level key, so listing it would remove a field this client never sends. Private input item TYPES are a third table, `CANONICAL_ONLY_INPUT_ITEM_TYPES` (`stripCanonicalOnlyInputItems`, same destination boundary in the passthrough and native compact gateway replay). It holds Codex 0.164 `configuration_update` (`{type, reasoning: {effort}}`), a positional effort control: OpenAI-operated destinations receive it verbatim and in order, other gateways get the conversation without it, the translated parser drops it without clearing pending reasoning, and the agent-task tail walkers skip it like `compaction_trigger`. Codex sends it only for rows with `supports_reasoning_effort_updates`, which `normalizeRoutedCatalogEntry` removes from routed rows; its effort is never mapped onto a routed request (#6895). Coverage: `tests/responses/codex-0164-request-compat.test.ts`. After that namespace boundary has produced public function tools, the Grok CLI Responses transport applies the same root-schema policy as its Chat transport. A root `oneOf`/`anyOf` is flattened only diff --git a/tests/ci-workflows/bun-runtime.test.ts b/tests/ci-workflows/bun-runtime.test.ts index 7b74ec4cff7..0c196b6774e 100644 --- a/tests/ci-workflows/bun-runtime.test.ts +++ b/tests/ci-workflows/bun-runtime.test.ts @@ -2,12 +2,13 @@ import { describe, it, expect, afterAll, afterEach } from "bun:test"; import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { join } from "node:path"; import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, isRealBunBinary, bundledBunPath, durableBunPath, durableBunRuntime, reportedBunRuntimeSource, withProcessRuntimeProvenance } from "../../src/lib/bun-runtime"; import { findPathBun } from "../../src/lib/bun-path-runtime.mjs"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; import { INTERNAL_DEADLINE_MS } from "../helpers/test-budget"; +import { trustedFixtureRoot } from "../helpers/trusted-fixture-root"; // realpath the temp root: on macOS /var is a symlink to /private/var, so a path built // from mkdtemp compares unequal to the same path resolved through process.cwd(). @@ -30,17 +31,11 @@ afterAll(() => { // findPathBun rejects a PATH candidate beneath any group/other-writable ancestor, // which includes the shared temp root on POSIX ("/tmp" is 1777). A fixture that is -// supposed to exercise a *trusted* PATH dir therefore cannot live under tmpdir(). +// supposed to exercise a *trusted* PATH dir therefore lives under a base whose +// ancestry the helper has checked against the same rule (#7402). const trustedPathRoots: string[] = []; function trustedPathRoot(prefix: string): string { - if (process.platform === "win32") { - const root = mkdtempSync(join(tmpdir(), prefix)); - trustedPathRoots.push(root); - return root; - } - const base = join(dirname(repoPath()), ".tmp", "path-bun-fixtures"); - mkdirSync(base, { recursive: true, mode: 0o700 }); - const root = mkdtempSync(join(base, prefix)); + const root = trustedFixtureRoot(prefix); trustedPathRoots.push(root); return root; } diff --git a/tests/ci-workflows/ci-review-lanes.test.ts b/tests/ci-workflows/ci-review-lanes.test.ts index 99d0aaf7164..ea51831dbab 100644 --- a/tests/ci-workflows/ci-review-lanes.test.ts +++ b/tests/ci-workflows/ci-review-lanes.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { repoPath } from "../helpers/repo-root"; @@ -408,6 +408,12 @@ describe("Windows and nightly CI contracts", () => { test.skipIf(process.platform === "win32" || !Bun.which("git"))("Windows diff scan considers only added src/tests markers and fails closed without a parent", () => { const scenarios = [ { path: "src/example.ts", before: "plain\n", after: "WIN32\n", expected: "true" }, + // An added spawnSync line is Windows-sensitive by content, outside the path filter too (#7468). + { path: "tests/update/example-host-guard.test.ts", before: "plain\n", after: "const original = Bun.spawnSync;\n", expected: "true" }, + // Process-signal code behaves differently on Windows (kill("SIGTERM") is TerminateProcess), #7484/#7502. + { path: "src/server/example-restart.ts", before: "plain\n", after: 'child.kill("SIGTERM");\n', expected: "true" }, + { path: "tests/server/example-restart.test.ts", before: "plain\n", after: "expect(child.signalCode).toBe(\"SIGKILL\");\n", expected: "true" }, + { path: "src/lib/example-stop.ts", before: "plain\n", after: "process.kill(pid, 0);\n", expected: "true" }, { path: "tests/example.ts", before: "plain\n", after: "ordinary\n", expected: "false" }, { path: "src/example.ts", before: "powershell\nplain\n", after: "plain\n", expected: "false" }, { path: "docs/example.md", before: "plain\n", after: "pwsh\n", expected: "false" }, @@ -437,13 +443,19 @@ describe("Windows and nightly CI contracts", () => { commit(); if (!scenario.orphan) { writeFileSync(path, scenario.after ?? scenario.before); commit(); } const output = join(directory, "output"); + const summary = join(directory, "summary"); const result = spawnSync("bash", [repoPath("scripts", "ci", "windows-sensitive-diff.sh")], { cwd: directory, encoding: "utf8", timeout: 5_000, - env: { ...process.env, EVENT_NAME: scenario.event ?? "pull_request", PATH_SELECTED: scenario.selected ?? "false", GITHUB_OUTPUT: output }, + env: { ...process.env, EVENT_NAME: scenario.event ?? "pull_request", PATH_SELECTED: scenario.selected ?? "false", GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary }, }); expect(result.error).toBeUndefined(); expect(result.status, result.stderr).toBe(scenario.status ?? 0); if (scenario.expected) expect(readFileSync(output, "utf8")).toBe(`windows=${scenario.expected}\n`); + // A Windows-sensitive pull request gets a non-blocking notice and summary line, and nothing + // else does; the PR still never runs the Windows shards (AGENTS.md CI policy). + const notice = (scenario.event ?? "pull_request") === "pull_request" && scenario.expected === "true"; + expect(result.stdout.includes("::notice title=Windows-sensitive::")).toBe(notice); + expect(existsSync(summary) && readFileSync(summary, "utf8").includes("lane=all")).toBe(notice); } finally { rmSync(directory, { recursive: true, force: true }); } } }, 30_000); diff --git a/tests/ci-workflows/skill-ocx-generated.test.ts b/tests/ci-workflows/skill-ocx-generated.test.ts index 2101702fedb..ef2d5ec32f7 100644 --- a/tests/ci-workflows/skill-ocx-generated.test.ts +++ b/tests/ci-workflows/skill-ocx-generated.test.ts @@ -13,7 +13,7 @@ const OWNER = "GENERATED by scripts/generate-ocx-skill-surface.ts"; const GROUPS: Record = { lifecycle: ["codex-provider", "chatgpt", "status", "resolve", "capabilities", "sync", "start", "stop", "restart", "service", "gui"], "providers-models": ["provider", "models", "alias"], accounts: ["account", "auth", "login", "logout"], - "agents-routing": ["agent", "combo", "route", "v2", "effort", "memory", "message", "advisor"], integrations: ["claude", "integration", "commandcode", "grok", "codex-shim"], + "agents-routing": ["agent", "combo", "route", "fallback", "v2", "effort", "memory", "message", "advisor"], integrations: ["claude", "integration", "commandcode", "grok", "codex-shim"], "observe-system": ["companion", "usage", "logs", "storage", "inspect", "system", "observe", "debug", "export", "import", "cost", "update", "config", "tray"], "access-remote": ["link", "remote-workspace", "hub", "connect", "api", "access"], lab: ["lab"], }; diff --git a/tests/ci-workflows/test-home-guard-unpreloaded.test.ts b/tests/ci-workflows/test-home-guard-unpreloaded.test.ts new file mode 100644 index 00000000000..9449f94d6cb --- /dev/null +++ b/tests/ci-workflows/test-home-guard-unpreloaded.test.ts @@ -0,0 +1,260 @@ +/** + * #7407: a `bun test` that never loads tests/preload.ts must still refuse credential writes. + * + * Bun reads bunfig.toml from the CURRENT WORKING DIRECTORY, so + * `cd elsewhere && bun test /tests/x.test.ts` runs with the real HOME, no OPENCODEX_HOME, + * and (before this fix) a disarmed guard: one such run added an account to a developer's real + * ~/.opencodex/auth.json and made it active. Every case here spawns exactly that shape, a test + * file outside the repository run from a cwd with no bunfig.toml, against a temp directory + * posing as the real home, so nothing in this suite can reach the developer's own. + * + * The negative case matters as much: the same writes from a plain `bun` process, even with + * NODE_ENV=test, must still land. Production persistence is unchanged. + */ +import { afterEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { pathToFileURL } from "node:url"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; +import { repoRoot } from "../helpers/repo-root"; +import { watchdogMs } from "../helpers/ci-watchdog"; + +const REPO_ROOT_URL = pathToFileURL(repoRoot() + "/").href; +const CHILD_MS = watchdogMs(20_000); +const SENTINEL_AUTH = JSON.stringify({ sentinel: { activeAccountId: "real", accounts: [] } }, null, 2) + "\n"; +const SENTINEL_CODEX_AUTH = JSON.stringify({ tokens: { access_token: "real-codex-sentinel" } }) + "\n"; + +const handles: TempHome[] = []; +afterEach(() => { for (const handle of handles.splice(0)) handle.remove(); }); + +/** A temp root this suite owns and removes after each case. */ +function owned(prefix: string): string { + const handle = createTempHome(prefix); + handles.push(handle); + return handle.root; +} + +/** A directory posing as the developer's home, seeded with live-looking credential stores. */ +function fakeRealHome(): string { + const home = owned("ocx-7407-real-home-"); + mkdirSync(join(home, ".opencodex"), { recursive: true, mode: 0o700 }); + mkdirSync(join(home, ".codex"), { recursive: true, mode: 0o700 }); + writeFileSync(join(home, ".opencodex", "auth.json"), SENTINEL_AUTH, { mode: 0o600 }); + writeFileSync(join(home, ".codex", "auth.json"), SENTINEL_CODEX_AUTH, { mode: 0o600 }); + return home; +} + +/** Every entry under the two credential homes, with its mode and content hash. */ +function snapshot(home: string): string[] { + const out: string[] = []; + const walk = (dir: string) => { + for (const name of readdirSync(dir).sort()) { + const path = join(dir, name); + const stat = lstatSync(path); + const digest = stat.isFile() ? createHash("sha256").update(readFileSync(path)).digest("hex") : ""; + out.push(relative(home, path) + " " + stat.mode.toString(8) + " " + digest); + if (stat.isDirectory()) walk(path); + } + }; + for (const tree of [".opencodex", ".codex"]) { + out.push(tree + " " + lstatSync(join(home, tree)).mode.toString(8)); + walk(join(home, tree)); + } + return out; +} + +/** A developer shell's environment: real HOME, nothing sandboxed, no preload receipts. */ +function unsandboxedEnv(home: string, extra: Record = {}): Record { + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) if (value !== undefined) env[key] = value; + for (const key of ["OCX_TEST_HOME_GUARD", "OCX_REAL_HOME", "OCX_TEST_PRELOAD_PID", "OPENCODEX_HOME", "CODEX_HOME", "NODE_ENV"]) { + delete env[key]; + } + return { ...env, HOME: home, USERPROFILE: home, ...extra }; +} + +async function run(args: string[], cwd: string, env: Record) { + const child = Bun.spawn([process.execPath, ...args], { cwd, env, stdout: "pipe", stderr: "pipe" }); + const timer = setTimeout(() => child.kill("SIGKILL"), CHILD_MS); + try { + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited, + ]); + return { stdout, stderr, code }; + } finally { + clearTimeout(timer); + } +} + +function report(stdout: string, stderr: string): Record { + const line = (stdout + "\n" + stderr).split("\n").find(entry => entry.startsWith("REPORT ")); + if (!line) throw new Error("child printed no REPORT line:\n" + stdout + "\n" + stderr); + return JSON.parse(line.slice("REPORT ".length)) as Record; +} + +/** + * One call per credential-write entry path. A call counts as REFUSED only when its error chain + * carries the guard's own message, so an unrelated failure cannot pass as protection. + */ +const ATTEMPTS = String.raw` + const repo = ${JSON.stringify(REPO_ROOT_URL)}; + const out: Record = {}; + const guard = await import(repo + "src/lib/test-home-guard.ts"); + out.armed = String(guard.isTestHomeGuardArmed()); + out.flag = String(process.env.OCX_TEST_HOME_GUARD); + out.preload = String(process.env.OCX_TEST_PRELOAD_PID); + globalThis.fetch = (async () => { throw new Error("network disabled in this probe"); }) as never; + const config = await import(repo + "src/config.ts"); + const store = await import(repo + "src/oauth/store.ts"); + const codexStore = await import(repo + "src/codex/account-store.ts"); + const kimi = await import(repo + "src/oauth/kimi.ts"); + const nous = await import(repo + "src/oauth/nous.ts"); + out.configDir = config.getConfigDir(); + const cred = { access: "fixture-access", refresh: "fixture-refresh", expires: Date.now() + 3_600_000, email: "fixture@example.test" }; + const chain = (error: unknown): string => { + const parts: string[] = []; + for (let e: any = error, i = 0; e && i < 6; e = e.cause, i++) parts.push(String(e?.message ?? e)); + return parts.join(" <- "); + }; + const attempt = async (name: string, fn: () => unknown) => { + try { await fn(); out[name] = "WROTE"; } + catch (error) { + const text = chain(error); + out[name] = /refusing to write the real (OpenCodex|Codex) home/.test(text) ? "REFUSED" : "OTHER: " + text.slice(0, 300); + if (name === "saveCredential") out.hint = String(text.includes("did not load tests/preload.ts")); + } + }; + await attempt("saveCredential", () => store.saveCredential("anthropic", cred)); + await attempt("upsertCredentialByIdentity", () => store.upsertCredentialByIdentity("kimi", cred)); + await attempt("saveAccountCredential", () => store.saveAccountCredential("kimi", "fixture-account", cred)); + await attempt("setActiveAccount", () => store.setActiveAccount("kimi", "fixture-account")); + await attempt("removeAccount", () => store.removeAccount("kimi", "fixture-account")); + await attempt("mergeAccountCredential", () => store.mergeAccountCredential("kimi", "fixture-account", cred)); + await attempt("writeOAuthRefreshIntent", () => store.writeOAuthRefreshIntent("kimi", "fixture-account", "generation")); + await attempt("refreshIntentLock", async () => (await store.createOAuthRefreshIntentLock("kimi", "fixture-account").acquire()).release()); + await attempt("saveCodexAccountCredential", () => codexStore.saveCodexAccountCredential("fixture-codex", { accessToken: "a", refreshToken: "r", accountId: "acct", expiresAt: Date.now() + 3_600_000 } as never)); + await attempt("codexAuthJsonMirror", () => config.atomicWriteFile("__CODEX_AUTH__", "{}\n")); + await attempt("saveConfig", () => config.saveConfig({ providers: {}, defaultProvider: "openai", port: 10100 } as never)); + await attempt("kimiDeviceId", () => kimi.refreshKimiToken("fixture-refresh")); + await attempt("nousRefreshIntent", () => nous.refreshNousToken("fixture-refresh")); + console.log("REPORT " + JSON.stringify(out)); +`; + +const WRITE_PATHS = [ + "saveCredential", "upsertCredentialByIdentity", "saveAccountCredential", "setActiveAccount", "removeAccount", + "mergeAccountCredential", "writeOAuthRefreshIntent", "refreshIntentLock", "saveCodexAccountCredential", + "codexAuthJsonMirror", "saveConfig", "kimiDeviceId", "nousRefreshIntent", +]; + +/** + * A helper whose entry is NOT a test file, spawned from inside the unpreloaded test. It cannot + * arm itself by name, so it is protected only if it inherits the flag the test process published. + */ +const CHILD_HELPER = String.raw` + const store = await import(${JSON.stringify(REPO_ROOT_URL)} + "src/oauth/store.ts"); + try { + await store.saveCredential("anthropic", { access: "child", refresh: "child", expires: Date.now() + 3_600_000, email: "child@example.test" }); + console.log("CHILD WROTE"); + } catch (error) { + console.log(/refusing to write the real OpenCodex home/.test(String((error as Error)?.message)) ? "CHILD REFUSED" : "CHILD OTHER"); + } +`; + +function writeFixture(dir: string, name: string, home: string, asTest: boolean): string { + // JSON-encoded so a Windows path keeps its backslashes inside the child's string literal. + let body = ATTEMPTS.replace('"__CODEX_AUTH__"', JSON.stringify(join(home, ".codex", "auth.json"))); + if (asTest) { + const helper = join(dir, "spawned-helper.ts"); + writeFileSync(helper, CHILD_HELPER); + body = body.replace(' console.log("REPORT "', " const spawned = Bun.spawnSync([process.execPath, \"run\", " + JSON.stringify(helper) + "], { env: process.env });\n" + + " out.child = /CHILD (\\w+)/.exec(spawned.stdout.toString())?.[1] ?? \"none\";\n" + + ' console.log("REPORT "'); + } + const source = asTest + ? 'import { test } from "bun:test";\ntest("unpreloaded writes", async () => {\n' + body + "\n}, 30_000);\n" + : body; + const path = join(dir, name); + writeFileSync(path, source); + return path; +} + +describe("bun test without the preload (#7407)", () => { + test("arms the guard and refuses every credential write to the real home", async () => { + const home = fakeRealHome(); + const outside = owned("ocx-7407-cwd-"); + expect(existsSync(join(outside, "bunfig.toml"))).toBe(false); + const fixture = writeFixture(outside, "unpreloaded-writes.test.ts", home, true); + const before = snapshot(home); + + const result = await run(["test", fixture], outside, unsandboxedEnv(home)); + const seen = report(result.stdout, result.stderr); + + // The incident's shape: no preload ran and the config dir is the real one. + expect(seen.preload).toBe("undefined"); + expect(seen.configDir).toBe(join(home, ".opencodex")); + // Armed anyway, and published so CLI children it spawns inherit the arming. + expect(seen.armed).toBe("true"); + expect(seen.flag).toBe("1"); + for (const path of WRITE_PATHS) expect({ path, outcome: seen[path] }).toEqual({ path, outcome: "REFUSED" }); + expect(seen.hint).toBe("true"); + // A spawned helper with a non-test entry inherits the arming instead of writing the real home. + expect(seen.child).toBe("REFUSED"); + // Nothing landed, nothing was created, and no mode changed. + expect(snapshot(home)).toEqual(before); + expect(readFileSync(join(home, ".opencodex", "auth.json"), "utf8")).toBe(SENTINEL_AUTH); + }, CHILD_MS + 5_000); + + test("a plain bun process with NODE_ENV=test still persists (production is unchanged)", async () => { + const home = fakeRealHome(); + const outside = owned("ocx-7407-prod-cwd-"); + const script = writeFixture(outside, "write-credentials.ts", home, false); + + const result = await run(["run", script], outside, unsandboxedEnv(home, { NODE_ENV: "test" })); + const seen = report(result.stdout, result.stderr); + + expect(seen.armed).toBe("false"); + expect(seen.flag).toBe("undefined"); + // fetch is disabled, so a network path may fail otherwise, but none may hit the guard. + for (const path of WRITE_PATHS) expect({ path, refused: seen[path] === "REFUSED" }).toEqual({ path, refused: false }); + expect(seen.saveCredential).toBe("WROTE"); + expect(seen.saveConfig).toBe("WROTE"); + expect(seen.codexAuthJsonMirror).toBe("WROTE"); + const auth = JSON.parse(readFileSync(join(home, ".opencodex", "auth.json"), "utf8")) as Record; + expect(Object.keys(auth)).toContain("anthropic"); + // The writers that now assert first still persist before their network call fails. + expect(readFileSync(join(home, ".opencodex", "kimi-device-id"), "utf8").trim()).not.toBe(""); + const intents = readdirSync(join(home, ".opencodex", ".nous-refresh-intent")).filter(name => name.endsWith(".json")); + expect(intents.length).toBe(1); + expect(JSON.parse(readFileSync(join(home, ".opencodex", ".nous-refresh-intent", intents[0]!), "utf8")).status).toBeString(); + }, CHILD_MS + 5_000); + + // Only "0" opts out; any other value is normalised to "1" before a helper can inherit it. + test.each(["", "yes"])("a stray OCX_TEST_HOME_GUARD=%p is normalised so spawned helpers stay armed", async stray => { + const home = fakeRealHome(); + const outside = owned("ocx-7407-stray-cwd-"); + const fixture = writeFixture(outside, "stray-flag.test.ts", home, true); + const before = snapshot(home); + + const result = await run(["test", fixture], outside, unsandboxedEnv(home, { OCX_TEST_HOME_GUARD: stray })); + const seen = report(result.stdout, result.stderr); + + expect(seen.armed).toBe("true"); + expect(seen.flag).toBe("1"); + expect(seen.child).toBe("REFUSED"); + expect(snapshot(home)).toEqual(before); + }, CHILD_MS + 5_000); + + test("an explicit OCX_TEST_HOME_GUARD=0 still disarms a test file", async () => { + const home = fakeRealHome(); + const outside = owned("ocx-7407-optout-cwd-"); + const fixture = join(outside, "optout.test.ts"); + writeFileSync(fixture, 'import { test } from "bun:test";\ntest("x", async () => {\n' + + " const guard = await import(" + JSON.stringify(REPO_ROOT_URL + "src/lib/test-home-guard.ts") + ");\n" + + ' console.log("REPORT " + JSON.stringify({ armed: String(guard.isTestHomeGuardArmed()) }));\n});\n'); + + const result = await run(["test", fixture], outside, unsandboxedEnv(home, { OCX_TEST_HOME_GUARD: "0" })); + + expect(report(result.stdout, result.stderr).armed).toBe("false"); + }, CHILD_MS + 5_000); +}); diff --git a/tests/claude-integration/claude-intercept-proxy.test.ts b/tests/claude-integration/claude-intercept-proxy.test.ts index 7fb32bf722b..7cde88079df 100644 --- a/tests/claude-integration/claude-intercept-proxy.test.ts +++ b/tests/claude-integration/claude-intercept-proxy.test.ts @@ -180,18 +180,22 @@ test("pending async choice holds pipelined bytes, then connects; rejection falls const echo = await startEchoUpstream(); cleanups.push(echo.close); let settle!: (choice: { kind: "intercept"; port: number }) => void; + // Resolved inside selectTunnel itself: the proxy has parsed CONNECT and is now holding the + // pipelined bytes. A fixed sleep only guessed at that moment and called an unset settle when + // the proxy had not consulted the choice yet. + const consulted = Promise.withResolvers(); let dialCount = 0; const proxy = await startConnectProxy(0, { interceptPort: 1, authToken: AUTH_TOKEN, selectTunnel: host => host === "claude.ai" - ? new Promise(resolve => { settle = resolve; }) + ? new Promise(resolve => { settle = resolve; consulted.resolve(); }) : Promise.reject(new Error("decision failed")), dialUpstream: () => { dialCount += 1; return connect({ host: "127.0.0.1", port: echo.port }); }, }); cleanups.push(proxy.close); const delayed = tunnelPayload(proxy.port, "claude.ai", "pending"); - await Bun.sleep(20); + await consulted.promise; expect(dialCount).toBe(0); settle({ kind: "intercept", port: echo.port }); expect(await delayed).toContain("echo:pending"); diff --git a/tests/cli/cli-client-start-fence.test.ts b/tests/cli/cli-client-start-fence.test.ts index 1840017d74d..99a5a713430 100644 --- a/tests/cli/cli-client-start-fence.test.ts +++ b/tests/cli/cli-client-start-fence.test.ts @@ -118,6 +118,10 @@ describe("connected-client start ownership fence", () => { expect(fence).toBeLessThan(branch.indexOf("startClientRuntime({")); expect(branch.slice(fence, branch.indexOf("return;"))).toContain("serviceChildOwnershipDecisionForClassifiedChild(supervisedServiceChild)"); const runtime = readFileSync(repoPath("src/client/runtime.ts"), "utf8"); - expect(runtime).toMatch(/writeRuntimePort\(clientRuntimeRecord\(process\.pid, boundPort\)\);\s*\n\s*options\.afterPublish\?\.\(\);/); + // serve() binds and publishes (it runs again for a restart rollback, OCX-6); the lease is + // released only once the first serve() returned, i.e. after publication. + const serve = runtime.slice(runtime.indexOf("const serve = async"), runtime.indexOf("const stopServing")); + expect(serve).toMatch(/writePid\(process\.pid\);\s*\n\s*writeRuntimePort\(clientRuntimeRecord\(process\.pid, boundPort, attestationSecret\)\);/); + expect(runtime).toMatch(/await serve\(\);\s*\n\s*options\.afterPublish\?\.\(\);/); }); }); diff --git a/tests/cli/cli-fallback.test.ts b/tests/cli/cli-fallback.test.ts new file mode 100644 index 00000000000..e0bcae28cfc --- /dev/null +++ b/tests/cli/cli-fallback.test.ts @@ -0,0 +1,45 @@ +/** + * `ocx fallback` (OCX-11): human status lines, `ocx status --json` fallback when no proxy answers, + * and usage refusals that never reach the proxy. + */ +import { describe, expect, test } from "bun:test"; +import { fallbackStatusLines, handleFallbackCommand } from "../../src/cli/fallback"; +import { collectStatusFallback } from "../../src/cli/status-fallback"; +import type { OcxConfig } from "../../src/types"; + +describe("ocx fallback", () => { + test("status lines name every scope, the device permission and the last event", () => { + expect(fallbackStatusLines({ + global: { model: true, provider: false }, + providers: { anthropic: { model: false } }, + routes: { "combo/coding": { provider: true } }, + devices: [{ id: "lnk_0123456789abcdef", alias: "mac-studio", localFallback: true, model: false, provider: null }], + last: { at: "2026-10-22T00:00:00.000Z", outcome: "refused", kind: "provider", route: "combo/coding", from: "a/m", to: "b/n", trigger: "rate_limited", scope: "device" }, + })).toEqual([ + "model fallback: on (global)", + "provider fallback: off (global)", + "provider anthropic: model off", + "route combo/coding: provider on", + "device lnk_0123456789abcdef (mac-studio): local fallback allowed, model off, provider inherit", + "last: 2026-10-22T00:00:00.000Z refused provider fallback on combo/coding: a/m -> b/n (rate_limited, device)", + ]); + expect(fallbackStatusLines({}).at(-1)).toBe("last: no fallback since this proxy started"); + }); + + test("status --json reports the local policy when no proxy answers; a Child claims nothing locally", async () => { + const config = { fallback: { model: false } } as OcxConfig; + const local = await collectStatusFallback(config, { live: false, linkChild: false }); + expect(local).toMatchObject({ schema: "ocx.fallback/1", source: "local", global: { model: false, provider: true }, recent: [] }); + const child = await collectStatusFallback(config, { live: false, linkChild: true }); + expect(child).toMatchObject({ source: "local", global: { model: true, provider: true } }); + }); + + test("usage errors exit 2 without a request", async () => { + const calls: string[] = []; + const deps = { findLiveProxy: async () => { calls.push("probe"); return null; } }; + for (const argv of [["bogus"], ["off", "--kind", "account"], ["off", "--route", "a", "--device", "b"], ["local", "maybe", "--device", "x"], ["local", "on"]]) { + expect(await handleFallbackCommand(argv, deps as never)).toBe(2); + } + expect(calls).toEqual([]); + }); +}); diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index 47a5721e8c8..f256ef9497f 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -477,14 +477,18 @@ describe("ocx agent sidecar --list (#2188)", () => { }); }); -afterEach(() => { - for (const server of servers.splice(0)) server.stop(true); +afterEach(async () => { + await Promise.all(servers.splice(0).map(server => server.stop(true))); process.exitCode = 0; }); function fakeRuntime(responder?: (req: Request, body: unknown) => unknown) { const requests: Recorded[] = []; const server = Bun.serve({ + // Bind the exact address the CLI dials (#7416). A wildcard bind lets another process on a + // loaded host bind 127.0.0.1 on the same port; it then receives this fake's requests, and a + // real proxy there answers the management call with 404. + hostname: "127.0.0.1", port: 0, async fetch(req) { const url = new URL(req.url); @@ -608,6 +612,7 @@ describe("headless GUI parity CLI", () => { // `ocx route policy`. Analytics is GUI-first for now; the same request // history remains available through observe/index tooling. ["/api/routing-profiles", "ocx route policy"], + ["/api/fallback", "ocx fallback"], ["/api/routing-analytics", "(none — GUI analytics surface; history via ocx observe/logs)"], // Remote Workspace is one product family in both surfaces. The current CLI owns // executor pairing, presence, and local status; Hub/device/session inspection is @@ -1807,6 +1812,19 @@ describe("ownership recovery advice does not assume injection is enabled", () => } }); +test("the fake runtime owns the loopback address the CLI dials", async () => { + const { deps } = fakeRuntime(); + const port = Number(new URL(deps.baseUrl).port); + let intruder: ReturnType | undefined; + try { + intruder = Bun.serve({ hostname: "127.0.0.1", port, fetch: () => new Response(null, { status: 404 }) }); + } catch (error) { + expect((error as { code?: string }).code).toBe("EADDRINUSE"); + } + await intruder?.stop(true); + expect(intruder).toBeUndefined(); +}); + test("Claude intercept start uses the management POST and preserves refusal exit status", async () => { const success = fakeRuntime(); expect(await handleClaudeInterceptCommand(["start", "--json"], success.deps)).toBe(0); diff --git a/tests/cli/cli-link-child.test.ts b/tests/cli/cli-link-child.test.ts index 08646ef0e60..b97c04d75ff 100644 --- a/tests/cli/cli-link-child.test.ts +++ b/tests/cli/cli-link-child.test.ts @@ -182,4 +182,30 @@ describe("management CLI on a Remote Link Child", () => { " fast mode: on", ]); }); + + test("a caller's deadline reaches the Home read and its body (ocx status keeps its 2 s bound)", async () => { + writeChild(); + // The request itself: the signal the relay fetch sees aborts with the caller's. + const requestCaller = new AbortController(); + let requestSignal: AbortSignal | undefined; + const pending = runtimeRequest("/api/fallback", { signal: requestCaller.signal }, { + findLiveProxy: clientLive, + linkHome: { fetchImpl: (async (_input: RequestInfo | URL, init?: RequestInit) => { + requestSignal = init?.signal ?? undefined; + return Response.json({ schema: "ocx.fallback/1" }); + }) as typeof fetch }, + }); + await pending; + expect(requestSignal?.aborted).toBe(false); + requestCaller.abort(); + expect(requestSignal?.aborted).toBe(true); + // The body: a Home that sends headers and then stalls is cut off by the caller, not after 30 s. + const bodyCaller = new AbortController(); + const stalled = runtimeRequest("/api/fallback", { signal: bodyCaller.signal }, { + findLiveProxy: clientLive, + linkHome: { fetchImpl: (async () => new Response(new ReadableStream({ start() { /* never ends */ } }), { status: 200 })) as typeof fetch }, + }).then(() => "resolved", () => "rejected"); + setTimeout(() => bodyCaller.abort(), 10); + expect(await stalled).toBe("rejected"); + }); }); diff --git a/tests/cli/cli-link-cleanup.test.ts b/tests/cli/cli-link-cleanup.test.ts new file mode 100644 index 00000000000..c60c4060af3 --- /dev/null +++ b/tests/cli/cli-link-cleanup.test.ts @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runLinkCommand, type LinkCliDeps } from "../../src/cli/link"; +import { linkKnownHostsPath, linkStorePath } from "../../src/link/paths"; +import { classifyRemotePlatform } from "../../src/link/remote-platform"; +import { REMOTE_PLATFORM_UNSUPPORTED, WINDOWS_OS_PROBE } from "../../src/link/ssh-argv"; +import type { SshRunner, SshRunResult } from "../../src/link/ssh-runner"; +import { writeLinkStore, type LinkStore } from "../../src/link/store"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const CMD_REFUSAL = "'sh' is not recognized as an internal or external command,\r\noperable program or batch file.\r\n"; +const POWERSHELL_REFUSAL = "sh : The term 'sh' is not recognized as the name of a cmdlet, function, script file, or operable program.\r\n"; +const ok = (stdout: string): SshRunResult => ({ code: 0, stdout, stderr: "" }); +const failed = (code: number, stderr: string): SshRunResult => ({ code, stdout: "", stderr }); + +const ids = { localized: "lnk_0000000000000010", mingw: "lnk_000000000000000a", cmd: "lnk_000000000000000b", ps: "lnk_000000000000000c", linux: "lnk_000000000000000d", down: "lnk_000000000000000e", joined: "lnk_000000000000000f" } as const; +const store: LinkStore = { + version: 1, + listenerPort: 56603, + links: [ + { id: ids.mingw, alias: "mini", direction: "hub-initiated", hostKeyFingerprint: "SHA256:aaaaaaaaaaaaaaaa", tunnelPort: 52889, apiKeyId: "key-mini", createdAt: "2026-10-11T13:35:02.871Z" }, + { id: ids.cmd, alias: "win-cmd", direction: "hub-initiated", hostKeyFingerprint: "SHA256:bbbbbbbbbbbbbbbb", tunnelPort: 52890, apiKeyId: "key-cmd", createdAt: "2026-10-11T13:35:02.871Z" }, + { id: ids.ps, alias: "win-ps", direction: "hub-initiated", hostKeyFingerprint: "SHA256:cccccccccccccccc", tunnelPort: 52891, apiKeyId: "key-ps", createdAt: "2026-10-11T13:35:02.871Z" }, + { id: ids.linux, alias: "studio", direction: "hub-initiated", hostKeyFingerprint: "SHA256:dddddddddddddddd", tunnelPort: 52892, apiKeyId: "key-linux", createdAt: "2026-10-11T13:35:02.871Z" }, + { id: ids.down, alias: "offline", direction: "hub-initiated", hostKeyFingerprint: "SHA256:eeeeeeeeeeeeeeee", tunnelPort: 52893, apiKeyId: "key-down", createdAt: "2026-10-11T13:35:02.871Z" }, + { id: ids.localized, alias: "win-de", direction: "hub-initiated", hostKeyFingerprint: "SHA256:ffffffffffffffff", tunnelPort: 52895, apiKeyId: "key-de", createdAt: "2026-10-11T13:35:02.871Z" }, + { id: ids.joined, alias: "laptop", direction: "client-initiated", hostKeyFingerprint: null, tunnelPort: 52894, apiKeyId: "key-joined", createdAt: "2026-10-11T13:35:02.871Z" }, + ], +}; +const answers: Record = { + mini: ok("MINGW64_NT-10.0-26200\n"), + "win-cmd": failed(1, CMD_REFUSAL), + "win-ps": failed(1, POWERSHELL_REFUSAL), + studio: ok("Linux\n"), + "win-de": failed(1, "Die Benennung \"sh\" wurde nicht als Name eines Cmdlet erkannt.\r\n"), + offline: failed(255, "ssh: connect to host offline port 22: Connection refused\n"), +}; + +let root: string; +let previousHome: string | undefined; +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + root = mkdtempSync(join(tmpdir(), "ocx-link-cleanup-test-")); + process.env.OPENCODEX_HOME = root; + writeLinkStore(linkStorePath(), store); +}); +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(root); +}); + +function harness() { + const calls: (readonly string[])[] = [], out: string[] = [], err: string[] = []; + const log = console.log, error = console.error; + console.log = (...args) => out.push(args.map(String).join(" ")); + console.error = (...args) => err.push(args.map(String).join(" ")); + const runner: SshRunner = { + async run(argv) { + calls.push(argv); + const alias = argv[argv.indexOf("--") + 1]!; + // The locale-independent follow-up: only the localized Windows host answers Windows_NT. + if (argv.at(-1) === WINDOWS_OS_PROBE) return alias === "win-de" ? ok("Windows_NT\r\n") : failed(127, "cmd: not found"); + const answer = answers[alias]; + if (!answer) throw new Error(`unexpected ssh to ${alias}`); + return answer; + }, + spawnTunnel() { throw new Error("cleanup must not start a tunnel"); }, + }; + const deps: LinkCliDeps = { + sshRunner: runner, + // Cleanup is local: any management request would be a revoke path or a status read it does not need. + fetchImpl: async () => { throw new Error("cleanup must not call the management API"); }, + findLiveProxy: async () => { throw new Error("cleanup must not discover the proxy"); }, + }; + return { calls, out, err, deps, restore: () => { console.log = log; console.error = error; } }; +} + +describe("classifyRemotePlatform", () => { + test.each([ + ["Git Bash uname", ok("MINGW64_NT-10.0-26200\n"), "windows"], + ["MSYS uname", ok("MSYS_NT-10.0\n"), "windows"], + ["Cygwin uname", ok("CYGWIN_NT-10.0\n"), "windows"], + ["ocx prelude status", failed(REMOTE_PLATFORM_UNSUPPORTED, ""), "windows"], + ["cmd.exe refusal", failed(1, CMD_REFUSAL), "windows"], + ["PowerShell refusal", failed(1, POWERSHELL_REFUSAL), "windows"], + ["PowerShell 7 refusal", failed(1, "sh: The term 'sh' is not recognized as a name of a cmdlet, function, script file, or executable program."), "windows"], + ["Linux", ok("Linux\n"), "posix"], + ["macOS", ok("Darwin\n"), "posix"], + ["ssh transport failure", failed(255, "Connection refused"), "unknown"], + ["empty output", ok(""), "unknown"], + ] as const)("%s", (_label, result, platform) => { + expect(classifyRemotePlatform(result)).toBe(platform); + }); +}); + +describe("ocx link cleanup", () => { + test("explains every Windows Child with the forced-revoke recovery order and changes nothing", async () => { + const before = readFileSync(linkStorePath(), "utf8"); + const h = harness(); try { + expect(await runLinkCommand(["cleanup", "--json"], h.deps)).toBe(0); + const report = JSON.parse(h.out.join("\n")); + expect(report.changed).toBe(false); + expect(report.links.map((entry: { linkId: string; platform: string }) => [entry.linkId, entry.platform])).toEqual([ + [ids.mingw, "windows"], [ids.cmd, "windows"], [ids.ps, "windows"], + [ids.linux, "posix"], [ids.down, "unknown"], [ids.localized, "windows"], [ids.joined, "not_checked"], + ]); + const mini = report.links[0]; + expect(mini.recovery).toEqual([ + "On mini, run 'ocx disconnect' and then 'ocx restart' so it stops using this link.", + `On this Home, run 'ocx link revoke --link-id ${ids.mingw} --force --yes'. A plain revoke cannot disconnect a Windows Child remotely.`, + "Then connect mini in Hub mode: run 'ocx hub invite' on a Hub and the printed 'ocx connect' command on mini.", + ]); + expect(report.links[3].recovery).toEqual([]); + expect(report.links[4].reason).toContain("Connection refused"); + // Only Home-initiated records are asked over the link's own trust: one uname each, plus the + // %OS% follow-up for the one localized refusal (never for ssh's 255 or a Linux answer). + expect(h.calls).toHaveLength(7); + expect(h.calls.filter(argv => argv.at(-1) === WINDOWS_OS_PROBE).map(argv => argv[argv.indexOf("--") + 1])).toEqual(["win-de"]); + for (const argv of h.calls) { + expect(argv).toContain(`UserKnownHostsFile=${linkKnownHostsPath()}`); + expect(argv).toContain("StrictHostKeyChecking=yes"); + } + expect(readFileSync(linkStorePath(), "utf8")).toBe(before); + expect(h.err).toEqual([]); + } finally { h.restore(); } + }); + + test("human output names the Windows Child, its steps and that nothing was revoked", async () => { + const h = harness(); try { + expect(await runLinkCommand(["cleanup", "--link-id", ids.mingw], h.deps)).toBe(0); + expect(h.calls).toHaveLength(1); + expect(h.out[0]).toBe(`${ids.mingw} (mini): Windows Child. Remote Link does not support Windows Children; this link predates that refusal.`); + expect(h.out.slice(1, 4).every(line => /^ [123]\. /.test(line))).toBe(true); + expect(h.out.at(-1)).toBe("Nothing was revoked or changed."); + } finally { h.restore(); } + }); + + test.each([[["--link-id", "lnk_ffffffffffffffff"]], [["--link-id", "bad"]], [["--unknown"]]])("refuses %j before any SSH", async args => { + const h = harness(); try { + expect(await runLinkCommand(["cleanup", ...args], h.deps)).toBe(2); + expect(h.calls).toHaveLength(0); + } finally { h.restore(); } + }); +}); diff --git a/tests/cli/cli-link-enrollment.test.ts b/tests/cli/cli-link-enrollment.test.ts index d7987bade24..b494ab08b42 100644 --- a/tests/cli/cli-link-enrollment.test.ts +++ b/tests/cli/cli-link-enrollment.test.ts @@ -72,6 +72,28 @@ describe("CLI Remote Link enrollment", () => { expect(h.calls).toHaveLength(stage === "probe" ? 1 : stage === "confirm" ? 2 : 3); } finally { h.restore(); } }); + test("a Windows Child is refused at the probe with a Hub-mode pointer and no further request", async () => { + const h = harness([{ error: { code: "remote_platform_unsupported", message: "ignored server text" } }], 409); try { + expect(await runLinkCommand(["add", "mini", "--expect-fingerprint", fingerprint], h.deps)).toBe(1); + expect(h.calls.map(x => new URL(x.url).pathname)).toEqual(["/api/link/probe"]); + const message = h.err.join("\n"); + expect(message).toContain("mini is a Windows computer."); + expect(message).toContain("no link key was issued and no tunnel was started"); + expect(message).toContain("'ocx hub invite'"); + expect(message).toContain("'ocx connect'"); + expect(h.out).toEqual([]); + } finally { h.restore(); } + }); + test("a residual Windows refusal after key issuance keeps the Child recovery", async () => { + const h = harness(); try { + let i = 0; + h.deps.fetchImpl = async () => ++i < 3 ? Response.json(i === 1 ? probe : confirm) : Response.json({ error: { code: "remote_platform_unsupported" }, residual: true }, { status: 409 }); + expect(await runLinkCommand(["add", "child-a", "--expect-fingerprint", fingerprint], h.deps)).toBe(1); + const message = h.err.join("\n"); + expect(message).toContain("Run ocx disconnect and ocx restart on the Child before retrying."); + expect(message).not.toContain("no link key was issued"); + } finally { h.restore(); } + }); test("residual failures retain fixed Child recovery without remote error text", async () => { const h = harness(); try { let i = 0; diff --git a/tests/cli/cli-link.test.ts b/tests/cli/cli-link.test.ts index c4d68317e56..1c1df2d698c 100644 --- a/tests/cli/cli-link.test.ts +++ b/tests/cli/cli-link.test.ts @@ -6,6 +6,8 @@ import { CAPABILITIES } from "../../src/cli/capabilities"; import { DISPATCH_COMMANDS } from "../../src/cli/dispatch"; import { findCommand } from "../../src/cli/registry"; +const NO_PARITY = { lastApplyAt: null, runtimeStartedAt: null, appliedCodexSettingsDigest: null, reportedAt: null }; + type RequestCall = { url: string; init: RequestInit | undefined }; function captureOutput(): { stdout: string[]; stderr: string[]; restore: () => void } { @@ -137,8 +139,12 @@ describe("ocx link", () => { readAdminToken: () => "ocx_admin_test-token", }); expect(code).toBe(0); - expect(JSON.parse(output.stdout[0]!)).toEqual(statusResponse); - expect(calls[0]?.url).toBe("http://127.0.0.1:19101/api/link/status"); + // An older runtime omits the OCX-6 parity fields; the CLI prints them as null. + expect(JSON.parse(output.stdout[0]!)).toEqual({ + ...statusResponse, + links: statusResponse.links.map(link => ({ ...link, ...NO_PARITY })), + }); + expect(calls[0]?.url).toBe("http://127.0.0.1:19101/api/link/status?compatibility=1"); expect(new Headers(calls[0]?.init?.headers).get("x-opencodex-api-key")).toBe("ocx_admin_test-token"); } finally { output.restore(); @@ -177,6 +183,7 @@ describe("ocx link", () => { since: "2026-09-25T00:00:00.000Z", reason: null, tunnelPort: 19200, + ...NO_PARITY, }], child: null, }); @@ -273,7 +280,7 @@ describe("ocx link", () => { } }); - test("registers the canonical command and all seven capability leaves", () => { + test("registers the canonical command and all eight capability leaves", () => { expect(findCommand("link")?.name).toBe("link"); expect(DISPATCH_COMMANDS).toContain("link"); expect(CAPABILITIES.filter(cap => cap.command[0] === "link").map(cap => cap.command.join(" "))).toEqual([ @@ -283,7 +290,7 @@ describe("ocx link", () => { "link issue", "link status", "link revoke", - "link reconnect", + "link reconnect", "link cleanup", ]); }); }); diff --git a/tests/cli/cli-provider-list-active.test.ts b/tests/cli/cli-provider-list-active.test.ts new file mode 100644 index 00000000000..aada7e634a9 --- /dev/null +++ b/tests/cli/cli-provider-list-active.test.ts @@ -0,0 +1,165 @@ +/** + * OCX-12: `ocx provider list --json|--jsonl` marks each configured row `active`. A device linked + * to a Home (Remote Link Child or Hub-mode client) routes through the Home, so its local rows are + * shadowed (`active: false`); a standalone device or a Hub serves them (`active: true`). No row + * may carry a credential value, including one hand-edited into a baseUrl. + */ +import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { localProvidersActive, providerListBaseUrl } from "../../src/cli/provider"; +import { serviceApiTokenFingerprint } from "../../src/lib/service-secrets"; +import { repoPath } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +setDefaultTimeout(SPAWN_BUDGET_MS); + +const KEY = `ocx_data_${"f".repeat(40)}`; +// Assembled so the privacy scan does not read the fixture as a real key. +const PROVIDER_SECRET = ["fixture", "provider", "credential", "value"].join("-"); +const URL_SECRET = "fixture-url-secret-value"; +const LINK_ID = `lnk_${"a".repeat(16)}`; + +let root = ""; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-provider-list-active-")); + for (const dir of ["ocx", "home", "claude", "codex"]) mkdirSync(join(root, dir)); +}); + +afterEach(() => { + if (root) removeTreeWithRetry(root); +}); + +const providers = { + openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }, + fixture: { + adapter: "openai-chat", + baseUrl: "https://models.example.test/v1", + apiKey: PROVIDER_SECRET, + defaultModel: "fixture-model", + models: ["fixture-model"], + }, +}; + +function clientBlock(transport: "link" | "hub") { + return { + serverUrl: transport === "link" ? "http://127.0.0.1:23458" : "https://hub.example.test", + managementUrl: transport === "link" ? "http://127.0.0.1:23458" : "https://hub.example.test", + managementTransport: "direct", + ...(transport === "link" ? { transport: "link", link: { tunnelPort: 23458, linkId: LINK_ID } } : {}), + selectedClients: ["codex"], + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + apiKeyId: "link-key-1", + tokenFingerprint: serviceApiTokenFingerprint(KEY), + protocolVersion: 1, + connectedAt: "2026-10-01T00:00:00.000Z", + }; +} + +function writeConfig(role: "standalone" | "hub" | "link" | "hub-client", extra: Record = {}): void { + const linked = role === "link" || role === "hub-client"; + writeFileSync(join(root, "ocx", "config.json"), JSON.stringify({ + port: 10100, + providers, + defaultProvider: "openai", + ...(role === "hub" ? { runtimeRole: "hub" } : {}), + ...(linked ? { runtimeRole: "client", client: clientBlock(role === "link" ? "link" : "hub") } : {}), + ...extra, + })); + if (linked) writeFileSync(join(root, "ocx", "service-api-token"), `${KEY}\n`, { mode: 0o600 }); +} + +function runList(flag: "--json" | "--jsonl") { + const preload = join(root, "no-proxy.ts"); + writeFileSync(preload, ` + import { mock } from "bun:test"; + const path = ${JSON.stringify(repoPath("src/server/proxy-liveness.ts"))}; + const original = await import(path); + mock.module(path, () => ({ ...original, findLiveProxy: async () => null })); + globalThis.fetch = () => { throw new Error("fixture network forbidden"); }; + `); + return spawnSync(process.execPath, ["--preload", preload, repoPath("src/cli/index.ts"), "provider", "list", flag], { + cwd: repoPath(), + env: { + ...process.env, + OPENCODEX_HOME: join(root, "ocx"), + HOME: join(root, "home"), + USERPROFILE: join(root, "home"), + CLAUDE_CONFIG_DIR: join(root, "claude"), + CODEX_HOME: join(root, "codex"), + }, + encoding: "utf8", + timeout: SPAWN_BUDGET_MS - 5_000, + }); +} + +function activeFlags(flag: "--json" | "--jsonl"): Record { + const result = runList(flag); + expect(result.status).toBe(0); + const rows = flag === "--json" + ? (JSON.parse(result.stdout) as { configured: Array> }).configured + : result.stdout.trim().split("\n").map(line => JSON.parse(line) as Record); + return Object.fromEntries(rows.map(row => [row.name, row.active])); +} + +describe("ocx provider list active", () => { + test.each(["--json", "--jsonl"] as const)("a Remote Link Child's rows are shadowed (%s)", flag => { + writeConfig("link"); + expect(activeFlags(flag)).toEqual({ openai: false, fixture: false }); + }); + + test("a Hub-mode client's rows are shadowed", () => { + writeConfig("hub-client"); + expect(activeFlags("--json")).toEqual({ openai: false, fixture: false }); + }); + + test.each(["--json", "--jsonl"] as const)("a standalone device's rows serve (%s)", flag => { + writeConfig("standalone"); + expect(activeFlags(flag)).toEqual({ openai: true, fixture: true }); + }); + + test("a Hub's own rows serve", () => { + writeConfig("hub"); + expect(activeFlags("--json")).toEqual({ openai: true, fixture: true }); + }); + + test("rows carry no credential values", () => { + writeConfig("link"); + const result = runList("--json"); + expect(result.status).toBe(0); + expect(result.stdout).not.toContain(PROVIDER_SECRET); + expect(result.stdout).not.toContain(KEY); + expect(JSON.parse(result.stdout).configured).toEqual([ + { + name: "openai", adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", defaultModel: null, isDefault: true, source: "registry", models: [], active: false, + }, + { + name: "fixture", adapter: "openai-chat", baseUrl: "https://models.example.test/v1", + authMode: "key", defaultModel: "fixture-model", isDefault: false, source: "custom", models: ["fixture-model"], active: false, + }, + ]); + }); +}); + +describe("provider list projections", () => { + test("baseUrl drops userinfo and masks credential-shaped query values only", () => { + expect(providerListBaseUrl(`https://user:${URL_SECRET}@models.example.test/v1?api-version=2026-01-01&key=${URL_SECRET}&access_token=${URL_SECRET}`)) + .toBe("https://models.example.test/v1?api-version=2026-01-01&key=redacted&access_token=redacted"); + expect(providerListBaseUrl("https://models.example.test/v1")).toBe("https://models.example.test/v1"); + expect(providerListBaseUrl("not a url")).toBe("not a url"); + }); + + test("only a connected client state shadows local rows", () => { + expect(localProvidersActive(() => ({ kind: "disconnected" }))).toBe(true); + expect(localProvidersActive(() => ({ kind: "invalid", reason: "fixture" }))).toBe(true); + expect(localProvidersActive(() => ({ kind: "mismatched", reason: "fixture" }))).toBe(true); + expect(localProvidersActive(() => { throw new Error("fixture"); })).toBe(true); + expect(localProvidersActive(() => ({ kind: "connected", value: clientBlock("hub") as never }))).toBe(false); + }); +}); + diff --git a/tests/cli/cli-provider.test.ts b/tests/cli/cli-provider.test.ts index 92a054f0b61..b79ddbd25ce 100644 --- a/tests/cli/cli-provider.test.ts +++ b/tests/cli/cli-provider.test.ts @@ -183,6 +183,7 @@ describe("ocx provider", () => { isDefault: false, source: "registry", models: [], + active: true, }, { name: "custom.models-1", @@ -193,6 +194,7 @@ describe("ocx provider", () => { isDefault: true, source: "custom", models: ["plain-model", escapedModel], + active: true, }, ]); } finally { diff --git a/tests/cli/cli-route-policy-write.test.ts b/tests/cli/cli-route-policy-write.test.ts index 8df7f104681..e215eb82c06 100644 --- a/tests/cli/cli-route-policy-write.test.ts +++ b/tests/cli/cli-route-policy-write.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -6,6 +6,7 @@ import { Readable } from "node:stream"; import { handleRoutePolicyWriteCommand } from "../../src/cli/route-policy-write"; import type { RuntimeApiDeps } from "../../src/cli/runtime-api"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { COLD_SPAWN_WARMUP_HOOK_BUDGET_MS, warmModuleGraph } from "../helpers/cold-spawn-warmup"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath, repoRoot } from "../helpers/repo-root"; import { spawnTestChild } from "../helpers/spawn-test-child"; @@ -191,8 +192,7 @@ describe("routing profile wire contract and output", () => { // A separate process contains Lab's process-global slots and all server imports. // Its empty homes keep automation disabled; injected transports never open a listener. -test("actual routing editor preserves stale/create/update/alias/delete authority", async () => { - const script = ` +const ROUTING_EDITOR_SCRIPT = ` import assert from "node:assert/strict"; import { writeFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; @@ -257,15 +257,27 @@ test("actual routing editor preserves stale/create/update/alias/delete authority originalLog(JSON.stringify({ scenarios: 9, saves, refreshes, requests: requests.length, automationEnabled: false })); } finally { resetLabActivationForTests(); } `; - const childHome = join(home, "real-handler"); - childCompleted = false; - const result = await spawnTestChild([process.execPath, "--eval", script], { cwd: repoRoot(), deadlineMs: 20_000, - env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, NODE_ENV: "test", HOME: childHome, - USERPROFILE: childHome, APPDATA: childHome, XDG_CONFIG_HOME: childHome, OPENCODEX_HOME: childHome, - CODEX_HOME: join(childHome, "codex"), NO_COLOR: "1" }, - }); - childCompleted = !result.timedOut && result.reaped && result.pipesClosed; - // Keep child diagnostics in the failure instead of leaving a live process after Bun times out. - expect(result).toMatchObject({ exitCode: 0, stderr: "", timedOut: false, reaped: true, pipesClosed: true }); - expect(JSON.parse(result.stdout.trim())).toEqual({ scenarios: 9, saves: 3, refreshes: 3, requests: 9, automationEnabled: false }); -}, SPAWN_BUDGET_MS); + +describe("routing editor child", () => { + // The child loads the management API, routing and Lab graphs. On a cold Windows runner that + // load alone used most of the 20s child deadline, so pay it once here and time a warm child. + beforeAll(async () => { + await warmModuleGraph({ graph: "cli/route-policy-write-editor", source: ROUTING_EDITOR_SCRIPT }); + }, COLD_SPAWN_WARMUP_HOOK_BUDGET_MS); + + test("actual routing editor preserves stale/create/update/alias/delete authority", async () => { + const childHome = join(home, "real-handler"); + childCompleted = false; + const result = await spawnTestChild([process.execPath, "--eval", ROUTING_EDITOR_SCRIPT], { cwd: repoRoot(), deadlineMs: 20_000, + env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, NODE_ENV: "test", HOME: childHome, + USERPROFILE: childHome, APPDATA: childHome, XDG_CONFIG_HOME: childHome, OPENCODEX_HOME: childHome, + CODEX_HOME: join(childHome, "codex"), NO_COLOR: "1", + // The run-private cache the warm-up filled; without it this child transpiles cold again. + BUN_RUNTIME_TRANSPILER_CACHE_PATH: process.env.BUN_RUNTIME_TRANSPILER_CACHE_PATH }, + }); + childCompleted = !result.timedOut && result.reaped && result.pipesClosed; + // Keep child diagnostics in the failure instead of leaving a live process after Bun times out. + expect(result).toMatchObject({ exitCode: 0, stderr: "", timedOut: false, reaped: true, pipesClosed: true }); + expect(JSON.parse(result.stdout.trim())).toEqual({ scenarios: 9, saves: 3, refreshes: 3, requests: 9, automationEnabled: false }); + }, SPAWN_BUDGET_MS); +}); diff --git a/tests/cli/cli-update-restart-child.test.ts b/tests/cli/cli-update-restart-child.test.ts index cbc2448e369..101fa61b28e 100644 --- a/tests/cli/cli-update-restart-child.test.ts +++ b/tests/cli/cli-update-restart-child.test.ts @@ -6,7 +6,7 @@ function setup() { const calls: string[] = []; let now = 1000; let exit: (() => void) | undefined; - const marker = { home: { config: { path: "/test/ocx", dev: 1, ino: 2 }, codex: { path: "/test/codex", dev: 1, ino: 3 }, revision: 0, serviceRecord: { schema: 1 as const, digest: "a".repeat(64) } }, version: "2.77.0", port: 10100, hostname: "127.0.0.1", deadlineAt: 5000 }; + const marker = { home: { config: { path: "/test/ocx", dev: 1, ino: 2, fileId: "1:2" }, codex: { path: "/test/codex", dev: 1, ino: 3, fileId: "1:3" }, revision: 0, serviceRecord: { schema: 1 as const, digest: "a".repeat(64) } }, version: "2.77.0", port: 10100, hostname: "127.0.0.1", deadlineAt: 5000 }; const env = { [UPDATE_RESTART_CHILD_ENV]: JSON.stringify(marker), [OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]: "parent-token" }; const io: UpdateRestartChildIo = { checkState: () => {}, diff --git a/tests/cli/cli-update-restart-home.test.ts b/tests/cli/cli-update-restart-home.test.ts index f8cc487f5cd..d3485d522c4 100644 --- a/tests/cli/cli-update-restart-home.test.ts +++ b/tests/cli/cli-update-restart-home.test.ts @@ -28,6 +28,22 @@ test("physical home identity detects same-path directory replacement", () => { renameSync(h.config, h.config + "-old"); mkdirSync(h.config); expect(() => checkHome(captured)).toThrow(); }); +test("file ids beyond double precision still distinguish a replaced directory", () => { + // NTFS ids carry the MFT sequence in the high bits; a replacement directory often gets the + // neighbouring MFT index, and above 2^53 a double cannot tell the two ids apart. + home(); + const original = 2n ** 58n + 4096n; + expect(Number(original + 1n)).toBe(Number(original)); + let ino = original; + const deps = { + statDirectory: () => ({ dev: 7n, ino, isDirectory: () => true }), + supervision: { stat: () => ({ isFile: () => true, mode: 0o100755 }), platform: "darwin" as const, run: () => ({ status: 113, stdout: "", stderr: "" }) }, + }; + const captured = readUpdateRestartHome(deps); + expect(() => assertUpdateRestartHome(captured, Date.now() + 5000, deps)).not.toThrow(); + ino = original + 1n; + expect(() => assertUpdateRestartHome(captured, Date.now() + 5000, deps)).toThrow("update_restart_home_changed"); +}); test("canonical aliases identify the same directory", () => { const h = home(); const captured = readUpdateRestartHome(); const alias = join(h.root, "alias"); symlinkSync(h.config, alias, process.platform === "win32" ? "junction" : "dir"); diff --git a/tests/cli/cli-update-restart.test.ts b/tests/cli/cli-update-restart.test.ts index 94954db1744..eee339f1288 100644 --- a/tests/cli/cli-update-restart.test.ts +++ b/tests/cli/cli-update-restart.test.ts @@ -6,7 +6,7 @@ import { updateRestartVeto } from "../../src/update/restart-ownership"; import type { LiveProxy } from "../../src/server/proxy-liveness"; const candidate: UpdateRestartCandidate = { - home: { config: { path: "/test/ocx", dev: 1, ino: 2 }, codex: { path: "/test/codex", dev: 1, ino: 3 }, revision: 0, serviceRecord: { schema: 1 as const, digest: "a".repeat(64) } }, + home: { config: { path: "/test/ocx", dev: 1, ino: 2, fileId: "1:2" }, codex: { path: "/test/codex", dev: 1, ino: 3, fileId: "1:3" }, revision: 0, serviceRecord: { schema: 1 as const, digest: "a".repeat(64) } }, target: { pid: 123, port: 10100, hostname: "127.0.0.1", source: "runtime", version: "2.76.0" }, runtime: { pid: 123, port: 10100, hostname: "127.0.0.1", attestationSecret: "a".repeat(43) }, cliVersion: "2.77.0", @@ -14,7 +14,7 @@ const candidate: UpdateRestartCandidate = { function setup() { const calls: string[] = []; let now = 1000; - const home = { config: { path: "/test/ocx", dev: 1, ino: 2 }, codex: { path: "/test/codex", dev: 1, ino: 3 }, revision: 0, serviceRecord: { schema: 1 as const, digest: "a".repeat(64) } }; + const home = { config: { path: "/test/ocx", dev: 1, ino: 2, fileId: "1:2" }, codex: { path: "/test/codex", dev: 1, ino: 3, fileId: "1:3" }, revision: 0, serviceRecord: { schema: 1 as const, digest: "a".repeat(64) } }; const live: LiveProxy = { ...candidate.target, pid: 456, version: "2.77.0" }; const io: UpdateRestartIo = { now: () => now, acquire: () => { calls.push("acquire"); return { release: () => { calls.push("release"); } }; }, diff --git a/tests/cli/link-apply-restart.test.ts b/tests/cli/link-apply-restart.test.ts new file mode 100644 index 00000000000..0210863da24 --- /dev/null +++ b/tests/cli/link-apply-restart.test.ts @@ -0,0 +1,210 @@ +/** + * OCX-6 on the CLI side of a Remote Link Child: + * + * - `ocx sync` restarts the client runtime only when it started before the recorded apply, and + * never starts one that was not running (apply -> restart -> recorded timestamps); + * - `ocx restart` asks a client runtime only while its rollback still fits inside the CLI's + * observation window, and reports a supervised refusal as "not performed, runtime kept"; + * - the shape of the `ocx status --json` fields a peer compares. + */ +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 { applyRestartMessage, restartRuntimeAfterApply, type ApplyRestartDeps } from "../../src/cli/link-apply-restart"; +import { requestBoundSystemRestart } from "../../src/cli/system-restart-client"; +import { reportRestartFailure } from "../../src/cli/restart-failure"; +import { collectAppliedCodexSettingsStatus } from "../../src/cli/status-codex-settings"; +import { recordHomeCodexSettings, type HomeCodexSettingsRecord } from "../../src/client/link-home"; +import type { HomeCodexSettingsDocument } from "../../src/link/codex-settings"; +import { + createLocalAttestationProof, + createLocalAttestationSecret, + LOCAL_ATTESTATION_CHALLENGE_HEADER, + LOCAL_ATTESTATION_PROOF_HEADER, +} from "../../src/lib/local-management-attestation"; +import { CLIENT_RESTART_REQUEST_MIN_REMAINING_MS, SYSTEM_RESTART_PATH } from "../../src/lib/system-restart-contract"; +import type { LiveProxy } from "../../src/server/proxy-liveness"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const LINK_ID = `lnk_${"e".repeat(16)}`; +const child: LiveProxy = { pid: 5151, port: 10100, hostname: "127.0.0.1", source: "runtime", role: "client" }; +const document: HomeCodexSettingsDocument = { + version: 1, + home: { name: "home-box", version: "2.91.0" }, + settings: { fastMode: true, multiAgentMode: "v1", webSearchSidecarEnabled: false, subagentDefaults: null }, +}; +const APPLIED = "2026-10-21T01:00:00.000Z"; +const record: HomeCodexSettingsRecord = { linkId: LINK_ID, fetchedAt: APPLIED, appliedAt: APPLIED, document }; + +let root = ""; +let previousHome: string | undefined; +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + root = mkdtempSync(join(tmpdir(), "ocx-link-apply-restart-")); + process.env.OPENCODEX_HOME = root; +}); +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(root); +}); + +function deps(startedAt: number | null, overrides: Partial = {}) { + const calls: string[] = []; + const value: ApplyRestartDeps = { + findLive: async () => child, + readStartedAt: async () => startedAt, + restart: async startWhenStopped => { + calls.push("restart"); + // The callback starts nothing on a first observation and recovers a vanished runtime. + calls.push(`first:${(await startWhenStopped(false)).status}`); + calls.push(`recovering:${(await startWhenStopped(true)).status}`); + return true; + }, + recoveryStart: async recovering => { calls.push(`recoveryStart:${recovering}`); return { status: "started" }; }, + connectionUnchanged: () => true, + ...overrides, + }; + return { value, calls }; +} + +describe("ocx sync restarts the runtime after an apply", () => { + test("apply -> restart -> the restarted runtime reads as current, so the next sync does nothing", async () => { + const appliedAt = Date.parse(APPLIED); + const stale = deps(appliedAt - 60_000); + expect(await restartRuntimeAfterApply(record, stale.value)).toEqual({ kind: "restarted" }); + expect(stale.calls).toEqual(["restart", "first:skipped", "recoveryStart:true", "recovering:started"]); + + const fresh = deps(appliedAt + 5_000); + expect(await restartRuntimeAfterApply(record, fresh.value)).toEqual({ kind: "current" }); + expect(fresh.calls).toEqual([]); + }); + + test("a runtime that reports no start time cannot prove it is current and is restarted", async () => { + const { value, calls } = deps(null); + expect(await restartRuntimeAfterApply(record, value)).toEqual({ kind: "restarted" }); + expect(calls[0]).toBe("restart"); + }); + + test("nothing running, or a standalone proxy answering, starts nothing", async () => { + for (const live of [null, { ...child, role: undefined }]) { + const { value, calls } = deps(0, { findLive: async () => live }); + expect(await restartRuntimeAfterApply(record, value)).toEqual({ kind: "not-running" }); + expect(calls).toEqual([]); + } + }); + + test("a changed connection or a failed restart is reported, never hidden", async () => { + const changed = deps(0, { connectionUnchanged: () => false }); + expect(await restartRuntimeAfterApply(record, changed.value)).toEqual({ kind: "connection-changed" }); + expect(changed.calls).toEqual([]); + const failed = deps(0, { restart: async () => false }); + const outcome = await restartRuntimeAfterApply(record, failed.value); + expect(outcome).toEqual({ kind: "failed" }); + expect(applyRestartMessage(outcome)).toContain("the previous runtime keeps serving"); + }); +}); + +function clientHealthDeps(postResponse: Response) { + const secret = createLocalAttestationSecret(); + const posts: string[] = []; + return { + posts, + deps: { + fetchImpl: (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/healthz")) { + const challenge = new Headers(init?.headers).get(LOCAL_ATTESTATION_CHALLENGE_HEADER)!; + return new Response(JSON.stringify({ + status: "ok", service: "opencodex", version: "test", uptime: 1, pid: child.pid, port: child.port, + role: "client", restartCapability: "v1", startedAt: APPLIED, + }), { headers: { [LOCAL_ATTESTATION_PROOF_HEADER]: createLocalAttestationProof(secret, challenge, child.pid!, child.port)! } }); + } + posts.push(url); + return postResponse.clone(); + }) as typeof fetch, + readRuntime: () => ({ pid: child.pid!, port: child.port, hostname: "127.0.0.1", attestationSecret: secret }), + findLive: async () => child, + createChallenge: () => "A".repeat(43), + now: () => 1_000, + cliVersion: "test", + }, + }; +} + +describe("ocx restart against a client runtime", () => { + test("a request that would leave the rollback too little time is refused before it is sent", async () => { + const { deps: d, posts } = clientHealthDeps(new Response(null, { status: 202 })); + const outcome = await requestBoundSystemRestart(child, 1_000 + CLIENT_RESTART_REQUEST_MIN_REMAINING_MS - 1, d); + expect(outcome).toMatchObject({ accepted: false, uncertain: false }); + expect(outcome.accepted ? "" : (outcome.error as Error).message).toBe("restart_deadline_expired"); + expect(posts).toEqual([]); + }); + + test("in time, the request is sent; a supervised refusal comes back as its own code", async () => { + const supervised = new Response(JSON.stringify({ success: false, error: { code: "restart_supervised", supervisor: "service" } }), { status: 409 }); + const { deps: d, posts } = clientHealthDeps(supervised); + const outcome = await requestBoundSystemRestart(child, 1_000 + CLIENT_RESTART_REQUEST_MIN_REMAINING_MS, d); + expect(posts).toEqual([`http://127.0.0.1:${child.port}${SYSTEM_RESTART_PATH}`]); + expect(outcome.accepted ? "" : (outcome.error as Error).message).toBe("restart_supervised_service"); + + const other = clientHealthDeps(new Response(JSON.stringify({ success: false, error: "Restart target identity changed." }), { status: 409 })); + const generic = await requestBoundSystemRestart(child, 1_000 + CLIENT_RESTART_REQUEST_MIN_REMAINING_MS, other.deps); + expect(generic.accepted ? "" : (generic.error as Error).message).toBe("restart_request_http_409"); + }); + + test("the supervised refusal says the runtime was kept and how to restart it", () => { + const lines: string[] = []; + const original = console.error; + console.error = (...args: unknown[]) => { lines.push(args.join(" ")); }; + try { + reportRestartFailure({ ok: false, phase: "request", error: new Error("restart_supervised_service") }); + reportRestartFailure({ ok: false, phase: "request", error: new Error("restart_supervised_desktop") }); + } finally { + console.error = original; + } + expect(lines).toEqual([ + "Restart not performed; the running client runtime was kept. Its background service owns it: run `ocx service restart`.", + "Restart not performed; the running client runtime was kept. The OpenCodex desktop app owns it: restart it from the app.", + ]); + }); +}); + +describe("ocx status --json OCX-6 fields (shape snapshot)", () => { + const homeConfig = { fastMode: true, multiAgentMode: "v1", webSearchSidecar: { enabled: false } } as unknown as OcxConfig; + const childConfig = { + runtimeRole: "client", + client: { transport: "link", link: { linkId: LINK_ID, tunnelPort: 23457 } }, + } as unknown as OcxConfig; + + test("a Home publishes the digest of its own values and no Child times", async () => { + expect(await collectAppliedCodexSettingsStatus(homeConfig, null)).toEqual({ + appliedCodexSettingsDigest: "57c751eff89844f93db71a6ee4acdac03afbc29fec52734ade80a40193e826cd", + lastApplyAt: null, + runtimeStartedAt: null, + }); + }); + + test("a Child reports its recorded apply and its runtime's start, as epoch ms", async () => { + expect(await collectAppliedCodexSettingsStatus(childConfig, child, { readStartedAt: async () => 1_761_008_405_000 })).toEqual({ + appliedCodexSettingsDigest: null, lastApplyAt: null, runtimeStartedAt: 1_761_008_405_000, + }); + recordHomeCodexSettings(LINK_ID, { source: "home", document, fetchedAt: APPLIED }, () => new Date(APPLIED)); + expect(await collectAppliedCodexSettingsStatus(childConfig, child, { readStartedAt: async () => Date.parse(APPLIED) + 9_000 })).toEqual({ + // Same values as the Home above: the digests agree. + appliedCodexSettingsDigest: "57c751eff89844f93db71a6ee4acdac03afbc29fec52734ade80a40193e826cd", + lastApplyAt: Date.parse(APPLIED), + runtimeStartedAt: Date.parse(APPLIED) + 9_000, + }); + expect(await collectAppliedCodexSettingsStatus(childConfig, null)).toMatchObject({ runtimeStartedAt: null }); + }); + + test("a hub client applies no Home settings", async () => { + const hubClient = { runtimeRole: "client", client: { transport: "hub" } } as unknown as OcxConfig; + expect(await collectAppliedCodexSettingsStatus(hubClient, null)).toEqual({ + appliedCodexSettingsDigest: null, lastApplyAt: null, runtimeStartedAt: null, + }); + }); +}); diff --git a/tests/cli/ocx-launcher-fixture-trust.test.ts b/tests/cli/ocx-launcher-fixture-trust.test.ts new file mode 100644 index 00000000000..17e62fe40d2 --- /dev/null +++ b/tests/cli/ocx-launcher-fixture-trust.test.ts @@ -0,0 +1,98 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { chmodSync, existsSync, lstatSync, mkdirSync, readlinkSync, realpathSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { findPathBun, type PathBunIo } from "../../src/lib/bun-path-runtime.mjs"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { TRUSTED_FIXTURE_DIR_ENV, fixtureBaseCandidates, trustedFixtureRoot, untrustedFixtureAncestor } from "../helpers/trusted-fixture-root"; + +// #7402: a checkout under group-writable directories (macOS external volumes) made every +// real PATH-Bun fixture fail, because the fixtures sat beside the checkout. These cases +// rebuild that ancestry with chmod 0775 inside a private root and pin both halves of the +// fix: the product check still refuses such a tree, and fixture placement steps around it. +const roots: string[] = []; +afterAll(() => { for (const root of roots) removeTreeWithRetry(root); }); + +function privateRoot(prefix: string): string { + const root = trustedFixtureRoot(prefix); + roots.push(root); + return root; +} + +function plantBun(directory: string): string { + const bin = join(directory, "bin"); + mkdirSync(bin, { recursive: true, mode: 0o700 }); + writeFileSync(join(bin, "bun"), "fixture"); + chmodSync(join(bin, "bun"), 0o755); + return bin; +} + +// Real filesystem trust walk; only the binary identity and version probe are stubbed. +function probe(binDir: string) { + let spawns = 0; + const io: PathBunIo = { + lstat: lstatSync, readlink: readlinkSync, realpath: realpathSync.native, stat: statSync, + geteuid: () => process.geteuid?.(), access: () => {}, isRealBunBinary: () => true, + spawnSync: () => { spawns++; return { status: 0, stdout: "1.4.2" }; }, + }; + const selected = findPathBun({ env: { PATH: binDir }, pinnedVersion: "1.4.2", io }); + return { selected, spawns }; +} + +describe.skipIf(process.platform === "win32")("PATH-Bun fixture trust under a group-writable ancestor", () => { + test("the product check rejects a Bun whose ancestor is only group-writable", () => { + const shared = join(privateRoot("ocx-7402-reject-"), "shared"); + mkdirSync(shared, { mode: 0o700 }); + const binDir = plantBun(join(shared, "checkout")); + chmodSync(shared, 0o775); + expect(probe(binDir)).toEqual({ selected: null, spawns: 0 }); + // Same tree with only the group bit cleared is accepted, so that bit alone drove the rejection. + chmodSync(shared, 0o755); + expect(probe(binDir).selected?.path).toBe(realpathSync.native(join(binDir, "bun"))); + }); + + test("fixture placement skips the group-writable candidate and lands where the product check passes", () => { + const outer = privateRoot("ocx-7402-select-"); + const shared = join(outer, "shared"); + mkdirSync(shared, { mode: 0o700 }); + chmodSync(shared, 0o775); + const sibling = join(shared, ".tmp", "path-bun-fixtures"); + const trusted = join(outer, "private-base"); + expect(untrustedFixtureAncestor(sibling)).toContain("group/other-writable (mode 775)"); + const root = trustedFixtureRoot("ocx-7402-fixture-", [sibling, trusted]); + expect(root.startsWith(realpathSync.native(trusted) + "/")).toBe(true); + expect(existsSync(sibling)).toBe(false); + const binDir = plantBun(root); + expect(probe(binDir).selected?.path).toBe(realpathSync.native(join(binDir, "bun"))); + }); + + test("a candidate beneath an ancestor node_modules/bun is refused", () => { + const outer = privateRoot("ocx-7402-provenance-"); + mkdirSync(join(outer, "node_modules", "bun"), { recursive: true, mode: 0o700 }); + const candidate = join(outer, "inner"); + expect(untrustedFixtureAncestor(candidate)).toBe(`${outer} contains node_modules/bun`); + expect(() => trustedFixtureRoot("ocx-7402-leak-", [candidate])).toThrow(TRUSTED_FIXTURE_DIR_ENV); + expect(existsSync(candidate)).toBe(false); + }); + + // Root ignores directory permission bits, so the uncreatable candidate only exists for other users. + test.skipIf(process.geteuid?.() === 0)("a trusted but uncreatable candidate is recorded and the next one is tried", () => { + const outer = privateRoot("ocx-7402-readonly-"); + const readonly = join(outer, "readonly"); + mkdirSync(readonly, { mode: 0o700 }); + chmodSync(readonly, 0o500); + try { + const blocked = join(readonly, "base"); + const fallback = join(outer, "fallback"); + expect(untrustedFixtureAncestor(blocked)).toBeNull(); + expect(trustedFixtureRoot("ocx-7402-next-", [blocked, fallback]).startsWith(realpathSync.native(fallback) + "/")).toBe(true); + expect(() => trustedFixtureRoot("ocx-7402-none-", [blocked])).toThrow(`${blocked}: cannot create (EACCES)`); + } finally { chmodSync(readonly, 0o700); } + }); + + test("candidates keep the override first and include the per-user runtime dir", () => { + const candidates = fixtureBaseCandidates({ [TRUSTED_FIXTURE_DIR_ENV]: "/override", XDG_RUNTIME_DIR: "/run/user/1000" }); + expect(candidates[0]).toBe("/override"); + expect(candidates).toContain("/run/user/1000/opencodex-path-bun-fixtures"); + expect(fixtureBaseCandidates({ [TRUSTED_FIXTURE_DIR_ENV]: "relative", XDG_RUNTIME_DIR: "" })).not.toContain("relative"); + }); +}); diff --git a/tests/cli/ocx-launcher-runtime.test.ts b/tests/cli/ocx-launcher-runtime.test.ts index ba9d14c21ce..aa596031ef9 100644 --- a/tests/cli/ocx-launcher-runtime.test.ts +++ b/tests/cli/ocx-launcher-runtime.test.ts @@ -3,7 +3,7 @@ import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { chmodSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, realpathSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { bundledBunPath } from "../../src/lib/bun-runtime"; import { findDesktopCli, findPathBun, type PathBunIo } from "../../src/lib/bun-path-runtime.mjs"; @@ -11,6 +11,7 @@ import { REAL_BUN_MIN_BYTES } from "../../src/lib/bun-binary-validator.mjs"; import { killProxy } from "../../src/lib/process-control"; import { repoPath } from "../helpers/repo-root"; import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "../helpers/test-budget"; +import { trustedFixtureRoot } from "../helpers/trusted-fixture-root"; const BIN_OCX = repoPath("bin", "ocx.mjs"); const nodeAvailable = spawnSync("node", ["--version"], { @@ -396,13 +397,9 @@ describe.skipIf(!runnable)("ocx package launcher effective Bun runtime", () => { }); function pathFixtureRoot(prefix: string): string { - if (process.platform === "win32") return mkdtempSync(join(tmpdir(), prefix)); - // A trusted-executable fixture cannot live beneath a shared temp ancestor whose - // owner is neither root nor this user (common in user-namespace sandboxes). - // Keep the launcher package isolated from this checkout's installed Bun. - const base = join(dirname(repoPath()), ".tmp", "path-bun-fixtures"); - mkdirSync(base, { recursive: true, mode: 0o700 }); - return mkdtempSync(join(base, prefix)); + // A trusted-executable fixture needs an ancestry the product check accepts and no + // ancestor node_modules/bun; the helper picks such a base per host (#7402). + return trustedFixtureRoot(prefix); } function pathProbeFixture(platform: NodeJS.Platform = "linux", stdout = "1.4.0\n") { diff --git a/tests/cli/status-install-method.test.ts b/tests/cli/status-install-method.test.ts new file mode 100644 index 00000000000..13ecd380d4a --- /dev/null +++ b/tests/cli/status-install-method.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { installDetectionStatus, type InstallDetectionStatus } from "../../src/cli/status-install"; +import { getDefaultConfig } from "../../src/config"; +import { detectInstallOwnershipFromPath, type InstallDetectionDeps } from "../../src/update/install-detection.mjs"; +import { repoPath } from "../helpers/repo-root"; + +/** + * `ocx status --json` `install` is GOAT's adopt/advise input (goat-si/goat#684 §5 step 3), so + * it must be the verdict `ocx update` itself acts on — the same detector over the same path — + * and must name its evidence without printing that path. + */ + +const lexicalOnly: InstallDetectionDeps = { + exists: () => false, + probe: () => "absent", + realpath: () => { throw new Error("no realpath for synthetic paths"); }, +}; + +function verdictFor(packagePath: string, deps: InstallDetectionDeps = lexicalOnly): InstallDetectionStatus { + return installDetectionStatus(() => detectInstallOwnershipFromPath(packagePath, deps)); +} + +const MISE_ROOT = "/tmp/test-user/.local/share/mise/installs/npm-bitkyc08-opencodex"; +const miseRecord = (record: "present" | "unreadable"): InstallDetectionDeps => ({ + ...lexicalOnly, + probe: path => path === `${MISE_ROOT}/.mise.backend.toml` ? record : "absent", + readFile: () => 'short = "npm:@bitkyc08/opencodex"\nfull = "npm:@bitkyc08/opencodex"\n', +}); + +describe("ocx status --json install-method matrix", () => { + const cases: Array<[string, string, InstallDetectionDeps | undefined, InstallDetectionStatus]> = [ + ["bun global", "/tmp/test-user/.bun/install/global/node_modules/@bitkyc08/opencodex/src/update", undefined, + { method: "bun", evidence: { basis: "package-layout" } }], + ["npm global", "/usr/lib/node_modules/@bitkyc08/opencodex/src/update", undefined, + { method: "npm", evidence: { basis: "package-layout" } }], + ["pnpm virtual store", "/tmp/test-user/.local/share/pnpm/global/v11/node_modules/.pnpm/@bitkyc08+opencodex@2.90.0/node_modules/@bitkyc08/opencodex/src/update", undefined, + { method: "pnpm", evidence: { basis: "package-layout" } }], + ["pnpm on Windows", "C:\\Users\\u\\AppData\\Local\\pnpm\\global\\5\\node_modules\\.pnpm\\@bitkyc08+opencodex@2.90.0\\node_modules\\@bitkyc08\\opencodex\\src\\update", undefined, + { method: "pnpm", evidence: { basis: "package-layout" } }], + ["mise npm backend", `${MISE_ROOT}/2.90.0/node_modules/@bitkyc08/opencodex/src/update`, miseRecord("present"), + { method: "mise", evidence: { basis: "mise-backend-record", mise: { tool: "npm:@bitkyc08/opencodex", backend: "npm:@bitkyc08/opencodex" } } }], + ["mise with an unreadable backend record", `${MISE_ROOT}/2.90.0/node_modules/@bitkyc08/opencodex/src/update`, miseRecord("unreadable"), + { method: "mise", evidence: { basis: "mise-backend-record", error: "metadata_unreadable" } }], + ["source checkout", "/work/opencodex/src/update", undefined, + { method: "source", evidence: { basis: "package-layout" } }], + ]; + + for (const [name, packagePath, deps, expected] of cases) { + test(name, () => { + const verdict = verdictFor(packagePath, deps); + expect(verdict).toEqual(expected); + // Evidence is a class, never the path it was read from. + expect(JSON.stringify(verdict)).not.toContain("test-user"); + expect(JSON.stringify(verdict)).not.toContain("/work/"); + }); + } + + test("the compiled standalone binary", () => { + expect(installDetectionStatus(() => ({ installer: "standalone" }))) + .toEqual({ method: "standalone", evidence: { basis: "standalone-binary" } }); + }); + + test("a detector failure is unknown, not a guess", () => { + expect(installDetectionStatus(() => { throw new Error("fileURLToPath failed"); })) + .toEqual({ method: "unknown", evidence: { basis: "unavailable", error: "detection_failed" } }); + }); + + test("ocx status --json carries the verdict for this checkout", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-status-install-")); + try { + mkdirSync(join(home, "codex"), { recursive: true }); + // Port 9 (discard) is never an opencodex proxy, so status reads nothing live. + writeFileSync(join(home, "config.json"), JSON.stringify({ ...getDefaultConfig(), port: 9, hostname: "127.0.0.1", codexAutoStart: false })); + const run = spawnSync(process.execPath, [repoPath("src", "cli", "index.ts"), "status", "--json"], { + cwd: repoPath(), + env: { ...process.env, OPENCODEX_HOME: home, CODEX_HOME: join(home, "codex") }, + encoding: "utf8", + }); + expect(run.status).toBe(0); + const json = JSON.parse(run.stdout) as { schemaVersion: number; install: InstallDetectionStatus }; + expect(json.schemaVersion).toBe(1); + expect(json.install).toEqual({ method: "source", evidence: { basis: "package-layout" } }); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/cli/system-restart-client.test.ts b/tests/cli/system-restart-client.test.ts index 54d50976e8e..1116d65467a 100644 --- a/tests/cli/system-restart-client.test.ts +++ b/tests/cli/system-restart-client.test.ts @@ -55,7 +55,7 @@ function successfulDeps() { // Matches the /healthz fixture version below so the skew guard stays out of the way; // the dedicated skew tests override it explicitly. cliVersion: "test", - readUpdateHome: () => ({ config: { path: "/test/ocx", dev: 1, ino: 2 }, codex: { path: "/test/codex", dev: 1, ino: 3 }, revision: 0, serviceRecord: { schema: 1 as const, digest: "a".repeat(64) } }), + readUpdateHome: () => ({ config: { path: "/test/ocx", dev: 1, ino: 2, fileId: "1:2" }, codex: { path: "/test/codex", dev: 1, ino: 3, fileId: "1:3" }, revision: 0, serviceRecord: { schema: 1 as const, digest: "a".repeat(64) } }), }, }; } diff --git a/tests/clients/client-home-unreachable.test.ts b/tests/clients/client-home-unreachable.test.ts new file mode 100644 index 00000000000..b8c7103a37c --- /dev/null +++ b/tests/clients/client-home-unreachable.test.ts @@ -0,0 +1,82 @@ +/** + * OCX-11 Home-down contract on a Remote Link Child and a Hub client. + * + * The client runtime that serves both has no provider dispatch, so "never serve from local + * providers while the Home is unreachable" is structural. This file pins that structure (no + * load-time path from the client runtime to provider, adapter or Responses dispatch code) and + * the stable `home_unreachable` answers the relays give instead. + */ +import { describe, expect, test } from "bun:test"; +import { firstLoadTimePathTo } from "../helpers/import-graph"; +import { relayHubManagementRequest } from "../../src/client/hub-relay"; +import { LINK_RELAY_AUTH_FAILED_CODE, relayLinkDataRequest } from "../../src/client/link-relay"; +import { LinkRelayAuthenticationError } from "../../src/client/link-relay-transport"; +import { HOME_UNREACHABLE_CODE, HOME_UNREACHABLE_MESSAGE } from "../../src/client/home-unreachable"; + +// The modules that turn a request into an upstream send: Responses dispatch, the router and +// adapter construction. Config schemas, provider metadata and the combo catalog projection the +// Child's catalog sync reads are shared and are not a serving path. +const LOCAL_SERVING = /\/src\/server\/responses\/|\/src\/router\.ts$|\/src\/server\/adapter-resolve\.ts$/; + +describe("a Child or Hub client cannot serve from local providers", () => { + for (const entry of ["src/client/runtime.ts", "src/client/machine-listener.ts", "src/client/link-relay.ts"]) { + test(entry + " has no load-time path to provider dispatch", () => { + expect(firstLoadTimePathTo(entry, path => LOCAL_SERVING.test(path))).toBeNull(); + }); + } + test("control: the standalone server does reach provider dispatch, so the walk is not vacuous", () => { + expect(firstLoadTimePathTo("src/server/index.ts", path => LOCAL_SERVING.test(path))).not.toBeNull(); + }); +}); + +describe("home_unreachable answers", () => { + test("a Child whose tunnel is down answers a retryable 503 and sends nothing", async () => { + let sends = 0; + const response = await relayLinkDataRequest(new Request("http://127.0.0.1:10100/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "m", input: "hello" }), + }), { tunnelPort: 41001, admissionKey: "fixture-link-key", linkId: "lnk_0123456789abcdef", apiKeyId: "key_link" }, { + fetchImpl: (async () => { sends += 1; return new Response(); }) as unknown as typeof fetch, + }); + expect(response.status).toBe(503); + expect(response.headers.get("retry-after")).not.toBeNull(); + expect(await response.json()).toEqual({ error: "link tunnel unavailable", code: HOME_UNREACHABLE_CODE, message: HOME_UNREACHABLE_MESSAGE }); + expect(sends).toBe(0); + }); + + test("a Hub client's relay keeps its 502 and adds the stable code when the Hub does not answer", async () => { + const response = await relayHubManagementRequest( + new Request("http://127.0.0.1:10100/api/machine/hub-relay/api/fallback"), + "/api/fallback", + { managementUrl: "https://hub.example.test", browserOrigin: "http://127.0.0.1:10100" }, + { fetchImpl: (async () => { throw new TypeError("connect ECONNREFUSED"); }) as unknown as typeof fetch }, + ); + expect(response.status).toBe(502); + expect(await response.json()).toEqual({ error: "hub relay unavailable", code: HOME_UNREACHABLE_CODE, message: HOME_UNREACHABLE_MESSAGE }); + }); + + test("the message says nothing was served locally and where local fallback is decided", () => { + expect(HOME_UNREACHABLE_MESSAGE).toContain("did not serve this request from this device's local providers"); + expect(HOME_UNREACHABLE_MESSAGE).toContain("ocx fallback local on --device"); + }); + + test("a failed relay authentication is not home_unreachable, so it never reads as Home down", async () => { + for (const unrecognized of [false, true]) { + const response = await relayLinkDataRequest(new Request("http://127.0.0.1:10100/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "m", input: "hello" }), + }), { tunnelPort: 41001, admissionKey: "fixture-link-key", linkId: "lnk_0123456789abcdef", apiKeyId: "key_link" }, { + tunnel: { connected: () => true, pending: () => false, waitForConnected: async () => true }, + fetchImpl: (async () => { throw new LinkRelayAuthenticationError(unrecognized); }) as unknown as typeof fetch, + }); + expect(response.status).toBe(503); + const body = await response.json() as { error: string; code: string; message?: string }; + expect(body.code).toBe(LINK_RELAY_AUTH_FAILED_CODE); + expect(body.code).not.toBe(HOME_UNREACHABLE_CODE); + expect(body.message).toBeUndefined(); + expect(body.error).toBe(new LinkRelayAuthenticationError(unrecognized).message); + } + }); +}); diff --git a/tests/clients/client-link-relay.test.ts b/tests/clients/client-link-relay.test.ts index a8bc3193262..6842a69a2b4 100644 --- a/tests/clients/client-link-relay.test.ts +++ b/tests/clients/client-link-relay.test.ts @@ -17,6 +17,7 @@ import { type LinkTunnelGate, } from "../../src/client/link-relay"; import { HUB_RELAY_REQUEST_BODY_MAX_BYTES } from "../../src/client/hub-relay"; +import { HOME_UNREACHABLE_MESSAGE } from "../../src/client/home-unreachable"; import { startMachineListener } from "../../src/client/machine-listener"; import { serviceApiTokenFingerprint } from "../../src/lib/service-secrets"; import type { OcxClientConnectionConfig } from "../../src/types"; @@ -587,7 +588,12 @@ describe("client link relay while the tunnel reconnects", () => { const response = await pending; expect(response.status).toBe(503); expect(response.headers.get("retry-after")).toBe("1"); - expect(await response.json()).toEqual({ error: "link tunnel unavailable" }); + // OCX-11: the legacy `error` string stays; `code` and `message` are the stable contract. + expect(await response.json()).toEqual({ + error: "link tunnel unavailable", + code: "home_unreachable", + message: HOME_UNREACHABLE_MESSAGE, + }); expect(calls).toBe(0); }); diff --git a/tests/clients/client-runtime-restart.test.ts b/tests/clients/client-runtime-restart.test.ts new file mode 100644 index 00000000000..4f256fe85fd --- /dev/null +++ b/tests/clients/client-runtime-restart.test.ts @@ -0,0 +1,227 @@ +/** + * OCX-6: `ocx restart` on a connected client runtime (a Remote Link Child). + * + * The listener proves its identity on `/healthz` and accepts `POST /api/system/restart` only with + * the capability bound to its pid and port. The controller restarts by starting its own + * replacement, and a replacement that does not come up leaves the old runtime serving. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Server } from "bun"; +import { startMachineListener } from "../../src/client/machine-listener"; +import { clientRestartRollbackAction, createClientRestartController, type ClientRestartAdmission, type ClientRestartIo } from "../../src/client/runtime-restart"; +import { + createLocalAttestationChallenge, + createLocalAttestationSecret, + LOCAL_ATTESTATION_CHALLENGE_HEADER, + LOCAL_ATTESTATION_PROOF_HEADER, + verifyLocalAttestationProof, +} from "../../src/lib/local-management-attestation"; +import { + CLIENT_RESTART_DRAIN_MS, + CLIENT_RESTART_READY_BY_MS, + createSystemRestartCapability, + SYSTEM_RESTART_CAPABILITY_HEADER, + SYSTEM_RESTART_EXPECTED_PID_HEADER, + SYSTEM_RESTART_NONCE_HEADER, + SYSTEM_RESTART_PATH, +} from "../../src/lib/system-restart-contract"; +import type { ManagementAuthState } from "../../src/server/management-auth"; +import type { OcxClientConnectionConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const STARTED_AT = "2026-10-21T00:00:00.000Z"; +const ADMIN = `ocx_admin_${"a".repeat(43)}`; +const connection: OcxClientConnectionConfig = { + serverUrl: "https://hub.example.test", + managementUrl: "https://hub.example.test", + managementTransport: "direct", + selectedClients: ["codex"], + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + apiKeyId: "client-key-a", + tokenFingerprint: "a".repeat(64), + protocolVersion: 1, + connectedAt: "2026-08-28T00:00:00.000Z", + catalogSyncedAt: "2026-08-28T00:01:00.000Z", +}; + +function authState(): ManagementAuthState { + return { available: true, token: ADMIN, source: "environment", sessions: new Map(), pairingGrants: new Map() }; +} + +let root = ""; +let previousHome: string | undefined; +const servers: Server[] = []; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + root = mkdtempSync(join(tmpdir(), "ocx-client-restart-")); + process.env.OPENCODEX_HOME = root; + writeFileSync(join(root, "config.json"), JSON.stringify({ port: 0, providers: {}, defaultProvider: "openai" })); +}); + +afterEach(async () => { + for (const server of servers.splice(0)) await server.stop(true); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(root); +}); + +function listen(request: () => Promise, secret = createLocalAttestationSecret()) { + const server = startMachineListener(0, { + state: connection, + managementAuthState: authState(), + restart: { attestationSecret: secret, startedAt: STARTED_AT, request }, + }); + servers.push(server); + return { server, secret, port: server.port! }; +} + +function restartHeaders(secret: string, port: number, pid = process.pid): Record { + const nonce = createLocalAttestationChallenge(); + return { + [SYSTEM_RESTART_EXPECTED_PID_HEADER]: String(pid), + [SYSTEM_RESTART_NONCE_HEADER]: nonce, + [SYSTEM_RESTART_CAPABILITY_HEADER]: createSystemRestartCapability(secret, nonce, "POST", SYSTEM_RESTART_PATH, process.pid, port)!, + }; +} + +describe("client listener: bound restart", () => { + test("/healthz proves this process to a challenge and advertises the restart and its start time", async () => { + const { server, secret, port } = listen(async () => ({ kind: "accepted", alreadyRestarting: false })); + const challenge = createLocalAttestationChallenge(); + const response = await fetch(new URL("/healthz", server.url), { headers: { [LOCAL_ATTESTATION_CHALLENGE_HEADER]: challenge } }); + const body = await response.json() as Record; + expect(body).toMatchObject({ role: "client", pid: process.pid, port, startedAt: STARTED_AT, restartCapability: "v1" }); + expect(verifyLocalAttestationProof(secret, challenge, process.pid, port, response.headers.get(LOCAL_ATTESTATION_PROOF_HEADER))).toBe(true); + }); + + test("accepts only the capability bound to this pid and port", async () => { + let requests = 0; + const { server, secret, port } = listen(async () => { requests += 1; return { kind: "accepted", alreadyRestarting: requests > 1 }; }); + const url = new URL(SYSTEM_RESTART_PATH, server.url); + const refusals: Array<[Record, number]> = [ + [{}, 409], + [{ [SYSTEM_RESTART_EXPECTED_PID_HEADER]: String(process.pid) }, 401], + [{ authorization: `Bearer ${ADMIN}`, "x-opencodex-api-key": ADMIN, [SYSTEM_RESTART_EXPECTED_PID_HEADER]: String(process.pid) }, 401], + [restartHeaders(createLocalAttestationSecret(), port), 401], + [restartHeaders(secret, port + 1 > 65535 ? port - 1 : port + 1), 401], + [restartHeaders(secret, port, process.pid + 1), 409], + ]; + for (const [headers, status] of refusals) { + const response = await fetch(url, { method: "POST", headers }); + expect(response.status).toBe(status); + } + expect(requests).toBe(0); + + const accepted = await fetch(url, { method: "POST", headers: restartHeaders(secret, port) }); + expect(accepted.status).toBe(202); + expect(await accepted.json()).toMatchObject({ success: true, alreadyDraining: false }); + const again = await fetch(url, { method: "POST", headers: restartHeaders(secret, port) }); + expect(await again.json()).toMatchObject({ success: true, alreadyDraining: true }); + expect(requests).toBe(2); + }); + + test("a supervised runtime refuses with the supervisor named and keeps serving", async () => { + const { server, secret, port } = listen(async () => ({ kind: "supervised", supervisor: "service" })); + const response = await fetch(new URL(SYSTEM_RESTART_PATH, server.url), { method: "POST", headers: restartHeaders(secret, port) }); + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ success: false, error: { code: "restart_supervised", supervisor: "service" } }); + expect((await fetch(new URL("/healthz", server.url))).status).toBe(200); + }); + + test("without the restart wiring the route stays unavailable and /healthz advertises nothing", async () => { + const server = startMachineListener(0, { state: connection, managementAuthState: authState() }); + servers.push(server); + const body = await (await fetch(new URL("/healthz", server.url))).json() as Record; + expect(body.restartCapability).toBeUndefined(); + expect((await fetch(new URL(SYSTEM_RESTART_PATH, server.url), { method: "POST" })).status).toBe(404); + }); +}); + +function fakeIo(overrides: Partial = {}) { + const calls: string[] = []; + const io: ClientRestartIo = { + supervisor: async () => null, + stop: async drainMs => { calls.push(`stop:${drainMs}`); }, + spawnReplacement: async deadlineAt => { calls.push(`spawn:${deadlineAt}`); }, + resume: async () => { calls.push("resume"); }, + exitProcess: code => { calls.push(`exit:${code}`); }, + now: () => 1_000, + sleep: async () => {}, + warn: message => { calls.push(`warn:${message}`); }, + ...overrides, + }; + return { io, calls }; +} + +describe("client restart controller", () => { + test("a replacement that answers ends this process", async () => { + const { io, calls } = fakeIo(); + const controller = createClientRestartController(io); + expect(await controller.request()).toEqual({ kind: "accepted", alreadyRestarting: false }); + await controller.settled(); + expect(calls).toEqual([`stop:${CLIENT_RESTART_DRAIN_MS}`, `spawn:${1_000 + CLIENT_RESTART_READY_BY_MS}`, "exit:0"]); + }); + + test("a failed replacement keeps the old runtime: it serves again and does not exit", async () => { + const { io, calls } = fakeIo({ + spawnReplacement: async () => { throw Object.assign(new Error("x"), { code: "not_ready" }); }, + }); + const controller = createClientRestartController(io); + await controller.request(); + await controller.settled(); + expect(calls).toEqual([ + `stop:${CLIENT_RESTART_DRAIN_MS}`, + "warn:[client] restart: the replacement did not start (not_ready); this runtime keeps serving", + "resume", + ]); + // The latch is released: a later restart is accepted afresh. + expect(await controller.request()).toEqual({ kind: "accepted", alreadyRestarting: false }); + }); + + test("only a rollback that cannot serve again exits, and it says so", async () => { + const { io, calls } = fakeIo({ + spawnReplacement: async () => { throw Object.assign(new Error("x"), { code: "child_exit" }); }, + resume: async () => { throw Object.assign(new Error("x"), { code: "EADDRINUSE" }); }, + }); + const controller = createClientRestartController(io); + await controller.request(); + await controller.settled(); + expect(calls.at(-2)).toBe("warn:[client] restart: this runtime could not serve again (EADDRINUSE); run 'ocx start'"); + expect(calls.at(-1)).toBe("exit:1"); + }); + + test("a supervised runtime is refused before anything stops", async () => { + const { io, calls } = fakeIo({ supervisor: async () => "desktop" }); + const controller = createClientRestartController(io); + expect(await controller.request()).toEqual({ kind: "supervised", supervisor: "desktop" }); + await controller.settled(); + expect(calls).toEqual([]); + }); + + test("a second request while one is in flight joins it", async () => { + let finish!: () => void; + let spawns = 0; + const { io } = fakeIo({ spawnReplacement: () => { spawns += 1; return new Promise(resolve => { finish = resolve; }); } }); + const controller = createClientRestartController(io); + expect(await controller.request()).toEqual({ kind: "accepted", alreadyRestarting: false }); + expect(await controller.request()).toEqual({ kind: "accepted", alreadyRestarting: true }); + while (!finish) await Bun.sleep(1); + finish(); + await controller.settled(); + expect(spawns).toBe(1); + }); +}); + +describe("rollback after a failed replacement", () => { + const link = { ...connection, transport: "link" as const, link: { linkId: `lnk_${"b".repeat(16)}`, tunnelPort: 23457 } }; + test("serves the same connection again only while it is still the durable one", () => { + expect(clientRestartRollbackAction(link, { kind: "connected", value: { ...link, catalogSyncedAt: "2026-10-21T00:00:00.000Z" } })).toBe("resume"); + expect(clientRestartRollbackAction(link, { kind: "disconnected" } as never)).toBe("recycle"); + expect(clientRestartRollbackAction(link, { kind: "connected", value: { ...link, connectedAt: "2026-10-21T00:00:00.000Z" } })).toBe("recycle"); + expect(clientRestartRollbackAction(link, { kind: "connected", value: { ...link, link: { ...link.link, linkId: `lnk_${"c".repeat(16)}` } } })).toBe("recycle"); + }); +}); diff --git a/tests/clients/desktop-compatibility-authority.test.ts b/tests/clients/desktop-compatibility-authority.test.ts index ea8582a6451..a5d3e5054f3 100644 --- a/tests/clients/desktop-compatibility-authority.test.ts +++ b/tests/clients/desktop-compatibility-authority.test.ts @@ -6,7 +6,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { ensureDesktopCompatibilityAuthority, inspectDesktopCompatibilityAuthority, loadDesktopCompatibilityAuthority, renewDesktopCompatibilityAuthority } from "../../src/codex/desktop-compatibility/certificate-store"; import { - powerShellAuthorityKeyProtectionForTests, usesNativeDpapiForTests, windowsAuthorityKeyProtection, type AuthorityKeyProtection, + powerShellAuthorityKeyProtectionForTests, powerShellDpapiScriptForTests, usesNativeDpapiForTests, + windowsAuthorityKeyProtection, type AuthorityKeyProtection, } from "../../src/codex/desktop-compatibility/windows-key-protection"; import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; @@ -165,6 +166,12 @@ describe("Codex Desktop compatibility authority lifecycle", () => { expect(readFileSync(path, "utf8")).toBe("broken state"); }); + test("the PowerShell DPAPI helper never invokes a cmdlet", () => { + // A cmdlet (Verb-Noun) triggers command discovery and module auto-loading, which a cold + // profile pays for before DPAPI runs at all. The helper must stay on .NET calls. + expect(powerShellDpapiScriptForTests().match(/\b[A-Z][A-Za-z]+-[A-Z][A-Za-z]+\b/g)).toBeNull(); + }); + test.skipIf(process.platform !== "win32")("real CurrentUser DPAPI survives separate helper invocations and rejects corruption", async () => { // x64 protects in-process; spawning PowerShell per call is what exceeded its bound on CI. expect(usesNativeDpapiForTests()).toBe(process.arch === "x64"); diff --git a/tests/clients/desktop-compatibility-launch.test.ts b/tests/clients/desktop-compatibility-launch.test.ts index 769b2971852..015a31e4740 100644 --- a/tests/clients/desktop-compatibility-launch.test.ts +++ b/tests/clients/desktop-compatibility-launch.test.ts @@ -1,5 +1,6 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeAll, describe, expect, test } from "bun:test"; import { execFileSync } from "node:child_process"; +import { COLD_SPAWN_WARMUP_HOOK_BUDGET_MS, warmColdSpawn } from "../helpers/cold-spawn-warmup"; import { compatibilityActivationScript, launchWindowsCodexCompatibility, validatedCompatibilityPacUrl } from "../../src/codex/desktop-compatibility/windows-package-launch"; import { WINDOWS_ACTIVATION_SOURCE } from "../../src/codex/desktop-compatibility/windows-activation-source"; import { setTrustedWindowsElevationExecutablesForTests } from "../../src/lib/windows-elevation"; @@ -147,15 +148,25 @@ describe("Codex Desktop compatibility package launch", () => { expect(launchWindowsCodexCompatibility(pac, { platform: "linux" })).toEqual({ status: "refused", reason: "unsupported_platform" }); expect(launchWindowsCodexCompatibility(pac, { platform: "win32" })).toEqual({ status: "refused", reason: "test_environment" }); }); +}); + +describe.skipIf(process.platform !== "win32")("Codex Desktop compatibility activation on real Windows", () => { + const encoded = Buffer.from(compatibilityActivationScript(install, pac), "utf8").toString("base64"); + const validation = ["$ErrorActionPreference='Stop'", + `$null=[scriptblock]::Create([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}')))`, + "Add-Type -TypeDefinition @'", WINDOWS_ACTIVATION_SOURCE, "'@", + "[OpenCodexPackageActivation]::ValidateActivationService()", "'validated-without-launch'"].join("\n"); + const validate = (timeout: number) => execFileSync(powershell, ["-NoProfile", "-NonInteractive", "-Command", validation], + { encoding: "utf8", timeout, windowsHide: true }); + + // The first run in a fresh test profile pays PowerShell start, Add-Type's module discovery, + // the C# compiler and COM activation, which reached the 10s bound on cold runners. Replaying + // the same call once here leaves the bound below measuring a warm validation. + beforeAll(async () => { + await warmColdSpawn("powershell/desktop-activation-validation", deadlineMs => { validate(deadlineMs); }); + }, COLD_SPAWN_WARMUP_HOOK_BUDGET_MS); - test.skipIf(process.platform !== "win32")("the real Windows parser and COM service accept the adapter without launching an app", () => { - const script = compatibilityActivationScript(install, pac); - const encoded = Buffer.from(script, "utf8").toString("base64"); - const validation = ["$ErrorActionPreference='Stop'", - `$null=[scriptblock]::Create([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}')))`, - "Add-Type -TypeDefinition @'", WINDOWS_ACTIVATION_SOURCE, "'@", - "[OpenCodexPackageActivation]::ValidateActivationService()", "'validated-without-launch'"].join("\n"); - const output = execFileSync(powershell, ["-NoProfile", "-NonInteractive", "-Command", validation], { encoding: "utf8", timeout: 10_000, windowsHide: true }); - expect(output.trim()).toBe("validated-without-launch"); + test("the real Windows parser and COM service accept the adapter without launching an app", () => { + expect(validate(10_000).trim()).toBe("validated-without-launch"); }, 15_000); }); diff --git a/tests/clients/link-apply-record.test.ts b/tests/clients/link-apply-record.test.ts new file mode 100644 index 00000000000..6c0028f3e11 --- /dev/null +++ b/tests/clients/link-apply-record.test.ts @@ -0,0 +1,120 @@ +/** + * OCX-6: the Child's apply record. Its `appliedAt` is the time a different Home settings document + * was applied; an idempotent re-apply keeps it, a write failure is reported, and the apply lock + * keeps two applies from interleaving. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import type { HomeCodexSettingsDocument } from "../../src/link/codex-settings"; +import { + homeCodexSettingsPath, + homeCodexSettingsReceipt, + readHomeCodexSettingsRecord, + recordHomeCodexSettings, + withLinkApply, + withdrawChangingHomeCodexSettingsRecord, +} from "../../src/client/link-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const LINK_ID = `lnk_${"c".repeat(16)}`; +const OTHER_LINK = `lnk_${"d".repeat(16)}`; +const document = (fastMode: boolean, name = "home-box"): HomeCodexSettingsDocument => ({ + version: 1, + home: { name, version: "2.91.0" }, + settings: { fastMode, multiAgentMode: "v1", webSearchSidecarEnabled: true, subagentDefaults: null }, +}); +const at = (iso: string) => () => new Date(iso); + +let root = ""; +let previousHome: string | undefined; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + root = mkdtempSync(join(tmpdir(), "ocx-link-apply-record-")); + process.env.OPENCODEX_HOME = root; +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(root); +}); + +describe("apply record", () => { + test("the first apply stamps appliedAt; the same settings keep it; new settings restamp it", () => { + const first = recordHomeCodexSettings(LINK_ID, { source: "home", document: document(true), fetchedAt: "2026-10-21T01:00:00.000Z" }, at("2026-10-21T01:00:01.000Z")); + expect(first?.appliedAt).toBe("2026-10-21T01:00:01.000Z"); + + // Same values from a renamed Home, read later: the apply time stays, the read time moves. + const again = recordHomeCodexSettings(LINK_ID, { source: "home", document: document(true, "renamed"), fetchedAt: "2026-10-21T02:00:00.000Z" }, at("2026-10-21T02:00:01.000Z")); + expect(again).toMatchObject({ appliedAt: "2026-10-21T01:00:01.000Z", fetchedAt: "2026-10-21T02:00:00.000Z" }); + expect(readHomeCodexSettingsRecord(LINK_ID)?.appliedAt).toBe("2026-10-21T01:00:01.000Z"); + + const changed = recordHomeCodexSettings(LINK_ID, { source: "home", document: document(false), fetchedAt: "2026-10-21T03:00:00.000Z" }, at("2026-10-21T03:00:01.000Z")); + expect(changed?.appliedAt).toBe("2026-10-21T03:00:01.000Z"); + }); + + test("a cached or local source records nothing", () => { + expect(recordHomeCodexSettings(LINK_ID, { source: "cached", document: document(true), fetchedAt: "2026-10-21T01:00:00.000Z", reason: "offline" })).toBeNull(); + expect(recordHomeCodexSettings(LINK_ID, { source: "local", reason: "old Home" })).toBeNull(); + expect(readHomeCodexSettingsRecord(LINK_ID)).toBeNull(); + }); + + test("another link's record never lends its apply time", () => { + const previous = homeCodexSettingsReceipt(OTHER_LINK, document(true), "2026-10-21T01:00:00.000Z", "2026-10-21T01:00:00.000Z", null); + const receipt = homeCodexSettingsReceipt(LINK_ID, document(true), "2026-10-21T05:00:00.000Z", "2026-10-21T05:00:01.000Z", previous); + expect(receipt.appliedAt).toBe("2026-10-21T05:00:01.000Z"); + }); + + test("a record written before OCX-6 reads its fetch time as the apply time", () => { + const path = homeCodexSettingsPath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify({ linkId: LINK_ID, fetchedAt: "2026-10-01T00:00:00.000Z", document: document(true) })); + expect(readHomeCodexSettingsRecord(LINK_ID)?.appliedAt).toBe("2026-10-01T00:00:00.000Z"); + }); + + test("a record that cannot be written is an error, not a silent success", () => { + // A directory where the record file belongs makes the atomic rename fail. + mkdirSync(join(homeCodexSettingsPath(), "blocked"), { recursive: true }); + expect(() => recordHomeCodexSettings(LINK_ID, { source: "home", document: document(true), fetchedAt: "2026-10-21T01:00:00.000Z" })).toThrow(); + expect(readHomeCodexSettingsRecord(LINK_ID)).toBeNull(); + }); +}); + +describe("apply lock", () => { + test("a second apply while one is in flight fails fast and changes nothing", async () => { + const lockPath = join(root, "link", "apply-lock.sqlite"); + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + let entered!: () => void; + const inside = new Promise(resolve => { entered = resolve; }); + const first = withLinkApply(async () => { entered(); await held; return "first"; }, lockPath); + await inside; + let ran = false; + await expect(withLinkApply(async () => { ran = true; return "second"; }, lockPath)) + .rejects.toThrow("another Home settings apply is in progress"); + expect(ran).toBe(false); + release(); + expect(await first).toBe("first"); + expect(await withLinkApply(async () => "third", lockPath)).toBe("third"); + }); + + test("a busy lock inside the apply is reported as itself, not as another apply", async () => { + const lockPath = join(root, "link", "apply-lock.sqlite"); + const inner = Object.assign(new Error("client_lifecycle_busy"), { code: "client_lifecycle_busy" }); + await expect(withLinkApply(async () => { throw inner; }, lockPath)).rejects.toBe(inner); + }); +}); + +describe("withdrawing a record that is about to change", () => { + test("different settings withdraw the record first; the same settings keep it", () => { + recordHomeCodexSettings(LINK_ID, { source: "home", document: document(true), fetchedAt: "2026-10-21T01:00:00.000Z" }, at("2026-10-21T01:00:01.000Z")); + expect(withdrawChangingHomeCodexSettingsRecord(LINK_ID, document(true, "renamed"))).toBe(false); + expect(readHomeCodexSettingsRecord(LINK_ID)?.appliedAt).toBe("2026-10-21T01:00:01.000Z"); + expect(withdrawChangingHomeCodexSettingsRecord(LINK_ID, document(false))).toBe(true); + // A failed injection or record write now reports no applied digest, never the old one. + expect(readHomeCodexSettingsRecord(LINK_ID)).toBeNull(); + }); +}); diff --git a/tests/clients/link-codex-settings-digest.test.ts b/tests/clients/link-codex-settings-digest.test.ts new file mode 100644 index 00000000000..10ab3008c4c --- /dev/null +++ b/tests/clients/link-codex-settings-digest.test.ts @@ -0,0 +1,76 @@ +/** + * OCX-6b: `appliedCodexSettingsDigest`, the fingerprint GOAT's hub recomputes byte for byte. + * + * The golden values were computed outside this code, with `printf '%s' '' | shasum -a 256` + * over the literal payloads below, so a change to the serialization fails here before it reaches a + * peer that compares digests. + */ +import { describe, expect, test } from "bun:test"; +import { + codexSettingsDigest, + codexSettingsDigestPayload, + parseHomeCodexSettingsDocument, + projectHomeCodexSettings, + type HomeCodexSettings, +} from "../../src/link/codex-settings"; +import type { OcxConfig } from "../../src/types"; + +const UNSET_DEFAULTS: HomeCodexSettings = { + fastMode: true, multiAgentMode: "v1", webSearchSidecarEnabled: false, subagentDefaults: null, +}; +const UNSET_PAYLOAD = '{"version":1,"settings":{"fastMode":true,"multiAgentMode":"v1","webSearchSidecarEnabled":false,"subagentDefaults":null}}'; +const UNSET_DIGEST = "57c751eff89844f93db71a6ee4acdac03afbc29fec52734ade80a40193e826cd"; + +const SET_DEFAULTS: HomeCodexSettings = { + fastMode: false, multiAgentMode: "v2", webSearchSidecarEnabled: true, + subagentDefaults: { model: "anthropic/claude-sonnet-5-5", reasoningEffort: "high" }, +}; +const SET_PAYLOAD = '{"version":1,"settings":{"fastMode":false,"multiAgentMode":"v2","webSearchSidecarEnabled":true,"subagentDefaults":{"model":"anthropic/claude-sonnet-5-5","reasoningEffort":"high"}}}'; +const SET_DIGEST = "55926fac816f7b59a00e01114d36742407fe79c42ad33a69271fb479ee8f346c"; + +describe("appliedCodexSettingsDigest", () => { + test("serializes in the published key order and matches the hand-computed digests", () => { + expect(codexSettingsDigestPayload(UNSET_DEFAULTS)).toBe(UNSET_PAYLOAD); + expect(codexSettingsDigest(UNSET_DEFAULTS)).toBe(UNSET_DIGEST); + expect(codexSettingsDigestPayload(SET_DEFAULTS)).toBe(SET_PAYLOAD); + expect(codexSettingsDigest(SET_DEFAULTS)).toBe(SET_DIGEST); + }); + + test("the input's key order and the source JSON's whitespace do not change it", () => { + const reordered = JSON.parse(`{ + "subagentDefaults" : { "reasoningEffort": "high", "model": "anthropic/claude-sonnet-5-5" }, + "webSearchSidecarEnabled": true, + "multiAgentMode": "v2", + "fastMode": false + }`) as HomeCodexSettings; + expect(Object.keys(reordered)[0]).toBe("subagentDefaults"); + expect(codexSettingsDigest(reordered)).toBe(SET_DIGEST); + }); + + test("every applied value changes it", () => { + const variants: HomeCodexSettings[] = [ + { ...SET_DEFAULTS, fastMode: true }, + { ...SET_DEFAULTS, multiAgentMode: "default" }, + { ...SET_DEFAULTS, webSearchSidecarEnabled: false }, + { ...SET_DEFAULTS, subagentDefaults: null }, + { ...SET_DEFAULTS, subagentDefaults: { model: "anthropic/claude-sonnet-5-5", reasoningEffort: "low" } }, + { ...SET_DEFAULTS, subagentDefaults: { model: "openai/gpt-6", reasoningEffort: "high" } }, + ]; + const digests = new Set(variants.map(codexSettingsDigest)); + expect(digests.size).toBe(variants.length); + expect(digests.has(SET_DIGEST)).toBe(false); + for (const digest of digests) expect(digest).toMatch(/^[0-9a-f]{64}$/); + }); + + test("a Home's projection and the document its Child parses agree, whatever the Home is called", () => { + const config = { + fastMode: false, multiAgentMode: "v2", webSearchSidecar: { enabled: true }, + syncCodexSubagentDefaults: true, injectionModel: " anthropic/claude-sonnet-5-5 ", injectionEffort: "high", + } as unknown as OcxConfig; + const home = projectHomeCodexSettings(config, "2.91.0"); + expect(codexSettingsDigest(home.settings)).toBe(SET_DIGEST); + const wire = JSON.parse(JSON.stringify({ ...home, home: { name: "another-name", version: "2.80.0" } })); + const child = parseHomeCodexSettingsDocument(wire); + expect(child && codexSettingsDigest(child.settings)).toBe(SET_DIGEST); + }); +}); diff --git a/tests/clients/link-parity-reporter.test.ts b/tests/clients/link-parity-reporter.test.ts new file mode 100644 index 00000000000..16069d6e97a --- /dev/null +++ b/tests/clients/link-parity-reporter.test.ts @@ -0,0 +1,85 @@ +/** + * OCX-6: the linked client runtime reports its last apply and its start to the Home: soon after + * start with backoff while the tunnel comes up, then once a minute, and never again to a Home + * that predates the route. + */ +import { describe, expect, test } from "bun:test"; +import { + createLinkParityReporter, + LINK_PARITY_PERIOD_MS, + LINK_PARITY_RETRY_MS, + linkChildParityReport, +} from "../../src/client/link-parity-reporter"; +import type { HomeCodexSettingsRecord } from "../../src/client/link-home"; +import type { LinkChildParityReport } from "../../src/link/child-parity"; +import type { OcxClientConnectionConfig } from "../../src/types"; + +const LINK_ID = `lnk_${"a".repeat(16)}`; +const connection = { transport: "link", link: { linkId: LINK_ID, tunnelPort: 23457 }, apiKeyId: "k" } as OcxClientConnectionConfig; +const STARTED = "2026-10-21T01:00:05.000Z"; +const record: HomeCodexSettingsRecord = { + linkId: LINK_ID, fetchedAt: "2026-10-21T01:00:00.000Z", appliedAt: "2026-10-21T01:00:00.000Z", + document: { version: 1, home: { name: "h", version: "2.91.0" }, settings: { fastMode: true, multiAgentMode: "v1", webSearchSidecarEnabled: false, subagentDefaults: null } }, +}; + +function harness(outcomes: Array<"accepted" | "unsupported" | "failed">) { + const delays: number[] = []; + const sent: LinkChildParityReport[] = []; + let pending: (() => void) | null = null; + const reporter = createLinkParityReporter({ + connection, + linkKey: () => "ocx_data_key", + runtimeStartedAt: STARTED, + readRecord: () => record, + send: async (_connection, _key, report) => { sent.push(report); return outcomes.shift() ?? "accepted"; }, + setTimer: (callback, ms) => { delays.push(ms); pending = callback; return ms; }, + clearTimer: () => { pending = null; }, + }); + const fire = async () => { + const callback = pending; + pending = null; + callback?.(); + for (let i = 0; i < 5; i += 1) await Promise.resolve(); + }; + return { reporter, delays, sent, fire, pending: () => pending }; +} + +describe("link parity reporter", () => { + test("the report carries the apply time, digest and start as epoch ms", () => { + expect(linkChildParityReport(connection, STARTED, record, 7)).toEqual({ + linkId: LINK_ID, + lastApplyAt: Date.parse("2026-10-21T01:00:00.000Z"), + runtimeStartedAt: Date.parse(STARTED), + appliedCodexSettingsDigest: "57c751eff89844f93db71a6ee4acdac03afbc29fec52734ade80a40193e826cd", + sequence: 7, + }); + expect(linkChildParityReport(connection, STARTED, null, 8)).toEqual({ + linkId: LINK_ID, lastApplyAt: null, runtimeStartedAt: Date.parse(STARTED), appliedCodexSettingsDigest: null, sequence: 8, + }); + // Without an explicit sequence each report counts up, process-wide. + const first = linkChildParityReport(connection, STARTED, null)!.sequence; + expect(linkChildParityReport(connection, STARTED, null)!.sequence).toBeGreaterThan(first); + }); + + test("backs off while the Home is unreachable, then reports once a minute", async () => { + const h = harness(["failed", "failed", "accepted", "accepted"]); + h.reporter.start(); + for (let i = 0; i < 4; i += 1) await h.fire(); + expect(h.delays).toEqual([LINK_PARITY_RETRY_MS[0], LINK_PARITY_RETRY_MS[1], LINK_PARITY_RETRY_MS[2], LINK_PARITY_PERIOD_MS, LINK_PARITY_PERIOD_MS]); + expect(h.sent).toHaveLength(4); + }); + + test("a Home without the route is not asked again, and stop cancels the timer", async () => { + const old = harness(["unsupported"]); + old.reporter.start(); + await old.fire(); + expect(old.pending()).toBeNull(); + expect(old.sent).toHaveLength(1); + + const stopped = harness([]); + stopped.reporter.start(); + stopped.reporter.stop(); + await stopped.fire(); + expect(stopped.sent).toHaveLength(0); + }); +}); diff --git a/tests/clients/link-peer-compatibility.test.ts b/tests/clients/link-peer-compatibility.test.ts new file mode 100644 index 00000000000..1ceee997a4c --- /dev/null +++ b/tests/clients/link-peer-compatibility.test.ts @@ -0,0 +1,196 @@ +/** + * OCX-8: a Home reports each Child's version and a compatibility verdict from what the link + * already carries. A Child from 2.91.0 sends its version and features on the relay challenge; an + * older Child shows only the relay-auth version its challenge names. + */ +import { describe, expect, test } from "bun:test"; +import { relayLinkDataRequest, HOME_INITIATED_LINK_TUNNEL } from "../../src/client/link-relay"; +import { runLinkCommand } from "../../src/cli/link"; +import { + compareReleases, LINK_PEER_REPORT_FEATURE, LINK_PROTOCOL_RELEASES, linkPeerStatus, localLinkPeer, type LinkLocalPeer, +} from "../../src/link/compatibility"; +import { + createLinkPeerObservations, LINK_PEER_FEATURES_HEADER, LINK_PEER_VERSION_HEADER, linkPeerReportHeaders, parseLinkPeerReport, + type LinkPeerEntry, +} from "../../src/link/peer-report"; +import { LINK_RELAY_AUTH_PATH, LINK_RELAY_AUTH_VERSION, linkRelayChallenge } from "../../src/link/relay-auth"; +import { attachLinkPeers, projectLinkStatus } from "../../src/link/status-projection"; +import type { LinkStore } from "../../src/link/store"; +import { packageVersion } from "../../src/lib/package-version"; +import { serviceApiTokenFingerprint } from "../../src/lib/service-secrets"; +import { buildPeerFeatures } from "../../src/remote/features"; +import { createLinkListenerLifecycle } from "../../src/server/index/link-listener"; + +const key = `ocx_data_${"a".repeat(40)}`; +const keyId = "test-link-key", linkId = "lnk_0123456789abcdef"; +const fp = serviceApiTokenFingerprint(key); +const at = (second: number) => new Date(Date.UTC(2026, 9, 22, 0, 0, second)); +const home: LinkLocalPeer = { version: "2.91.0", relayAuth: "2", features: ["link-management-relay", "relay-auth/2", LINK_PEER_REPORT_FEATURE] }; +const empty: LinkPeerEntry = { observed: null, refused: null }; + +function store(): LinkStore { + return { version: 1, listenerPort: null, links: [{ id: linkId, alias: "child", direction: "client-initiated", + hostKeyFingerprint: null, tunnelPort: 22222, apiKeyId: keyId, createdAt: "2026-10-22T00:00:00.000Z" }] }; +} + +function entry(record: (peers: ReturnType) => void): LinkPeerEntry { + let second = 0; + const peers = createLinkPeerObservations(() => at(++second)); + record(peers); + return peers.get(linkId); +} + +/** A Home lifecycle on a real socket; run gets its port and the observation it holds. */ +async function withHome(run: (port: number, observed: (id?: string) => LinkPeerEntry) => Promise): Promise { + let current = store(); + const lifecycle = createLinkListenerLifecycle({ readStore: () => current, writeStore: (_path, next) => { current = next; } }); + lifecycle.start({ maxRequestBodySize: 1024, keyFingerprints: id => id === keyId ? [fp] : [], dispatch: async () => Response.json({ ok: true }) }); + try { await run(lifecycle.status().port!, (id = linkId) => lifecycle.peerObservation(id)); } finally { await lifecycle.stop(); } +} + +/** The challenge a Child older than 2.91.0 sends: the relay-auth query and no report headers. */ +async function legacyChallenge(port: number, version = LINK_RELAY_AUTH_VERSION, link = linkId): Promise { + const proof = linkRelayChallenge(fp, keyId, link); + const url = new URL(LINK_RELAY_AUTH_PATH, "http://127.0.0.1:" + port); + url.search = new URLSearchParams({ version, key: keyId, link, nonce: proof.nonce, proof: proof.caller }).toString(); + const response = await fetch(url); + await response.arrayBuffer(); + return response.status; +} + +async function cliStatus(body: unknown, lines: string[]): Promise { + const log = console.log, error = console.error; + console.log = (...args: unknown[]) => { lines.push(args.map(String).join(" ")); }; + console.error = () => {}; + try { + return await runLinkCommand(["status", "--json"], { baseUrl: "http://127.0.0.1:19101", readAdminToken: () => "ocx_admin_test", + fetchImpl: (async () => Response.json(body)) as unknown as typeof fetch }); + } finally { console.log = log; console.error = error; } +} + +describe("published link protocol releases", () => { + test("every id is a feature this build advertises, released no later than this build", () => { + const ids = LINK_PROTOCOL_RELEASES.map(release => release.id); + expect(new Set(ids).size).toBe(ids.length); + for (const release of LINK_PROTOCOL_RELEASES) { + expect(buildPeerFeatures()).toContain(release.id); + expect(compareReleases(release.since, packageVersion())).toBeLessThanOrEqual(0); + } + expect(ids).toContain("relay-auth/" + LINK_RELAY_AUTH_VERSION); + }); + + test("releases compare numerically", () => { + expect(compareReleases("2.9.0", "2.10.0")).toBe(-1); + expect(compareReleases("2.91.0-preview.1", "2.91.0")).toBe(0); + expect(compareReleases("3.0.0", "2.99.9")).toBe(1); + }); +}); + +describe("compatibility verdicts", () => { + test("a Child the Home has not heard from is unknown", () => { + expect(linkPeerStatus(home, empty)).toMatchObject({ compatibility: "unknown", version: null, observedAt: null }); + }); + + test("a Child that predates the report is placed in a release range by its relay-auth", () => { + const status = linkPeerStatus(home, entry(peers => peers.observe(linkId, "2", { reported: false, version: null, features: null }))); + expect(status).toMatchObject({ compatibility: "compatible", version: null, relayAuth: "2", + versionRange: { min: "2.69.0", below: "2.91.0" }, observedAt: at(1).toISOString() }); + }); + + test("a reporting Child is compatible while the Home serves every feature it uses", () => { + const older = linkPeerStatus(home, entry(peers => peers.observe(linkId, "2", { reported: true, version: "2.91.0", features: ["relay-auth/2", LINK_PEER_REPORT_FEATURE] }))); + expect(older).toMatchObject({ compatibility: "compatible", version: "2.91.0", versionRange: null }); + const newer = linkPeerStatus(home, entry(peers => peers.observe(linkId, "2", { reported: true, version: "2.95.0", features: ["relay-auth/2", "quota-pool/1"] }))); + expect(newer.compatibility).toBe("degraded"); + expect(newer.reasons[0]).toContain("quota-pool/1"); + expect(newer.reasons[0]).toContain("Upgrade the Home"); + }); + + test("a partial report is unknown: a valid version with a rejected feature list proves nothing about features", () => { + const noFeatures = linkPeerStatus(home, entry(peers => peers.observe(linkId, "2", { reported: true, version: "2.95.0", features: null }))); + expect(noFeatures).toMatchObject({ compatibility: "unknown", version: "2.95.0", versionRange: null }); + expect(noFeatures.reasons[0]).toContain("feature list"); + const noVersion = linkPeerStatus(home, entry(peers => peers.observe(linkId, "2", { reported: true, version: null, features: ["relay-auth/2"] }))); + expect(noVersion).toMatchObject({ compatibility: "unknown", versionRange: null }); + // Both headers sent and both rejected is a reporting Child, never a pre-report one. + const rejected = parseLinkPeerReport(new Headers({ [LINK_PEER_VERSION_HEADER]: "garbage", [LINK_PEER_FEATURES_HEADER]: "a,a" })); + expect(rejected).toEqual({ reported: true, version: null, features: null }); + const bothRejected = linkPeerStatus(home, entry(peers => peers.observe(linkId, "2", rejected))); + expect(bothRejected).toMatchObject({ compatibility: "unknown", versionRange: null }); + expect(parseLinkPeerReport(new Headers()).reported).toBe(false); + }); + + test("a refused relay-auth version is incompatible until a verified challenge follows", () => { + const newer = linkPeerStatus(home, entry(peers => peers.refuse(linkId, "3"))); + expect(newer).toMatchObject({ compatibility: "incompatible", relayAuth: "3" }); + expect(newer.reasons[0]).toContain("Upgrade the Home"); + expect(linkPeerStatus(home, entry(peers => peers.refuse(linkId, "1"))).reasons[0]).toContain("Upgrade the Child"); + const recovered = entry(peers => { peers.refuse(linkId, "3"); peers.observe(linkId, "2", { reported: true, version: "2.91.0", features: ["relay-auth/2"] }); }); + expect(linkPeerStatus(home, recovered).compatibility).toBe("compatible"); + const regressed = entry(peers => { peers.observe(linkId, "2", { reported: false, version: null, features: null }); peers.refuse(linkId, "3"); }); + expect(linkPeerStatus(home, regressed).compatibility).toBe("incompatible"); + }); +}); + +describe("the report on the wire", () => { + test("header values round-trip and invalid fields are dropped one by one", () => { + const headers = new Headers(linkPeerReportHeaders("2.91.0", ["relay-auth/2", "link-management-relay"])); + expect(parseLinkPeerReport(headers)).toEqual({ reported: true, version: "2.91.0", features: ["relay-auth/2", "link-management-relay"] }); + expect(linkPeerReportHeaders("not a version", ["bad id"])).toEqual({}); + const mixed = new Headers({ [LINK_PEER_VERSION_HEADER]: "2.91.0-x y", [LINK_PEER_FEATURES_HEADER]: "a,a" }); + expect(parseLinkPeerReport(mixed)).toEqual({ reported: true, version: null, features: null }); + const many = Array.from({ length: 65 }, (_, i) => "f" + i).join(","); + expect(parseLinkPeerReport(new Headers({ [LINK_PEER_VERSION_HEADER]: "2.91.0", [LINK_PEER_FEATURES_HEADER]: many }))) + .toEqual({ reported: true, version: "2.91.0", features: null }); + }); + + test("a relayed request records this build's version and features on the Home", async () => { + await withHome(async (port, observed) => { + expect(observed()).toEqual(empty); + const response = await relayLinkDataRequest(new Request("http://127.0.0.1/v1/models"), + { tunnelPort: port, admissionKey: key, apiKeyId: keyId, linkId }, { tunnel: HOME_INITIATED_LINK_TUNNEL }); + expect(response.status).toBe(200); + await response.arrayBuffer(); + expect(observed().observed).toMatchObject({ relayAuth: LINK_RELAY_AUTH_VERSION, version: packageVersion(), features: buildPeerFeatures() }); + const local = localLinkPeer(packageVersion(), buildPeerFeatures()); + expect(linkPeerStatus(local, observed())).toMatchObject({ compatibility: "compatible", version: packageVersion() }); + }); + }); + + test("an older Child's challenge records relay-auth only; a mismatch is recorded only for a known link", async () => { + await withHome(async (port, observed) => { + expect(await legacyChallenge(port)).toBe(204); + expect(observed().observed).toMatchObject({ relayAuth: "2", reported: false, version: null, features: null }); + const unknownLink = "lnk_fedcba9876543210"; + expect(await legacyChallenge(port, "3", unknownLink)).toBe(404); + expect(observed(unknownLink)).toEqual(empty); + expect(observed().refused).toBeNull(); + expect(await legacyChallenge(port, "3")).toBe(404); + expect(observed().refused).toMatchObject({ relayAuth: "3" }); + }); + }); +}); + +describe("ocx link status --json", () => { + test("a Home lists each Child's peer status and its own identity, and the CLI validates both", async () => { + const dto = attachLinkPeers(projectLinkStatus(store(), [], { state: "listening", port: 19100 }, { runtimeRole: "hub" }, { version: 1, entries: {} }), + home, () => entry(peers => peers.observe(linkId, "2", { reported: false, version: null, features: null }))); + expect(dto.local).toEqual(home); + expect(dto.links[0]!.peer).toMatchObject({ compatibility: "compatible", versionRange: { min: "2.69.0", below: "2.91.0" } }); + const lines: string[] = []; + expect(await cliStatus(dto, lines)).toBe(0); + expect(JSON.parse(lines[0]!)).toEqual(JSON.parse(JSON.stringify(dto))); + const malformed = { ...dto, links: [{ ...dto.links[0]!, peer: { ...dto.links[0]!.peer!, compatibility: "fine" } }] }; + expect(await cliStatus(malformed, [])).not.toBe(0); + const { local: _local, ...older } = dto; + expect(await cliStatus({ ...older, links: dto.links.map(({ peer: _peer, ...link }) => link) }, [])).toBe(0); + }); + + test("a Child reports its own identity and no peer verdicts", () => { + const dto = attachLinkPeers(projectLinkStatus(store(), [], { state: "off", port: null }, { runtimeRole: "client" }, { version: 1, entries: {} }), + home, () => { throw new Error("a Child has no peers"); }); + expect(dto.role).toBe("child"); + expect(dto.local).toEqual(home); + expect(dto.links.every(link => link.peer === undefined)).toBe(true); + }); +}); diff --git a/tests/clients/link-ssh-argv.test.ts b/tests/clients/link-ssh-argv.test.ts index 3c19f3a783d..72dc1a9ace8 100644 --- a/tests/clients/link-ssh-argv.test.ts +++ b/tests/clients/link-ssh-argv.test.ts @@ -6,10 +6,12 @@ import { buildExecArgv, buildProbeArgv, buildTunnelArgv, + buildWindowsProbeArgv, LinkSshArgumentError, quoteRemote, REMOTE_OCX_SCRIPT, remoteOcxArgv, + WINDOWS_OS_PROBE, } from "../../src/link/ssh-argv"; import { boundHint, @@ -81,6 +83,17 @@ test("exec argv emits only sh bare, quotes arguments, and clears forwarding", () expect(argv[argv.length - 1]).toBe(`sh 'it'"'"'s ready'`); }); +test("Windows %OS% probe is a fixed literal that trusts only an already recorded host key", () => { + const knownHostsFile = tempPath("windows-probe"); + const argv = buildWindowsProbeArgv({ alias: "gamma.example.test", knownHostsFile }); + expect(argv.slice(0, 2)).toEqual(["ssh", "-T"]); + expectCommonTrustOptions(argv, "yes", knownHostsFile); + expect(argv).toContain("ClearAllForwardings=yes"); + expect(argv.slice(-3)).toEqual(["--", "gamma.example.test", "cmd /c echo %OS%"]); + expect(WINDOWS_OS_PROBE).toBe("cmd /c echo %OS%"); + expect(() => buildWindowsProbeArgv({ alias: "-oProxyCommand=x", knownHostsFile })).toThrow(LinkSshArgumentError); +}); + test("probe argv uses accept-new only with its temporary known_hosts file", () => { const knownHostsFile = tempPath("probe"); writeFileSync(knownHostsFile, "", { mode: 0o600 }); diff --git a/tests/clients/link-status-projection.test.ts b/tests/clients/link-status-projection.test.ts index e4be602cdb7..8dbb22ceaa3 100644 --- a/tests/clients/link-status-projection.test.ts +++ b/tests/clients/link-status-projection.test.ts @@ -9,6 +9,9 @@ import { projectLinkStatus } from "../../src/link/status-projection"; import type { LinkStore } from "../../src/link/store"; import type { CompensationStore } from "../../src/link/compensation"; +/** OCX-6: every link row carries the Child's last apply report; null without one. */ +const NO_PARITY = { lastApplyAt: null, runtimeStartedAt: null, appliedCodexSettingsDigest: null, reportedAt: null }; + const store: LinkStore = { version: 1, listenerPort: 19001, @@ -41,6 +44,7 @@ test("projects tunnel state and listener status into K16", () => { since: "2026-09-25T00:01:00.000Z", reason: null, tunnelPort: 19002, + ...NO_PARITY, }], child: null, }); @@ -92,7 +96,7 @@ test.each(["connected", "reconnecting", "failed"] as const)("an empty-store Home }, { version: 1, entries: {} }, clientChild, { direction: "hub-initiated" }); expect(dto).toEqual({ role: "child", listener: { state: "off", port: null }, - links: [{ id: store.links[0]!.id, direction: "hub-initiated", tunnelPort: 19002, ...clientChild }], + links: [{ id: store.links[0]!.id, direction: "hub-initiated", tunnelPort: 19002, ...clientChild, ...NO_PARITY }], child: clientChild, }); }); diff --git a/tests/codex-integration/codex-catalog-write-audit.test.ts b/tests/codex-integration/codex-catalog-write-audit.test.ts index f8f4cee61f5..e3b1a2a7a77 100644 --- a/tests/codex-integration/codex-catalog-write-audit.test.ts +++ b/tests/codex-integration/codex-catalog-write-audit.test.ts @@ -29,6 +29,7 @@ import { initializeConfigOwnership, CONFIG_UNINSTALL_MANIFEST } from "../../src/ import type { CatalogWriteAuditEvent } from "../../src/codex/catalog/write-audit-contract"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { redactUserPath } from "../../src/lib/redact"; +import { setSyntheticWindowsPrincipalForTests } from "../../src/lib/windows-user-principal"; let root = ""; let codexHome = ""; @@ -38,6 +39,7 @@ let previousOpencodexHome: string | undefined; const native = { slug: "gpt-5.5", description: "native" }; const routed = { slug: "ark/glm-5.3", description: "Routed via opencodex → ark/glm-5.3 (ark)." }; const catalogBytes = (...models: object[]) => `${JSON.stringify({ models }, null, 2)}\n`; +const aclSuccess = () => ({ success: true, exitCode: 0, timedOut: false, stdout: "" }); function auditPath(): string { return join(codexHome, CODEX_CATALOG_AUDIT_FILE); } const auditEvent = (): CatalogWriteAuditEvent => ({ @@ -78,6 +80,13 @@ function replaceAs(intent: CatalogWriteIntent, content: string) { } beforeEach(() => { + // A fresh audit file is hardened inside a 1s product deadline. On Windows that window held a + // cold PowerShell SID lookup plus real icacls, so a slow runner reported "skipped" where the + // audit contract says "created". These cases test the audit's publication rules; the ACL + // sequence has its own suite (tests/windows/windows-secret-acl.test.ts). + setSyntheticWindowsPrincipalForTests("*S-1-5-21-1-2-3-1001"); + secretAcl.setIcaclsRunnerForTests(aclSuccess); + secretAcl.setAsyncIcaclsRunnerForTests(async () => aclSuccess()); previousOpencodexHome = process.env.OPENCODEX_HOME; root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-catalog-audit-"))); codexHome = join(root, "codex"); @@ -88,6 +97,9 @@ beforeEach(() => { }); afterEach(() => { + secretAcl.setIcaclsRunnerForTests(null); + secretAcl.setAsyncIcaclsRunnerForTests(null); + setSyntheticWindowsPrincipalForTests(null); if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousOpencodexHome; const path = resolveCodexCatalogSerializationDatabasePath(resolveEffectiveUserIdentity(), codexHome); diff --git a/tests/codex-integration/codex-model-entitlements-program-shape.test.ts b/tests/codex-integration/codex-model-entitlements-program-shape.test.ts index a91f82f67f0..f5a04c7d9fb 100644 --- a/tests/codex-integration/codex-model-entitlements-program-shape.test.ts +++ b/tests/codex-integration/codex-model-entitlements-program-shape.test.ts @@ -41,6 +41,21 @@ describe("Codex roster access program metadata", () => { }); expect(codexForward).toHaveProperty("available_access_programs", { cyber: ["standard"] }); }); + test("a routed row never inherits configuration_update support from a GPT-6 template", () => { + // Codex 0.164 sends positional `configuration_update` effort items only for rows declaring + // this flag; a routed provider must keep effort on the ordinary request parameter. + const template: RawEntry = { slug: "gpt-6-sol", supports_reasoning_effort_updates: true, use_responses_lite: true }; + const routed = deriveEntry(template, "other/model", "Other provider", 5, { provider: "other", id: "model" }); + expect(routed).not.toHaveProperty("supports_reasoning_effort_updates"); + expect(routed).not.toHaveProperty("use_responses_lite"); + expect(template.supports_reasoning_effort_updates).toBe(true); + // The native row and the exact Codex-forward alias keep the upstream contract. + expect(deriveEntry(template, "gpt-6-sol", "Native", 5)).toHaveProperty("supports_reasoning_effort_updates", true); + const codexForward = deriveEntry(template, "openai/gpt-6-sol", "Codex forward", 5, { + provider: "openai", id: "gpt-6-sol", codexForwardNativeCapabilityAlias: true, + }); + expect(codexForward).toHaveProperty("supports_reasoning_effort_updates", true); + }); test("keeps access programs from each authenticated roster separate", async () => { const snapshot = await resolveCodexModelEntitlements({ codexAccounts: [] }, { credentials: [credential(MAIN_CODEX_ACCOUNT_ID), credential("secondary")], diff --git a/tests/codex-integration/codex-pool-caller-fallback-policy.test.ts b/tests/codex-integration/codex-pool-caller-fallback-policy.test.ts index 159a1cdfef6..960e597b44f 100644 --- a/tests/codex-integration/codex-pool-caller-fallback-policy.test.ts +++ b/tests/codex-integration/codex-pool-caller-fallback-policy.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { resolveCodexAuthContext, CodexPoolAuthenticationError } from "../../src/codex/auth-context"; +import { resolveCodexAuthContext, CodexPoolAuthenticationError, CodexAccountCooldownError } from "../../src/codex/auth-context"; import { saveCodexAccountCredential } from "../../src/codex/account-store"; import { clearThreadAccountMap, clearCodexUpstreamHealth, recordCodexUpstreamOutcome } from "../../src/codex/routing"; import { clearAccountQuota } from "../../src/codex/quota"; @@ -68,7 +68,7 @@ test("an exact pool binding refuses cooldown instead of using caller main", asyn recordCodexUpstreamOutcome(cfg, "pool-a", 429, { resetAt: Date.now() + 600_000, fixedAccount: true }); await expect(resolveCodexAuthContext(inbound(), cfg, "pool", { requestScopedMainCredential: true, accountId: "pool-a", - })).rejects.toThrow(); + })).rejects.toBeInstanceOf(CodexAccountCooldownError); expect(info).not.toHaveBeenCalled(); }); diff --git a/tests/codex-integration/codex-prompt-lock.test.ts b/tests/codex-integration/codex-prompt-lock.test.ts index 7cc149660e5..7828a12fe12 100644 --- a/tests/codex-integration/codex-prompt-lock.test.ts +++ b/tests/codex-integration/codex-prompt-lock.test.ts @@ -249,7 +249,10 @@ describe("interleavings", () => { const fs = await import('node:fs'); const {tryAcquire, stillHeld} = await import(${JSON.stringify(modulePath)}); const result = tryAcquire(${JSON.stringify(path)}); - fs.writeFileSync(${JSON.stringify(path + ".b")}, JSON.stringify(result)); + // Publish by rename: the parent polls for this name and parses it at once, so the + // name must never be visible before the content is complete. + fs.writeFileSync(${JSON.stringify(path + ".b.tmp")}, JSON.stringify(result)); + fs.renameSync(${JSON.stringify(path + ".b.tmp")}, ${JSON.stringify(path + ".b")}); const until = Date.now() + 3000; while (!fs.existsSync(${JSON.stringify(stop)})) { if (Date.now() > until) throw Error('barrier timeout'); diff --git a/tests/codex-integration/main-account-hard-lock-fallback-off.test.ts b/tests/codex-integration/main-account-hard-lock-fallback-off.test.ts new file mode 100644 index 00000000000..163afd07185 --- /dev/null +++ b/tests/codex-integration/main-account-hard-lock-fallback-off.test.ts @@ -0,0 +1,130 @@ +/** + * OCX-11 at the main-account hard-lock fallback (src/server/responses/main-hard-lock-routing.ts): + * refused, the reroute leaves the body untouched and the request answers fallback_refused + * without contacting the configured fallback; allowed, the reroute is unchanged. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { readdirSync } from "node:fs"; +import { applyMainHardLockFallbackOverride } from "../../src/server/responses/main-hard-lock-routing"; +import { captureMainQuotaWriter, clearMainAccountInfoCache, observeMainQuotaIdentity } from "../../src/codex/main-account-cache"; +import { clearAccountQuota, setAccountQuotaFromParsed, type StoredAccountQuota } from "../../src/codex/quota"; +import { handleResponses } from "../../src/server/responses"; +import { FallbackRefusedError, clearFallbackEventsForTests, recentFallbackEvents } from "../../src/routing/fallback-policy"; +import type { OcxConfig } from "../../src/types"; +import type { OcxFallbackConfig } from "../../src/types/fallback"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; + +const FALLBACK = { model: "deepseek/deepseek-chat", reasoningEffort: "low" }; +const originalFetch = globalThis.fetch; +let home: TempHome; +let releaseSpendHome: (() => void) | undefined; + +beforeEach(() => { + home = createTempHome("ocx-main-lock-fallback-off-"); + releaseSpendHome = acquireOwnedSpendHome(); + clearAccountQuota(); + clearMainAccountInfoCache(); + clearFallbackEventsForTests(); + observeMainQuotaIdentity("fallback-account-a"); +}); + +afterEach(() => { + releaseSpendHome?.(); + releaseSpendHome = undefined; + globalThis.fetch = originalFetch; + clearAccountQuota(); + clearMainAccountInfoCache(); + clearFallbackEventsForTests(); + home.remove(); +}); + +function observe(quota: Omit): void { + const writer = captureMainQuotaWriter("fallback-account-a"); + if (!writer) throw new Error("fixture identity was not observed"); + setAccountQuotaFromParsed("__main__", quota, undefined, writer); +} + +function config(fallback?: OcxFallbackConfig): OcxConfig { + return { + providers: { deepseek: { adapter: "openai-responses", authMode: "key", apiKey: "fixture-key", baseUrl: "https://deepseek.example.test/v1" } }, + codexAccounts: [], + codexMainAccountHardLockFallback: FALLBACK, + ...(fallback ? { fallback } : {}), + } as OcxConfig; +} + +describe("hard-lock fallback with fallback off", () => { + test("classifying a routing-profile target reads config only: nothing opens under OPENCODEX_HOME", async () => { + observe({ weeklyPercent: 98 }); + const cfg = { + ...config({ provider: false }), + providers: { + ...config().providers, + openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex" }, + }, + routingProfiles: { rescue: { alias: "chosen-policy", candidates: [{ provider: "openai", model: "gpt-5.4" }, { provider: "deepseek", model: "deepseek-chat" }] } }, + } as OcxConfig; + for (const model of ["policy/rescue", "chosen-policy"]) { + cfg.codexMainAccountHardLockFallback = { model }; + // The evaluator would open routing-history.sqlite here, and on Windows its handle blocks + // the home's removal. The hop is classified from the declared candidates instead. + const before = readdirSync(home.root).sort(); + let thrown: unknown; + try { + await applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg); + } catch (error) { + thrown = error; + } + expect(readdirSync(home.root).sort()).toEqual(before); + expect(thrown).toBeInstanceOf(FallbackRefusedError); + const refusal = await (thrown as FallbackRefusedError).response.json() as { error: { fallback: Record } }; + expect(refusal.error.fallback).toMatchObject({ kind: "provider", from: "openai/gpt-5.3", to: "deepseek/deepseek-chat" }); + } + }); + + test("provider fallback off: the reroute is refused and the body is untouched", async () => { + observe({ weeklyPercent: 98 }); + const body: Record = { model: "gpt-5.3", reasoning: { effort: "high" } }; + let thrown: unknown; + try { + await applyMainHardLockFallbackOverride(body, config({ provider: false })); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(FallbackRefusedError); + const response = (thrown as FallbackRefusedError).response; + expect(response.status).toBe(429); + const refusal = await response.json() as { error: { code: string; fallback: Record } }; + expect(refusal.error.code).toBe("fallback_refused"); + expect(refusal.error.fallback).toMatchObject({ kind: "provider", trigger: "hard_lock", from: "openai/gpt-5.3", to: "deepseek/deepseek-chat" }); + expect(body).toEqual({ model: "gpt-5.3", reasoning: { effort: "high" } }); + }); + + test("a route override on the source model reopens it; absent policy is unchanged", async () => { + observe({ weeklyPercent: 98 }); + const routed: Record = { model: "gpt-5.3" }; + expect(await applyMainHardLockFallbackOverride(routed, config({ provider: false, routes: { "gpt-5.3": { provider: true } } }))) + .toEqual({ sourceModel: "gpt-5.3", targetModel: "deepseek/deepseek-chat" }); + const plain: Record = { model: "gpt-5.3" }; + expect(await applyMainHardLockFallbackOverride(plain, config())).toEqual({ sourceModel: "gpt-5.3", targetModel: "deepseek/deepseek-chat" }); + expect(recentFallbackEvents().map(event => event.outcome)).toEqual(["taken", "taken"]); + }); + + test("end to end: the request answers fallback_refused and nothing is sent", async () => { + observe({ weeklyPercent: 98 }); + const sent: string[] = []; + globalThis.fetch = (async (input: string | URL | Request) => { + sent.push(String(input instanceof Request ? input.url : input)); + return Response.json({ error: { message: "must not be called" } }, { status: 500 }); + }) as typeof fetch; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "gpt-5.3", stream: false, input: "hello" }), + }), config({ provider: false }), { model: "", provider: "" }); + expect(response.status).toBe(429); + expect(response.headers.get("x-opencodex-fallback")).toBe("refused"); + expect(sent).toEqual([]); + }); +}); diff --git a/tests/codex-integration/main-account-hard-lock-fallback-routing.test.ts b/tests/codex-integration/main-account-hard-lock-fallback-routing.test.ts index ddfa97d006d..5db587eed26 100644 --- a/tests/codex-integration/main-account-hard-lock-fallback-routing.test.ts +++ b/tests/codex-integration/main-account-hard-lock-fallback-routing.test.ts @@ -285,7 +285,7 @@ describe("applyMainHardLockFallbackOverride", async () => { .toEqual({ sourceModel: "gpt-5.3", targetModel: "deepseek/deepseek-chat" }); }); -+ test.each(["none", "identity", "bearer"] as const)("Direct ownership is rechecked after the async caller check (change: %s)", async change => { + test.each(["none", "identity", "bearer"] as const)("Direct ownership is rechecked after the async caller check (change: %s)", async change => { observeMainQuotaCredential("fixture-caller-a", "fallback-account-a"); observe({ weeklyPercent: 100 }); const cfg = config({ diff --git a/tests/codex-integration/main-account-hard-lock-recovery.test.ts b/tests/codex-integration/main-account-hard-lock-recovery.test.ts index 9d22e29efd5..c6e92fdd0af 100644 --- a/tests/codex-integration/main-account-hard-lock-recovery.test.ts +++ b/tests/codex-integration/main-account-hard-lock-recovery.test.ts @@ -214,7 +214,7 @@ describe("main hard-lock background recovery", () => { } finally { clock.mockRestore(); } }); -+ test("a predicted reset that moves earlier pulls the next hold read in, never under one base interval (#7312)", async () => { + test("a predicted reset that moves earlier pulls the next hold read in, never under one base interval (#7312)", async () => { let now = Date.now(); const start = now; const clock = spyOn(Date, "now").mockImplementation(() => now); @@ -236,7 +236,7 @@ describe("main hard-lock background recovery", () => { } finally { clock.mockRestore(); } }); -+ test("a revised reset that expires between sweeps still releases the old reservation (#7312)", async () => { + test("a revised reset that expires between sweeps still releases the old reservation (#7312)", async () => { let now = Date.now(); const start = now; const clock = spyOn(Date, "now").mockImplementation(() => now); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 563b2d53550..20aa180eed2 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -86,7 +86,7 @@ "cli-account-key-quota.test.ts": "cli", "cli-observe-snapshot.test.ts": "cli", "cli-access-data-plane.test.ts": "cli", "cli-access-key-guard.test.ts": "cli", "cli-access-rename.test.ts": "cli", "cli-access-audio.test.ts": "cli", "cli-access-audio-live.test.ts": "cli", "cli-log-follow.test.ts": "cli", "cli-injection-follow.test.ts": "cli", "cli-usage-scope.test.ts": "cli", "cli-system-health.test.ts": "cli", "cli-companion-timeline.test.ts": "cli", "server-cli-key-probe-admission.test.ts": "server", - "cli-integration-preview.test.ts": "cli", "cli-integration-droid.test.ts": "cli", "cli-integration-journal.test.ts": "cli", "cli-aside-sync.test.ts": "cli", "cli-claude-desktop-profile.test.ts": "cli", "cli-integration-cursor.test.ts": "cli", "cli-remote-workspace-hub.test.ts": "cli", "cli-storage-policy-fields.test.ts": "cli", "cli-link-force.test.ts": "cli", + "cli-integration-preview.test.ts": "cli", "cli-integration-droid.test.ts": "cli", "cli-integration-journal.test.ts": "cli", "cli-aside-sync.test.ts": "cli", "cli-claude-desktop-profile.test.ts": "cli", "cli-integration-cursor.test.ts": "cli", "cli-remote-workspace-hub.test.ts": "cli", "cli-storage-policy-fields.test.ts": "cli", "cli-link-force.test.ts": "cli", "cli-link-cleanup.test.ts": "cli", "cli-account-policy.test.ts": "cli", "cli-account-login-options.test.ts": "cli", "cli-logout-runtime.test.ts": "cli", "cli-agent-settings.test.ts": "cli", "cli-v2-runtime.test.ts": "cli", "cli-agent-runtime-settings.test.ts": "cli", "cli-system-settings-parity.test.ts": "cli", "cli-catalog-command-result.test.ts": "cli", "cli-models-local-result.test.ts": "cli", @@ -174,7 +174,7 @@ "discovered-native-models.test.ts": "codex-integration", "responses-hosted-image-delivery.test.ts": "responses", "responses-hosted-image-display.test.ts": "responses", "responses-hosted-image-limits.test.ts": "responses", "cursor-local-installer.test.ts": "providers/cursor", "codex-quota-query-backoff.test.ts": "codex-integration", - "link-relay-bound-transport.test.ts": "clients", "pnpm-command-isolation.test.ts": "update", + "link-relay-bound-transport.test.ts": "clients", "link-peer-compatibility.test.ts": "clients", "pnpm-command-isolation.test.ts": "update", "dsh-login.test.ts": "oauth", "dsh-account.test.ts": "providers", "dsh-account-adapter.test.ts": "adapters", @@ -201,7 +201,7 @@ "abort-race.test.ts": "adapters", "account-import.test.ts": "server", "account-import-project-fallback.test.ts": "server", "account-pool-management-api.test.ts": "server", "link-listener-admission.test.ts": "server", - "link-management-relay.test.ts": "server", + "link-management-relay.test.ts": "server", "quota-pool-relay.test.ts": "server", "quota-pool.test.ts": "usage", "fallback-policy.test.ts": "routing", "responses-combo-fallback-off.test.ts": "responses", "main-account-hard-lock-fallback-off.test.ts": "codex-integration", "subagent-model-fallback-off.test.ts": "routing", "routing-policy-fallback-off.test.ts": "routing", "fallback-routes.test.ts": "server", "client-home-unreachable.test.ts": "clients", "cli-fallback.test.ts": "cli", "link-listener-lifecycle.test.ts": "server", "link-listener-mutation.test.ts": "server", "link-join-route.test.ts": "server", "core-link-boundary.test.ts": "lab", "acl-error-classification.test.ts": "lib", @@ -217,6 +217,7 @@ "adapter-usage.test.ts": "adapters", "agent-driven.test.ts": "cli", "agent-task-recovery-cache.test.ts": "server", "agent-task-recovery-combo.test.ts": "server", "agent-task-recovery-fallback.test.ts": "server", "agent-task-recovery-security.test.ts": "server", + "agent-assertion-auth.test.ts": "server", "agent-assertion-routes.test.ts": "responses", "authorization-scheme.test.ts": "lib", "agent-task-recovery.test.ts": "server", "alias-management-api.test.ts": "server", "alibaba-intl-token-plan.test.ts": "gui", "alibaba-region-backup.test.ts": "providers", "alibaba-region-migration.test.ts": "providers", "alibaba-region-startup.test.ts": "providers", @@ -330,6 +331,7 @@ "responses-first-send-reservation.test.ts": "responses", "responses-first-send-spend-policy.test.ts": "responses", "responses-dispatch-receipt.test.ts": "responses", "responses-hosted-send-hop.test.ts": "responses", "responses-hosted-send-producer.test.ts": "responses", "responses-hosted-send-reservation.test.ts": "responses", "responses-hosted-send-round-parity.test.ts": "responses", "responses-hosted-send-unsent-hop.test.ts": "responses", "responses-hosted-send-ws.test.ts": "responses", "messages-native-oauth-eligibility.test.ts": "responses", "chat-inbound-reasoning-none.test.ts": "responses", "chat-native-decline-reason.test.ts": "responses", "chat-inbound-reasoning-replay.test.ts": "responses", + "chat-anthropic-reasoning-details.test.ts": "responses", "chat-inline-document-bytes.test.ts": "responses", "chat-json-sse-fallback.test.ts": "responses", "chat-media-translation.test.ts": "responses", "chat-native-developer-role.test.ts": "responses", "chat-native-reasoning-replay.test.ts": "responses", @@ -427,6 +429,7 @@ "cli-models-runtime-dispatch.test.ts": "cli", "cli-models-set.test.ts": "cli", "cli-models.test.ts": "cli", "cli-native-profile.test.ts": "cli", "cli-observe-logs.test.ts": "cli", "cli-provider.test.ts": "cli", + "cli-provider-list-active.test.ts": "cli", "cli-ready-subprocess.test.ts": "cli", "cli-ready.test.ts": "cli", "cli-registry.test.ts": "cli", "cli-resolve-subprocess.test.ts": "cli", "cli-resolve.test.ts": "cli", "cli-restart-health.test.ts": "cli", @@ -434,7 +437,7 @@ "cli-start-auxiliary-bind.test.ts": "cli", "cli-start-journal-order.test.ts": "cli", "sibling-home-client-sync.test.ts": "cli", "cli-status-hub-state.test.ts": "cli", "cross-home-loopback-family.test.ts": "cli", - "cli-status-json.test.ts": "cli", "cli-status-oauth-health.test.ts": "cli", + "cli-status-json.test.ts": "cli", "cli-status-oauth-health.test.ts": "cli", "status-install-method.test.ts": "cli", "cli-stop-json.test.ts": "cli", "cli-storage-inspect.test.ts": "cli", "cli-transport-honesty.test.ts": "cli", "cli-update-badge.test.ts": "cli", "cli-usage-hub.test.ts": "cli", "cli-usage-report.test.ts": "cli", @@ -925,7 +928,7 @@ "oauth-refresh-lock-multiprocess.test.ts": "oauth", "oauth-refresh.test.ts": "oauth", "oauth-status-privacy.test.ts": "oauth", "oauth-store-multi.test.ts": "oauth", "oauth-store-quota-isolation.test.ts": "oauth", "oauth-tos-warning.test.ts": "gui", "oauth-upsert-preserves-api-key.test.ts": "oauth", - "ocx-launcher-runtime.test.ts": "cli", "ocx-launcher-source.test.ts": "cli", + "ocx-launcher-runtime.test.ts": "cli", "ocx-launcher-fixture-trust.test.ts": "cli", "ocx-launcher-source.test.ts": "cli", "ocx-launcher-desktop-handoff.test.ts": "cli", "cli-path-diagnostics.test.ts": "cli", "ocx-run.test.ts": "cli", "ollama-native-parser.test.ts": "providers/ollama", "ollama-native-reasoning-wire.test.ts": "providers/ollama", @@ -1021,7 +1024,7 @@ "provider-workspace-data.test.ts": "gui", "provider-workspace-rail.test.ts": "gui", "provider-workspace-state.test.ts": "gui", "proxy-env-macos.test.ts": "server", "proxy-env.test.ts": "server", "proxy-liveness-package-tree-fence.test.ts": "server", - "proxy-liveness.test.ts": "server", "qoder-adapter.test.ts": "providers", + "proxy-liveness.test.ts": "server", "readyz-features.test.ts": "server", "served-by-header.test.ts": "server", "qoder-adapter.test.ts": "providers", "qoder-live-models.test.ts": "providers", "qoder-mcp-server.test.ts": "providers", "qoder-scaffold-guard.test.ts": "providers", "quota-401-recovery-runtime.test.ts": "usage", "quota-401-recovery.test.ts": "usage", "quota-bars-rows.test.ts": "gui", "quota-reset-account-key.test.ts": "usage", @@ -1102,6 +1105,7 @@ "responses-hosted-tool-declaration.test.ts": "responses", "responses-hosted-tool-min-spread.test.ts": "responses", "responses-hide-raw-reasoning.test.ts": "responses", "responses-image-gen-repair.test.ts": "responses", "responses-inbound-store-default.test.ts": "responses", "responses-item-id-repair.test.ts": "responses", + "codex-0164-request-compat.test.ts": "responses", "responses-json-events.test.ts": "responses", "responses-legacy-dotted-tool-name-repair.test.ts": "responses", "responses-muse-tool-name-alias.test.ts": "responses", "responses-muse-tool-choice.test.ts": "responses", "responses-native-main-refresh.test.ts": "responses", "responses-opaque-blob-recovery.test.ts": "responses", "responses-cold-opaque-recovery.test.ts": "responses", @@ -1232,7 +1236,8 @@ "system-routes.test.ts": "server", "systemd-install-cleanup-hardening.test.ts": "service", "tencent-siliconflow-providers.test.ts": "gui", "terminal-continuation-owner-rotation.test.ts": "adapters", "terminal-guard-server.test.ts": "server", "terminal-guard.test.ts": "server", - "test-home-guard.test.ts": "ci-workflows", "test-runner.test.ts": "ci-workflows", "test-runner-bun.test.ts": "ci-workflows", + "test-home-guard.test.ts": "ci-workflows", "test-home-guard-unpreloaded.test.ts": "ci-workflows", + "test-runner.test.ts": "ci-workflows", "test-runner-bun.test.ts": "ci-workflows", "test-runner-spawnsync-finalizer.test.ts": "ci-workflows", "test-sandbox-cleanup.test.ts": "ci-workflows", "test-temp-quarantine.test.ts": "ci-workflows", "thought-signature-credential-scope.test.ts": "responses", "token-estimate.test.ts": "lib", "token-guardian.test.ts": "codex-integration", @@ -1246,7 +1251,7 @@ "update-async-routes.test.ts": "server", "update-badge.test.ts": "update", "update-bun-ownership-lease.test.ts": "update", "update-desktop-badge.test.ts": "update", "update-desktop-owner.test.ts": "update", "update-supervised-recovery.test.ts": "update", - "update-job.test.ts": "update", "update-job-supervised-restart.test.ts": "update", + "update-job.test.ts": "update", "update-recovery-host-free.test.ts": "update", "update-job-supervised-restart.test.ts": "update", "update-standalone.test.ts": "update", "update-worker-launch.test.ts": "update", "update-notify.test.ts": "update", "update-npm-cache-preflight.test.ts": "update", "update-npm-invocation.test.ts": "update", @@ -1313,7 +1318,7 @@ "windows-user-principal-nonascii.test.ts": "windows", "windows-user-principal.test.ts": "windows", "winsw-stop-hardening.test.ts": "windows", - "winsw.test.ts": "service", + "winsw.test.ts": "service", "service-status-json.test.ts": "service", "workflow-budget.test.ts": "lib", "ws-ambiguous-resend.test.ts": "responses", "ws-pre-output-refusal.test.ts": "responses", "responses-pool-overload-failover.test.ts": "responses", "compact-transient-alternate.test.ts": "responses", "ws-capacity-reclamation.test.ts": "responses", @@ -1388,4 +1393,6 @@ "server-desktop-compatibility-startup.test.ts": "server", "codex-set-prompt.test.ts": "gui", "codex-prompt-writes.test.ts": "codex-integration", "codex-prompt-scalar-scope.test.ts": "codex-integration", "advisor-settings.test.ts": "advisor", "advisor-context.test.ts": "advisor", "advisor-state.test.ts": "advisor", "advisor-guard.test.ts": "advisor", "advisor-consult.test.ts": "advisor", "advisor-plan.test.ts": "advisor", "advisor-responses-wiring.test.ts": "advisor", "advisor-routes.test.ts": "server", "advisor-internal-authority.test.ts": "advisor", "advisor-core-boundary.test.ts": "advisor", "advisor-continuation-limit.test.ts": "advisor", "advisor-authority-transport.test.ts": "advisor", "advisor-evidence.test.ts": "advisor", "advisor-budget.test.ts": "advisor", "advisor-adaptive.test.ts": "advisor" + ,"link-codex-settings-digest.test.ts": "clients", "link-apply-record.test.ts": "clients", "client-runtime-restart.test.ts": "clients", "link-parity-reporter.test.ts": "clients", + "link-apply-restart.test.ts": "cli", "link-child-parity.test.ts": "server", "restart-replacement-not-ready.test.ts": "server" } diff --git a/tests/gui/gui-pair-http.test.ts b/tests/gui/gui-pair-http.test.ts index 0af7c46d7a3..56791297653 100644 --- a/tests/gui/gui-pair-http.test.ts +++ b/tests/gui/gui-pair-http.test.ts @@ -1,9 +1,10 @@ -import { expect, test } from "bun:test"; +import { beforeAll, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { findAvailablePort } from "../../src/server/ports"; import { watchdogMs } from "../helpers/ci-watchdog"; +import { COLD_SPAWN_WARMUP_HOOK_BUDGET_MS, warmModuleGraph } from "../helpers/cold-spawn-warmup"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; import { randomBytes } from "node:crypto"; @@ -33,177 +34,188 @@ async function bootstrap(response: Response, origin: string) { return { token: token!, csrf: csrf! }; } -test("standalone CLI pairing crosses real HTTP admission once and stops before SSH", async () => { - const root = mkdtempSync(join(tmpdir(), "ocx-gui-pair-http-")); - const home = join(root, "home"), ocxHome = join(root, "ocx"); - const env: Record = { - HOME: home, USERPROFILE: home, OPENCODEX_HOME: ocxHome, CODEX_HOME: join(root, "codex"), - XDG_CONFIG_HOME: join(root, "xdg-config"), XDG_CACHE_HOME: join(root, "xdg-cache"), - XDG_DATA_HOME: join(root, "xdg-data"), XDG_STATE_HOME: join(root, "xdg-state"), - XDG_RUNTIME_DIR: join(root, "xdg-runtime"), APPDATA: join(home, "AppData", "Roaming"), - LOCALAPPDATA: join(home, "AppData", "Local"), TMPDIR: join(root, "tmp"), - TMP: join(root, "tmp"), TEMP: join(root, "tmp"), PATH: join(root, "empty-bin"), - OCX_OWNER_REGISTRY_DIR: join(root, "owner-registry"), NO_PROXY: "127.0.0.1,localhost", - OCX_TEST_HOME_GUARD: "1", OCX_DISABLE_UPDATE_CHECK: "1", OPENCODEX_KIRO_MODEL_DISCOVERY: "0", - CODEX_CI: "1", - }; - // Preserve only the guard's deny-list and Windows OS locator, never the parent environment. - if (process.env.OCX_REAL_HOME) env.OCX_REAL_HOME = process.env.OCX_REAL_HOME; - if (process.platform === "win32" && process.env.SystemRoot) env.SystemRoot = process.env.SystemRoot; - const children = new Set(); - const controller = new AbortController(); - const deadline = setTimeout(() => { - controller.abort(); - for (const child of children) if (child.exitCode === null) child.kill("SIGKILL"); - }, DEADLINE); - try { - for (const name of ["HOME", "OPENCODEX_HOME", "CODEX_HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", - "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_RUNTIME_DIR", "APPDATA", "LOCALAPPDATA", "TMPDIR", - "PATH", "OCX_OWNER_REGISTRY_DIR"]) mkdirSync(env[name]!, { recursive: true, mode: 0o700 }); - const port = await findAvailablePort(0, "127.0.0.1"); - const origin = `http://127.0.0.1:${port}`; - writeFileSync(join(ocxHome, "config.json"), JSON.stringify({ - port, hostname: "127.0.0.1", runtimeRole: "standalone", providers: {}, defaultProvider: "openai", - apiKeys: [{ id: "paired-read-fixture", name: "Fixture", key: "ocx_data_paired_read_fixture", - createdAt: "2026-10-06T00:00:00.000Z" }], - codexAutoStart: false, syncResumeHistory: false, - clientIntegrations: { codex: false, grok: false, "claude-desktop": false }, - claudeCode: { enabled: false, systemEnv: false }, - })); - const server = Bun.spawn([process.execPath, repoPath("src/cli/index.ts"), "start", "--port", String(port)], { - cwd: root, env, - stdin: "ignore", stdout: "ignore", stderr: "ignore", - }); - children.add(server); - const request = (path: string, init: RequestInit = {}) => fetch(`${origin}${path}`, { - ...init, redirect: "error", proxy: null, signal: controller.signal, - }); - // Health must identify our child, and the runtime publication must agree before CLI discovery. - let ready = false; - while (!controller.signal.aborted && server.exitCode === null) { - try { - const health = await request("/healthz"); - const identity = parseObject(await health.text()); - if (health.status === 200 && identity.service === "opencodex" - && identity.pid === server.pid && identity.port === port) { - const runtime = parseObject(readFileSync(join(ocxHome, "runtime-port.json"), "utf8")); - const readiness = await request("/readyz"); - await readiness.body?.cancel(); - ready = runtime.pid === server.pid && runtime.port === port && readiness.status === 200; - if (ready) break; - } - } catch { /* Connection refusal/publication absence is expected only during bounded startup. */ } - await Bun.sleep(25); - } - expect(ready, `owned CLI readiness (exit ${server.exitCode ?? "running"})`).toBe(true); - const ordinary = await bootstrap(await request("/opencodex-session", { headers: { Origin: origin } }), origin); - const headers = (session: typeof ordinary) => ({ - Origin: origin, "content-type": "application/json", "x-opencodex-gui-origin": origin, - "x-opencodex-api-key": session.token, "x-opencodex-csrf-token": session.csrf, - }); - const joinRequest = (sessionHeaders: Record) => request("/api/link/join", { - method: "POST", headers: sessionHeaders, body: JSON.stringify({ alias: "pair-http-unconfirmed" }), - }); - const expectError = async (response: Response, status: number, code: string) => { - expect(response.status, `expected ${code} HTTP status`).toBe(status); - const body = parseObject(await response.text()); - const error = body.error; - expect(!!error && typeof error === "object" && "code" in error && error.code === code, - `expected error code ${code} (body withheld)`).toBe(true); - }; - const expectStatus = async (session: typeof ordinary, paired: boolean) => { - const response = await request("/api/link/status", { headers: headers(session) }); - expect(response.status, "link status HTTP status").toBe(200); - const status = parseObject(await response.text()); - expect(status.joinAvailable === paired, "join availability matches session issuance").toBe(true); - expect(status.joinDenied === (paired ? null : "pairing_required"), "exact join denial").toBe(true); - expect(Array.isArray(status.links) && status.links.length === 0, "no durable links").toBe(true); +describe("standalone CLI pairing", () => { + // Both children run src/cli/index.ts, whose start path loads the whole server graph. On a cold + // Windows runner that load took most of the readiness watchdog; pay it here, not in the case. + beforeAll(async () => { + await warmModuleGraph({ graph: "cli-index/gui-pair-http", entry: repoPath("src/cli/index.ts") }); + }, COLD_SPAWN_WARMUP_HOOK_BUDGET_MS); + + test("standalone CLI pairing crosses real HTTP admission once and stops before SSH", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-gui-pair-http-")); + const home = join(root, "home"), ocxHome = join(root, "ocx"); + const env: Record = { + HOME: home, USERPROFILE: home, OPENCODEX_HOME: ocxHome, CODEX_HOME: join(root, "codex"), + XDG_CONFIG_HOME: join(root, "xdg-config"), XDG_CACHE_HOME: join(root, "xdg-cache"), + XDG_DATA_HOME: join(root, "xdg-data"), XDG_STATE_HOME: join(root, "xdg-state"), + XDG_RUNTIME_DIR: join(root, "xdg-runtime"), APPDATA: join(home, "AppData", "Roaming"), + LOCALAPPDATA: join(home, "AppData", "Local"), TMPDIR: join(root, "tmp"), + TMP: join(root, "tmp"), TEMP: join(root, "tmp"), PATH: join(root, "empty-bin"), + OCX_OWNER_REGISTRY_DIR: join(root, "owner-registry"), NO_PROXY: "127.0.0.1,localhost", + OCX_TEST_HOME_GUARD: "1", OCX_DISABLE_UPDATE_CHECK: "1", OPENCODEX_KIRO_MODEL_DISCOVERY: "0", + CODEX_CI: "1", }; - await expectStatus(ordinary, false); - await expectError(await joinRequest(headers(ordinary)), 403, "forbidden"); - const reveal = (session: typeof ordinary) => request("/api/keys/reveal", { - method: "POST", headers: headers(session), body: JSON.stringify({ id: "paired-read-fixture" }), - }); - const ordinaryReveal = await reveal(ordinary); - expect(ordinaryReveal.status, "automatic session cannot read a stored key").toBe(403); - await ordinaryReveal.body?.cancel(); + // Preserve only the guard's deny-list, the Windows OS locator and the run-private transpiler + // cache, never the rest of the parent environment. + if (process.env.OCX_REAL_HOME) env.OCX_REAL_HOME = process.env.OCX_REAL_HOME; + if (process.platform === "win32" && process.env.SystemRoot) env.SystemRoot = process.env.SystemRoot; + // The warm-up filled that cache; without it both CLI children would transpile cold again. + if (process.env.BUN_RUNTIME_TRANSPILER_CACHE_PATH) env.BUN_RUNTIME_TRANSPILER_CACHE_PATH = process.env.BUN_RUNTIME_TRANSPILER_CACHE_PATH; + const children = new Set(); + const controller = new AbortController(); + const deadline = setTimeout(() => { + controller.abort(); + for (const child of children) if (child.exitCode === null) child.kill("SIGKILL"); + }, DEADLINE); + try { + for (const name of ["HOME", "OPENCODEX_HOME", "CODEX_HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", + "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_RUNTIME_DIR", "APPDATA", "LOCALAPPDATA", "TMPDIR", + "PATH", "OCX_OWNER_REGISTRY_DIR"]) mkdirSync(env[name]!, { recursive: true, mode: 0o700 }); + const port = await findAvailablePort(0, "127.0.0.1"); + const origin = `http://127.0.0.1:${port}`; + writeFileSync(join(ocxHome, "config.json"), JSON.stringify({ + port, hostname: "127.0.0.1", runtimeRole: "standalone", providers: {}, defaultProvider: "openai", + apiKeys: [{ id: "paired-read-fixture", name: "Fixture", key: "ocx_data_paired_read_fixture", + createdAt: "2026-10-06T00:00:00.000Z" }], + codexAutoStart: false, syncResumeHistory: false, + clientIntegrations: { codex: false, grok: false, "claude-desktop": false }, + claudeCode: { enabled: false, systemEnv: false }, + })); + const server = Bun.spawn([process.execPath, repoPath("src/cli/index.ts"), "start", "--port", String(port)], { + cwd: root, env, + stdin: "ignore", stdout: "ignore", stderr: "ignore", + }); + children.add(server); + const request = (path: string, init: RequestInit = {}) => fetch(`${origin}${path}`, { + ...init, redirect: "error", proxy: null, signal: controller.signal, + }); + // Health must identify our child, and the runtime publication must agree before CLI discovery. + let ready = false; + while (!controller.signal.aborted && server.exitCode === null) { + try { + const health = await request("/healthz"); + const identity = parseObject(await health.text()); + if (health.status === 200 && identity.service === "opencodex" + && identity.pid === server.pid && identity.port === port) { + const runtime = parseObject(readFileSync(join(ocxHome, "runtime-port.json"), "utf8")); + const readiness = await request("/readyz"); + await readiness.body?.cancel(); + ready = runtime.pid === server.pid && runtime.port === port && readiness.status === 200; + if (ready) break; + } + } catch { /* Connection refusal/publication absence is expected only during bounded startup. */ } + await Bun.sleep(25); + } + expect(ready, `owned CLI readiness (exit ${server.exitCode ?? "running"})`).toBe(true); + const ordinary = await bootstrap(await request("/opencodex-session", { headers: { Origin: origin } }), origin); + const headers = (session: typeof ordinary) => ({ + Origin: origin, "content-type": "application/json", "x-opencodex-gui-origin": origin, + "x-opencodex-api-key": session.token, "x-opencodex-csrf-token": session.csrf, + }); + const joinRequest = (sessionHeaders: Record) => request("/api/link/join", { + method: "POST", headers: sessionHeaders, body: JSON.stringify({ alias: "pair-http-unconfirmed" }), + }); + const expectError = async (response: Response, status: number, code: string) => { + expect(response.status, `expected ${code} HTTP status`).toBe(status); + const body = parseObject(await response.text()); + const error = body.error; + expect(!!error && typeof error === "object" && "code" in error && error.code === code, + `expected error code ${code} (body withheld)`).toBe(true); + }; + const expectStatus = async (session: typeof ordinary, paired: boolean) => { + const response = await request("/api/link/status", { headers: headers(session) }); + expect(response.status, "link status HTTP status").toBe(200); + const status = parseObject(await response.text()); + expect(status.joinAvailable === paired, "join availability matches session issuance").toBe(true); + expect(status.joinDenied === (paired ? null : "pairing_required"), "exact join denial").toBe(true); + expect(Array.isArray(status.links) && status.links.length === 0, "no durable links").toBe(true); + }; + await expectStatus(ordinary, false); + await expectError(await joinRequest(headers(ordinary)), 403, "forbidden"); + const reveal = (session: typeof ordinary) => request("/api/keys/reveal", { + method: "POST", headers: headers(session), body: JSON.stringify({ id: "paired-read-fixture" }), + }); + const ordinaryReveal = await reveal(ordinary); + expect(ordinaryReveal.status, "automatic session cannot read a stored key").toBe(403); + await ordinaryReveal.body?.cancel(); - // A process which reads runtime-state and reproduces its HMAC still lacks CLI write intent. - // No approved record is published here; no real user credential or external server is used. - const runtime = parseObject(readFileSync(join(ocxHome, "runtime-port.json"), "utf8")); - for (const fakeIntent of [undefined, "B".repeat(43)]) { - const nonce = randomBytes(32).toString("base64url"), expiresAt = Date.now() + 10_000; - const capability = createGuiPairCapability(String(runtime.attestationSecret), nonce, "POST", GUI_PAIR_PATH, - origin, server.pid, port, expiresAt)!; - const refused = await request(GUI_PAIR_PATH, { method: "POST", headers: { - "Content-Length": "0", [GUI_PAIR_EXPECTED_PID_HEADER]: String(server.pid), - [GUI_PAIR_NONCE_HEADER]: nonce, [GUI_PAIR_EXPIRES_AT_HEADER]: String(expiresAt), - [GUI_PAIR_BROWSER_ORIGIN_HEADER]: origin, [GUI_PAIR_CAPABILITY_HEADER]: capability, - ...(fakeIntent ? { [GUI_PAIR_INTENT_HEADER]: fakeIntent } : {}), - } }); - expect(refused.status, "runtime-only pairing refuses without minting").toBe(403); - const denied = parseObject(await refused.text()); - expect(denied.code).toBe("local_pairing_intent_required"); - } - await expectStatus(ordinary, false); + // A process which reads runtime-state and reproduces its HMAC still lacks CLI write intent. + // No approved record is published here; no real user credential or external server is used. + const runtime = parseObject(readFileSync(join(ocxHome, "runtime-port.json"), "utf8")); + for (const fakeIntent of [undefined, "B".repeat(43)]) { + const nonce = randomBytes(32).toString("base64url"), expiresAt = Date.now() + 10_000; + const capability = createGuiPairCapability(String(runtime.attestationSecret), nonce, "POST", GUI_PAIR_PATH, + origin, server.pid, port, expiresAt)!; + const refused = await request(GUI_PAIR_PATH, { method: "POST", headers: { + "Content-Length": "0", [GUI_PAIR_EXPECTED_PID_HEADER]: String(server.pid), + [GUI_PAIR_NONCE_HEADER]: nonce, [GUI_PAIR_EXPIRES_AT_HEADER]: String(expiresAt), + [GUI_PAIR_BROWSER_ORIGIN_HEADER]: origin, [GUI_PAIR_CAPABILITY_HEADER]: capability, + ...(fakeIntent ? { [GUI_PAIR_INTENT_HEADER]: fakeIntent } : {}), + } }); + expect(refused.status, "runtime-only pairing refuses without minting").toBe(403); + const denied = parseObject(await refused.text()); + expect(denied.code).toBe("local_pairing_intent_required"); + } + await expectStatus(ordinary, false); - const mint = Bun.spawn([process.execPath, repoPath("src/cli/index.ts"), "gui", "pair", "--origin", origin, "--json"], { - cwd: root, env, stdin: "ignore", stdout: "pipe", stderr: "ignore", - }); - children.add(mint); - const [mintExit, output] = await Promise.all([mint.exited, new Response(mint.stdout).text()]); - expect(mintExit, "headless gui pair CLI succeeds (output withheld)").toBe(0); - const minted = parseObject(output); - expect(minted.kind === "created", "CLI retains its original grant response").toBe(true); - expect(minted.browserOrigin === origin && minted.serverOrigin === origin, "CLI exact origin binding").toBe(true); - const grant = String(minted.grant); - expect(/^ocx_pair_[A-Za-z0-9_-]{43}$/.test(grant), "one-use code returned only to the CLI with write intent").toBe(true); - expect(readdirSync(join(ocxHome, "gui-pair-intents")).length, "one-use commitment removed").toBe(0); - const redeem = (extraHeaders: Record = {}) => request("/opencodex-session", { - method: "POST", headers: { Origin: origin, "content-type": "application/json", ...extraHeaders }, - body: JSON.stringify({ grant }), - }); - // Refused attempts must not consume the valid grant. - for (const extra of [{ Origin: "https://foreign.example.test" }, { Authorization: "Bearer invalid-test-credential" }]) { - const refused = await redeem(extra); - expect(refused.status, "wrong origin/alternate credential redemption refused").toBe(401); - await refused.body?.cancel(); - } - const paired = await bootstrap(await redeem(), origin); - const pairedReveal = await reveal(paired); - expect(pairedReveal.status, "config-write-authorized session may read a stored key").toBe(200); - expect(pairedReveal.headers.get("cache-control")).toBe("no-store"); - expect(parseObject(await pairedReveal.text()).key === "ocx_data_paired_read_fixture", "exact fixture key returned").toBe(true); - const replay = await redeem(); - expect(replay.status, "single-use grant replay refused").toBe(401); - await replay.body?.cancel(); - await expectStatus(paired, true); - await expectStatus(ordinary, false); - for (const csrf of [undefined, "invalid-test-csrf"]) { - const withoutCsrf = new Headers(headers(paired)); - if (csrf === undefined) withoutCsrf.delete("x-opencodex-csrf-token"); - else withoutCsrf.set("x-opencodex-csrf-token", csrf); - const refused = await joinRequest(Object.fromEntries(withoutCsrf)); - expect(refused.status, "missing/wrong CSRF refused before join").toBe(401); - await refused.body?.cancel(); - } - // Fresh state has no confirmed host: production joinHome rejects BEFORE allocating a port or SSH. - await expectError(await joinRequest(headers(paired)), 409, "host_not_confirmed"); - await expectStatus(paired, true); - for (const file of ["links.json", "client-link.json", "child-initiated.json", "known_hosts"]) { - expect(existsSync(join(ocxHome, "link", file)), `no durable ${file} after refused joins`).toBe(false); + const mint = Bun.spawn([process.execPath, repoPath("src/cli/index.ts"), "gui", "pair", "--origin", origin, "--json"], { + cwd: root, env, stdin: "ignore", stdout: "pipe", stderr: "ignore", + }); + children.add(mint); + const [mintExit, output] = await Promise.all([mint.exited, new Response(mint.stdout).text()]); + expect(mintExit, "headless gui pair CLI succeeds (output withheld)").toBe(0); + const minted = parseObject(output); + expect(minted.kind === "created", "CLI retains its original grant response").toBe(true); + expect(minted.browserOrigin === origin && minted.serverOrigin === origin, "CLI exact origin binding").toBe(true); + const grant = String(minted.grant); + expect(/^ocx_pair_[A-Za-z0-9_-]{43}$/.test(grant), "one-use code returned only to the CLI with write intent").toBe(true); + expect(readdirSync(join(ocxHome, "gui-pair-intents")).length, "one-use commitment removed").toBe(0); + const redeem = (extraHeaders: Record = {}) => request("/opencodex-session", { + method: "POST", headers: { Origin: origin, "content-type": "application/json", ...extraHeaders }, + body: JSON.stringify({ grant }), + }); + // Refused attempts must not consume the valid grant. + for (const extra of [{ Origin: "https://foreign.example.test" }, { Authorization: "Bearer invalid-test-credential" }]) { + const refused = await redeem(extra); + expect(refused.status, "wrong origin/alternate credential redemption refused").toBe(401); + await refused.body?.cancel(); + } + const paired = await bootstrap(await redeem(), origin); + const pairedReveal = await reveal(paired); + expect(pairedReveal.status, "config-write-authorized session may read a stored key").toBe(200); + expect(pairedReveal.headers.get("cache-control")).toBe("no-store"); + expect(parseObject(await pairedReveal.text()).key === "ocx_data_paired_read_fixture", "exact fixture key returned").toBe(true); + const replay = await redeem(); + expect(replay.status, "single-use grant replay refused").toBe(401); + await replay.body?.cancel(); + await expectStatus(paired, true); + await expectStatus(ordinary, false); + for (const csrf of [undefined, "invalid-test-csrf"]) { + const withoutCsrf = new Headers(headers(paired)); + if (csrf === undefined) withoutCsrf.delete("x-opencodex-csrf-token"); + else withoutCsrf.set("x-opencodex-csrf-token", csrf); + const refused = await joinRequest(Object.fromEntries(withoutCsrf)); + expect(refused.status, "missing/wrong CSRF refused before join").toBe(401); + await refused.body?.cancel(); + } + // Fresh state has no confirmed host: production joinHome rejects BEFORE allocating a port or SSH. + await expectError(await joinRequest(headers(paired)), 409, "host_not_confirmed"); + await expectStatus(paired, true); + for (const file of ["links.json", "client-link.json", "child-initiated.json", "known_hosts"]) { + expect(existsSync(join(ocxHome, "link", file)), `no durable ${file} after refused joins`).toBe(false); + } + expect(server.exitCode, "join denial must not restart the server").toBeNull(); + } finally { + controller.abort(); + clearTimeout(deadline); + const force = setTimeout(() => { + for (const child of children) if (child.exitCode === null) child.kill("SIGKILL"); + }, 2_000); + try { + for (const child of children) if (child.exitCode === null) child.kill(); + await Promise.all([...children].map(child => child.exited)); + } finally { clearTimeout(force); } + removeTreeWithRetry(root); } - expect(server.exitCode, "join denial must not restart the server").toBeNull(); - } finally { - controller.abort(); - clearTimeout(deadline); - const force = setTimeout(() => { - for (const child of children) if (child.exitCode === null) child.kill("SIGKILL"); - }, 2_000); - try { - for (const child of children) if (child.exitCode === null) child.kill(); - await Promise.all([...children].map(child => child.exited)); - } finally { clearTimeout(force); } - removeTreeWithRetry(root); - } -}, DEADLINE + 10_000); + }, DEADLINE + 10_000); +}); diff --git a/tests/helpers/quota-pool-schema.ts b/tests/helpers/quota-pool-schema.ts new file mode 100644 index 00000000000..6390154d47c --- /dev/null +++ b/tests/helpers/quota-pool-schema.ts @@ -0,0 +1,68 @@ +/** + * A local mirror of GOAT's `ocx.quota-pool/1` decoder (goat-si/goat + * packages/contracts/src/quotaPool.ts, branch feat/quota-pool-contract, PR #690). + * + * Stricter than the consumer on purpose: objects are strict, so an unexpected key or a `null` + * where the contract says "optional" fails here even though GOAT's forward-compatible arrays + * would silently drop the element. + */ +import { z } from "zod"; + +const nonBlank = z.string().refine(value => value.trim().length > 0 && value === value.trim(), "trimmed non-empty"); +const isoDateTime = z.string().refine(value => Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value, "ISO date-time"); + +export const QuotaWindowSchema = z.strictObject({ + id: nonBlank, + kind: z.enum(["burst_5h", "daily", "weekly", "monthly", "credits", "custom"]), + label: nonBlank, + usedPercent: z.number().min(0).max(100).optional(), + used: z.number().optional(), + limit: z.number().optional(), + unit: z.enum(["requests", "tokens", "credits", "usd"]).optional(), + resetsAt: isoDateTime.optional(), + windowSeconds: z.number().min(0).optional(), + scopeModel: nonBlank.optional(), + source: z.enum(["measured", "header", "estimated"]), + observedAt: isoDateTime, + exhausted: z.boolean().optional(), +}); + +export const QuotaAccountSchema = z.strictObject({ + provider: nonBlank, + driver: nonBlank.optional(), + accountRef: z.string().min(1), + label: nonBlank.optional(), + plan: nonBlank.optional(), + state: z.enum(["available", "exhausted", "paused", "reauth", "unknown"]), + windows: z.array(QuotaWindowSchema), + failure: nonBlank.optional(), +}); + +export const QuotaPoolSchema = z.strictObject({ + schema: z.literal("ocx.quota-pool/1"), + producer: z.strictObject({ + version: nonBlank, + role: z.enum(["standalone", "hub", "client"]), + homeId: nonBlank, + }), + generatedAt: isoDateTime, + providers: z.array(z.strictObject({ + provider: nonBlank, + accounts: z.array(QuotaAccountSchema), + pooled: z.strictObject({ + windows: z.array(QuotaWindowSchema), + headroom: z.number().optional(), + nextResetAt: isoDateTime.optional(), + }), + })), +}); + +/** Every `null` anywhere in a decoded body: the contract omits unknown fields instead. */ +export function nullPaths(value: unknown, path = "$"): string[] { + if (value === null) return [path]; + if (Array.isArray(value)) return value.flatMap((item, index) => nullPaths(item, `${path}[${index}]`)); + if (value && typeof value === "object") { + return Object.entries(value).flatMap(([key, item]) => nullPaths(item, `${path}.${key}`)); + } + return []; +} diff --git a/tests/helpers/trusted-fixture-root.ts b/tests/helpers/trusted-fixture-root.ts new file mode 100644 index 00000000000..4a018607cc4 --- /dev/null +++ b/tests/helpers/trusted-fixture-root.ts @@ -0,0 +1,90 @@ +import { existsSync, lstatSync, mkdirSync, mkdtempSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, join } from "node:path"; +import { repoPath } from "./repo-root"; + +/** + * Where tests build a real directory that the PATH-Bun trust check + * (`trustedPosixCandidate` in src/lib/bun-path-runtime.mjs) is supposed to accept. + * + * Test-only. Production never imports this file and nothing here relaxes the product + * check: it only chooses a fixture location whose whole ancestry already satisfies it. + * A fixed location cannot do that everywhere. The checkout's parent is group-writable on + * some macOS volumes (#7402), and `/tmp` is 1777 on Linux, so each candidate is walked + * with the product's rule and the first one that passes is used. + * + * A second hazard is provenance: the Node launcher resolves `bun/package.json` upward + * from the fixture, so an ancestor holding `node_modules/bun` would leak a real runtime + * into a fixture that is meant to have none. + */ +export const TRUSTED_FIXTURE_DIR_ENV = "OPENCODEX_TEST_TRUSTED_FIXTURE_DIR"; + +/** + * Ordered candidates, each a dedicated subdirectory: an explicit override, the temp dir + * (a private per-user dir on macOS, but 1777 `/tmp` on most Linux hosts, which the rule + * rejects), the per-user runtime dir when the host provides one, then the checkout's sibling `.tmp`. + */ +export function fixtureBaseCandidates(env: NodeJS.ProcessEnv = process.env): string[] { + const candidates: string[] = []; + const override = env[TRUSTED_FIXTURE_DIR_ENV]; + if (override && isAbsolute(override)) candidates.push(override); + candidates.push(join(tmpdir(), "opencodex-path-bun-fixtures")); + const runtimeDir = env.XDG_RUNTIME_DIR; + if (runtimeDir && isAbsolute(runtimeDir)) candidates.push(join(runtimeDir, "opencodex-path-bun-fixtures")); + candidates.push(join(dirname(repoPath()), ".tmp", "path-bun-fixtures")); + return candidates; +} + +/** + * Why `path` cannot host a trusted fixture, or null when it can. The part of `path` that + * does not exist yet is ignored; {@link trustedFixtureRoot} creates it 0700 and re-checks. + */ +export function untrustedFixtureAncestor(path: string, uid: number | undefined = process.geteuid?.()): string | null { + if (!Number.isSafeInteger(uid)) return "effective uid unavailable"; + let existing = path; + while (!existsSync(existing)) { + const parent = dirname(existing); + if (parent === existing) return "no existing ancestor"; + existing = parent; + } + let current = "/"; + for (const component of realpathSync.native(existing).split("/").filter(Boolean).concat("")) { + const info = lstatSync(current); + if (!info.isDirectory()) return `${current} is not a directory`; + if (info.uid !== 0 && info.uid !== uid) return `${current} is owned by uid ${info.uid}`; + if ((info.mode & 0o022) !== 0) return `${current} is group/other-writable (mode ${(info.mode & 0o777).toString(8)})`; + if (existsSync(join(current, "node_modules", "bun"))) return `${current} contains node_modules/bun`; + if (component) current = join(current, component); + } + return null; +} + +/** + * A fresh private directory under the first usable trusted candidate. A candidate that passes + * the walk but cannot be created (a read-only parent, say) is recorded and skipped. + * + * When none qualifies this throws, naming every rejection. It deliberately does not skip: + * these fixtures are the only real-filesystem coverage of the product trust check, and a skip + * would let CI or prepush go green without it. The override is the remedy. + */ +export function trustedFixtureRoot(prefix: string, candidates: string[] = fixtureBaseCandidates()): string { + if (process.platform === "win32") return mkdtempSync(join(tmpdir(), prefix)); + const rejected: string[] = []; + for (const base of candidates) { + const before = untrustedFixtureAncestor(base); + if (before) { rejected.push(`${base}: ${before}`); continue; } + try { + mkdirSync(base, { recursive: true, mode: 0o700 }); + const after = untrustedFixtureAncestor(base); + if (after) { rejected.push(`${base}: ${after}`); continue; } + return realpathSync.native(mkdtempSync(join(base, prefix))); + } catch (error) { + rejected.push(`${base}: cannot create (${(error as NodeJS.ErrnoException).code ?? String(error)})`); + } + } + throw new Error( + `No fixture base passes the PATH-Bun trust rule. Set ${TRUSTED_FIXTURE_DIR_ENV} to an absolute directory ` + + `whose every ancestor is owned by root or you and is not group/other-writable, with no node_modules/bun above it.\n` + + rejected.join("\n"), + ); +} diff --git a/tests/helpers/update-recovery-host-free.ts b/tests/helpers/update-recovery-host-free.ts new file mode 100644 index 00000000000..112a123592b --- /dev/null +++ b/tests/helpers/update-recovery-host-free.ts @@ -0,0 +1,117 @@ +import { expect } from "bun:test"; +import { tmpdir } from "node:os"; +import { win32 } from "node:path"; + +/** + * Service-recovery scenarios answer every host probe themselves (#7417). On macOS the default + * listener scan is `lsof -iTCP`, which walks every process's descriptors: ~50ms on an idle + * host, seconds once concurrent suites fill the process table. A service-to-direct fallback + * scans twice, and that alone pushed these cases past Bun's 5s budget. The service-manager + * step is answered explicitly too, instead of relying on the armed test guard refusing the + * real launchctl/systemctl call. + */ +export const HOST_FREE_RECOVERY_IO = { + listListenPidsFn: (): number[] => [], + restartServiceManagerFn: async () => "failed" as const, +}; + +/** + * Runs a scenario with host process launches refused, and fails it if it attempted any. The + * launch is refused rather than observed, so a regressed path neither waits on the command nor + * touches the host; the record still fails the case if production code swallows the refusal. + * + * One attempt is expected on a real Windows host and only there: the recovery path's + * best-effort scheduler-wrapper cleanup (`killWindowsSchedulerWrappers`) launches PowerShell, + * scoped to this test home's own wrapper paths, and swallows any failure. It is refused like + * everything else, so it does no host work, but it is not counted against the scenario. + * + * The Windows ACL belt is the other exception. Every private config write on Windows tightens + * its ACL with `%SystemRoot%\\System32\\icacls.exe` (src/config/atomic-write.ts, + * src/config/paths.ts), and these scenarios write config inside the test home. Before this + * helper existed, those calls ran for real on Windows CI. Exactly that executable, called in one + * of the ACL belt's own argv shapes (one target, then nothing or /grant:r, /inheritance:r, + * /remove:g or /findsid with principal/SID/ACE tokens only), with the target canonicalized and + * inside the test's OPENCODEX_HOME or the temp directory, is passed through to the real spawn. + * It is recorded in a separate `allowed` list (readable through `observed`) rather than the + * refused list, so a caller can assert on it. Everything else is still refused. + */ +export async function withoutHostProcesses( + run: () => Promise, + observed?: { allowed: string[] }, +): Promise { + const bun = Bun as unknown as { spawnSync: (...args: unknown[]) => unknown }; + const original = bun.spawnSync; + const spawned: string[] = []; + const allowed: string[] = observed?.allowed ?? []; + bun.spawnSync = (...args: unknown[]) => { + const first = args[0] as string[] | { cmd?: string[] } | undefined; + const command = String(Array.isArray(first) ? first[0] : first?.cmd?.[0]); + if (isTestScopedWindowsAcl(command, Array.isArray(first) ? first : first?.cmd ?? [])) { + allowed.push(command); + return (original as (...a: unknown[]) => unknown).apply(bun, args); + } + spawned.push(command); + throw new Error(`unexpected host process launch: ${command}`); + }; + let outcome: { ok: true; value: T } | { ok: false; error: unknown }; + try { + outcome = { ok: true, value: await run() }; + } catch (error) { + outcome = { ok: false, error }; + } finally { + bun.spawnSync = original; + } + expect(spawned.filter(command => !isWindowsWrapperCleanup(command))).toEqual([]); + expect(allowed.every(command => process.platform === "win32" && isWindowsIcaclsBinary(command))).toBe(true); + if (!outcome.ok) throw outcome.error; + return outcome.value; +} + +function isWindowsWrapperCleanup(command: string): boolean { + return process.platform === "win32" && /[\\/]powershell\.exe$/i.test(command); +} + +/** Exported for the helper's own test; see the Windows ACL note on {@link withoutHostProcesses}. */ +export function isTestScopedWindowsAcl( + command: string, + argv: readonly unknown[], + platform: string = process.platform, + roots: readonly string[] = testScopedRoots(), + systemRoot: string = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows", +): boolean { + if (platform !== "win32" || !isWindowsIcaclsBinary(command, systemRoot)) return false; + // Exactly the argv shapes src/lib/windows-secret-acl.ts builds: one target path, then nothing, + // or one of its four switches followed only by principal/SID/ACE tokens. Any other switch + // (/save, /restore, /setowner, ...) or any further path or file operand is refused. + const [, target, step, ...rest] = argv.map(String); + if (target === undefined || !/^(?:[a-zA-Z]:[\\/]|\\\\)/.test(target)) return false; + if (step !== undefined && !ACL_BELT_STEPS.has(step)) return false; + if (step === undefined && rest.length > 0) return false; + if (!rest.every(token => ACE_TOKEN.test(token))) return false; + // win32.resolve collapses "." and ".." segments, so a traversal cannot pass the prefix check. + const canonical = normalizeWindowsPath(win32.resolve(target)); + return roots.filter(Boolean) + .map(root => normalizeWindowsPath(win32.resolve(root))) + .some(root => canonical === root || canonical.startsWith(`${root}/`)); +} + +/** The four switches the production ACL belt passes to icacls (src/lib/windows-secret-acl.ts). */ +const ACL_BELT_STEPS = new Set(["/grant:r", "/inheritance:r", "/remove:g", "/findsid"]); +/** A principal or SID, optionally with rights: "user:(OI)(CI)(F)", "*S-1-1-0", "*S-1-5-32-545:(R)". */ +const ACE_TOKEN = /^\*?[^\\/:\s][^\\/:]*(?::(?:\([A-Z]+\))+)?$/; + +/** True only for the exact `%SystemRoot%\\System32\\icacls.exe`, compared case-insensitively. */ +export function isWindowsIcaclsBinary( + command: string, + systemRoot: string = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows", +): boolean { + return normalizeWindowsPath(command) === normalizeWindowsPath(win32.join(systemRoot, "System32", "icacls.exe")); +} + +function normalizeWindowsPath(value: string): string { + return value.replace(/[\\/]+/g, "/").replace(/\/$/, "").toLowerCase(); +} + +function testScopedRoots(): string[] { + return [process.env.OPENCODEX_HOME ?? "", tmpdir()]; +} diff --git a/tests/lib/authorization-scheme.test.ts b/tests/lib/authorization-scheme.test.ts new file mode 100644 index 00000000000..237362f3d04 --- /dev/null +++ b/tests/lib/authorization-scheme.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import { + AGENT_ASSERTION_HEADER, + AGENT_ASSERTION_UNSUPPORTED_CODE, + agentAssertionUnsupportedResponse, + authorizationCredential, + carryAgentAssertionForCallerBearer, + hasAgentAssertionAuthorization, + parseAuthorizationScheme, +} from "../../src/lib/authorization-scheme"; +import { redactHeaders, redactSecretString, REDACTED_SECRET } from "../../src/lib/redact"; +import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; + +// Codex 0.164 runtime agent assertions are JWT-shaped; the bytes only have to be recognisable. +const ASSERTION = fakeChatGptJwt({ agent: "runtime-agent-assertion-fixture" }); + +describe("Authorization scheme parsing", () => { + test("AgentAssertion is its own scheme in any case and never yields a credential", () => { + for (const value of [`AgentAssertion ${ASSERTION}`, `agentassertion ${ASSERTION}`, `AGENTASSERTION\t${ASSERTION}`, "AgentAssertion"]) { + expect(parseAuthorizationScheme(value)).toEqual({ kind: "agent-assertion" }); + const headers = new Headers({ authorization: value }); + expect(authorizationCredential(headers)).toBeUndefined(); + expect(hasAgentAssertionAuthorization(headers)).toBe(true); + } + }); + + test("Bearer and scheme-less values keep the legacy read", () => { + expect(authorizationCredential(new Headers({ authorization: "Bearer abc.def" }))).toBe("abc.def"); + expect(authorizationCredential(new Headers({ authorization: "bearer spaced " }))).toBe("spaced"); + expect(authorizationCredential(new Headers({ authorization: "ocx_data_bare" }))).toBe("ocx_data_bare"); + expect(authorizationCredential(new Headers())).toBeUndefined(); + // A longer scheme that merely starts with the name is not an agent assertion. + expect(parseAuthorizationScheme("AgentAssertionX token")).toEqual({ kind: "other", credential: "AgentAssertionX token" }); + expect(hasAgentAssertionAuthorization(new Headers({ authorization: `Bearer ${ASSERTION}` }))).toBe(false); + }); + + test("the dual_header assertion is carried only beside the caller's own unchanged Bearer", () => { + const caller = new Headers({ authorization: "Bearer caller-token", [AGENT_ASSERTION_HEADER]: ASSERTION }); + const same = new Headers({ authorization: "Bearer caller-token" }); + carryAgentAssertionForCallerBearer(same, caller); + expect(same.get(AGENT_ASSERTION_HEADER)).toBe(ASSERTION); + + const pooled = new Headers({ authorization: "Bearer pool-token", [AGENT_ASSERTION_HEADER]: ASSERTION }); + carryAgentAssertionForCallerBearer(pooled, caller); + expect(pooled.has(AGENT_ASSERTION_HEADER)).toBe(false); + + const stripped = new Headers(); + carryAgentAssertionForCallerBearer(stripped, caller); + expect(stripped.has(AGENT_ASSERTION_HEADER)).toBe(false); + + const assertionOnly = new Headers({ authorization: `AgentAssertion ${ASSERTION}`, [AGENT_ASSERTION_HEADER]: ASSERTION }); + const target = new Headers({ authorization: `AgentAssertion ${ASSERTION}` }); + carryAgentAssertionForCallerBearer(target, assertionOnly); + expect(target.has(AGENT_ASSERTION_HEADER)).toBe(false); + }); + + test("the typed refusal is stable and does not echo the assertion", async () => { + const response = agentAssertionUnsupportedResponse(); + expect(response.status).toBe(400); + const text = await response.text(); + expect(JSON.parse(text).error.code).toBe(AGENT_ASSERTION_UNSUPPORTED_CODE); + expect(text).not.toContain(ASSERTION); + }); +}); + +describe("agent assertion redaction", () => { + test("header records redact both the AgentAssertion Authorization and the dual_header header", () => { + const redacted = redactHeaders(new Headers({ + authorization: `AgentAssertion ${ASSERTION}`, + [AGENT_ASSERTION_HEADER]: ASSERTION, + })); + expect(redacted.authorization).toBe(REDACTED_SECRET); + expect(redacted[AGENT_ASSERTION_HEADER]).toBe(REDACTED_SECRET); + }); + + test("diagnostic strings never keep the assertion bytes", () => { + for (const raw of [ + `upstream echoed authorization: AgentAssertion ${ASSERTION}`, + `upstream echoed ${AGENT_ASSERTION_HEADER}: ${ASSERTION}`, + JSON.stringify({ [AGENT_ASSERTION_HEADER]: ASSERTION }), + ]) { + expect(redactSecretString(raw)).not.toContain(ASSERTION); + } + }); +}); diff --git a/tests/oauth/oauth-reauth-bind.test.ts b/tests/oauth/oauth-reauth-bind.test.ts index 66d53f79e73..de7a06bb46e 100644 --- a/tests/oauth/oauth-reauth-bind.test.ts +++ b/tests/oauth/oauth-reauth-bind.test.ts @@ -124,6 +124,24 @@ describe("OAuth account-scoped reauth", () => { expect(getAccountSet("xai")?.accounts[0]?.loginId).not.toBe(loginId); }); + test("runLogin reauthAccountId matches an email-only identity across case and surrounding whitespace", async () => { + await saveCredential("xai", { + access: "a1", refresh: "r1", expires: Date.now() + 60_000, email: " Slot-A@Example.Test ", + }); + const slotId = getAccountSet("xai")!.activeAccountId; + const original = OAUTH_PROVIDERS.xai.login; + OAUTH_PROVIDERS.xai.login = async () => ({ + access: "a2", refresh: "r2", expires: Date.now() + 60_000, email: " sLoT-a@example.test ", + }); + try { + await runLogin("xai", {} as OAuthController, { reauthAccountId: slotId }); + } finally { + OAUTH_PROVIDERS.xai.login = original; + } + expect(getAccountCredential("xai", slotId)?.access).toBe("a2"); + expect(getAccountSet("xai")?.accounts).toHaveLength(1); + }); + test("runLogin reauth keeps an unobserved plan and clears it on an observed unknown", async () => { await saveCredential("xai", { access: "a1", refresh: "r1", expires: Date.now() + 60_000, diff --git a/tests/preload.ts b/tests/preload.ts index ee4f990913c..0e66597830a 100644 --- a/tests/preload.ts +++ b/tests/preload.ts @@ -10,10 +10,11 @@ * * It does still depend on WHERE the run starts. Bun resolves bunfig.toml from the current * working directory, so a run launched outside the repository never loads this file: no - * sandbox, no arming, and getConfigDir() resolves the real ~/.opencodex. On 2026-09-15 a - * run of that shape deleted a live home. Nothing here can close that hole from inside, so - * a test that needs a config directory pins its own OPENCODEX_HOME rather than inheriting - * one, and tests/ci-workflows/test-home-guard.test.ts enforces it for destructive calls. + * sandbox, and getConfigDir() resolves the real ~/.opencodex. On 2026-09-15 a run of that + * shape deleted a live home. The write guard covers that run by arming from its test-file + * entry (#7407), which turns such writes into refusals, but nothing here can sandbox it, so a + * test that needs a config directory pins its own OPENCODEX_HOME rather than inheriting one, + * and tests/ci-workflows/test-home-guard.test.ts enforces it for destructive calls. * (devlog `_plan/260730_codex_rs_upstream_v2_live_handoff/070`.) * * Import order below is load-bearing: importing the guard captures the real home at diff --git a/tests/providers/claude-cli-adapter.test.ts b/tests/providers/claude-cli-adapter.test.ts index 0566323475a..2b006903f7e 100644 --- a/tests/providers/claude-cli-adapter.test.ts +++ b/tests/providers/claude-cli-adapter.test.ts @@ -82,6 +82,41 @@ async function run(adapter: ReturnType, p: OcxPar return events; } +// The CLI-emitted name of the one caller-owned tool the capture-only bridge advertises. +const CLAUDE_CLI_MCP_TOOL = "mcp__opencodex__read_file"; + +/** A request that advertises one caller-owned tool, so the turn arms the capture-only bridge. */ +function claudeCliToolRequest(overrides: Partial = {}): OcxParsedRequest { + return parsed({ + context: { + messages: [{ role: "user", content: "Read the fixture", timestamp: 0 }], + tools: [{ + name: "read_file", + description: "Read a file", + parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] }, + }], + }, + ...overrides, + }); +} + +/** One tool_use block: start, the argument delta, and the matching stop. */ +function claudeCliToolFrame(index: number, id: string | undefined, name: string, args: string): string { + return JSON.stringify({ + type: "stream_event", + event: { + type: "content_block_start", + index, + content_block: { type: "tool_use", ...(id === undefined ? {} : { id }), name }, + }, + }) + "\n" + + JSON.stringify({ + type: "stream_event", + event: { type: "content_block_delta", index, delta: { type: "input_json_delta", partial_json: args } }, + }) + "\n" + + JSON.stringify({ type: "stream_event", event: { type: "content_block_stop", index } }) + "\n"; +} + describe("claude-cli is an official-harness provider, not a Messages relay", () => { test("the registry row and the adapter agree on the one canonical destination", () => { const entry = PROVIDER_REGISTRY.find(candidate => candidate.id === "claude-cli"); @@ -462,3 +497,547 @@ describe("claude-cli runTurn streams a subscription turn", () => { ]); }); }); + +describe("claude-cli malformed/truncated protocol output (offline)", () => { + test("a malformed JSON line surfaces a protocol error", async () => { + const stdout = [enc.encode("not-json\n")]; + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => fakeChild(stdout, { exitCode: 0 }) as unknown as ChildProcess, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 20, + }); + const events = await run(adapter, parsed()); + const err = events.find(e => e.type === "error") as { code?: string; message: string }; + expect(err).toBeDefined(); + expect(err.code).toBe("protocol_error"); + expect(err.message).toContain("Malformed stream-json frame received from coding-agent CLI"); + }); + + test("an incomplete final JSON line without a newline reports a protocol error", async () => { + const stdout = [enc.encode('{"type":"result","subtype":"success"')]; // incomplete, no newline + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => fakeChild(stdout, { exitCode: 0 }) as unknown as ChildProcess, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 20, + }); + const events = await run(adapter, parsed()); + const err = events.find(e => e.type === "error") as { code?: string; message: string }; + expect(err).toBeDefined(); + expect(err.code).toBe("protocol_error"); + expect(err.message).toContain("Malformed stream-json frame received from coding-agent CLI"); + }); + + test("a final JSON line without trailing newline is still parsed", async () => { + const frame = JSON.stringify({ type: "result", subtype: "success", is_error: false, usage: { input_tokens: 1, output_tokens: 1 } }); + const stdout = [enc.encode(frame)]; + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => fakeChild(stdout, { exitCode: 0 }) as unknown as ChildProcess, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 20, + }); + const events = await run(adapter, parsed()); + const done = events.find(e => e.type === "done") as { usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }; + expect(done).toBeDefined(); + expect(done.usage).toMatchObject({ inputTokens: 1, outputTokens: 1, totalTokens: 2 }); + }); + + test("empty output reports no terminal result frame", async () => { + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => fakeChild([], { exitCode: 0 }) as unknown as ChildProcess, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 20, + }); + const events = await run(adapter, parsed()); + const err = events.find(e => e.type === "error") as { code?: string; message: string }; + expect(err).toBeDefined(); + expect(err.code).toBe("protocol_error"); + expect(err.message).toContain("without a terminal result frame"); + }); +}); + +describe("claude-cli cancellation (offline)", () => { + test("an already-aborted signal fails before spawning the CLI", async () => { + let spawned = 0; + const controller = new AbortController(); + controller.abort(); + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => { spawned++; return fakeChild([]) as unknown as ChildProcess; }, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 20, + }); + const evs: any[] = []; + await adapter.runTurn!(parsed(), { headers: new Headers(), translatorBudget: createTestTranslatorBudget(), abortSignal: controller.signal }, e => evs.push(e)); + expect(spawned).toBe(0); + expect(evs[0]).toMatchObject({ type: "error", message: "Coding-agent turn was aborted before start." }); + }); +}); + +describe("claude-cli buffered completion (offline)", () => { + test("surfaces a complete assistant text message in one frame", async () => { + const stdout = [ + enc.encode('{"type":"system","subtype":"init"}\n'), + enc.encode(JSON.stringify({ + type: "assistant", + message: { + role: "assistant", + content: [{ type: "text", text: "Hello world" }], + stop_reason: "stop", + usage: { input_tokens: 5, output_tokens: 2 }, + }, + }) + "\n"), + enc.encode('{"type":"result","subtype":"success","is_error":false,"usage":{"input_tokens":5,"output_tokens":2}}\n'), + ]; + const child = fakeChild(stdout); + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => child as unknown as ChildProcess, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 20, + }); + const events = await run(adapter, parsed()); + expect(events.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join("")).toBe("Hello world"); + // The buffered path is the fallback: one complete assistant frame yields exactly one + // text_delta, not a second copy of text the client already saw. + expect(events.filter(e => e.type === "text_delta")).toHaveLength(1); + expect(events.at(-1)).toMatchObject({ type: "done", usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }); + }); +}); + +describe("claude-cli captured tool-call events preserved after protocol error (offline)", () => { + test("tool-call events emitted before a malformed final line remain intact", async () => { + const stdout = [ + enc.encode('{"type":"system","subtype":"init","mcp_servers":[{"name":"opencodex","status":"connected"}]}\n'), + enc.encode(claudeCliToolFrame(0, "tu_1", CLAUDE_CLI_MCP_TOOL, '{"path":"fixture.txt"}')), + enc.encode("not-json\n"), // malformed line after valid tool calls + ]; + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => fakeChild(stdout, { exitCode: 0 }) as unknown as ChildProcess, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 20, + }); + const events = await run(adapter, claudeCliToolRequest()); + // The call captured before the malformed line stays complete: the client still learns which + // tool was asked for and with which arguments, so it can execute it and return a result. + const starts = events.filter(e => e.type === "tool_call_start"); + expect(starts).toHaveLength(1); + expect(starts[0]).toMatchObject({ type: "tool_call_start", id: "tu_1", name: "read_file" }); + expect(events.filter(e => e.type === "tool_call_delta").map(e => (e as { arguments: string }).arguments)) + .toEqual(['{"path":"fixture.txt"}']); + expect(events.filter(e => e.type === "tool_call_end")).toHaveLength(1); + // The malformed line still surfaces a protocol error + const err = events.find(e => e.type === "error") as { code?: string; message: string }; + expect(err).toBeDefined(); + expect(err.code).toBe("protocol_error"); + }); +}); + +describe("claude-cli stream-JSON chunk boundaries (offline)", () => { + test("a valid JSON frame split across multiple chunks is still parsed", async () => { + const frame = JSON.stringify({ type: "result", subtype: "success", is_error: false, usage: { input_tokens: 3, output_tokens: 1 } }); + // Split the frame across chunks at arbitrary byte boundaries + const bytes = enc.encode(frame + "\n"); + const stdout = [bytes.slice(0, 5), bytes.slice(5)]; + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => fakeChild(stdout, { exitCode: 0 }) as unknown as ChildProcess, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 20, + }); + const events = await run(adapter, parsed()); + const done = events.find(e => e.type === "done") as { usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }; + expect(done).toBeDefined(); + expect(done.usage).toMatchObject({ inputTokens: 3, outputTokens: 1, totalTokens: 4 }); + }); +}); + +describe("claude-cli cancellation during running turn (offline)", () => { + test("abort signal during streaming kills the child process", async () => { + let spawned = 0; + let killed = false; + const controller = new AbortController(); + const stdout = [ + enc.encode('{"type":"system","subtype":"init"}\n'), + enc.encode('{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hel"}}}\n'), + ]; + const child = fakeChild(stdout, { emitClose: false }); + child.kill = () => { killed = true; return true; }; + let promptFile = ""; + const adapter = createClaudeCliAdapter(provider(), { + spawn: (_command, args) => { + spawned++; + promptFile = args[args.indexOf("--system-prompt-file") + 1] ?? ""; + return child as unknown as ChildProcess; + }, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 50, + }); + // Abort on the FIRST client-visible event. That is the only deterministic point that is + // both after the spawn and inside the turn: a timer would race the spawn (the turn stages + // a prompt file first) and could land before the CLI was ever launched. + const evs: any[] = []; + let aborted = false; + await adapter.runTurn!( + parsed(), + { headers: new Headers(), translatorBudget: createTestTranslatorBudget(), abortSignal: controller.signal }, + (event: AdapterEvent) => { + evs.push(event); + if (!aborted) { + aborted = true; + controller.abort(); + } + }, + ); + expect(spawned).toBe(1); + expect(aborted).toBe(true); + expect(killed).toBe(true); + const err = evs.find(e => e.type === "error"); + expect(err).toBeDefined(); + expect(err.message).toContain("aborted"); + // Cleanup is part of the abort contract, not a side effect of the happy path: the private + // staged prompt file is removed even when the turn died mid-stream (adapter.ts finally block). + expect(promptFile).not.toBe(""); + expect(existsSync(promptFile)).toBe(false); + }); +}); + +describe("claude-cli JSON Schema + caller-owned tools coexistence (offline)", () => { + test("textFormat.json_object is not forwarded as any CLI schema flag", () => { + // The json_schema half of this claim is covered by the propagation trace further down; + // this one covers the second textFormat type, which no other test exercises. + const args = buildArgs(CLAUDE_CLI_PROFILE, parsed({ + options: { + textFormat: { type: "json_object" }, + }, + }), provider()); + expect(args).not.toContain("--json-schema"); + expect(args).not.toContain("--output-schema"); + expect(args).not.toContain("--schema"); + }); +}); + +describe("claude-cli missing/duplicate/unknown result IDs in tool calls (offline)", () => { + const init = enc.encode('{"type":"system","subtype":"init","mcp_servers":[{"name":"opencodex","status":"connected"}]}\n'); + + test("a repeated tool call ID is surfaced once per block and is never rejected", async () => { + // The upstream guard for a conflicting reuse (protocol.ts `isEmittedToolCallRepeat`, and + // the `previous.name !== name` branch at protocol.ts:597) reads `state.emittedToolCalls`. + // That snapshot is filled in exactly two places — protocol.ts:352 and protocol.ts:511 — + // both inside `if (state.completeAssistantToolUse)`, which only Qoder sets + // (src/adapters/qoder/adapter.ts:196). The claude-cli adapter + // (src/adapters/claude-cli/adapter.ts:241-254) never arms it, so the snapshot stays empty + // for the whole turn and the conflicting-reuse branch is unreachable here. + // + // Consequence, recorded as observed behavior and NOT as an endorsement: a repeated ID is + // neither deduplicated nor rejected, whether the repeat carries the same arguments or + // different ones. The client sees two calls under one ID and has to resolve them itself. + for (const secondArgs of ['{"path":"a"}', '{"path":"b"}']) { + const stdout = [ + init, + enc.encode(claudeCliToolFrame(0, "dup_id", CLAUDE_CLI_MCP_TOOL, '{"path":"a"}')), + enc.encode(claudeCliToolFrame(1, "dup_id", CLAUDE_CLI_MCP_TOOL, secondArgs)), + enc.encode('{"type":"stream_event","event":{"type":"message_stop"}}\n'), + ]; + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => fakeChild(stdout, { exitCode: 0 }) as unknown as ChildProcess, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 20, + }); + const events = await run(adapter, claudeCliToolRequest()); + const starts = events.filter(e => e.type === "tool_call_start"); + expect(starts).toHaveLength(2); + expect(starts.map(e => (e as { id: string }).id)).toEqual(["dup_id", "dup_id"]); + expect(events.filter(e => e.type === "tool_call_delta").map(e => (e as { arguments: string }).arguments)) + .toEqual(['{"path":"a"}', secondArgs]); + // The guard's own error is never raised, and the turn is served successfully. + expect(events.some(e => e.type === "error")).toBe(false); + expect(events.at(-1)).toMatchObject({ type: "done", stopReason: "tool_use" }); + } + }); + + test("a tool_result whose call id matches no prior tool call is replayed to the CLI unvalidated", async () => { + // The other half of the ID contract: a RESULT id the client sends back. Nothing on this + // route matches it against the calls of the turn: `formatMessageForHistory` + // (src/adapters/coding-agent/protocol.ts:758-763) interpolates `toolCallId` into + // `TOOL RESULT (call_id: ...)` and stops there — no lookup, no rejection, no orphan + // conversion, unlike the anthropic adapter's `orphanToolResultText` + // (src/adapters/anthropic.ts:718). The unmatched result reaches the model as a labelled + // block. Recorded as observed behavior, NOT as an endorsement. + const request = parsed({ + context: { + messages: [ + { role: "user", content: "Read the fixture", timestamp: 0 }, + { role: "assistant", content: [{ type: "toolCall", id: "call_known", name: "read_file", arguments: { path: "fixture.txt" } }], timestamp: 1 }, + { role: "toolResult", toolCallId: "call_unknown", toolName: "read_file", content: "result of a call that never happened", isError: false, timestamp: 2 }, + { role: "toolResult", toolCallId: "call_known", toolName: "a_different_tool", content: "result whose tool name does not match the call", isError: false, timestamp: 3 }, + ], + tools: [{ name: "read_file", description: "Read a file", parameters: { type: "object" } }], + }, + }); + const child = fakeChild([ + enc.encode('{"type":"system","subtype":"init","mcp_servers":[{"name":"opencodex","status":"connected"}]}\n'), + enc.encode('{"type":"result","subtype":"success"}\n'), + ]); + const adapter = createClaudeCliAdapter(provider(), { + which: () => "/opt/homebrew/bin/claude", + spawn: () => child as unknown as ChildProcess, + killGraceMs: 20, + }); + const events = await run(adapter, request); + const written = child.written.join(""); + // The unmatched id is passed through verbatim, next to the call that DOES match. + expect(written).toContain("TOOL RESULT (call_id: call_unknown)"); + expect(written).toContain("[Tool call: mcp__opencodex__read_file (call_id: call_known)"); + // Both result bodies reach the CLI, so the model sees the content and not only the labels. + expect(written).toContain("result of a call that never happened"); + expect(written).toContain("result whose tool name does not match the call"); + // A result whose tool NAME disagrees with the call it claims is not rejected either, and + // the name is not even rendered: the replay carries the id and the content, nothing else. + expect(written).toContain("TOOL RESULT (call_id: call_known)"); + expect(written).not.toContain("a_different_tool"); + // And the turn is served: an unmatched result id does not fail the request. + expect(events.some(e => e.type === "error")).toBe(false); + expect(events.at(-1)).toMatchObject({ type: "done" }); + }); + + test("a tool_use block without an id never becomes a tool call", async () => { + const stdout = [init, enc.encode(claudeCliToolFrame(0, undefined, CLAUDE_CLI_MCP_TOOL, '{"path":"a"}'))]; + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => fakeChild(stdout, { exitCode: 0 }) as unknown as ChildProcess, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 20, + }); + const events = await run(adapter, claudeCliToolRequest()); + // A tool_use without an id opens no block: protocol.ts:591 reads the id with + // `asString(block?.id) ?? ""` and the whole block body is behind `if (id)`. The argument + // delta that follows therefore has no open block to attribute to, and the strict capture + // path (armed because this turn has a tool bridge) fails the turn at protocol.ts:570 + // instead of dropping the arguments silently and reporting a successful tool turn. + const err = events.find(e => e.type === "error"); + expect(err).toBeDefined(); + expect(err).toMatchObject({ code: "protocol_error", status: 502 }); + expect(String((err as { message: string }).message)).toContain("cannot be attributed to an open tool block"); + expect(events.some(e => e.type === "tool_call_start")).toBe(false); + }); +}); + +/** + * The observed offline contract for this route, as established by the source and by the tests + * below. + * + * SUPPORTED: + * - a request is served by a headless `claude -p` turn with built-in tools disabled and + * user/project/plugin MCP servers excluded (--tools "", --strict-mcp-config, + * --setting-sources "") — asserted by the existing argument tests in this file; + * - a declared tool catalog reaches the CLI as the isolated capture-only MCP catalog + * (--allowedTools + --mcp-config) and its call intent is returned to the client — + * asserted by tests/providers/claude-cli-tool-capture.test.ts; + * - the completion is represented as text deltas plus a terminal done, and usage comes from + * the result frame; + * - invalid or truncated final JSON is reported as protocol_error without discarding the tool + * calls captured before it. + * + * UNSUPPORTED: the requested output schema has no consumer on this route. options.textFormat is + * parsed off the Responses API request and consumed by the anthropic, google, openai-chat, + * ollama-native and cursor adapters; the claude-cli adapter reads neither it nor any schema + * flag, so a json_schema or json_object request is served as an ordinary text turn with the + * schema silently not applied. + * + * NOT ESTABLISHED, and deliberately left open: whether the Claude Code CLI itself accepts any + * structured-output flag at the version this proxy drives. Nothing in this repository records + * such a flag, and no live CLI was exercised to find one, so this suite makes no claim either + * way. The transport/dependency decision stays with @lidge-jun. + */ +describe("claude-cli schema propagation and completion contract (offline)", () => { + // A distinctive marker so a leaked schema would be visible in argv, in the staged prompt + // file, and in the replayed stdin frame alike. + const SCHEMA = { + type: "object" as const, + properties: { ocx_schema_probe_marker: { type: "string" } }, + required: ["ocx_schema_probe_marker"], + }; + const schemaRequest = (overrides: Partial = {}): OcxParsedRequest => parsed({ + options: { textFormat: { type: "json_schema", name: "probe", schema: SCHEMA } }, + ...overrides, + }); + const textDelta = (text: string): string => JSON.stringify({ + type: "stream_event", + event: { type: "content_block_delta", delta: { type: "text_delta", text } }, + }) + "\n"; + + test("the requested schema reaches neither the argument builder nor the staged input", async () => { + // Source trace: `buildArgs` (src/adapters/claude-cli/adapter.ts) reads parsed.modelId and + // parsed.options.reasoning. options.textFormat has no consumer on this route, so the + // schema is dropped before argv is built — argv is byte-identical with and without it. + const plain = buildArgs(CLAUDE_CLI_PROFILE, parsed(), provider()); + const withSchema = buildArgs(CLAUDE_CLI_PROFILE, schemaRequest(), provider()); + expect(withSchema).toEqual(plain); + expect(withSchema).not.toContain("--json-schema"); + expect(withSchema).not.toContain("--output-schema"); + expect(withSchema).not.toContain("--schema"); + expect(JSON.stringify(withSchema)).not.toContain("ocx_schema_probe_marker"); + + // The other two inputs this route builds are the staged system-prompt file and the + // replayed stream-json stdin frame. The schema reaches neither of them either. + let promptText = ""; + const child = fakeChild([enc.encode('{"type":"result","subtype":"success"}\n')]); + const adapter = createClaudeCliAdapter(provider(), { + which: () => "/opt/homebrew/bin/claude", + spawn: (_command, args) => { + promptText = readFileSync(args[args.indexOf("--system-prompt-file") + 1]!, "utf8"); + return child as unknown as ChildProcess; + }, + killGraceMs: 20, + }); + const events = await run(adapter, schemaRequest()); + expect(events.some(e => e.type === "error")).toBe(false); + expect(promptText).not.toContain("ocx_schema_probe_marker"); + expect(child.written.join("")).not.toContain("ocx_schema_probe_marker"); + }); + + test("a structured payload is surfaced as plain text and is never validated against the requested schema", async () => { + const payload = '{"answer":"42"}'; + const stdout = [ + enc.encode('{"type":"system","subtype":"init"}\n'), + enc.encode(textDelta(payload)), + enc.encode('{"type":"result","subtype":"success","is_error":false,"usage":{"input_tokens":4,"output_tokens":2}}\n'), + ]; + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => fakeChild(stdout, { exitCode: 0 }) as unknown as ChildProcess, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 20, + }); + const events = await run(adapter, schemaRequest()); + // The payload deliberately VIOLATES the requested schema — it has no + // `ocx_schema_probe_marker` — so this case is only meaningful if the route does not + // enforce what it asked for. + expect(payload).not.toContain("ocx_schema_probe_marker"); + // How this route represents a final structured response: as ordinary text deltas plus a + // terminal done. There is no schema validation, no parse, and no conformance signal — + // the requested schema never reached the CLI, so the CLI could not have honoured it. + expect(events.map(e => e.type)).toEqual(["text_delta", "done"]); + expect((events[0] as { text: string }).text).toBe(payload); + expect(events.at(-1)).toMatchObject({ type: "done", stopReason: "stop", usage: { inputTokens: 4, outputTokens: 2, totalTokens: 6 } }); + }); + + test("a streamed completion emits one text_delta per CLI delta, not one coalesced frame", async () => { + const stdout = [ + enc.encode('{"type":"system","subtype":"init"}\n'), + enc.encode(textDelta('{"answer":')), + enc.encode(textDelta('"42"}')), + // The CLI also sends the complete assistant message it already streamed. It must not + // produce a second copy of the text. + enc.encode(JSON.stringify({ + type: "assistant", + message: { + role: "assistant", + content: [{ type: "text", text: '{"answer":"42"}' }], + stop_reason: "stop", + usage: { input_tokens: 4, output_tokens: 2 }, + }, + }) + "\n"), + enc.encode('{"type":"result","subtype":"success","is_error":false,"usage":{"input_tokens":4,"output_tokens":2}}\n'), + ]; + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => fakeChild(stdout, { exitCode: 0 }) as unknown as ChildProcess, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 20, + }); + const events = await run(adapter, schemaRequest()); + // The streamed path is the primary one; the complete-assistant fallback only runs when no + // partial delta was seen. Two CLI deltas therefore mean two client-visible deltas, and + // the fallback frame does not add a third copy of the same text. + const deltas = events.filter(e => e.type === "text_delta"); + expect(deltas.map(e => (e as { text: string }).text)).toEqual(['{"answer":', '"42"}']); + expect(events.map(e => e.type)).toEqual(["text_delta", "text_delta", "done"]); + }); + + test("tool-call events captured before a truncated final frame remain intact", async () => { + const stdout = [ + enc.encode('{"type":"system","subtype":"init","mcp_servers":[{"name":"opencodex","status":"connected"}]}\n'), + enc.encode(claudeCliToolFrame(0, "tu_1", CLAUDE_CLI_MCP_TOOL, '{"path":"a"}')), + // The turn dies at EOF: a final frame that was cut off mid-write. + enc.encode('{"type":"result","subtype":"success","is_error":false,"usage":{"input_tokens":5,'), + ]; + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => fakeChild(stdout, { exitCode: 0 }) as unknown as ChildProcess, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 20, + }); + const events = await run(adapter, claudeCliToolRequest()); + // The truncated frame is reported, but the call captured before it is not discarded: the + // client still learns which tool was asked for and with which arguments, so it can execute + // it and return the result. + const starts = events.filter(e => e.type === "tool_call_start"); + expect(starts).toHaveLength(1); + expect(starts[0]).toMatchObject({ type: "tool_call_start", id: "tu_1", name: "read_file" }); + expect(events.filter(e => e.type === "tool_call_delta").map(e => (e as { arguments: string }).arguments)) + .toEqual(['{"path":"a"}']); + expect(events.filter(e => e.type === "tool_call_end")).toHaveLength(1); + const err = events.find(e => e.type === "error"); + expect(err).toBeDefined(); + expect(err).toMatchObject({ code: "protocol_error", status: 502 }); + expect(String((err as { message: string }).message)).toContain("Malformed stream-json frame received from coding-agent CLI"); + }); + + test("a caller-owned tool catalog and its results are unaffected by a requested schema", async () => { + let capturedArgs: string[] = []; + let promptText = ""; + const child = fakeChild([ + enc.encode('{"type":"system","subtype":"init","mcp_servers":[{"name":"opencodex","status":"connected"}]}\n'), + enc.encode('{"type":"result","subtype":"success","is_error":false}\n'), + ]); + const adapter = createClaudeCliAdapter(provider(), { + which: () => "/opt/homebrew/bin/claude", + spawn: (_command, args) => { + capturedArgs = [...args]; + promptText = readFileSync(args[args.indexOf("--system-prompt-file") + 1]!, "utf8"); + return child as unknown as ChildProcess; + }, + killGraceMs: 20, + }); + const events = await run(adapter, claudeCliToolRequest({ + options: { textFormat: { type: "json_schema", name: "probe", schema: SCHEMA } }, + context: { + messages: [ + { role: "user", content: "Read the fixture", timestamp: 0 }, + { role: "assistant", content: [{ type: "toolCall", id: "call_1", name: "read_file", arguments: { path: "fixture.txt" } }], timestamp: 1 }, + { role: "toolResult", toolCallId: "call_1", toolName: "read_file", content: "the executed result", isError: false, timestamp: 2 }, + ], + tools: [{ name: "read_file", description: "Read a file", parameters: { type: "object" } }], + }, + })); + // The catalog still reaches the CLI exactly as it does without a schema... + expect(capturedArgs[capturedArgs.indexOf("--tools") + 1]).toBe(""); + expect(capturedArgs).toContain("--strict-mcp-config"); + expect(capturedArgs).toContain("--allowedTools"); + expect(capturedArgs).toContain("--mcp-config"); + // ...and the schema still reaches neither the argv nor either input the route builds, even + // when the replayed history carries the call and the executed result. + expect(JSON.stringify(capturedArgs)).not.toContain("ocx_schema_probe_marker"); + expect(promptText).not.toContain("ocx_schema_probe_marker"); + const written = child.written.join(""); + expect(written).not.toContain("ocx_schema_probe_marker"); + expect(written).toContain("TOOL RESULT (call_id: call_1)"); + expect(events.some(e => e.type === "error")).toBe(false); + }); + + test("the shared capture-only MCP flow tells the CLI that built-in tools and user MCP servers are disabled", async () => { + let promptText = ""; + const adapter = createClaudeCliAdapter(provider(), { + which: () => "/opt/homebrew/bin/claude", + spawn: (_command, args) => { + promptText = readFileSync(args[args.indexOf("--system-prompt-file") + 1]!, "utf8"); + return fakeChild([ + enc.encode('{"type":"system","subtype":"init","mcp_servers":[{"name":"opencodex","status":"connected"}]}\n'), + enc.encode('{"type":"result","subtype":"success","is_error":false}\n'), + ]) as unknown as ChildProcess; + }, + killGraceMs: 20, + }); + await run(adapter, claudeCliToolRequest()); + // The argv half of the capture-only contract is covered by the existing argument tests + // (--tools "", --strict-mcp-config, no permission bypass). The prompt half is this line: + // the turn also tells the CLI, in the instructions it actually reads, that it has no + // built-in tools and no user-configured MCP servers. + expect(promptText).toContain("Your built-in tools and user-configured MCP servers are disabled."); + expect(promptText).toContain("The MCP process captures call intent only."); + }); +}); diff --git a/tests/providers/mistral-recovery.test.ts b/tests/providers/mistral-recovery.test.ts index 7c9af23629a..c472abf4736 100644 --- a/tests/providers/mistral-recovery.test.ts +++ b/tests/providers/mistral-recovery.test.ts @@ -366,13 +366,39 @@ describe("Mistral observed budget pacing", () => { const waiting = Array.from({ length: 128 }, () => pacing.acquire("pressure", abort.signal)); const settled = Promise.allSettled(waiting); await expect(pacing.acquire("pressure")).rejects.toMatchObject({ reason: "queue_full" }); + // A full queue belongs to its identity: a healthy identity sends at once, and another + // held identity still queues behind its own deadline instead of being refused. + (await pacing.acquire("unrelated"))(true); + pacing.observe("held", budget("0", "1", "10", "30")); + const held = pacing.acquire("held", abort.signal); abort.abort(); + await expect(held).rejects.toMatchObject({ name: "AbortError" }); expect((await settled).every(result => result.status === "rejected")).toBe(true); const cancelled = new AbortController(); const next = pacing.acquire("pressure", cancelled.signal); cancelled.abort(); await expect(next).rejects.toThrow(); }); + + test("an aggregate waiter bound refuses only new parked waiters, never a request that can send", async () => { + const pacing = new MistralBudgetPacer(); + const abort = new AbortController(); + // Distinct identities (client-chosen model strings) cannot grow the waiters without bound. + const waiting = ["a", "b", "c", "d"].flatMap(identity => { + pacing.observe(identity, budget("0", "1", "10", "30")); + return Array.from({ length: 128 }, () => pacing.acquire(identity, abort.signal)); + }); + const settled = Promise.allSettled(waiting); + pacing.observe("e", budget("0", "1", "10", "30")); + await expect(pacing.acquire("e", abort.signal)).rejects.toMatchObject({ reason: "queue_full" }); + (await pacing.acquire("healthy"))(true); + abort.abort(); + expect((await settled).every(result => result.status === "rejected")).toBe(true); + const after = new AbortController(); + const parked = pacing.acquire("e", after.signal); + after.abort(); + await expect(parked).rejects.toMatchObject({ name: "AbortError" }); + }); }); describe("Mistral error-triggered minute recovery", () => { diff --git a/tests/providers/provider-antigravity-quota-retry.test.ts b/tests/providers/provider-antigravity-quota-retry.test.ts index 97d4e28e964..ac3ea59a1bd 100644 --- a/tests/providers/provider-antigravity-quota-retry.test.ts +++ b/tests/providers/provider-antigravity-quota-retry.test.ts @@ -84,4 +84,12 @@ describe("Antigravity quota summary 403 compatibility retry (#5940)", () => { expect(calls).toEqual([expectedCall(summaryUrl), expectedCall(summaryUrl, "antigravity/1.0"), expectedCall(modelsUrl)]); }); } + + for (const [status, failure] of [[302, "redirect_blocked"], [401, "access_denied"], [403, "access_denied"], [429, "rate_limited"], [503, "upstream_error"]] as const) { + test(`models fallback status ${status} maps to ${failure}`, async () => { + const calls = transport([new Response(null, { status: 500 }), new Response(null, { status, headers: { location: "https://redirect.example/" } })]); + expect(await probeAntigravityUsageQuota("test-access", "test-project")).toMatchObject({ kind: "unavailable", failure }); + expect(calls).toEqual([expectedCall(summaryUrl), expectedCall(modelsUrl)]); + }); + } }); diff --git a/tests/responses/agent-assertion-routes.test.ts b/tests/responses/agent-assertion-routes.test.ts new file mode 100644 index 00000000000..83d015227f2 --- /dev/null +++ b/tests/responses/agent-assertion-routes.test.ts @@ -0,0 +1,222 @@ +import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { waitForNativeMainStartupGate } from "../../src/codex/native-profile-startup"; +import { saveConfig } from "../../src/config"; +import { AGENT_ASSERTION_HEADER, AGENT_ASSERTION_UNSUPPORTED_CODE } from "../../src/lib/authorization-scheme"; +import { startServer } from "../../src/server"; +import type { OcxConfig } from "../../src/types"; +import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { ownedServiceHomeInspection } from "../helpers/owned-service-home-inspection"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { SERVER_BUDGET_MS } from "../helpers/test-budget"; + +// Every case starts a real server; on Windows its config-dir ACL work alone can exceed 5 s. +setDefaultTimeout(SERVER_BUDGET_MS); + +/** + * Codex 0.164 runtime agent instances (openai/codex#55402, #55636) end to end. dual_header keeps + * working with its Bearer and forwards x-openai-agent-assertion only on first-party Direct. + * assertion_only (Authorization: AgentAssertion) is refused with a typed error before any pool, + * routed, or admission decision, so no other credential is ever sent in its place. + */ +const inspectNativeCodexOwnership = ownedServiceHomeInspection("agent assertion routes test"); +const ACCOUNT = "agent-assertion-route-account"; +const CALLER = fakeChatGptJwt({ exp: 4_000_000_000, "https://api.openai.com/auth": { chatgpt_account_id: ACCOUNT } }); +const ASSERTION = fakeChatGptJwt({ agent: "runtime-agent-assertion-fixture", "https://api.openai.com/auth": { chatgpt_account_id: ACCOUNT } }); +const ADMISSION_KEY = "ocx_data_agent_assertion_route_fixture"; + +type Seen = { path: string; authorization: string | null; assertion: string | null }; + +function completed(id: string): Response { + return Response.json({ + id, object: "response", status: "completed", model: "gpt-5.5", + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "ok" }] }], + usage: { input_tokens: 1, output_tokens: 1 }, + }); +} + +function filesUnder(root: string): string[] { + const out: string[] = []; + for (const name of readdirSync(root)) { + const path = join(root, name); + if (statSync(path).isDirectory()) out.push(...filesUnder(path)); + else out.push(path); + } + return out; +} + +describe("Codex runtime agent assertion routes", () => { + const originalFetch = globalThis.fetch; + let testDir = ""; + let previousHome: string | undefined; + let isolatedCodexHome: IsolatedCodexHome | null = null; + let seen: Seen[] = []; + let upstream: ReturnType | undefined; + let server: ReturnType | undefined; + + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-agent-assertion-codex-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-agent-assertion-")); + process.env.OPENCODEX_HOME = testDir; + seen = []; + upstream = Bun.serve({ + port: 0, + fetch(req) { + seen.push({ + path: new URL(req.url).pathname, + authorization: req.headers.get("authorization"), + assertion: req.headers.get(AGENT_ASSERTION_HEADER), + }); + return completed("resp_agent_assertion"); + }, + }); + const target = upstream.url; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url); + if (url.hostname === "chatgpt.com" && url.pathname.startsWith("/backend-api/codex")) { + return originalFetch(new URL(`/chatgpt${url.pathname.slice("/backend-api/codex".length)}`, target), init); + } + if (url.hostname === "gateway.example.test") return originalFetch(new URL(`/gateway${url.pathname}`, target), init); + return originalFetch(input, init); + }) as typeof fetch; + }); + + afterEach(async () => { + if (server) await server.stop(true); + server = undefined; + upstream?.stop(true); + globalThis.fetch = originalFetch; + process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) await removeTreeWithRetry(testDir); + }); + + function start(config: Partial): URL { + saveConfig({ port: 0, ...config } as OcxConfig); + server = startServer(0, { inspectNativeCodexOwnership }); + return new URL(server.url); + } + + function post(base: URL, headers: Record, model = "gpt-5.5"): Promise { + return fetch(new URL("/v1/responses", base), { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify({ model, stream: false, store: false, input: "hello" }), + }); + } + + async function expectRefused(response: Response): Promise { + expect(response.status).toBe(400); + const text = await response.text(); + expect(JSON.parse(text).error.code).toBe(AGENT_ASSERTION_UNSUPPORTED_CODE); + expect(text).not.toContain(ASSERTION); + } + + function expectNoAssertionPersisted(): void { + for (const path of filesUnder(testDir)) expect(readFileSync(path, "latin1")).not.toContain(ASSERTION); + } + + const direct = { openai: { + adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", + codexAccountMode: "direct", upstreamWebsocket: false, + } } as unknown as OcxConfig["providers"]; + const pooled = { openai: { + adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", + codexAccountMode: "pool", upstreamWebsocket: false, + } } as unknown as OcxConfig["providers"]; + + function savePoolAccount(): Partial { + saveCodexAccountCredential("pool-account", { + accessToken: "pool-account-token", refreshToken: "pool-account-refresh", + expiresAt: Date.now() + 3_600_000, chatgptAccountId: "pool-account-uuid", + }); + return { + activeCodexAccountId: "pool-account", + codexAccounts: [{ id: "pool-account", email: "pool@example.test", chatgptAccountId: "pool-account-uuid", isMain: false }], + } as Partial; + } + + test("dual_header on Direct forwards the caller Bearer with its assertion", async () => { + const base = start({ defaultProvider: "openai", providers: direct }); + const response = await post(base, { + authorization: `Bearer ${CALLER}`, "chatgpt-account-id": ACCOUNT, [AGENT_ASSERTION_HEADER]: ASSERTION, + }); + expect(response.status).toBe(200); + await response.text(); + expect(seen).toEqual([{ path: "/chatgpt/responses", authorization: `Bearer ${CALLER}`, assertion: ASSERTION }]); + expectNoAssertionPersisted(); + }); + + test("dual_header on a pooled account sends the pool Bearer and strips the assertion", async () => { + const base = start({ defaultProvider: "openai", providers: pooled, ...savePoolAccount() }); + const response = await post(base, { + authorization: `Bearer ${CALLER}`, "chatgpt-account-id": ACCOUNT, [AGENT_ASSERTION_HEADER]: ASSERTION, + }); + expect(response.status).toBe(200); + await response.text(); + expect(seen).toEqual([{ path: "/chatgpt/responses", authorization: "Bearer pool-account-token", assertion: null }]); + expectNoAssertionPersisted(); + }); + + test("dual_header with an admission Bearer substitutes stored main and drops the assertion", async () => { + const stored = fakeChatGptJwt({ exp: 4_000_000_000, "https://api.openai.com/auth": { chatgpt_account_id: "stored-main-account" } }); + writeFileSync(join(isolatedCodexHome!.path, "auth.json"), + JSON.stringify({ tokens: { access_token: stored, account_id: "stored-main-account" } })); + const base = start({ + hostname: "0.0.0.0", + defaultProvider: "openai", + providers: direct, + apiKeys: [{ id: "agent-key", name: "agent", key: ADMISSION_KEY, createdAt: "2026-10-21T00:00:00.000Z" }], + } as unknown as Partial); + await waitForNativeMainStartupGate(); + const response = await post(base, { authorization: `Bearer ${ADMISSION_KEY}`, [AGENT_ASSERTION_HEADER]: ASSERTION }); + expect(response.status).toBe(200); + await response.text(); + // The assertion was sent beside the admission key, so it never rides with the stored credential. + expect(seen).toEqual([{ path: "/chatgpt/responses", authorization: `Bearer ${stored}`, assertion: null }]); + expectNoAssertionPersisted(); + }); + + test("assertion_only on a pooled account is refused without spending the pool credential", async () => { + const base = start({ defaultProvider: "openai", providers: pooled, ...savePoolAccount() }); + await expectRefused(await post(base, { authorization: `AgentAssertion ${ASSERTION}`, "chatgpt-account-id": ACCOUNT })); + expect(seen).toEqual([]); + expectNoAssertionPersisted(); + }); + + test("assertion_only on Direct and on a routed provider is refused before any upstream call", async () => { + const base = start({ + defaultProvider: "openai", + providers: { + ...direct, + gateway: { + adapter: "openai-responses", baseUrl: "https://gateway.example.test/v1", authMode: "key", + apiKey: "sk-routed-provider-key", models: ["gateway-model"], allowPrivateNetwork: true, + }, + } as unknown as OcxConfig["providers"], + }); + await expectRefused(await post(base, { authorization: `AgentAssertion ${ASSERTION}` })); + await expectRefused(await post(base, { authorization: `AgentAssertion ${ASSERTION}` }, "gateway/gateway-model")); + expect(seen).toEqual([]); + expectNoAssertionPersisted(); + }); + + test("a remote bind refuses assertion_only with the typed error whether or not the caller is admitted", async () => { + const base = start({ + hostname: "0.0.0.0", + defaultProvider: "openai", + providers: pooled, + ...savePoolAccount(), + apiKeys: [{ id: "agent-key", name: "agent", key: ADMISSION_KEY, createdAt: "2026-10-21T00:00:00.000Z" }], + } as unknown as Partial); + await expectRefused(await post(base, { "x-opencodex-api-key": ADMISSION_KEY, authorization: `AgentAssertion ${ASSERTION}` })); + await expectRefused(await post(base, { authorization: `AgentAssertion ${ADMISSION_KEY}` })); + expect(seen).toEqual([]); + }); +}); diff --git a/tests/responses/chat-anthropic-reasoning-details.test.ts b/tests/responses/chat-anthropic-reasoning-details.test.ts new file mode 100644 index 00000000000..e9958ce89c2 --- /dev/null +++ b/tests/responses/chat-anthropic-reasoning-details.test.ts @@ -0,0 +1,312 @@ +/** + * #7429: a Chat Completions client routed to Claude kept losing its reasoning on every tool step. + * + * The proxy now returns provider-issued thinking state as OpenRouter-style `reasoning_details` + * and, for a single first-party Anthropic route only, replays what the client echoes back as the + * signed thinking block Anthropic requires before a `tool_use`. These cases drive the real Chat + * ingress against a stubbed Messages endpoint, on the bridge converter and on the direct encoder, + * streaming and not. + */ +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 { chatCompletionsToResponsesBody } from "../../src/chat/inbound"; +import { collectChatCompletion } from "../../src/chat/outbound"; +import { resetWorkflowBudgetsForTest } from "../../src/lib/workflow-budget"; +import { clearReasoningReplayCacheForTests } from "../../src/responses/reasoning-replay-cache"; +import { getAccountSet, saveCredential, setActiveAccount } from "../../src/oauth/store"; +import { resetThoughtSignatureReplayForTests } from "../../src/responses/thought-signature-replay"; +import { handleChatCompletions } from "../../src/server/chat-completions"; +import type { OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +type Item = Record; +const originalFetch = globalThis.fetch; +const originalHome = process.env.OPENCODEX_HOME; +const MODEL = "claude-sonnet-4-5"; +/** Opus 5.5 thinks with `display: "updates"`: each signed block is shown to the client as content. */ +const PROGRESS_MODEL = "claude-opus-5-5"; +const FIRST_PARTY = "https://api.anthropic.com"; +const COMPATIBLE = "https://anthropic.example.test"; +const SIGNATURES = ["SyntheticStepOneSignature0000==", "SyntheticStepTwoSignature0000=="]; +const THINKING = ["Step one reasoning.", "Step two reasoning."]; +const REDACTED = "SyntheticRedactedThinkingPayload"; +const FORMAT = "anthropic-claude-v1"; +const TOOLS = [{ type: "function", function: { name: "lookup", parameters: { type: "object", properties: {} } } }]; +const USER = { role: "user", content: "Find it." }; +let home = ""; +let release: (() => void) | undefined; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-chat-reasoning-details-")); + process.env.OPENCODEX_HOME = home; + release = acquireOwnedSpendHome(); + clearReasoningReplayCacheForTests(); + resetThoughtSignatureReplayForTests(); + resetWorkflowBudgetsForTest(); +}); +afterEach(() => { + try { release?.(); } finally { + release = undefined; + globalThis.fetch = originalFetch; + clearReasoningReplayCacheForTests(); + resetThoughtSignatureReplayForTests(); + resetWorkflowBudgetsForTest(); + if (originalHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalHome; + removeTreeWithRetry(home); + } +}); + +/** The OAuth store knows Anthropic accounts by the canonical provider name. */ +const OAUTH_PROVIDER = "anthropic"; + +function config(baseUrl: string, direct: boolean, oauth = false): OcxConfig { + const name = oauth ? OAUTH_PROVIDER : "anth"; + return { + defaultProvider: name, emptyCompletionRetry: false, + providers: { [name]: { adapter: "anthropic", baseUrl, proxy: "direct", models: [MODEL, PROGRESS_MODEL], + ...(oauth ? { authMode: "oauth" } : { authMode: "key", apiKey: "synthetic-test-key" }) } }, + ...(direct ? { protocols: { rollout: { directEncoders: true } } } : {}), + } as OcxConfig; +} + +function frame(type: string, data: Item = {}): string { + return `event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`; +} + +/** One assistant turn: optional redacted block, signed thinking, then a tool call (or text). */ +function upstreamTurn(step: number, stream: boolean, options: { redacted?: boolean; tool?: boolean } = {}): Response { + const tool = options.tool !== false; + const blocks: Item[] = [ + ...(options.redacted ? [{ type: "redacted_thinking", data: REDACTED }] : []), + { type: "thinking", thinking: THINKING[step] ?? "Final reasoning.", signature: SIGNATURES[step] ?? "SyntheticFinalSignature00000==" }, + tool ? { type: "tool_use", id: `toolu_${step + 1}`, name: "lookup", input: {} } : { type: "text", text: "Done." }, + ]; + const stopReason = tool ? "tool_use" : "end_turn"; + if (!stream) { + return Response.json({ id: `msg_${step}`, type: "message", role: "assistant", model: MODEL, content: blocks, + stop_reason: stopReason, usage: { input_tokens: 2, output_tokens: 3 } }); + } + const frames = [frame("message_start", { message: { id: `msg_${step}`, role: "assistant", content: [], usage: { input_tokens: 2 } } })]; + blocks.forEach((block, index) => { + if (block.type === "redacted_thinking") { + frames.push(frame("content_block_start", { index, content_block: block })); + } else if (block.type === "thinking") { + frames.push( + frame("content_block_start", { index, content_block: { type: "thinking", thinking: "" } }), + frame("content_block_delta", { index, delta: { type: "thinking_delta", thinking: block.thinking } }), + frame("content_block_delta", { index, delta: { type: "signature_delta", signature: block.signature } }), + ); + } else if (block.type === "tool_use") { + frames.push( + frame("content_block_start", { index, content_block: { ...block, input: {} } }), + frame("content_block_delta", { index, delta: { type: "input_json_delta", partial_json: "{}" } }), + ); + } else { + frames.push( + frame("content_block_start", { index, content_block: { type: "text", text: "" } }), + frame("content_block_delta", { index, delta: { type: "text_delta", text: block.text } }), + ); + } + frames.push(frame("content_block_stop", { index })); + }); + frames.push(frame("message_delta", { delta: { stop_reason: stopReason }, usage: { output_tokens: 3 } }), frame("message_stop")); + return new Response(frames.join(""), { headers: { "content-type": "text/event-stream" } }); +} + +/** + * A Messages endpoint that, like Anthropic with thinking on, refuses a tool-use continuation whose + * latest assistant turn does not open with a signed or redacted thinking block. + */ +function transport(baseUrl: string, enforce: boolean, answer: (step: number, stream: boolean) => Response) { + const bodies: Item[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + if (url !== `${baseUrl}/v1/messages`) throw new Error(`Unexpected network target blocked: ${url}`); + const body = JSON.parse(String(init?.body)) as Item; + bodies.push(body); + const messages = body.messages as { role: string; content: Item[] | string }[]; + const lastAssistant = [...messages].reverse().find(message => message.role === "assistant"); + if (enforce && lastAssistant && Array.isArray(lastAssistant.content)) { + const first = lastAssistant.content[0]; + const signed = first?.type === "redacted_thinking" + || (first?.type === "thinking" && typeof first.signature === "string" && SIGNATURES.includes(first.signature)); + if (!signed) { + return Response.json({ type: "error", error: { type: "invalid_request_error", + message: "messages: Expected `thinking` or `redacted_thinking`, but found `tool_use`." } }, { status: 400 }); + } + } + return answer(bodies.length - 1, body.stream === true); + }) as typeof fetch; + return bodies; +} + +async function chat(cfg: OcxConfig, messages: unknown[], stream: boolean, model = MODEL, provider = "anth"): Promise { + const request = new Request("http://localhost/v1/chat/completions", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: `${provider}/${model}`, messages, tools: TOOLS, reasoning_effort: "high", stream }), + }); + const response = await handleChatCompletions(request, cfg, { model: "", provider: "" }); + expect(response.status).toBe(200); + const completion = stream + ? await collectChatCompletion(response.body!, MODEL, createTestTranslatorBudget()) + : await response.json() as Item; + const message = ((completion.choices as Item[])[0]!.message) as Item; + // What a Chat client stores and echoes back. + return { + role: "assistant", content: message.content ?? null, + ...(message.tool_calls ? { tool_calls: message.tool_calls } : {}), + ...(message.reasoning_content ? { reasoning_content: message.reasoning_content } : {}), + ...(message.reasoning_details ? { reasoning_details: message.reasoning_details } : {}), + }; +} + +function toolResult(assistant: Item): Item { + const call = (assistant.tool_calls as { id: string }[])[0]!; + return { role: "tool", tool_call_id: call.id, content: "result" }; +} + +function assistantTurns(body: Item): Item[][] { + return (body.messages as { role: string; content: Item[] }[]) + .filter(message => message.role === "assistant").map(message => message.content); +} + +const LANES = [ + { name: "bridge", direct: false, stream: false }, + { name: "bridge stream", direct: false, stream: true }, + { name: "direct encoder", direct: true, stream: false }, + { name: "direct encoder stream", direct: true, stream: true }, +]; + +describe("#7429 Chat reasoning_details carry Anthropic thinking across tool steps", () => { + for (const lane of LANES) { + for (const model of [MODEL, PROGRESS_MODEL]) { + const progress = model === PROGRESS_MODEL; + test(`${lane.name} ${model}: signatures survive a multi-step tool loop and Anthropic accepts them`, async () => { + const cfg = config(FIRST_PARTY, lane.direct); + const bodies = transport(FIRST_PARTY, true, (step, stream) => upstreamTurn(step, stream, { tool: step < 2 })); + const first = await chat(cfg, [USER], lane.stream, model); + if (progress) { + // The update is shown as content; the same text is what the signature covers. + expect(first.content).toBe(THINKING[0]); + expect(first.reasoning_content).toBeUndefined(); + } else { + expect(first.reasoning_content).toBe(THINKING[0]); + } + expect(first.reasoning_details).toEqual([ + { type: "reasoning.text", text: THINKING[0], signature: SIGNATURES[0], format: FORMAT, index: 0 }, + ]); + const second = await chat(cfg, [USER, first, toolResult(first)], lane.stream, model); + expect((second.reasoning_details as Item[])[0]!.signature).toBe(SIGNATURES[1]); + const final = await chat(cfg, [USER, first, toolResult(first), second, toolResult(second)], lane.stream, model); + expect(final.content).toBe(progress ? "Final reasoning.Done." : "Done."); + + expect(bodies).toHaveLength(3); + if (progress) expect(bodies[2]!.thinking).toMatchObject({ display: "updates" }); + const turns = assistantTurns(bodies[2]!); + expect(turns).toHaveLength(2); + turns.forEach((content, step) => { + // The signed block first. A progress update's visible copy stays in the text the + // client sent: nothing on the wire tells it from answer text, so nothing is removed. + expect(content).toEqual([ + { type: "thinking", thinking: THINKING[step], signature: SIGNATURES[step] }, + ...(progress ? [{ type: "text", text: THINKING[step] }] : []), + expect.objectContaining({ type: "tool_use", id: `toolu_${step + 1}` }), + ]); + }); + }); + } + + test(`${lane.name}: redacted thinking round-trips through reasoning.encrypted`, async () => { + const cfg = config(FIRST_PARTY, lane.direct); + const bodies = transport(FIRST_PARTY, true, (step, stream) => upstreamTurn(step, stream, { redacted: step === 0, tool: step === 0 })); + const first = await chat(cfg, [USER], lane.stream); + expect(first.reasoning_details).toEqual([ + { type: "reasoning.encrypted", data: REDACTED, format: FORMAT, index: 0 }, + { type: "reasoning.text", text: THINKING[0], signature: SIGNATURES[0], format: FORMAT, index: 1 }, + ]); + await chat(cfg, [USER, first, toolResult(first)], lane.stream); + expect(assistantTurns(bodies[1]!)[0]!.slice(0, 2)).toEqual([ + { type: "redacted_thinking", data: REDACTED }, + { type: "thinking", thinking: THINKING[0], signature: SIGNATURES[0] }, + ]); + }); + } + + // A Claude subscription reaches the same first-party host with a pooled OAuth account. + test("pooled OAuth route: signatures survive a multi-step tool loop", async () => { + await saveCredential(OAUTH_PROVIDER, { access: "synthetic-oauth-access", refresh: "synthetic-oauth-refresh", + expires: Date.now() + 3_600_000, accountId: "synthetic-oauth-account", source: "oauth" }); + await setActiveAccount(OAUTH_PROVIDER, getAccountSet(OAUTH_PROVIDER)!.accounts[0]!.id); + const cfg = config(FIRST_PARTY, false, true); + const bodies = transport(FIRST_PARTY, true, (step, stream) => upstreamTurn(step, stream, { tool: step < 2 })); + const first = await chat(cfg, [USER], true, MODEL, OAUTH_PROVIDER); + expect((first.reasoning_details as Item[])[0]!.signature).toBe(SIGNATURES[0]); + const second = await chat(cfg, [USER, first, toolResult(first)], true, MODEL, OAUTH_PROVIDER); + const final = await chat(cfg, [USER, first, toolResult(first), second, toolResult(second)], true, MODEL, OAUTH_PROVIDER); + expect(final.content).toBe("Done."); + expect(bodies).toHaveLength(3); + assistantTurns(bodies[2]!).forEach((content, step) => { + expect(content[0]).toEqual({ type: "thinking", thinking: THINKING[step], signature: SIGNATURES[step] }); + }); + }); + + test("a non-first-party Anthropic destination never receives the echoed signature", async () => { + const cfg = config(COMPATIBLE, false); + const bodies = transport(COMPATIBLE, false, (step, stream) => upstreamTurn(step, stream, { redacted: step === 0 })); + const first = await chat(cfg, [USER], false); + // The response still offers the state; the gate is on what is accepted back. + expect(first.reasoning_details).toHaveLength(2); + await chat(cfg, [USER, first, toolResult(first)], false); + const { reasoning_details: _dropped, ...plain } = first; + await chat(cfg, [USER, plain, toolResult(first)], false); + const sent = JSON.stringify(bodies[1]); + expect(sent).not.toContain(SIGNATURES[0]); + expect(sent).not.toContain(REDACTED); + // Exactly today's request: as if the client had sent reasoning_content alone. + expect(bodies[1]!.messages).toEqual(bodies[2]!.messages); + }); + + test("a missing or invalid signature keeps today's plaintext fallback", async () => { + const cfg = config(FIRST_PARTY, false); + const bodies = transport(FIRST_PARTY, false, (step, stream) => upstreamTurn(step, stream)); + const first = await chat(cfg, [USER], false); + const { reasoning_details: _dropped, ...plain } = first; + const variants: Item[] = [ + plain, + { ...plain, reasoning_details: [{ type: "reasoning.text", text: THINKING[0], format: FORMAT, index: 0 }] }, + { ...plain, reasoning_details: [{ type: "reasoning.text", text: THINKING[0], signature: "rs_not_a_signature", format: FORMAT }] }, + { ...plain, reasoning_details: [{ type: "reasoning.text", text: THINKING[0], signature: SIGNATURES[0], format: "openai-responses-v1" }] }, + { ...plain, reasoning_details: [...(first.reasoning_details as Item[]), { type: "reasoning.summary", summary: "x" }] }, + ]; + for (const assistant of variants) await chat(cfg, [USER, assistant, toolResult(first)], false); + const replays = bodies.slice(1).map(body => body.messages); + for (const messages of replays) { + expect(messages).toEqual(replays[0]!); + expect(JSON.stringify(messages)).not.toContain(SIGNATURES[0]); + } + }); + + test("without reasoning_details the projection is byte for byte unchanged", () => { + const messages = [USER, { role: "assistant", content: null, reasoning_content: "prior", tool_calls: [ + { id: "toolu_1", type: "function", function: { name: "lookup", arguments: "{}" } }, + ] }, { role: "tool", tool_call_id: "toolu_1", content: "result" }]; + const body = { model: "m", messages, tools: TOOLS }; + expect(JSON.stringify(chatCompletionsToResponsesBody(body, { acceptAnthropicReasoningSignatures: true }))) + .toBe(JSON.stringify(chatCompletionsToResponsesBody(body))); + }); + + test("answer text that begins with a signed thought is replayed intact", () => { + const messages = [USER, { role: "assistant", content: "Same answer", reasoning_details: [ + { type: "reasoning.text", text: "Same", signature: SIGNATURES[0], format: FORMAT, index: 0 }, + ] }]; + const input = chatCompletionsToResponsesBody({ model: "m", messages }, { acceptAnthropicReasoningSignatures: true }).input as Item[]; + expect(input.at(-1)).toEqual({ type: "message", role: "assistant", content: [{ type: "output_text", text: "Same answer" }] }); + }); +}); + + diff --git a/tests/responses/codex-0164-request-compat.test.ts b/tests/responses/codex-0164-request-compat.test.ts new file mode 100644 index 00000000000..760e5e23325 --- /dev/null +++ b/tests/responses/codex-0164-request-compat.test.ts @@ -0,0 +1,311 @@ +/** + * Codex 0.164 request compatibility (openai/codex 816fa702..af91f6ef2c). + * + * prompt_cache_key policy (openai/codex#55587): a host keeps the session-derived key, sends a + * Custom key, or Omits the field. Session and thread headers do not change with the policy, and + * sub-agents inherit it. The key is a cache hint only: an omitted or custom key must reach the + * wire exactly as sent, and must not move conversation identity, lineage, or cache diagnostics. + * + * configuration_update (ResponseItem::ConfigurationUpdate): a positional reasoning-effort control. + * OpenAI-operated destinations receive it verbatim and in order; other Responses gateways and + * translated routes drop it without failing the request or turning it into a message. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { codexConversationIdentity } from "../../src/codex/lineage"; +import { codexPoolAffinityKey } from "../../src/codex/auth-context"; +import { parseRequest } from "../../src/responses/parser"; +import { handleResponses, hasUnreadableEncryptedAgentTask } from "../../src/server/responses"; +import { recoverEncryptedAgentTaskWithResult, resetAgentTaskRecoveryState } from "../../src/server/responses/agent-task-recovery"; +import type { RequestLogContext } from "../../src/server/request-log"; +import { appendFinalCacheDiagnostic, cacheDiagnosticPath } from "../../src/usage/cache-diagnostic"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { encryptedInput, routedConfig } from "../helpers/agent-task-recovery"; +import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const originalFetch = globalThis.fetch; +const CANONICAL = "https://chatgpt.com/backend-api/codex"; +const SESSION = "sess-0164-root"; +const ROOT_THREAD = "thread-0164-root"; +const CHILD_THREAD = "thread-0164-child"; +const CONFIG_UPDATE = { type: "configuration_update", reasoning: { effort: "high" } }; +const IDENTITY_HEADERS = ["session-id", "session_id", "thread-id", "x-codex-parent-thread-id"]; + +let isolated: IsolatedCodexHome; +let home: string; +let previousHome: string | undefined; +let previousCacheDebug: string | undefined; +let releaseSpendHome: (() => void) | undefined; + +beforeEach(() => { + isolated = installIsolatedCodexHome("ocx-0164-codex-"); + previousHome = process.env.OPENCODEX_HOME; + previousCacheDebug = process.env.OPENCODEX_CACHE_DEBUG; + home = mkdtempSync(join(tmpdir(), "ocx-0164-home-")); + process.env.OPENCODEX_HOME = home; + releaseSpendHome = acquireOwnedSpendHome(); +}); + +afterEach(() => { + releaseSpendHome?.(); + releaseSpendHome = undefined; + globalThis.fetch = originalFetch; + isolated.restore(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousCacheDebug === undefined) delete process.env.OPENCODEX_CACHE_DEBUG; + else process.env.OPENCODEX_CACHE_DEBUG = previousCacheDebug; + removeTreeWithRetry(home); +}); + +interface Wire { url: string; headers: Headers; body: Record } + +const RESPONSES_SSE = "data: " + JSON.stringify({ type: "response.completed", response: { + id: "resp_0164", object: "response", status: "completed", output: [], + usage: { input_tokens: 4, output_tokens: 1, total_tokens: 5, input_tokens_details: { cached_tokens: 0 } }, +} }) + "\n\ndata: [DONE]\n\n"; +const CHAT_SSE = [ + { id: "c", object: "chat.completion.chunk", choices: [{ index: 0, delta: { role: "assistant", content: "ok" }, finish_reason: null }] }, + { id: "c", object: "chat.completion.chunk", choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 4, completion_tokens: 1, total_tokens: 5 } }, +].map(chunk => "data: " + JSON.stringify(chunk) + "\n\n").join("") + "data: [DONE]\n\n"; + +function captureWire(): Wire[] { + const seen: Wire[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + seen.push({ url, headers: new Headers(init?.headers), body: JSON.parse(String(init?.body)) }); + return new Response(url.includes("/chat/completions") ? CHAT_SSE : RESPONSES_SSE, { + status: 200, headers: { "content-type": "text/event-stream" }, + }); + }) as typeof fetch; + return seen; +} + +function config(provider: Partial): OcxConfig { + return { + defaultProvider: "p", + openaiProviderTierVersion: 2, + providers: { p: { adapter: "openai-responses", models: ["gpt-5.6-luna"], ...provider } }, + } as unknown as OcxConfig; +} + +const canonical = (): OcxConfig => config({ authMode: "forward", codexAccountMode: "direct", baseUrl: CANONICAL }); + +function user(text: string): Record { + return { type: "message", role: "user", content: [{ type: "input_text", text }] }; +} + +async function drive( + cfg: OcxConfig, + model: string, + input: unknown[], + options: { headers?: Record; promptCacheKey?: string } = {}, +): Promise<{ status: number; logCtx: RequestLogContext }> { + const token = fakeChatGptJwt({ chatgpt_account_id: "acct-0164", exp: Math.floor(Date.now() / 1000) + 86_400 }); + const logCtx: RequestLogContext = { model: "", provider: "" }; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer " + token, + "chatgpt-account-id": "acct-0164", + ...options.headers, + }, + body: JSON.stringify({ + model, input, stream: true, store: false, + ...(options.promptCacheKey === undefined ? {} : { prompt_cache_key: options.promptCacheKey }), + }), + }), cfg, logCtx); + await response.text(); + return { status: response.status, logCtx }; +} + +function identityOf(wire: Wire): Record { + return Object.fromEntries(IDENTITY_HEADERS.map(name => [name, wire.headers.get(name)])); +} + +const rootHeaders = { "session-id": SESSION, "thread-id": ROOT_THREAD }; +const childHeaders = { "session-id": SESSION, "thread-id": CHILD_THREAD, "x-codex-parent-thread-id": ROOT_THREAD }; + +describe("prompt_cache_key policy (openai/codex#55587)", () => { + const policies = [ + { name: "SessionDerived", key: SESSION }, + { name: "Custom", key: "host-chosen-cache-key" }, + { name: "Omit", key: undefined }, + ] as const; + + test("native passthrough sends each policy verbatim and keeps session/thread identity", async () => { + const seen = captureWire(); + const conversationIds: Array = []; + for (const policy of policies) { + const { status, logCtx } = await drive(canonical(), "p/gpt-5.6-luna", [user("ping")], { + headers: rootHeaders, promptCacheKey: policy.key, + }); + expect(status).toBe(200); + conversationIds.push(logCtx.conversationId); + } + expect(seen).toHaveLength(3); + for (const [index, policy] of policies.entries()) { + const wire = seen[index]!; + expect(wire.url).toBe(CANONICAL + "/responses"); + expect(Object.hasOwn(wire.body, "prompt_cache_key")).toBe(policy.key !== undefined); + expect(wire.body.prompt_cache_key).toBe(policy.key); + // The policy controls the body only; the identity headers are the same on every request. + expect(identityOf(wire)).toEqual(identityOf(seen[0]!)); + } + expect(identityOf(seen[0]!)).toMatchObject({ "session-id": SESSION, "thread-id": ROOT_THREAD }); + // A custom or missing key is not a conversation change in Logs. + expect(conversationIds[0]).toBeString(); + expect(new Set(conversationIds).size).toBe(1); + }, 20_000); + + test("a sub-agent that inherits Omit keeps its headers, lineage cohort, and log identity", async () => { + const seen = captureWire(); + const withKey = await drive(canonical(), "p/gpt-5.6-luna", [user("child task")], { + headers: childHeaders, promptCacheKey: SESSION, + }); + const omitted = await drive(canonical(), "p/gpt-5.6-luna", [user("child task")], { headers: childHeaders }); + expect([withKey.status, omitted.status]).toEqual([200, 200]); + expect(seen).toHaveLength(2); + expect(seen[1]!.body).not.toHaveProperty("prompt_cache_key"); + expect(identityOf(seen[1]!)).toEqual(identityOf(seen[0]!)); + expect(identityOf(seen[1]!)).toMatchObject({ + "session-id": SESSION, "thread-id": CHILD_THREAD, "x-codex-parent-thread-id": ROOT_THREAD, + }); + expect(omitted.logCtx.conversationId).toBe(withKey.logCtx.conversationId); + + // Lineage and pool affinity read the headers the child actually sent; with the key omitted the + // child still joins its root's cohort instead of splitting off. + const root = new Headers(rootHeaders); + const child = new Headers(childHeaders); + expect(codexPoolAffinityKey(child)).toBe(codexPoolAffinityKey(root)); + expect(codexConversationIdentity(child)?.conversationKey) + .toBe(codexConversationIdentity(root)?.conversationKey); + }, 20_000); + + test("translated Chat route forwards Custom verbatim and omits the field under Omit", async () => { + const seen = captureWire(); + const chat = config({ + adapter: "openai-chat", authMode: "key", apiKey: "chat-key", baseUrl: "https://chat.example/v1", + promptCacheKey: true, models: ["m"], + } as Partial); + for (const key of ["host-chosen-cache-key", undefined]) { + const { status } = await drive(chat, "p/m", [user("ping")], { headers: rootHeaders, promptCacheKey: key }); + expect(status).toBe(200); + } + expect(seen.map(wire => wire.url)).toEqual(Array(2).fill("https://chat.example/v1/chat/completions")); + expect(seen[0]!.body.prompt_cache_key).toBe("host-chosen-cache-key"); + expect(seen[1]!.body).not.toHaveProperty("prompt_cache_key"); + }, 20_000); + + test("cache diagnostics record an omitted key as absent, never as a mismatch", async () => { + process.env.OPENCODEX_CACHE_DEBUG = "1"; + captureWire(); + for (const [index, policy] of policies.entries()) { + const { logCtx } = await drive(canonical(), "p/gpt-5.6-luna", [user("ping")], { + headers: rootHeaders, promptCacheKey: policy.key, + }); + expect(logCtx.cacheDiagnosticDraft).toBeDefined(); + appendFinalCacheDiagnostic({ + requestId: "req-0164-" + index, protocol: "responses", provider: "p", model: "gpt-5.6-luna", + cacheProvenance: "observed", draft: logCtx.cacheDiagnosticDraft, + }); + } + const records = readFileSync(cacheDiagnosticPath(), "utf8").trim().split("\n") + .map(line => JSON.parse(line) as { promptCacheKey: Record; session: Record }); + expect(records).toHaveLength(3); + const [derived, custom, omitted] = records; + for (const record of [derived!, custom!]) { + expect(record.promptCacheKey).toMatchObject({ + inbound: { present: true, source: "caller" }, outbound: { present: true, source: "caller" }, equal: true, + }); + } + expect(omitted!.promptCacheKey).toEqual({ inbound: { present: false }, outbound: { present: false } }); + // The session header is the identity signal, and it is the same under every policy. + expect(new Set(records.map(record => JSON.stringify(record.session))).size).toBe(1); + }, 20_000); +}); + +describe("configuration_update input item", () => { + const input = () => [user("one"), structuredClone(CONFIG_UPDATE), user("two")]; + const provenance = { content_item_metadata: [{ source: "user" }] }; + + test("OpenAI-operated destinations receive it verbatim and in order", async () => { + const seen = captureWire(); + for (const cfg of [canonical(), config({ authMode: "key", apiKey: "sk-test", baseUrl: "https://api.openai.com/v1" })]) { + const { status } = await drive(cfg, "p/gpt-5.6-luna", input(), { headers: rootHeaders }); + expect(status).toBe(200); + } + expect(seen).toHaveLength(2); + for (const wire of seen) { + const items = wire.body.input as Array>; + const at = items.findIndex(item => item.type === "configuration_update"); + expect(items[at]).toEqual(CONFIG_UPDATE); + expect(items[at - 1]).toMatchObject({ role: "user", content: [{ text: "one" }] }); + expect(items[at + 1]).toMatchObject({ role: "user", content: [{ text: "two" }] }); + } + }, 20_000); + + test("a third-party Responses gateway gets the conversation without it or RLite provenance", async () => { + const seen = captureWire(); + const items = input(); + items[0] = { ...items[0], internal_chat_message_metadata_passthrough: provenance }; + const { status } = await drive( + config({ authMode: "key", apiKey: "gw-key", baseUrl: "https://gateway.example/v1" }), "p/gw-model", items, + { headers: rootHeaders }, + ); + expect(status).toBe(200); + const sent = seen[0]!.body.input as Array>; + expect(sent.some(item => item.type === "configuration_update")).toBe(false); + expect(sent.some(item => Object.hasOwn(item, "internal_chat_message_metadata_passthrough"))).toBe(false); + expect(sent.filter(item => item.role === "user").map(item => (item.content as Array<{ text: string }>)[0]!.text)) + .toEqual(["one", "two"]); + }, 20_000); + + test("a translated route drops it without failing or inventing a message", async () => { + const seen = captureWire(); + const { status } = await drive(config({ + adapter: "openai-chat", authMode: "key", apiKey: "chat-key", baseUrl: "https://chat.example/v1", models: ["m"], + } as Partial), "p/m", input(), { headers: rootHeaders }); + expect(status).toBe(200); + const messages = seen[0]!.body.messages as Array<{ role: string; content: unknown }>; + expect(JSON.stringify(messages)).not.toContain("configuration_update"); + expect(messages.filter(message => message.role === "user").map(message => JSON.stringify(message.content))) + .toEqual([expect.stringContaining("one"), expect.stringContaining("two")]); + }, 20_000); + + test("the parser keeps pending reasoning attached across it", () => { + const parsed = parseRequest({ + model: "p/m", + input: [ + user("q"), + { type: "reasoning", summary: [{ type: "summary_text", text: "thinking" }] }, + structuredClone(CONFIG_UPDATE), + { type: "message", role: "assistant", content: [{ type: "output_text", text: "answer" }] }, + ], + }); + expect(parsed.context.messages.map(message => message.role)).toEqual(["user", "assistant"]); + const assistant = parsed.context.messages[1] as { content: Array<{ type: string }> }; + expect(assistant.content.map(part => part.type)).toEqual(["thinking", "text"]); + }); + + test("a trailing update does not hide the agent task before it", async () => { + resetAgentTaskRecoveryState(); + let fetches = 0; + globalThis.fetch = (async () => { fetches += 1; throw new Error("must-not-fetch"); }) as typeof fetch; + const tailed = [...encryptedInput(), structuredClone(CONFIG_UPDATE)]; + expect(hasUnreadableEncryptedAgentTask(tailed)).toBe(true); + // Without credentials the envelope is found and then refused; a missed envelope would read as + // unsupported instead. + expect(await recoverEncryptedAgentTaskWithResult(new Request("http://localhost/v1/responses"), tailed, {}, routedConfig())) + .toEqual({ recovered: false, reason: "admission_denied" }); + expect(fetches).toBe(0); + resetAgentTaskRecoveryState(); + }); +}); diff --git a/tests/responses/protocol-direct-encoders-chat.test.ts b/tests/responses/protocol-direct-encoders-chat.test.ts index 029ae974e03..ff81a2e50ad 100644 --- a/tests/responses/protocol-direct-encoders-chat.test.ts +++ b/tests/responses/protocol-direct-encoders-chat.test.ts @@ -192,6 +192,56 @@ const SCENARIOS: Record