diff --git a/.github/pr-assets/260901-cap-slot-ko-1280.png b/.github/pr-assets/260901-cap-slot-ko-1280.png new file mode 100644 index 0000000000..fb5f2a0487 Binary files /dev/null and b/.github/pr-assets/260901-cap-slot-ko-1280.png differ diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 8da4e1f724..5357985bca 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -9,6 +9,9 @@ * src/cli/index.ts — only the published npm `bin` routes through here.) */ import { spawn, spawnSync } from "node:child_process"; +import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../src/update/stop-contract.mjs"; +import { probeProxyLiveness } from "../src/update/proxy-liveness-probe.mjs"; +import { decidePostStopUpdate } from "../src/update/stop-decision.mjs"; import { randomBytes } from "node:crypto"; import { createRequire } from "node:module"; import { existsSync, readFileSync, readdirSync } from "node:fs"; @@ -17,6 +20,7 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { isRealBunBinary } from "../src/lib/bun-binary-validator.mjs"; import { npmInvocation } from "../src/update/npm-invocation.mjs"; +import { hasPendingTeardownIn } from "../src/config/pending-teardown-names.mjs"; import { npmCachePreflightFailureMessage, runNpmCachePreflight, @@ -202,7 +206,12 @@ function runNpmSelfUpdate() { // Capture listen target before stop clears runtime-port.json (mirrors GUI/CLI update worker). // Do not treat a live runtime port of 10100 as "missing" — track whether the read succeeded. let bakePort = 10100; + // The hostname travels with the port: a proxy bound to ::1 or a specific interface is + // invisible to a probe that assumes 127.0.0.1, and "no answer" would then read as + // "stopped" for exactly the proxy the probe exists to find. + let bakeHostname = "127.0.0.1"; let sawRuntimePort = false; + let sawRuntimeHostname = false; try { const rt = JSON.parse(readFileSync(join(configDir(), "runtime-port.json"), "utf8")); if (Number.isFinite(rt?.port) && rt.port > 0 && rt.port <= 65535) { @@ -219,16 +228,29 @@ function runNpmSelfUpdate() { } if (runtimeLive) { bakePort = Math.trunc(rt.port); + if (typeof rt?.hostname === "string" && rt.hostname.trim() !== "") { + bakeHostname = rt.hostname.trim(); + sawRuntimeHostname = true; + } sawRuntimePort = true; } } } catch { /* fall through to config */ } - if (!sawRuntimePort) { + // Port and hostname resolve INDEPENDENTLY: a legacy runtime record carries a port and no + // hostname, and skipping config in that case probed 127.0.0.1 for a proxy bound to ::1. + if (!sawRuntimePort || bakeHostname === "127.0.0.1") { try { const cfg = JSON.parse(readFileSync(join(configDir(), "config.json"), "utf8")); - if (Number.isFinite(cfg?.port) && cfg.port > 0 && cfg.port <= 65535) bakePort = Math.trunc(cfg.port); + if (!sawRuntimePort && Number.isFinite(cfg?.port) && cfg.port > 0 && cfg.port <= 65535) { + bakePort = Math.trunc(cfg.port); + } + if (!sawRuntimeHostname && typeof cfg?.hostname === "string" && cfg.hostname.trim() !== "") { + bakeHostname = cfg.hostname.trim(); + } } catch { /* keep default */ } } + // Wildcard and bracketed-IPv6 normalization lives in probeProxyLiveness, so both lanes + // get it from one place. const launcher = fileURLToPath(import.meta.url); @@ -343,17 +365,47 @@ function runNpmSelfUpdate() { } } - if (serviceWasInstalled || hasRuntimeState) { + // An outstanding pending-teardown receipt is a fourth reason to run the stop. After a + // parent crashed mid-deferral the service, pid and runtime records can all be absent + // while the shared client config still points at a proxy that is gone; installing over + // that silently skips the recovery the receipt was written to trigger (#3008). Presence + // is the whole test here — the launcher cannot parse it, and `ocx stop` is what decides + // whether the obligation is safe to finish. + const hasPendingTeardown = hasPendingTeardownIn(readdirSync, configDir()); + if (serviceWasInstalled || hasRuntimeState || hasPendingTeardown) { console.log("⏹ Stopping the running proxy before updating..."); const stopRes = spawnSync(process.execPath, [launcher, "stop"], { stdio: "inherit", windowsHide: true }); const stillHasRuntimeState = existsSync(join(configDir(), "ocx.pid")) || existsSync(join(configDir(), "runtime-port.json")); - if (stopRes.status !== 0 || stillHasRuntimeState) { + // A history-only failure means teardown succeeded and a backup manifest is waiting for + // review: the proxy is down and replacing package files is safe. Every other nonzero + // status is a stop that did not finish, and a signal kill (status null) says nothing + // about whether it did - both abort, because replacing files under a live server + // leaves it running mixed old and new modules (#3008). + // The same decision the Bun updater makes, from the same module (#3008). Absent PID and + // runtime files are weak evidence, so the captured endpoint is asked; "unknown" aborts + // because a silent listener is exactly the state where replacing files is dangerous. + const decision = decidePostStopUpdate({ + status: stopRes.status, + hasRuntimeState: stillHasRuntimeState, + // Re-checked AFTER the stop: a quarantined receipt lets the stop itself succeed + // (there is nothing left to stop), so a pre-stop check alone let the retry install + // over a teardown that never ran. + teardownOutstanding: hasPendingTeardownIn(readdirSync, configDir()), + liveness: probeProxyLiveness(bakePort, bakeHostname), + }); + const historyOnlyStop = decision.reason === "history-only"; + if (!decision.proceed) { if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start"); - console.error("opencodex: could not stop the running proxy; aborting the update. Run 'ocx stop' and retry."); + if (decision.reason === "teardown-outstanding") { + console.error("opencodex: a shared teardown from an earlier stop is still outstanding and needs manual review; aborting the update."); + console.error("opencodex: confirm no proxy is running, run 'ocx restore', then remove the pending-teardown file in the opencodex home."); + } else console.error(decision.reason === "proxy-unknown" + ? `opencodex: could not confirm the proxy on ${bakeHostname}:${bakePort} is stopped; aborting the update. Run 'ocx stop' and retry.` + : "opencodex: could not stop the running proxy; aborting the update. Run 'ocx stop' and retry."); process.exit(1); } - if (historyRestoreIncomplete()) { + if (historyOnlyStop || historyRestoreIncomplete()) { console.warn( "opencodex: WARNING — Codex resume-history metadata restore is incomplete (a backup manifest remains).\n" + " The DB may be busy or the manifest/target may need review; untracked routed history is intentionally unchanged.\n" + diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/000_plan.md b/devlog/_fin/260831_aside_client_and_integrations_ux/000_plan.md similarity index 88% rename from devlog/_plan/260831_aside_client_and_integrations_ux/000_plan.md rename to devlog/_fin/260831_aside_client_and_integrations_ux/000_plan.md index ff81cf010a..5f3cc876d3 100644 --- a/devlog/_plan/260831_aside_client_and_integrations_ux/000_plan.md +++ b/devlog/_fin/260831_aside_client_and_integrations_ux/000_plan.md @@ -34,6 +34,12 @@ per-row borders are literally what produces that texture. | wp4 | 030 | Rollback surface redesign | | wp5 | 040 | Brand marks for the nine clients showing a monogram | | wp6 | 050 | Stacked PR chain | +| wp7 | 060 | Marks for the last three monogram clients | +| wp8 | 070 | Marks on every Integrations surface, not just the API rows | +| wp9 | 080 | Conflict overwrite: writer, route, GUI dialog | + +wp7 through wp9 were appended after the original six. Outcome and corrections: +`090_outcome.md`. Research docs: 001 (Aside contract), 002 (registration checklist), 003 (Integrations UX diagnosis), 004 (brand mark provenance). diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/001_aside_contract.md b/devlog/_fin/260831_aside_client_and_integrations_ux/001_aside_contract.md similarity index 100% rename from devlog/_plan/260831_aside_client_and_integrations_ux/001_aside_contract.md rename to devlog/_fin/260831_aside_client_and_integrations_ux/001_aside_contract.md diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/002_registration_checklist.md b/devlog/_fin/260831_aside_client_and_integrations_ux/002_registration_checklist.md similarity index 100% rename from devlog/_plan/260831_aside_client_and_integrations_ux/002_registration_checklist.md rename to devlog/_fin/260831_aside_client_and_integrations_ux/002_registration_checklist.md diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/003_integrations_ux_diagnosis.md b/devlog/_fin/260831_aside_client_and_integrations_ux/003_integrations_ux_diagnosis.md similarity index 100% rename from devlog/_plan/260831_aside_client_and_integrations_ux/003_integrations_ux_diagnosis.md rename to devlog/_fin/260831_aside_client_and_integrations_ux/003_integrations_ux_diagnosis.md diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/004_brand_mark_provenance.md b/devlog/_fin/260831_aside_client_and_integrations_ux/004_brand_mark_provenance.md similarity index 100% rename from devlog/_plan/260831_aside_client_and_integrations_ux/004_brand_mark_provenance.md rename to devlog/_fin/260831_aside_client_and_integrations_ux/004_brand_mark_provenance.md diff --git a/devlog/_fin/260831_aside_client_and_integrations_ux/005_remaining_marks_provenance.md b/devlog/_fin/260831_aside_client_and_integrations_ux/005_remaining_marks_provenance.md new file mode 100644 index 0000000000..3c893eec35 --- /dev/null +++ b/devlog/_fin/260831_aside_client_and_integrations_ux/005_remaining_marks_provenance.md @@ -0,0 +1,97 @@ +# The three clients still on a monogram + +Continuation of `004_brand_mark_provenance.md`, which closed six clients. After +that pass `CLIENT_MARKS` covers nine of twelve; `hermes`, `gajae` and `mcode` +still render `label.slice(0, 1)`. + +004 recorded `gajae` and `hermes` as monogram-only because neither publishes an +SVG with path geometry. That verdict stands on its own terms and is now +superseded by a wider rule: raster-to-vector conversion is authorized, so "the +vendor ships no SVG" no longer ends the search. Every mark below is traced from +the product's own raster asset rather than redrawn. + +All three were located 2026-08-31 through the `aside-jun` skill driving a +signed-in Aside browser, then re-fetched and verified locally. + +## mcode — MiniMax Code + +A genuine first-party SVG exists and 004 simply had not found it. + +- Source: `https://raw.githubusercontent.com/MiniMax-AI/MiniMax-01/main/figures/minimax.svg` +- 1255 bytes, `viewBox="0 0 490.16 411.7"`, one `` filled by a three-stop + linear gradient (`#e4177f` to `#e73562` to `#e94e4a`). +- It is the standalone symbol — the interlocking wave glyph with no wordmark + beside it. The docs-site asset (`mintcdn.com/minimax-zh/.../logo/light.svg`) + is the 129x32 horizontal lockup and was rejected for that reason: a wordmark + in a 20px square renders as unreadable letter mush. +- Publisher mark rather than product mark. MiniMax Code ships no mark of its + own and MiniMax is its publisher, so this is the closest first-party asset. +- Committed unmodified apart from dropping the Chinese-language `` and + layer-name metadata the authoring tool left behind. The gradient id is + renamed: `未命名的渐变_6` means "unnamed gradient 6", it collides across + inlined documents, and a non-ASCII id in a shared namespace is a trap. +- Multi-color, so it must NOT enter `MONOCHROME_CLIENT_MARKS`: masking would + flatten the gradient to one ink. + +## hermes — Hermes agent + +No usable SVG upstream; traced from the product's own application icon. + +- Rejected first: `website/static/img/favicon.svg` is 113 bytes and its whole + body is one `<text>` element. 004 already recorded this. +- Rejected second: `https://nousresearch.com/safari-pinned-tab.svg` (12746 + bytes, potrace output). Its first path is `M40 2560 l0 -2560 2520 0 2520 0 0 + 2560 0 2560 -2520 0 -2520 0 0 -2560z` — the full 512-unit frame. Rendered at + 20px that is a black square with a hairline hole, which is worse than a + monogram. +- Accepted: `apps/desktop/assets/icon.png` from `NousResearch/hermes-agent`, + 574273 bytes, 1024x1024 RGBA, artwork bounded at (101,108)-(924,914). This is + the icon the Hermes desktop application ships, so it is the product's own + mark, not the publisher's. +- Quantizing the opaque pixels shows two inks: a light plate (340877 px) and + black art (241765 px), with ~20k px of antialiasing between them. It is a + single-ink illustration on a rounded plate. +- Traced with `potrace -s --flat --turdsize 8 --alphamax 1.0 --opttolerance + 0.2` over the mask `alpha > 128 AND mean(rgb) < 110`, which keeps the black + art and discards the plate. One path, squared to `viewBox="0 0 823 823"` by + centering the 823x806 trace. +- `fill="currentColor"`, and it MUST join `MONOCHROME_CLIENT_MARKS`. A 20px + render on `#0d1117` confirmed the untinted mark is invisible in dark mode — + the same failure `prime`, `opencode` and `kimi` already have. + +## gajae — Gajae Code + +No SVG anywhere upstream, confirmed twice; traced from the mascot. + +- Searched and found empty: `assets/`, `public/` (404), `docs/`, plus + `assets/logo.svg`, `assets/favicon.svg`, `public/logo.svg`, + `public/favicon.svg`, `docs/logo.svg` (all 404), and every published + `@gajae-code/*` npm tarball at 0.15.6 (no SVG entries). `docs/brand-assets.md` + lists the active marks as PNG only. +- Accepted source: `assets/character.png`, 3190496 bytes, 1550x2048 RGBA, + transparent background. +- It is a vertical lockup: the mascot occupies y < 1650 and the `gajae-code` + wordmark sits below it. Rows 1650-1682 are fully transparent, which is the + seam the crop uses. Only the mascot is traced; a wordmark would not survive + 20px. +- The artwork is upscaled pixel art, so tracing at source resolution follows + every staircase and produced a 1.3 MB SVG. Downsampling to a 128px box with + Lanczos plus a 0.6px Gaussian first, then tracing, gives ~31 KB. That is + larger than any existing mark (`zcode.svg`, 11037 bytes) because this one is + an illustration rather than a glyph. +- Seven color layers, k-means++ seeded at 3 for determinism, painted + largest-area first. The committed file's fills are `#1d0a04`, `#561203`, + `#981001`, `#e3770c`, `#d32e02`, `#8f3a04` and `#02ac61` — read off + `gajae-code.svg` rather than off an earlier tuning run, whose centers differed + because it quantized at a different target size. The smallest layer is the + visor green and a fixed area floor would have dropped it, so the floor is a + fraction of the opaque area instead. +- Multi-color, so NOT in `MONOCHROME_CLIENT_MARKS`. + +## Rule this pass establishes + +A mark may be traced from the product's own raster asset when no vector exists, +provided the trace follows the source pixels rather than redrawing them, the +conversion parameters are recorded, and the result is verified by rendering at +the size it will actually be used. Tracing a wordmark into a square slot is +still refused, and so is a full-frame silhouette plate. diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/010_wp2_aside_backend.md b/devlog/_fin/260831_aside_client_and_integrations_ux/010_wp2_aside_backend.md similarity index 100% rename from devlog/_plan/260831_aside_client_and_integrations_ux/010_wp2_aside_backend.md rename to devlog/_fin/260831_aside_client_and_integrations_ux/010_wp2_aside_backend.md diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/020_wp3_aside_gui.md b/devlog/_fin/260831_aside_client_and_integrations_ux/020_wp3_aside_gui.md similarity index 100% rename from devlog/_plan/260831_aside_client_and_integrations_ux/020_wp3_aside_gui.md rename to devlog/_fin/260831_aside_client_and_integrations_ux/020_wp3_aside_gui.md diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/030_wp4_history_redesign.md b/devlog/_fin/260831_aside_client_and_integrations_ux/030_wp4_history_redesign.md similarity index 100% rename from devlog/_plan/260831_aside_client_and_integrations_ux/030_wp4_history_redesign.md rename to devlog/_fin/260831_aside_client_and_integrations_ux/030_wp4_history_redesign.md diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/040_wp5_brand_marks.md b/devlog/_fin/260831_aside_client_and_integrations_ux/040_wp5_brand_marks.md similarity index 100% rename from devlog/_plan/260831_aside_client_and_integrations_ux/040_wp5_brand_marks.md rename to devlog/_fin/260831_aside_client_and_integrations_ux/040_wp5_brand_marks.md diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/050_wp6_stacked_prs.md b/devlog/_fin/260831_aside_client_and_integrations_ux/050_wp6_stacked_prs.md similarity index 65% rename from devlog/_plan/260831_aside_client_and_integrations_ux/050_wp6_stacked_prs.md rename to devlog/_fin/260831_aside_client_and_integrations_ux/050_wp6_stacked_prs.md index 20bcc7a6c9..3b17a30488 100644 --- a/devlog/_plan/260831_aside_client_and_integrations_ux/050_wp6_stacked_prs.md +++ b/devlog/_fin/260831_aside_client_and_integrations_ux/050_wp6_stacked_prs.md @@ -89,3 +89,45 @@ And `integrations.catalog.title` -- the new `h3` that fixes the overview's headi outline -- reads "Clients" in both English and French, which the French accidental-English guard is right to flag. It is on the intentional-English allowlist now. + +## Outcome + +Seven PRs on `dev`: #3047 (`8c1294828`) the Aside client, #3050 (`efa2ba5ad`) the +bounded rollback surface, #3049 (`704d0d91a`) the first-party marks, #3048 +(`93b7ee80a`) the Aside GUI surface, #3065 (`7853e8e05`) Aside's own mark and the +single-ink fix, #3060 (`a1c332e9a`) the MAINTAINERS correction, and #3074 the +regression guards. Issue #3059 tracks the one deferral. + +### What the pre-merge audit changed + +An adversarial reviewer ran two rounds against the stack and neither round was +ceremonial. Round 1 found that squash-merging the parent would strand the child: +the repo merges by squash, so `git rebase` of the child onto the new `dev` +conflicts add/add, and `enforce-pr-target` resolves `stackedBase` from **open** +PRs only, so #3048 flips to `wrong_base` the moment its parent lands and its +branch is deleted. The fix is `git rebase --onto <new-dev> <recorded-parent-tip>`, +with the tip captured *before* the merge because `delete_branch_on_merge` removes +the name. + +Round 2 caught the more dangerous one. A fix had landed on the parent after the +child was cut, so the child was *behind* its own base while touching the same two +files. Replaying it would have auto-merged cleanly and silently reverted the +guard. The bad outcome there is not a conflict you resolve; it is the clean +rebase you do not look at. Cascade first, then `--onto`, and verify by grepping +for the guard rather than trusting the exit code. + +Both are properties of *this* repository's settings, not general stacking advice. + +### The claim that was wrong + +`aside` was recorded here as having no first-party mark, on the evidence that +`aside.com/favicon.svg` is a 404. True of the web, wrong about the product: the +installed application ships the mark in a module the vendor named +`official-brand-symbol`. The lookup had been scoped to what a vendor publishes +for the web, and an app that ships no web assets falls through it. + +Rendering all nine marks at 28px against both themes then showed three were +invisible to somebody -- `prime` white-on-transparent in light mode, `opencode` +and `kimi` near-black in dark. Every existing assertion passed: the files +existed, the `src` values were right, the geometry check was satisfied. Only +looking at them found it. diff --git a/devlog/_fin/260831_aside_client_and_integrations_ux/060_wp7_remaining_marks.md b/devlog/_fin/260831_aside_client_and_integrations_ux/060_wp7_remaining_marks.md new file mode 100644 index 0000000000..a585c6eb44 --- /dev/null +++ b/devlog/_fin/260831_aside_client_and_integrations_ux/060_wp7_remaining_marks.md @@ -0,0 +1,75 @@ +# wp7 — the three remaining marks + +Depends on nothing in this unit that is not already merged. Provenance and +conversion parameters are in 005; this document is the diff. + +## New assets + +`gui/public/provider-icons/minimax.svg` — the MiniMax symbol, fetched not +traced. Two edits to the upstream file: the `<title>资源 2` and the +`data-name` layer wrappers go (authoring-tool residue), and the gradient id +becomes `minimax-wave` so it cannot collide and carries no non-ASCII. + +`gui/public/provider-icons/hermes-agent.svg` — one `currentColor` path, +`viewBox="0 0 823 823"`. Named `hermes-agent` rather than `hermes` because +Hermes is also a provider name in this repo and `provider-icons/` is one flat +namespace. + +`gui/public/provider-icons/gajae-code.svg` — seven fill layers, largest area +first, `viewBox="0 0 128 128"`-class square box produced by centering the +traced bounds. + +## gui/public/provider-icons/README.md + +The "Two export clients deliberately have NO mark" section is now false and is +replaced. `gajae` and `hermes` move into the provenance list with their trace +parameters; `mcode` joins them. The section that remains explains the tracing +rule from 005 — a raster may be traced, a wordmark may not be squeezed into a +square slot, and a full-frame silhouette is rejected. + +## gui/src/components/apikeys-workspace/client-config-clients.ts + +`CLIENT_MARKS` gains three entries: + +```ts +hermes: "/provider-icons/hermes-agent.svg", +gajae: "/provider-icons/gajae-code.svg", +mcode: "/provider-icons/minimax.svg", +``` + +`MONOCHROME_CLIENT_MARKS` gains `hermes` only. `gajae` is seven inks and +`mcode` is a gradient; masking either would flatten it. + +The block comment above `CLIENT_MARKS` currently says two clients are absent on +purpose. That is now wrong in a way a reader would trust, so it is rewritten to +state the tracing rule and that every client has a mark. + +## Tests + +`gui/tests/client-marks-assets.test.ts` already covers more than the plan +originally credited it with. It asserts file existence, README provenance, the +no-``/no-``/must-have-geometry rule, that no multi-color mark is +masked, that the four known-invisible marks ARE masked, and that `dsh` is not. +Every one of those extends to the three new files without an edit, so the +"new guard: no `` element" the plan proposed would have been a duplicate +of an existing test rather than new coverage. + +What is genuinely uncovered is the completeness of the map. Nothing asserts that +every id in `CLIENTS` has a mark, so an entry dropped in a merge degrades to a +monogram silently and looks identical to a client that never had one. That guard +is new, and it was driven red by removing the `mcode` entry. + +Second new guard: a traced mark must record its raster source and its tracer +invocation in the README. A fetched mark has a URL to check; a traced one has +nothing to reproduce it from unless the parameters are written down. Driven red +by replacing the word `potrace` in the README. + +The mask-set expectations do need extending: `hermes` joins the pinned list of +marks that must be masked, while `gajae` and `mcode` are caught by the existing +multi-color assertion the moment they are added to the set by mistake. + +## Verification + +`cd gui && bun test tests/client-marks-assets.test.ts` plus the mask guard. +A 20px render of each new mark on `#ffffff` and on `#0d1117`, which is the check +that caught the Hermes dark-mode invisibility in the first place. diff --git a/devlog/_fin/260831_aside_client_and_integrations_ux/070_wp8_integration_marks.md b/devlog/_fin/260831_aside_client_and_integrations_ux/070_wp8_integration_marks.md new file mode 100644 index 0000000000..60ee9c7ca3 --- /dev/null +++ b/devlog/_fin/260831_aside_client_and_integrations_ux/070_wp8_integration_marks.md @@ -0,0 +1,121 @@ +# wp8 — one mark, every Integrations surface + +Depends on wp7: the shared component is only worth building once every client +has an asset, otherwise half the surfaces render monograms and the change looks +like a regression. + +## The problem + +`ClientConfigRow.tsx` is the only surface that draws a mark, and it owns the +img-versus-mask decision inline: + +```tsx +{mark ? monochrome ? + : + : {label.slice(0, 1)}} +``` + +Copying that ternary into three more files would put the invisibility rule in +four places, and the rule is exactly the thing that was already got wrong once. + +## New module: gui/src/components/ClientMark.tsx + +One component, one decision. Props: `markId` (the asset key, not the client id), +`label` (for the monogram letter), `size`, and `className`. + +It reads from a new shared map rather than `CLIENT_MARKS`, because the +Integrations page needs marks for four clients that are not export clients at +all — `codex`, `claude`, `claudeDesktop`, `grok`. Those are different id +namespaces that happen to overlap on strings like `claude`. + +## New module: gui/src/components/integration-marks.ts + +`INTEGRATION_MARKS: Record` mapping +every Integrations row to an asset already committed: + +- `codex` -> `/provider-icons/openai.svg` +- `claude`, `claudeDesktop` -> `/provider-icons/claude-color.svg` +- `grok` -> `/provider-icons/grok.svg` +- the twelve file clients -> the same assets `CLIENT_MARKS` uses + +None of the three non-file marks is masked, and the audit caught the plan being +wrong about two. `openai.svg` is a single fill, but that fill is `#10A37F` — +OpenAI's own green, the brand itself, which is exactly the `dsh` case +`client-config-clients.ts` already documents. Masking it would repaint a +trademark in the theme's text color. `grok.svg` is `#000000`, a genuine neutral +and therefore a real masking candidate — but it is xAI's published asset with a +literal fill, and rewriting it to `currentColor` is a change to someone else's +mark rather than a rendering decision. It stays an image and the dark-theme +contrast problem is recorded rather than papered over. + +So `MONOCHROME_INTEGRATION_MARKS` is exactly `MONOCHROME_CLIENT_MARKS`' assets +and nothing more. Keying it by ASSET PATH rather than client id is the one +improvement worth keeping from the original sketch: `kimi-color.svg` is reachable +as both a provider icon and a client mark, and a path-keyed set cannot mask it +on one surface and leave it unmasked on the other. + +## Surfaces + +`IntegrationsOverview.tsx` — `OverviewCard` puts a `` before the +`

`, inside `.integration-card-head`. It is `aria-hidden`: the card's +accessible name is the title button, and a mark that joined the name would read +"Claude Claude". + +`Integrations.tsx` — each tab button gets a 16px mark before its label. The tab +strip is 17 items on one row, so the mark is the thing that makes it scannable; +this is where the change pays for itself. + +`FileIntegrationPage.tsx` — a 24px mark in `.integration-client-head`, before +the `

`. + +`ClientConfigRow.tsx` — the inline ternary is deleted and replaced by +``. Behavior is identical, which the existing panel test asserts. + +## The Codex label + +`integrations.tab.codex` is `"Codex CLI"` in all nine locales. With a mark +beside it the "CLI" is redundant, and the row covers the Codex app and SDK too, +so it is also slightly wrong. It becomes `"Codex"` in every locale. `ja`, `zh`, +`zh-TW`, `ko`, `ru`, `tr`, `de`, `fr`, `en` all currently hold the literal +string `Codex CLI`, so this is one substitution per file. + +`integrations.codex.title` and `.body` are separate keys and are left alone; +they describe the routing behavior, where "CLI" is accurate. + +## CSS + +`gui/src/styles-apikeys-workspace.css` keeps `.awi-clientconfig-mark`; the new +component emits `.client-mark` plus `.client-mark--mask` / `.client-mark--img` +/ `.client-mark--monogram`, sized from a `--client-mark-size` custom property +so one rule serves 16/20/24/28px. The mask branch reuses the existing +`background: var(--text)` treatment. + +## Tests + +Extend `gui/tests/integrations-surfaces.test.tsx`: each of the three surfaces +renders a mark element per row/tab/header. Driven red by omitting the tab-strip +mark. + +New guard: every id in `INTEGRATION_MARKS` has a value, and every value resolves +to a committed file. Falsify by pointing one at a missing asset. + +New guard: `MONOCHROME_INTEGRATION_MARKS` contains no asset whose file has more +than one distinct fill. Falsify by adding `claude-color.svg`. + +New guard, from the audit: no asset whose single ink is a BRAND color may be +masked. `openai.svg` (`#10A37F`) and `deepseek-harness.svg` (`#4d6bfe`) are pinned +by name with their inks, the way the existing `dsh` test does, because no +property of the file distinguishes a brand ink from a neutral one. Falsify by +adding `openai.svg` to the mask set. + +New guard: no mark element contributes to an accessible name — each is +`aria-hidden` or has an empty `alt`. Falsify by giving the img an `alt={label}`. + +New guard: no locale contains `Codex CLI` under `integrations.tab.codex`. +Falsify by reverting one locale. + +## Verification + +`cd gui && bun test tests` focused files, plus headless Chrome screenshots of +`#integrations` at 1280 and 390 wide in both themes. The screenshots are the +only thing that proves the tab strip still wraps sanely with 17 marks in it. diff --git a/devlog/_fin/260831_aside_client_and_integrations_ux/080_wp9_conflict_overwrite.md b/devlog/_fin/260831_aside_client_and_integrations_ux/080_wp9_conflict_overwrite.md new file mode 100644 index 0000000000..c294b1ebae --- /dev/null +++ b/devlog/_fin/260831_aside_client_and_integrations_ux/080_wp9_conflict_overwrite.md @@ -0,0 +1,160 @@ +# wp9 — overwrite a conflict with defaults + +Independent of wp7/wp8 in code; ordered after them in the stack because they +touch the same page and a three-way overlap on `IntegrationsOverview.tsx` is not +worth reviewing. + +## The dead end today + +`classifyIntegration` reports `conflict` for two reasons and the writer refuses +both unconditionally (`writer.ts` apply branch): + +- `unowned-key` — our fragment paths are occupied by a value we did not write, + or there is no ownership record at all. +- `foreign-edit` — the recorded block's bytes changed, or a comment-capable + format drifted at file level. + +The GUI mirrors that: `FileIntegrationPage` sets `locked` for conflict and +`OverviewCard` disables the switch. So the only recovery is to open the file and +edit it by hand, which is precisely what a user reaching for a dashboard is +avoiding. The refusal is correct as a default — it is what stops us deleting +someone's work — but "correct default" and "only path" are different things. + +## Design: an explicit, snapshotted, journaled force + +The escape hatch is a THIRD operation, not a flag that weakens apply. + +`src/integrations/writer.ts` gains `overwriteIntegration(input)` and +`overwriteIntegrationCoordinated(input, options)`, next to apply/refresh/disable. +It differs from apply in exactly one place: where +`applyOrRefreshIntegration` returns `refuse(clientId, "conflict", ...)`, this +path continues. Everything else is shared, which is the point — the snapshot, +the atomic write, the compare-before-commit recheck, the ownership record and +the journal row all come from the same `commit()` call apply uses. + +Two properties it must NOT relax: + +- `unsafe` still refuses. A blocked container means our write would replace a + value the merge cannot reason about; forcing that is data loss with a receipt, + and 005's rule for marks applies here too — the snapshot is not a licence. +- `not_installed` and `non_loopback` still refuse. Neither is a conflict. + +The merge base is the parsed document as it stands, and the recorded fragment +paths are removed first WHEN A RECORD EXISTS — the same `removeFragments` call +the stale-refresh path makes. Without that, forcing over a drifted block leaves +orphans the new record does not cover, exactly as the refresh comment documents. + +The audit asked what happens in the other case, and it is worth writing down +because the answer looks like a bug and is not. `unowned-key` with NO record +gives `removeFragments` nothing to remove, so the merge runs against the +user's document with their values in our paths. `createdContainerPaths` +(`merge.ts`) then walks the same paths and records a container only where one +is NOT already a plain object — so every container the user already had is +correctly attributed to them, and the `createdContainers` we persist is +empty or near-empty. A later disable removes our leaves and leaves their +containers standing. That is the right outcome: we did not create those +containers, and pruning them would be the second act of destruction after the +one the user explicitly authorized. + +What the audit was right to flag is the `record!` non-null assertions in the +disable branch, whose comment records a real TypeError shipped that way. The +force path must not reuse them: it reaches the merge with a possibly-null +record by design, so it takes the apply-side code, where `record` is already +nullable, and never the disable-side code. + +New journal kind: `overwrite`. It could reuse `apply`, and that is the tempting +shortcut, but the rollback list is the one place a user goes after a mistake and +"applied" is a lie about an operation that replaced someone else's block. + +The kind string is declared in THREE independent places, none of which imports +another — the audit enumerated them and the plan originally named only two: + +- `src/integrations/journal.ts:22` — `export type OperationKind`, the persisted + vocabulary. +- `src/server/management/integration-routes.ts:73` — the route's own + `IntegrationJournalRow.kind` union, re-declared rather than imported. +- `gui/src/pages/integrations/integration-api.ts:57` — the GUI's union, also + re-declared, because the GUI does not import backend types. + +Plus `JOURNAL_KIND_KEY` in `overview-clients.ts`, which is an exhaustive +`Record` — so the compiler catches a missing +entry there, and only there. The two re-declared unions drift silently: a row +persisted as `overwrite` would fail no type check and render as an untranslated +key. A guard asserting the three declarations agree is worth more than the +feature test. + +The new i18n key goes into nine locale files. `gui/tests/locale-parity.test.ts` +already enforces key-set parity and additionally fails a zh-TW value left +identical to English unless it is allowlisted, so a placeholder translation is +not an option here. + +Undo is not special-cased: `restoreIntegration` already restores from the +snapshot by `opId` and does not care which kind produced it. The regression test +proves that end to end rather than asserting it here. + +## Route + +`src/server/management/integration-routes.ts`, the `PUT +/api/client-integrations/:client` handler. The body today is `{enabled: +boolean}`. It gains an optional `overwriteConflict?: boolean`, validated the +same way `confirmDrift` is on the restore route: present-and-not-boolean is a +400 `invalid_overwrite_conflict`. + +`enabled: false` plus `overwriteConflict: true` is a 400, not a silent ignore. +Disabling a block we do not own is the deletion this whole subsystem exists to +prevent, and a caller asking for it has misunderstood the field. + +`writerFailureResponse` needs no new branch: an `unsafe` refusal from a forced +call is still `integration_unsafe`. + +## GUI + +`FileIntegrationPage.tsx` — when `status.state === "conflict"`, a +`btn-danger` button appears beside the locked switch, opening a +`ConsequenceDialog`. The switch stays locked; the button is the only way +through. + +`IntegrationsOverview.tsx` — the same action on a conflicted card, reusing the +existing `ConsequenceDialog` and `pendingToggle` focus-restore machinery. + +`ConsequenceDialog` copy needs the file path, the sentence that the current +block is replaced by opencodex defaults, the sentence that a snapshot is taken +and the operation is undoable from the rollback list, and a confirm label that +is not "OK". Nine locales. + +The reason matters in the copy: `unowned-key` means "a block we did not write +is in the way", `foreign-edit` means "your edit to our block will be +discarded". Same operation, materially different thing being lost, so two +`changes` strings selected on `status.reason`. + +## Tests + +Backend, driven red first: + +1. force apply over `unowned-key` succeeds, writes our block, journals kind + `overwrite`, and `restore` of that op returns the original bytes exactly. + Falsify by leaving the conflict refusal in place. +2. force apply over `foreign-edit` drops the recorded fragments before merging, + so no orphan survives. Falsify by merging without `removeFragments`. +3. force apply over `unsafe` still refuses. Falsify by moving the force branch + above the unsafe check. +4. `{enabled: false, overwriteConflict: true}` is a 400. Falsify by ignoring + the combination. +5. a normal apply is unchanged — no `overwriteConflict` means the conflict + refusal still fires. Falsify by defaulting the field to true. + +GUI, driven red first: + +6. the overwrite button renders only for `conflict`, and never for `absent`, + `current`, `stale`, `unsafe` or not-installed. Falsify by widening the + condition to `unsafe`. +7. clicking it does not mutate until the dialog is confirmed. Falsify by wiring + the button straight to the mutation. +8. the dialog names the config path. Falsify by dropping the `path` var. + +## Verification + +Focused backend files run in CI, not locally (the full local suite is +forbidden). `cd gui && bun test tests` for the GUI files. A screenshot of a +conflicted client page is worth having but needs a conflicted config to exist; +the GUI test asserting the button's presence is the real gate. diff --git a/devlog/_fin/260831_aside_client_and_integrations_ux/090_outcome.md b/devlog/_fin/260831_aside_client_and_integrations_ux/090_outcome.md new file mode 100644 index 0000000000..cc6ad1d4fd --- /dev/null +++ b/devlog/_fin/260831_aside_client_and_integrations_ux/090_outcome.md @@ -0,0 +1,84 @@ +# Outcome + +Unit closed 2026-08-31. Every phase in this unit is on `dev`. + +| Phase | Doc | PR | On `dev` as | +|---|---|---|---| +| wp1 | 000 | -- | docs only | +| wp2 | 010 | #3047 | `8c1294828` -- Aside export client + registry | +| wp3 | 020 | #3048 | `93b7ee80a` -- Aside GUI surface, nine locales | +| wp4 | 030 | #3050 | `efa2ba5ad` -- rollback journal stops flooding | +| wp5 | 040 | #3049 | `704d0d91a` -- brand marks for the export clients | +| wp6 | 050 | -- | the stacked chain itself | +| wp7 | 060 | #3082 | `44b4de39d` -- the last three marks | +| wp8 | 070 | #3083 | `d86ec3e03` -- marks on every surface | +| wp9 | 080 | #3084 | `2a90cdaa9` -- conflict overwrite | + +Two follow-ups landed after the table above, both from auditing the merged head +rather than from the plan: + +| what | PR | on `dev` as | +|---|---|---| +| Grok mark masked so it survives the dark theme | #3086 | `0cc73411a` | +| `--overwrite-conflict` for the CLI, plus mobile dialog guards | #3088 | `91b2c4e19` | + +#3065 (`7853e8e05`) and #3074 landed alongside wp5: Aside's own mark plus the +first version of the single-ink rule, and the property guards the pair relies on. + +wp7 through wp9 were appended after the original six, planned in #3081 +(`873d08e63`). The unit grew because the page repair exposed what the marks work +had left behind: three clients still drawing a monogram, and marks present on the +API-keys rows but nowhere else on the page that names the same clients. + +## What the plan got wrong + +**A mark's absence upstream is not the same as its absence in a repository.** +060 assumed each of the three vendors publishes an SVG somewhere findable. None +did in usable form: the Hermes repo favicon is 113 bytes of ``, the +MiniMax docs asset is a 129x32 wordmark, and Gajae Code has no vector anywhere. +Two of the three marks are traced from raster art, which the plan did not +anticipate and which the README now records per asset. + +**Masking is a decision about trademark, not about ink count.** 040 framed the +mask set as "single-ink logos", which would have swept in `openai.svg` (one fill, +but that fill is OpenAI's brand green) and `grok.svg` (genuinely neutral, but +xAI's published file with a literal fill). Both stay images. The rule in +`integration-marks.ts` is now derived from one id-keyed set with the two +exclusions argued in place, rather than restated per surface. + +**A guard can pass for the wrong reason.** 080 listed a stranding test for the +forced `foreign-edit` path. Its first implementation passed with the fix removed, +because every path the old record owned was also one the new contribution writes, +so dropping the old fragments changed nothing observable. It was rewritten around +a layout the new write does not cover -- which is what an upgrade actually leaves +behind -- and only then failed when falsified. + +**Three declarations of one union had no guard.** 080 did not notice that +`OperationKind` is declared in `src/integrations/journal.ts`, restated in the +management envelope and restated again in the GUI adapter, with no import between +them. The doc comment in `journal.ts` claimed a test asserted the three agree. +None did, so a kind added in one place rendered as a raw i18n key with no type +error anywhere. `tests/integrations-journal.test.ts` now reads all three. + +**A documented tradeoff was a defect.** 070 recorded `grok.svg` as staying an image +because masking would be "editing someone else's mark". Measured on the dark card +surface it was about 1.9:1 -- the glyph was effectively invisible, and had been since +the mark landed. The reasoning was simply wrong about what masking does: the file is +not modified, it is read as a shape and tinted, which is how xAI renders it +themselves. Writing a tradeoff down does not make it correct, and neither this unit +nor the pass that followed it measured the thing it was excusing. + +**Half a surface is not a surface.** 080 specified the overwrite escape hatch for the +GUI and stopped there, which left `ocx integration client enable` dead-ending on the +exact state the feature exists to escape. The user with no browser -- an SSH session, +or an agent driving the proxy -- was the one still stuck. Fixed in #3088; the docs had +meanwhile been asserting that a conflict simply locks. + +## Verification as merged + +CI green on each PR head, including the unsharded macOS job, before every admin +squash merge. Two flakes surfaced and were diagnosed rather than absorbed: +`tests/install-scripts.test.ts` restart-helper on #3082, and +`tests/shutdown-launcher.test.ts` SIGHUP health-wait on #3083 -- the latter's +SIGINT and SIGTERM siblings passed in the same run, and PR #3061 is already open +for it. Neither file is touched by this unit. diff --git a/devlog/_fin/260901_provider_marks/000_plan.md b/devlog/_fin/260901_provider_marks/000_plan.md new file mode 100644 index 0000000000..8902c88306 --- /dev/null +++ b/devlog/_fin/260901_provider_marks/000_plan.md @@ -0,0 +1,81 @@ +# Provider marks + +Unit opened 2026-09-01. The Integrations page now draws a logo beside every +client it names. The provider surface does not: 38 of 83 registry providers fall +back to a coloured initial tile, and the Add-Provider catalog draws no mark at +all for any provider. + +## The measurement + +``` +PROVIDER_REGISTRY 83 entries +providerIconSrc() resolves 45 +returns undefined for 38 +``` + +Three of the 38 are not a sourcing problem at all -- the asset is committed and +the alias map simply never learned about it: + +| provider | asset already on disk | +|---|---| +| `minimax` | `minimax.svg` | +| `minimax-cn` | `minimax.svg` | +| `xiaomi-mimo` | `xiaomi-color.svg` | + +`minimax.svg` landed in #3082 for the MiniMax Code *client*. Nothing connected +it to the MiniMax *provider*, because the two maps are unrelated: +`CLIENT_MARKS` is keyed by `ExportClientId` and `PROVIDER_ICON_ALIASES` by +provider id. That is the whole defect, and it is the cheapest one in this unit. + +The remaining 35 have no asset: + +``` +nous umans neuralwatt orcarouter bizrouter cerebras chutes deepinfra hyperbolic +nscale vultr baseten sambanova nebius digitalocean scaleway featherless novita +together venice zai zhipu-bigmodel zhipu-bigmodel-coding nanogpt synthetic +siliconflow tencent-coding-plan volcengine volcengine-coding-plan +volcengine-agent-plan parallel zenmux litellm kilo mimo +``` + +## Where a missing mark shows + +`providerIconSrc` has exactly one consumer, `ProviderIcon` in `ProviderRail.tsx`, +which three surfaces render: the rail itself, `ProviderDetails`, and +`ProviderOverviewDashboard`. When it returns `undefined` the component falls back +to `ProviderFallbackMark` -- a deterministic hue-per-id initial tile. That +fallback is good; a page of them where competitors show logos is not. + +`ProviderCatalog.tsx` is separate and worse: its preset rows draw a title, an +adapter chip and badges, and no mark whatsoever. This is the surface the user +picks a provider FROM, so it is the one place a logo does the most work. + +## Sourcing has real targets + +Every registry entry carries `baseUrl` and `dashboardUrl`, so no lane has to +guess where a vendor lives. `cerebras` is `cloud.cerebras.ai`, `sambanova` is +`cloud.sambanova.ai`, `nebius` is `tokenfactory.nebius.com`, and so on. Those +URLs are the lane inputs. + +Three of the 35 are not independent brands and should be handled as aliases +rather than sourced twice: `zhipu-bigmodel-coding` is `zhipu-bigmodel`, +`volcengine-coding-plan` and `volcengine-agent-plan` are `volcengine`, and +`mimo` is the same Xiaomi MiMo brand as `xiaomi-mimo`. + +## Work phases + +| Phase | Doc | Deliverable | +|---|---|---| +| wp1 | this unit | Research and roadmap (docs only) | +| wp2 | 010 | Wire the three present-but-unmapped assets + the gap-class guard | +| wp3 | 020 | Aside lane A: sourcing the first half | +| wp4 | 030 | Aside lane B: sourcing the second half, vectorize raster-only | +| wp5 | 040 | Painting contract on the provider surface, measured in both themes | +| wp6 | 050 | Catalog marks + stack delivery | + +## Ordering constraint + +wp2 depends on nothing but the map and can land first. wp3 and wp4 are +independent of each other and of wp2 -- they only add files and alias rows -- so +they can run as parallel Aside lanes and land as sibling PRs. wp5 depends on both +sourcing lanes, because the mask decision needs the actual assets to measure. wp6 +is last because the catalog change should draw the full set, not a partial one. diff --git a/devlog/_fin/260901_provider_marks/010_wp2_wire_present_assets.md b/devlog/_fin/260901_provider_marks/010_wp2_wire_present_assets.md new file mode 100644 index 0000000000..8d8d1ba050 --- /dev/null +++ b/devlog/_fin/260901_provider_marks/010_wp2_wire_present_assets.md @@ -0,0 +1,49 @@ +# wp2 — wire the assets that are already committed + +Three providers render an initial tile while their artwork sits in the repo. No +sourcing, no tracing, no vendor site: three map rows and a guard. + +## The change + +`gui/src/provider-icons.ts`, in `PROVIDER_ICON_ALIASES`: + +```ts + minimax: "minimax.svg", + "minimax-cn": "minimax.svg", + "xiaomi-mimo": "xiaomi-color.svg", +``` + +`minimax` and `minimax-cn` are one brand on two endpoints (`api.minimax.io` and +`api.minimaxi.com`), the same way `alibaba` and `alibaba-token-plan-intl` +already share `alibaba-color.svg`. `xiaomi-mimo` is Xiaomi's MiMo endpoint and +`xiaomi-color.svg` is already wired for `xiaomi` and `mimo-free`. + +`mimo` (the token-plan id) belongs here too by the same argument, and takes +`xiaomi-color.svg`. + +## The guard, and why this class needs one + +The defect is not that someone forgot a row. It is that nothing could tell them: +`CLIENT_MARKS` and `PROVIDER_ICON_ALIASES` are different maps over different key +spaces, so committing `minimax.svg` for the client left no signal that the +provider of the same name was still bare. + +New test, `gui/tests/provider-icons.test.ts`: + +1. **Every registry id whose brand asset exists on disk is wired.** For each + provider without an alias, probe `.svg`, `-color.svg`, and the same + two for the id's first dash-segment. A hit is a failure with the filename + named, because it means the artwork is present and the map is stale. + Falsify by deleting the `minimax` row. +2. **Every alias points at a file that exists.** A typo'd filename renders a + broken image, which is worse than the fallback tile it replaced. Falsify by + pointing one alias at a name that is not committed. + +Test 1 is the one that matters: it is the only thing that would have caught this +gap, and it will catch the next one automatically as wp3/wp4 add assets. + +## Out of scope here + +No new artwork, no README changes (nothing new is sourced), no painting-mode +decisions -- `minimax.svg` is a gradient wave and `xiaomi-color.svg` is +multi-colour, so both stay images under the existing rule. diff --git a/devlog/_fin/260901_provider_marks/020_wp3_lane_a.md b/devlog/_fin/260901_provider_marks/020_wp3_lane_a.md new file mode 100644 index 0000000000..e29e89f00b --- /dev/null +++ b/devlog/_fin/260901_provider_marks/020_wp3_lane_a.md @@ -0,0 +1,67 @@ +# wp3 — Aside sourcing lane A + +Half the unsourced providers. Lane A and lane B (030) are independent: each only +adds files under `gui/public/provider-icons/`, alias rows, and README entries, so +they can run as parallel Aside dispatches and land as sibling PRs. + +## Lane A targets + +The registry gives each one a canonical domain, so no lane guesses where a vendor +lives. + +| provider | domain to search | +|---|---| +| `cerebras` | cloud.cerebras.ai | +| `together` | together.xyz | +| `deepinfra` | deepinfra.com | +| `sambanova` | sambanova.ai | +| `baseten` | baseten.co | +| `nebius` | tokenfactory.nebius.com | +| `hyperbolic` | hyperbolic.ai | +| `novita` | novita.ai | +| `featherless` | featherless.ai | +| `venice` | venice.ai | +| `chutes` | chutes.ai | +| `nscale` | nscale.com | +| `vultr` | vultr.com | +| `digitalocean` | digitalocean.com | +| `scaleway` | scaleway.com | +| `siliconflow` | siliconflow.cn | +| `litellm` | litellm.ai (BerriAI/litellm) | + +## Acceptance per asset + +An accepted mark is a square-ish brand mark with real path geometry. Rejected on +sight, with the rejection recorded: + +- a `` element standing in for a glyph (the Hermes favicon case: 113 bytes, + renders per-machine, blank where the font lacks the character); +- a `` or base64 raster inside an SVG wrapper -- that is a PNG wearing a + costume, and it will not scale or mask; +- a horizontal wordmark where a square slot needs a mark. The rail draws a 19px + box; a 129x32 lockup renders as an illegible smear. This is what disqualified + the MiniMax docs asset in the previous unit. + +When the vendor publishes only raster -- a favicon PNG, an app icon, an OG image +-- vectorize it. That path is already proven in this repository: +`hermes-agent.svg` is `potrace -s --flat --turdsize 8 --alphamax 1.0` over an +alpha+luma mask, and `gajae-code.svg` is seven k-means colour layers at a 128px +box. Single-ink silhouette takes potrace; multi-colour art takes the layered +trace. Downsample before tracing or upscaled pixel art produces a megabyte of +staircase geometry. + +## Naming + +`.svg` for a mark that carries its own colour, `-color.svg` +only where the directory's existing convention already uses that suffix for the +same brand. The directory is one flat namespace shared with client marks, so a +name that collides with a client gets the vendor-qualified form (`hermes-agent.svg` +is the precedent). + +## Provenance + +Every accepted asset gets a README entry: source URL, fetch date, whether it is +the product's mark or the publisher's, every modification made, and every +candidate rejected with the reason. A provider with nothing usable upstream gets +an entry too -- what was searched, what was found, why it was refused -- and keeps +its fallback tile. That is a recorded partial and a legitimate outcome. diff --git a/devlog/_fin/260901_provider_marks/030_wp4_lane_b.md b/devlog/_fin/260901_provider_marks/030_wp4_lane_b.md new file mode 100644 index 0000000000..bec183bf1a --- /dev/null +++ b/devlog/_fin/260901_provider_marks/030_wp4_lane_b.md @@ -0,0 +1,55 @@ +# wp4 — Aside sourcing lane B + +The other half. Same acceptance rules, same vectorization path, same provenance +obligations as 020; this doc records only what differs. + +## Lane B targets + +| provider | domain to search | +|---|---| +| `zai` | z.ai | +| `zhipu-bigmodel` | bigmodel.cn | +| `volcengine` | volcengine.com (Ark) | +| `tencent-coding-plan` | cloud.tencent.com | +| `nous` | nousresearch.com | +| `nanogpt` | nano-gpt.com | +| `synthetic` | synthetic.new | +| `parallel` | platform.parallel.ai | +| `zenmux` | zenmux.ai | +| `kilo` | kilo.ai | +| `umans` | umans.ai | +| `neuralwatt` | neuralwatt.com | +| `orcarouter` | orcarouter.ai | +| `bizrouter` | bizrouter.ai | + +## Aliases, not separate sourcing + +Four ids are plan variants of a brand already in this lane and must NOT be +sourced independently -- a second search would either duplicate the file or +produce a different asset for the same company: + +| variant | takes the mark of | +|---|---| +| `zhipu-bigmodel-coding` | `zhipu-bigmodel` | +| `volcengine-coding-plan` | `volcengine` | +| `volcengine-agent-plan` | `volcengine` | +| `mimo` | Xiaomi MiMo, wired in wp2 | + +This mirrors how `alibaba`, `alibaba-token-plan` and `alibaba-token-plan-intl` +already share one asset: the plan is a billing arrangement, not a brand. + +## The two that may legitimately come back empty + +`nous` is Nous Research, whose desktop icon was already traced for the Hermes +client mark in the previous unit. If the portal publishes nothing better, reusing +`hermes-agent.svg` is WRONG -- that is the Hermes product mark, not the Nous +company mark, and the registry entry is the company's inference portal. Record +the distinction and prefer an empty result over a misattribution. + +`litellm` (lane A) and `parallel` are the other likely empties: one is a +self-hosted proxy whose brand is a docs site, the other's `baseUrl` and +`dashboardUrl` are the same host, which usually means there is no separate +product identity to find. + +A recorded empty is a result. Inventing a mark, or borrowing a neighbouring +brand's, is a misattribution that outlives the commit. diff --git a/devlog/_fin/260901_provider_marks/040_wp5_painting.md b/devlog/_fin/260901_provider_marks/040_wp5_painting.md new file mode 100644 index 0000000000..b93a4f3818 --- /dev/null +++ b/devlog/_fin/260901_provider_marks/040_wp5_painting.md @@ -0,0 +1,76 @@ +# wp5 — the painting contract on the provider surface + +Depends on both sourcing lanes: the mask decision is a property of the artwork, +so it cannot be made before the artwork exists. + +## The rule, and its record of shipping broken + +A mark is drawn one of two ways. As an image it keeps its own colour, which is +correct for multi-colour art and for a single ink that IS the brand. As a themed +mask the file is used as a shape and filled with the surrounding text colour, +which is correct for a neutral silhouette that would otherwise vanish against one +of the two surfaces. + +Getting this wrong is not hypothetical. It has shipped four times: `prime` +(white on transparent, invisible in light mode), `opencode` (#211E1E) and `kimi` +(#1A1A1A) invisible in dark, and then `grok` (#000000, ~1.9:1 on the dark card), +which survived two passes over the same file because a code comment argued it +away instead of measuring it. + +## What the provider surface does today + +`ProviderIcon` renders `` unconditionally. There is no mask path +at all, so a neutral silhouette added by wp3/wp4 is invisible in one theme with +no mechanism to fix it short of editing the vendor's file. + +The client side already solved this in `ClientMark` + `MASKED_MARKS`. The +provider side needs the same two-branch decision, and the sets must stay +DERIVED rather than restated -- two hand-maintained lists of the same fact drift, +and the drift is a mark masked on one surface and not another. + +## The change + +1. `provider-icons.ts` grows a masked-set export, keyed by asset path exactly as + `MASKED_MARKS` is, so an asset reachable from both surfaces cannot be masked on + one and not the other. +2. `ProviderIcon` branches on it: masked assets render a `` with + `mask-image` and `background: var(--text)`; everything else stays an ``. + The fallback tile is untouched. +3. The luminance guard from `gui/tests/integration-marks.test.ts` is generalized + to cover provider assets: any single-ink, near-neutral mark (channel spread + <= 24, luminance outside 0.12-0.75) that is NOT masked fails, and any + multi-colour or gradient mark that IS masked fails. + +## Verification is measurement, not reading + +A hex string does not tell you what the user sees; the surface colour is half the +equation. Drive a headless browser over the built GUI, emulate +`prefers-color-scheme` light and dark, read `getComputedStyle` for each mark and +for the surface behind it, and compute the contrast ratio. Capture desktop (1440) +and mobile (390) in both themes. + +That procedure is what found grok at 1.9:1 after two passes had declared it +acceptable. Any provider mark landing under 3:1 against its own surface is a +defect regardless of what the file's colours suggest. + +## Three things the audit corrected in this doc + +**The surface is the tile, not the page.** `.provider-icon` is a 31px tile with +`background: var(--raised)` and a border, so a provider mark is measured against +`light-dark(#f4f4f4, #303030)` -- not against the card or page background the +client marks sit on. Same numbers by coincidence today, different variables +tomorrow; the measurement must read the tile's computed background rather than +assume the client surface. + +**The tile already sets `color: var(--text)`.** That is what a `mask` branch needs +for `background: var(--text)` to resolve correctly, so the mask path costs one +rule and no new custom property. + +**A third painting mechanism already exists and must not become a fourth.** +`.usage-source-mark--mono` on the Usage page uses `filter: invert(1)` under a dark +theme -- a different technique from `ClientMark`'s CSS mask, applied to the same +`grok.svg` this repository just masked on the Integrations page. Inverting is not +equivalent: it maps #000 to #fff rather than to `--text`, and it inverts any +colour present rather than recolouring a silhouette. wp5 either brings that +call site onto the shared decision or documents why Usage differs. What it must +not do is add a third spelling of the same idea. diff --git a/devlog/_fin/260901_provider_marks/050_wp6_catalog_and_delivery.md b/devlog/_fin/260901_provider_marks/050_wp6_catalog_and_delivery.md new file mode 100644 index 0000000000..f4fa1d3248 --- /dev/null +++ b/devlog/_fin/260901_provider_marks/050_wp6_catalog_and_delivery.md @@ -0,0 +1,55 @@ +# wp6 — marks in the Add-Provider catalog, and stack delivery + +Last because the catalog should draw the full set. Landing it before the sourcing +lanes would show a page of fallback tiles and invite a second pass over the same +file. + +## The catalog draws no marks at all + +`ProviderCatalog.tsx` renders each preset row as a title, an adapter chip and +badges. This is the surface a user picks a provider FROM -- a list of names where +a logo would do the most work of anywhere in the app, and the one place that has +none. + +`providerIconSrc` is keyed by provider id and `CatalogPreset.id` is that same id, +so the existing map serves this surface with no new lookup. The row grows a +`ProviderIcon` before its title block, matching the rail's markup so both +surfaces share the mask decision and the fallback tile. + +The `accounts` tier rows get the same treatment: they are providers too, and a +row that shows a logo next to `Cursor` and a bare tile next to `Kiro` reads as a +bug rather than a distinction. + +## Guards + +1. Every catalog row renders a mark element -- either the asset or the fallback + tile, never nothing. Falsify by removing the component from the row. +2. The mark is `aria-hidden` beside a visible label, or a screen reader announces + the provider twice. This is the rule the client marks already follow. +3. Row geometry is stable: adding a 19px mark must not push the badges out of the + row at mobile width. Measured, not assumed. + +## Delivery + +Stacked parent-to-child, each PR reviewable alone: + +``` +wp1 roadmap ──► wp2 wire+guard ──► wp5 painting ──► wp6 catalog + wp3 lane A ──┤ + wp4 lane B ──┘ +``` + +wp3 and wp4 are siblings off wp2 rather than a chain: they touch disjoint asset +files and adjacent alias rows, so serializing them would only add rebases. wp5 +merges after both because it measures the assets they add. + +Every push uses `--no-verify`; the local full backend suite is forbidden, so +backend proof comes from CI. Each PR merges with `--squash --admin` only after +its own CI is green including the unsharded macOS job, and each child is rebased +onto the new `dev` tip after its parent lands. + +## Closing + +The unit moves to `_fin` with an outcome doc recording, per phase, the merge +commit and what the plan got wrong. The previous unit's outcome doc is the +template: it is worth more for the corrections than for the table. diff --git a/devlog/_fin/260901_provider_marks/090_outcome.md b/devlog/_fin/260901_provider_marks/090_outcome.md new file mode 100644 index 0000000000..dec45604af --- /dev/null +++ b/devlog/_fin/260901_provider_marks/090_outcome.md @@ -0,0 +1,60 @@ +# Outcome + +Unit closed 2026-09-01. Every phase is on `dev`. + +| Phase | Doc | PR | On `dev` as | +|---|---|---|---| +| wp1 | 000 | #3092 | `5ef84b61e` -- roadmap, docs only | +| wp2 | 010 | #3093 | `a11038cb2` -- wire the committed assets + gap guard | +| wp3/wp4 | 020, 030 | #3095 | `910b4c736` -- 26 marks sourced | +| wp5 | 040 | #3098 | `15f92e3f6` -- the painting contract | +| wp6 | 050 | #3099 | `d71aa07c0` -- catalog marks | + +Providers resolving a mark: **45 -> 77 of 83**. Painting split on the merged +head: 47 image, 17 mask, 7 light plate, 6 dark plate. + +## What the plan got wrong + +**One plate is not enough.** 040 specified a single constant light plate for +colour artwork too dark for the dark tile. That fixed twelve marks and broke +nothing, but it left six others still failing -- `parallel` at 1.00 dominant +luminance, `bizrouter` 0.98, `nebius` 0.87 -- because their artwork is near-*white*, +drawn for a dark header. They needed the opposite plate. The doc had assumed the +failure mode was one-directional because every example it had was. + +**A mark can solve this itself.** `digitalocean.svg` carries its own +`@media (prefers-color-scheme: dark)` rule that repaints the glyph `#F4F5F5`. +Plating it defeated the vendor and produced light-on-light at 1.01:1 -- worse +than doing nothing. Nothing in the plan anticipated an asset that adapts, and +only rendered measurement caught it; reading the file's fill colour says #000 and +stops there. + +**Tracing a favicon traces the plate.** 020 described the vectorization path as +settled work because `hermes-agent.svg` and `gajae-code.svg` had gone smoothly. +Those sources were transparent-background artwork. A favicon is usually a glyph +on a filled rounded square, and the first pass traced the square: `baseten` came +out 97.7% ink, `bizrouter` 89.3%. Border-ring plate detection was added and found +real plates behind six assets. + +**The gap was older and wider than the count suggested.** The luminance guard, +written for the 26 new marks, immediately failed on five old ones: +`opencode.svg` (#211e1e) and `kimi-color.svg` (#1a1a1a) are the very files the +Integrations page already masks -- invisible on the provider surface the whole +time, because the two surfaces had no shared decision. `grok.svg` is the same +story one PR later. `ollama-color.svg` and `vercel-ai-gateway-color.svg` had never +been caught by either pass. + +## Six providers keep the fallback tile + +`chutes`, `nscale`, `tencent-coding-plan`, and the three `volcengine` plan ids. +Each was probed at its registry `baseUrl` and `dashboardUrl`, its docs subdomain, +and the conventional icon paths. What was found and why it was refused is in the +provider-icons README. A recorded empty is a result; borrowing a neighbouring +brand's mark would be a misattribution that outlives the commit. + +## Verification as merged + +CI green on every PR head, including the unsharded macOS job, before each admin +squash merge. On `d71aa07c0`: root and gui `tsc --noEmit` exit 0, `oxlint` clean, +`privacy:scan` clean. Every guard added in this unit was driven red before being +kept -- eleven falsifications across five test files. diff --git a/devlog/_plan/260830_models_provider_header/030_uniform_row_and_hover.md b/devlog/_plan/260830_models_provider_header/030_uniform_row_and_hover.md new file mode 100644 index 0000000000..6c129cf514 --- /dev/null +++ b/devlog/_plan/260830_models_provider_header/030_uniform_row_and_hover.md @@ -0,0 +1,227 @@ +# 030 — Uniform control row: hover as emphasis, not as hiding + +Extends the `260830_models_provider_header` unit. `010` shipped (toggle flex +basis + child shrink). `020` is written and NOT implemented. This doc is the +third reported defect on the same header, and it arrives with a user mandate +that the plan auditor initially rejected. + +## The report + +> 각 버튼이 뭘 의미하는지 설명좀해봐 이거 너무 많아서 그리고 일관성도 없음 +> 규칙찾아서 좀 줄이고 깔끔하게 하고 싶은데 방법없나? +> 이렇게 많은곳도 있고 스위치만 달랑있는것도 있어서 호버도 만들고 싶음 + +Two claims, and the measurement below shows both are literally true: the row +carries 7-8 controls, and the count is not the same from card to card. + +## Measured baseline + +Harness: full CDP (`Emulation.setDeviceMetricsOverride`, dpr 2) against the live +proxy on 127.0.0.1:10100, reading `getBoundingClientRect` on the real provider +list. Raw capture: `evidence/030-baseline.json`. + +| width | cards | control count | horizontal overflow | zero-width children | +|-------|-------|---------------|---------------------|---------------------| +| 780 | 10 | **7 or 8** | none | 0 | +| 1280 | 10 | **7 or 8** | none | 0 | +| 1440 | 10 | **7 or 8** | none | 0 | + +Two findings, and they point in opposite directions. + +**The count really does vary, and the actions row really does start at a +different x.** At 1280 the actions cluster begins at 684.1px on `anthropic`, +710.2px on `openai`, and 798.9px on `cursor` — a 114.8px spread across cards in +one list. Two independent conditionals cause it: + +- `Models.tsx:1360` — `(capOn || nativeProviderGroup)` hides the cap `Select`, + so a cap-off provider loses a ~74px control. +- `Models.tsx:1287` — `if (!preset) return null` drops the preset segmented + control for any provider with no shipped preset. + +So `openai` shows `1.05M` and `anthropic` does not; `anthropic` shows +`프리셋 / 전체` and `openai` does not. Neither is a bug. Both are invisible +rules, which is why the row reads as arbitrary. + +**But there is no overflow and nothing is clipped.** `scrollWidth === clientWidth` +at all three widths, `overflowRight` is -13px everywhere (inside the card), and no +child measures under 6px. `010` did its job. The remaining defect is +*legibility of the rule*, not geometry — which changes what this phase is allowed +to do: it must not buy tidiness with a layout regression, because the layout is +currently correct. + +## The conflict, and how it resolves + +The user mandated hover ("호버는 일단 이거는 무조건해야되고"). The independent +plan auditor (xai/grok-4.6, read-only lane) returned **VERDICT: fail** against +the first draft, which had proposed hiding the alias pencil, the alias-defaults +switch, and the custom-add `+` until hover: + +> hover-reveal of alias pencil / alias-defaults switch / custom-add creates a +> discoverability regression on expert controls; users must already know to hover +> to see them [...] the proposed Tier 2 items are exactly the ones `020` said +> needed visible text because they are undiscoverable at rest. +> +> TIER ASSIGNMENT YOU RECOMMEND: Tier 1: [everything]; Tier 2: none. +> hover-reveal is a density hack that fights the meaning problem `020` solved. + +The auditor is right about the mechanism and wrong about the conclusion, and the +distinction is worth stating precisely because it is the whole design of this +phase: **the auditor rejected hover-as-gating. The user asked for hover-as- +affordance.** Those are different features that share a word. + +- **Hover-as-gating** removes a control from the resting layout. It trades + discoverability for density, and on a surface where `020` already established + that three controls are unlabeled and opaque, it makes the opacity worse. This + is what the auditor blocked, and it stays blocked. +- **Hover-as-affordance** leaves every control in the resting layout and uses + pointer proximity to *explain* it — the row lifts its own labels and tooltips + into view. Nothing is hidden, so there is nothing to discover. + +Adopted: **no control leaves the resting layout.** Tier 2 is empty. The auditor's +Tier-1 assignment is adopted verbatim; its recommendation to land `020` first is +also adopted, which is why the label work moves ahead of this doc in the order. + +**`020`'s label text is visible at rest. Hover only emphasizes; it never +introduces the label.** That sentence is the whole contract, and the second audit +round demanded it because an earlier phrasing here ("raises labels into view") +re-created the defect in a new place: + +> If `050` rest-hides that text and reveals it on hover, blocker 1 returns as +> meaning-gating: a labeled control that is unlabeled until hover. + +So the affordance is deliberately small: pencil idle-dim, and the `title` +tooltips `020` already specifies. Nothing in `050` may make `020`'s visible text +conditional on pointer state. If that leaves `050` with little label work, that is +the correct outcome — `020` already did it. + +That also resolves blocker 3 without further argument: if no control is added to +or removed from the resting row, the 780px case cannot be made worse by +reserving space, because the space is already reserved by the control itself. + +## Column alignment without placeholders + +The first draft proposed reserving space with disabled placeholders. The auditor +killed that too, and its reasoning is the same one `011` already recorded: + +> No shipped preset -> no control. Alignment comes from cluster order, not a dead +> segmented. [...] A disabled openai preset placeholder is that dead switch. + +It also identified which conditional actually causes the jump the user sees. Not +the preset — the cap: + +> The actual column jump is the cap `Select` gated on +> `(capOn || nativeProviderGroup)` at Models.tsx ~1360, not missing aliases. + +Adopted, and it is a better fix than the one it replaces: **always render the cap +`Select`, disabled when the cap is off.** A cap-off card then occupies the same +slot as a cap-on card, so `anthropic` lines up with `openai` — with a real +control carrying a real value instead of a ghost. The preset control keeps +`if (!preset) return null`; a provider with nothing to curate still shows nothing, +because a dead segmented is worse than an absent one. + +### What this does and does not fix, measured + +The second audit round corrected an overclaim in the first draft of this section, +using the captured baseline. Both corrections are adopted: + +> Always rendering the Select ADDS a control to 8 of 10 cards (not "replaces" +> one). At 780 that is extra wrap height, not overflow — `010` still holds — but it +> is not aggregate-neutral. After the Select is always present, anthropic still has +> the preset and cursor still does not, so the 1280 left-edge spread cannot go to +> zero. + +Precisely, from `evidence/030-baseline.json`: + +- **780px is the wrong proof for alignment.** Every card already reports + `actsLeft: 281` there, because `@container (max-width: 720px)` has already + wrapped the actions to full width. Alignment cannot regress or improve at a + width where it is already uniform, so **the column gate lives at 1280 and 1440.** +- **The 114.8px spread is two missing pieces, not one.** `openai`/`kiro` carry a + `custom-select`; `anthropic`/`openrouter` carry a `segmented`; the other six + carry neither. + +So the honest scope: always rendering the cap `Select` fixes the +**openai-shows-a-number / anthropic-does-not** mismatch the user actually +reported, and it costs wrap height at 780 rather than being free. It does **not** +drive the left-edge spread to zero, because the preset control legitimately +remains absent on providers with nothing to curate. **That residual +preset-driven delta is accepted, not fixed**, and the `050` gate must state it as +an accepted delta instead of asserting equal left edges. + +## What this phase must NOT do + +Also from the audit, and load-bearing: + +- **No functional merge of `사용자 지정 창` into the cap control.** It opens + per-model overrides (`openContextSettings`, `Models.tsx:538`); the cap + switch+select is the provider-wide default. Those are different scopes. The + cluster is **visual grouping only, three tab stops**. +- **Do not move the count out of `.models-provider-toggle`.** `010`'s child + shrink rule and the element-wrapped-children test own that side of the row. +- **Nothing becomes hover-only.** Not the pencil, not the alias-defaults switch, + not custom-add. The pencil may idle dimmed (`opacity >= 0.6`) and must stay in + layout and in tab order, force-visible under `(hover: none)`. + +## What actually reduces the count + +The user asked to reduce, and one cluster is genuinely redundant. The context +window is **one setting wearing three controls** (`Models.tsx:1355-1394`): + +1. a bare `Switch` whose accessible name is `기본 128k` — a value, not a function +2. a `Select` showing the number, conditional on that switch +3. a `사용자 지정 창` button opening the per-model modal + +`020` already found (2) mislabeled and (1) opaque. Merging them into one +labeled cluster removes one control from every card and makes the conditional +`Select` a state of that cluster rather than an item that appears and vanishes. + +`모두 켜기` / `모두 끄기` also read as overlapping with `전체`, but they are not: +the segmented control selects a curated *set*, the bulk buttons toggle +*visibility* of the current rows. Per UX-LAZY-01 neither can be deleted, so they +are disambiguated by label, not removed. + +## Phase order + +1. `020` — visible labels for the three opaque controls (already written; the + auditor requires it first). +2. `040` — merge the context-window triple into one labeled cluster. +3. `050` — hover-as-affordance on the actions row: pointer/`focus-within` + emphasizes controls that are already labeled and already present (pencil + idle-dim, `020`'s tooltips), with `@media (hover: none)` keeping the resting + state fully usable and `prefers-reduced-motion` making any opacity change + instant. + + **No `min-width` floors on the conditional slots.** The first draft proposed + them and the audit correctly identified that as the ghost placeholder in CSS + form, contradicting this doc's own "no shipped preset -> no control" and + reviving the child-floor move `011` already killed. An always-rendered + `Select` needs no floor, and a floor for an absent preset is a dead slot. + +## Verification contract + +Inherited from `000`, with the column gate corrected by the second audit round: + +- Re-measure at 780/1280/1440. Require `scrollWidth === clientWidth` and no child + under 6px at every width. +- **Column gate at 1280/1440 only** — 780 is already uniform at `actsLeft: 281` + and proves nothing about alignment. +- The cap-value slot must be occupied on every card. The remaining left-edge + delta between a preset-carrying and a preset-free provider is recorded as an + accepted delta, with its measured magnitude. +- 780 is allowed to gain wrap HEIGHT from the always-present `Select`; it is not + allowed to overflow. +- Implementation guard, from the audit: the strings the existing tests pin must + stay byte-exact — `className="row models-provider-toggle"`, + `className="row models-provider-actions"`, `t("models.contextSettings")`, the + 720/768 wrap rules, and the toggle's `flex: "1 1 auto"`. A template-literal + className or a hover wrapper around the actions row fails + `gui/tests/models-provider-head.test.ts` even if the design is right. + +Focused `gui/tests` regression per phase, driven red first. Remote gates only +(`ssh lidge`); the local full suite is forbidden by the user. Push `--no-verify`. + +Dials: `DESIGN_VARIANCE 2`, `MOTION_INTENSITY 1`, density D6 — unchanged from +`000`. An earlier draft of this doc raised them to 3/2 to justify a hover +transition; the audit rejected that as motion inflation on an admin row, and it +was right. Any opacity change is instant under `prefers-reduced-motion`, and the +dials are not raised to license the affordance. diff --git a/devlog/_plan/260830_models_provider_header/040_cap_cluster_and_occupied_slot.md b/devlog/_plan/260830_models_provider_header/040_cap_cluster_and_occupied_slot.md new file mode 100644 index 0000000000..a1847fd941 --- /dev/null +++ b/devlog/_plan/260830_models_provider_header/040_cap_cluster_and_occupied_slot.md @@ -0,0 +1,143 @@ +# 040 — One cap cluster, and a slot that is always occupied + +Second phase of the row work. Depends on `020`: the labels must already be +visible so this cluster is measured at its real width rather than at bare-knob +width. + +## What is wrong + +The provider-wide context window is **one setting wearing three sibling +controls** in `.models-provider-actions` (`Models.tsx:1355-1394`): + +| control | source | today | +|---------|--------|-------| +| cap on/off | `Switch` | bare knob; accessible name is `기본 128k`, a VALUE not a function | +| cap value | `Select` | rendered only when `(capOn \|\| nativeProviderGroup)` | +| per-model overrides | `사용자 지정 창` button | labeled, opens `openContextSettings` | + +Two consequences, both measured in `evidence/030-baseline.json`: + +- Three peer items for one concept, inflating the 7-8 control count the user + complained about. +- The conditional `Select` is the **cap-driven half of the 114.8px left-edge + spread**: `openai` and `kiro` carry a `custom-select`, the seven cap-off + providers carry nothing where that control would be. + +## Change 1 — always render the cap Select, disabled when off + +```tsx +-{(capOn || nativeProviderGroup) && ( ++{/* Always rendered. A cap-off provider previously dropped this control ++ entirely, which is what made anthropic's row start 26px right of openai's ++ and left the switch with no adjacent number to explain it. Disabled-with-a- ++ value is honest and keeps the slot occupied. */} ++{( +``` + +with `disabled={busy || !capOn}` already present on the `Select`, so the off +state is inert rather than absent. + +This is the audit's own recommendation, and its cost is recorded rather than +hidden: it **adds** a control to 8 of 10 cards. At 780 that is extra wrap height +inside a row that is already full-width, and it must not overflow. + +## Change 2 — group the three into one visual cluster + +```tsx +
+ + setModelFilter(e.target.value)} + placeholder={t("logs.filter.model.placeholder")} + aria-label={t("logs.filter.model.label")} + /> + {conversationQuery && (
- - void setDefaultAliases(!(aliases.defaults.providers[provider] ?? aliases.defaults.global), provider)} - label={t("models.useDefaultAliases")} - /> - {/* Available on every card, including the native one: the canonical `openai` seed - check now admits contextWindow/modelContextWindows as user-owned overlays, and - the native accessors only ever narrow the measured window with them. The cap - next to it is the coarser sibling — one value for the whole provider. */} - - { + + void setDefaultAliases(!(aliases.defaults.providers[provider] ?? aliases.defaults.global), provider)} + label={t("models.useDefaultAliases")} + showLabel + /> + { + > {t("models.customAdd")} } {(() => { // #2465: Preset / All / Custom. Only providers with a shipped preset get the @@ -1350,32 +1340,44 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; ); })()} - - <> - toggleProviderCap(provider, nativeProviderGroup)} disabled={busy} label={t("models.capValue", { value: fmtK(capDisplayValue) })} /> - {/* The native group keeps the value visible with the cap off: its rows always - advertise SOME window, so hiding the number leaves the card saying nothing - about the context Codex will actually see. Routed providers keep the old - behaviour, where an off cap genuinely means "no opinion". */} - {(capOn || nativeProviderGroup) && ( - <> - ({ value: String(v), label: fmtK(v) })), { value: CUSTOM_OPTION, label: t("models.custom") }, ]} - onChange={v => onSelectProviderCap(provider, v)} - disabled={busy || !capOn} - label={t("models.capValue", { value: fmtK(capDisplayValue) })} - /> - {providerCapCustomOpen[provider] && ( + onChange={v => onSelectProviderCap(provider, v)} + disabled={busy || !capOn} + label={t("models.capValue", { value: fmtK(capDisplayValue) })} + title={t("models.contextCapLabel")} + /> + {/* `capOn &&` is load-bearing now that the Select no longer disappears with + the cap. providerCapCustomOpen is independent state: with Custom already + open, flipping the cap off used to leave this input and its Apply button + standing, and Apply sends enabled: true — turning the cap back on from a + field that looks like part of an off cluster. */} + {capOn && providerCapCustomOpen[provider] && ( <> - - - )} - - )} - -
+ + + )} + + {/* Available on every card, including the native one: the canonical `openai` seed + check now admits contextWindow/modelContextWindows as user-owned overlays, and + the native accessors only ever narrow the measured window with them. The cap + beside it is the coarser sibling — one value for the whole provider. + + It sits in the cluster for VISUAL grouping only. This is deliberately not a + functional merge: this button opens PER-MODEL overrides while the switch and + select are the provider-wide default, and two different scopes cannot honestly + become one control. Three separate tab stops remain. Moving it here from + beside the alias controls does change tab order — switch, then select when + enabled, then this — which reads in the order the controls are now seen. */} + +
+ {!isCollapsed && (
diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx index cf441a912c..377a94eeed 100644 --- a/gui/src/pages/integrations/FileIntegrationPage.tsx +++ b/gui/src/pages/integrations/FileIntegrationPage.tsx @@ -3,7 +3,10 @@ import { useDataSurface } from "../../data-surface"; import { DataSurfaceSkeleton } from "../../components/data-surface"; import { useT, type TKey } from "../../i18n/shared"; import { Notice, Switch } from "../../ui"; +import ClientMark from "../../components/ClientMark"; +import { markFor } from "../../components/integration-marks"; import IntegrationStateBadge from "./IntegrationStateBadge"; +import ConsequenceDialog, { type ConsequenceCopy } from "./ConsequenceDialog"; import RestoreDialog from "./RestoreDialog"; import { RollbackHistory } from "./RollbackHistory"; import { describeRefusal } from "./refusal-copy"; @@ -18,6 +21,27 @@ import { export type { FileIntegrationClientId }; +/* + * Copy for the overwrite dialog, selected on WHICH conflict it is. + * + * Same operation, materially different thing being lost: `unowned-key` means a + * block we did not write is in the way, `foreign-edit` means the user's own + * change inside our block is what gets discarded. One sentence covering both + * would have to be vague about the only part that matters. + */ +function overwriteCopy(reason: string | undefined, path: string): ConsequenceCopy { + return { + titleKey: "integrations.dialog.overwrite.title", + changesKey: reason === "foreign-edit" + ? "integrations.dialog.overwrite.changesForeign" + : "integrations.dialog.overwrite.changesUnowned", + breakageKey: "integrations.dialog.overwrite.breakage", + undoKey: "integrations.dialog.overwrite.undo", + confirmKey: "integrations.dialog.overwrite.confirm", + vars: { path }, + }; +} + const SEMANTICS_KEY: Record = { opencode: "integrations.semantics.opencode", pi: "integrations.semantics.pi", @@ -61,6 +85,8 @@ export default function FileIntegrationPage({ const [pending, setPending] = useState(false); const [failure, setFailure] = useState(null); const [restoring, setRestoring] = useState(null); + /* Open only while the user is confirming an overwrite. */ + const [overwriting, setOverwriting] = useState(false); const fetchState = useCallback( (signal: AbortSignal) => loadIntegrationState(apiBase, client, signal), @@ -114,6 +140,27 @@ export default function FileIntegrationPage({ } }; + /* + * The one way past a conflict. Separate from `mutate` because it must not be + * reachable from the switch: the switch is locked in this state precisely + * because we cannot know what the user wants kept, and the whole point of the + * escape hatch is that they say so. + * + * Errors are NOT swallowed here -- they propagate so ConsequenceDialog can + * render them inside the dialog, where the user still has the cancel button. + */ + const overwrite = async () => { + if (!status) return; + setFailure(null); + try { + await toggleIntegration(apiBase, client, true, undefined, true); + refresh(); + } catch (error) { + setFailure(describeRefusal(t, error)); + throw error; + } + }; + /* * The switch means exactly what its label says. * @@ -143,6 +190,7 @@ export default function FileIntegrationPage({ return (
+

{t(TAB_LABEL_KEY[client])}

)} + {/* + Conflict used to be a dead end: the switch locks, the page explains why, + and the only way forward was to open the file and edit it by hand -- which + is the thing a user came to a dashboard to avoid. The switch stays locked + and this is the one way past it, behind a dialog that names the file and + says what is lost. + */} + {status.installed && status.state === "conflict" && ( + + )} +

{t(SEMANTICS_KEY[client])}

{status.configPath}

@@ -215,6 +281,16 @@ export default function FileIntegrationPage({ onRestored={refresh} /> )} + {overwriting && ( + setOverwriting(false)} + onConfirm={async () => { + await overwrite(); + setOverwriting(false); + }} + /> + )}
); } diff --git a/gui/src/pages/integrations/IntegrationsOverview.tsx b/gui/src/pages/integrations/IntegrationsOverview.tsx index 13661a23ee..4bff17cebf 100644 --- a/gui/src/pages/integrations/IntegrationsOverview.tsx +++ b/gui/src/pages/integrations/IntegrationsOverview.tsx @@ -4,6 +4,8 @@ import { DataSurfaceSkeleton } from "../../components/data-surface"; import { navigateHash } from "../../hash-routing"; import { useT } from "../../i18n/shared"; import { Notice, Switch } from "../../ui"; +import ClientMark from "../../components/ClientMark"; +import { markFor } from "../../components/integration-marks"; import IntegrationStateBadge from "./IntegrationStateBadge"; import ConsequenceDialog, { type ConsequenceCopy } from "./ConsequenceDialog"; import RestoreDialog from "./RestoreDialog"; @@ -75,12 +77,15 @@ function OverviewCard({ result, onOpen, onToggle, + onOverwrite, }: { row: OverviewRow; pending: boolean; result: { tone: "ok" | "err"; text: string } | null; onOpen: () => void; onToggle: (() => void) | null; + /** Present only for a conflicted file client; null everywhere else. */ + onOverwrite: (() => void) | null; }) { const t = useT(); const detail = row.detail ?? (row.detailKey ? t(row.detailKey, row.detailVars ?? undefined) : null); @@ -98,6 +103,12 @@ function OverviewCard({ return (
  • + {/* + Before the title, not inside it: the title IS the card's one control + and its accessible name, so a mark inside the button would be read as + part of the client name. The mark is decorative and aria-hidden. + */} +

    + {/* + Only in conflict, and only for a file client. The switch beside it stays + disabled -- this is not a second way to toggle, it is the way past a state + the toggle deliberately refuses to guess about. + */} + {onOverwrite && ( + + )}

  • ); @@ -157,6 +178,8 @@ export default function IntegrationsOverview({ const [restoring, setRestoring] = useState(null); const [cardResults, setCardResults] = useState>>({}); const [pendingToggle, setPendingToggle] = useState(null); + /* The conflicted row awaiting overwrite confirmation. */ + const [pendingOverwrite, setPendingOverwrite] = useState(null); const restoreFocusRef = useRef(null); useEffect(() => { @@ -452,6 +475,31 @@ export default function IntegrationsOverview({ setPendingToggle(row); }; + /* + * Replace a conflicted block, after the dialog. File clients only: the native + * surfaces have their own ownership model and no writer path that takes this + * flag, which is why `row.status` gates the button that opens the dialog. + * + * Errors propagate so the dialog can show them while the user still has cancel. + */ + const overwriteCard = async (row: OverviewRow) => { + if (!row.status) return; + setCardPending(row.id); + setCardResult(row.id, null); + try { + await toggleIntegration(apiBase, row.status.clientId, true, undefined, true); + refresh(); + } catch (error) { + setCardResult(row.id, { + tone: "err", + text: describeRefusal(t, error, undefined, row.togglePath ?? undefined), + }); + throw error; + } finally { + setCardPending(null); + } + }; + return (
    @@ -542,6 +590,9 @@ export default function IntegrationsOverview({ result={cardResults[row.id] ?? null} onOpen={() => navigateHash(row.hash)} onToggle={row.toggle ? () => requestToggle(row, !(row.toggleOn ?? row.applied)) : null} + onOverwrite={row.status !== null && row.status.state === "conflict" && row.installed + ? () => setPendingOverwrite(row) + : null} /> ))} @@ -601,6 +652,25 @@ export default function IntegrationsOverview({ }} /> )} + {pendingOverwrite && pendingOverwrite.status && ( + setPendingOverwrite(null)} + onConfirm={async () => { + await overwriteCard(pendingOverwrite); + setPendingOverwrite(null); + }} + /> + )}
    ); } diff --git a/gui/src/pages/integrations/RestoreDialog.tsx b/gui/src/pages/integrations/RestoreDialog.tsx index 19fe62fff5..08d676f9a7 100644 --- a/gui/src/pages/integrations/RestoreDialog.tsx +++ b/gui/src/pages/integrations/RestoreDialog.tsx @@ -32,6 +32,8 @@ export default function RestoreDialog({ const t = useT(); const dialogRef = useRef(null); const restoreFocusRef = useRef(null); + const restoreFallbackRef = useRef(null); + const restoredRef = useRef(false); const [drift, setDrift] = useState(false); const [pending, setPending] = useState(false); const [failure, setFailure] = useState(null); @@ -43,12 +45,40 @@ export default function RestoreDialog({ // focus-restore uses the same tagName check. const active = document.activeElement; restoreFocusRef.current = active?.tagName === "BUTTON" ? active as HTMLElement : null; + // The trigger may not survive the asynchronous history refresh. A row whose + // snapshot is consumed re-renders as an `expired` badge with no button + // (RollbackHistory.tsx:44-46), so remember the enclosing region too: it + // remains a focus target after the trigger disappears (#3059). + restoreFallbackRef.current = + (active?.closest?.("section, [role='region'], main") as HTMLElement | null) ?? null; if (dialog && !dialog.open) dialog.showModal(); return () => { if (dialog?.open) dialog.close(); - // The row's button is gone from the DOM in the collapsed case, so this is - // a best effort: focus returns only if the trigger survived the close. - restoreFocusRef.current?.focus?.(); + const fallback = restoreFallbackRef.current; + // A successful restore starts the history refresh before it closes the + // dialog. The trigger is therefore still connected during this cleanup, + // but can disappear when the asynchronous refresh consumes its snapshot. + // Put successful restores on the stable region now; cancellation keeps + // the usual trigger restoration below. + if (restoredRef.current && fallback?.isConnected) { + // A region is not focusable by default; -1 makes it programmatically + // focusable without adding it to the Tab order. + if (!fallback.hasAttribute("tabindex")) fallback.setAttribute("tabindex", "-1"); + fallback.focus?.(); + return; + } + // Prefer the trigger when the user cancelled; fall back to its region + // only if it was already removed. `isConnected` is the check that + // matters: a detached node accepts .focus() silently and focus stays on + // , which is the reported symptom. + const trigger = restoreFocusRef.current; + if (trigger?.isConnected) { + trigger.focus?.(); + return; + } + if (!fallback?.isConnected) return; + if (!fallback.hasAttribute("tabindex")) fallback.setAttribute("tabindex", "-1"); + fallback.focus?.(); }; }, []); @@ -63,6 +93,7 @@ export default function RestoreDialog({ setFailure(null); try { await restoreIntegration(apiBase, row.opId, drift); + restoredRef.current = true; onRestored(); onClose(); } catch (error) { diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index 42a3613cbb..9277840b61 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -54,7 +54,7 @@ export interface IntegrationStateListEnvelope { export interface IntegrationJournalRow { opId: string; clientId: IntegrationClientId; - kind: "apply" | "disable" | "refresh" | "restore"; + kind: "apply" | "disable" | "refresh" | "restore" | "overwrite"; at: string; configPath: string; snapshot: "none" | "stored" | "expired"; @@ -221,12 +221,17 @@ export async function toggleIntegration( client: FileIntegrationClientId, enabled: boolean, signal?: AbortSignal, + /** + * Opt in to replacing a conflicted block. Deliberately last and optional: no + * existing call site can acquire it, and a caller has to name it. + */ + overwriteConflict?: boolean, ) { return readResponse( await fetch(`${apiBase}/api/client-integrations/${encodeURIComponent(client)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enabled }), + body: JSON.stringify(overwriteConflict === true ? { enabled, overwriteConflict: true } : { enabled }), signal, }), ); diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts index b454e47bd9..bfe973b692 100644 --- a/gui/src/pages/integrations/overview-clients.ts +++ b/gui/src/pages/integrations/overview-clients.ts @@ -164,6 +164,12 @@ export const JOURNAL_KIND_KEY: Record = { disable: "integrations.kind.disable", refresh: "integrations.kind.refresh", restore: "integrations.kind.restore", + /* + * Distinct from `apply` on purpose. This row is the only signal that an + * operation replaced a block somebody else wrote, and it sits in the one list + * a user reads after a mistake. + */ + overwrite: "integrations.kind.overwrite", }; export function isAppliedState(state: VisualIntegrationState): boolean { diff --git a/gui/src/pages/logs-model-filter.ts b/gui/src/pages/logs-model-filter.ts new file mode 100644 index 0000000000..7f6af18f65 --- /dev/null +++ b/gui/src/pages/logs-model-filter.ts @@ -0,0 +1,45 @@ +/** + * Free-text matching over a log row's model and provider, kept out of Logs.tsx so that + * module exports components only (react-refresh/only-export-components). + * + * #3070: an operator running custom providers saw their OpenAI monthly window shrink + * and could not find which turns were responsible. `?model=` filtering exists on the + * CLI and the API (b68edc077) but never reached the dashboard, and the one filter the + * page did have — the intercepted-helpers toggle — keys on `shadowCallRewrittenFrom`, + * which a plain account-gated turn does not carry. So the rows that explain the bill + * were exactly the rows no control could isolate. + */ +export interface LogModelFields { + model?: string; + resolvedModel?: string; + provider?: string; + attempts?: Array<{ + provider?: string; + model?: string; + }>; +} + +/** + * Case-insensitive substring over the requested model, resolved target, provider, and + * every failover attempt target. + * + * `resolvedModel` is matched as well as `model` on purpose. They differ precisely when + * routing redirected the turn, which is the case this filter exists to expose — matching + * only the requested id would hide the redirect that caused the charge. + * + * Substring rather than exact: an operator types `terra`, not + * `gpt-5.6-terra-2026-08-01`. Empty or whitespace-only query matches everything, so the + * control is inert until it is used. + */ +export function logMatchesModelQuery(log: LogModelFields, query: string): boolean { + const needle = query.trim().toLowerCase(); + if (!needle) return true; + const attemptTargets = Array.isArray(log.attempts) + ? log.attempts.flatMap(attempt => ( + attempt && typeof attempt === "object" ? [attempt.provider, attempt.model] : [] + )) + : []; + return [log.model, log.resolvedModel, log.provider, ...attemptTargets].some( + value => typeof value === "string" && value.toLowerCase().includes(needle), + ); +} diff --git a/gui/src/provider-icons.ts b/gui/src/provider-icons.ts index 2bfb727c71..3192862648 100644 --- a/gui/src/provider-icons.ts +++ b/gui/src/provider-icons.ts @@ -28,6 +28,8 @@ const PROVIDER_ICON_ALIASES: Record = { kiro: "kiro-color.svg", "lm-studio": "lm-studio-color.svg", mistral: "mistral-color.svg", + minimax: "minimax.svg", + "minimax-cn": "minimax.svg", moonshot: "moonshot-color.svg", nvidia: "nvidia-color.svg", ollama: "ollama-color.svg", @@ -42,12 +44,49 @@ const PROVIDER_ICON_ALIASES: Record = { alibaba: "alibaba-color.svg", "alibaba-token-plan": "alibaba-color.svg", "alibaba-token-plan-intl": "alibaba-color.svg", + baseten: "baseten.svg", + bizrouter: "bizrouter.svg", + cerebras: "cerebras.svg", + deepinfra: "deepinfra.svg", + digitalocean: "digitalocean.svg", + featherless: "featherless.svg", + hyperbolic: "hyperbolic.svg", + kilo: "kilo.svg", + nanogpt: "nanogpt.svg", + nebius: "nebius.svg", + neuralwatt: "neuralwatt.svg", + nous: "nous.svg", + novita: "novita.svg", + orcarouter: "orcarouter.svg", + parallel: "parallel.svg", + sambanova: "sambanova.svg", + scaleway: "scaleway.svg", + siliconflow: "siliconflow.svg", + synthetic: "synthetic.svg", + together: "together.svg", + umans: "umans.svg", + venice: "venice.svg", + vultr: "vultr.svg", + litellm: "litellm.svg", + zenmux: "zenmux.svg", + /* + * Z.AI and Zhipu's BigModel are the same company on two brands. `zai` is the + * international GLM Coding Plan and the mark comes from z.ai; the two + * `zhipu-bigmodel*` ids are the mainland console, which publishes only a + * horizontal wordmark, so they borrow it rather than render a lockup squeezed + * into a 19px box. + */ + zai: "zai.svg", + "zhipu-bigmodel": "zai.svg", + "zhipu-bigmodel-coding": "zai.svg", "qwen-cloud": "qwen-portal-color.svg", "vercel-ai-gateway": "vercel-ai-gateway-color.svg", vllm: "vllm-color.svg", xai: "grok.svg", "mimo-free": "xiaomi-color.svg", + mimo: "xiaomi-color.svg", xiaomi: "xiaomi-color.svg", + "xiaomi-mimo": "xiaomi-color.svg", }; /** @@ -130,6 +169,108 @@ export function providerIconSrc(provider: string, _hints?: ProviderIconHints): s return icon ? `/provider-icons/${icon}` : undefined; } +/** + * Marks whose artwork is one neutral ink, so the ink has to come from the theme. + * + * Keyed by asset path, deliberately, and for the same reason `MASKED_MARKS` is on + * the client side: an asset reachable from two surfaces cannot be masked on one + * and drawn plain on the other without looking like a bug. + * + * Membership is a measurement, not a guess. Each of these renders a single fill + * that is either near-black or near-white, which means it disappears against one + * of the two tile surfaces (`--raised` resolves to #f4f4f4 light, #303030 dark). + * `zenmux` is #000, `synthetic` is #ffffff, `neuralwatt` is #081a17. + * + * A mark that carries real colour never belongs here. Masking discards every ink + * in the file and repaints the silhouette, so applying it to a palette is + * destructive in a way that still looks deliberate on screen. + */ +const MASKED_PROVIDER_ICONS: ReadonlySet = new Set([ + "cerebras.svg", + "deepinfra.svg", + "neuralwatt.svg", + "nous.svg", + "novita.svg", + "siliconflow.svg", + "synthetic.svg", + "zenmux.svg", + + /* + * Marks that predate this pass and were invisible on one tile the whole time. + * + * `opencode.svg` (#211e1e) and `kimi-color.svg` (#1a1a1a) are the same two files + * the client surface already masks -- the Integrations page fixed them and the + * provider rail kept drawing them plain, because the two surfaces had no shared + * decision. `grok.svg` is the same story one PR later. `ollama-color.svg` + * (#141414) and `vercel-ai-gateway-color.svg` (#000000) were never caught by + * either pass; the luminance guard found all five at once. + */ + "grok.svg", + "kimi-color.svg", + "ollama-color.svg", + "opencode.svg", + "vercel-ai-gateway-color.svg", +]); + +/** + * Marks that carry colour but whose dominant ink is near-black. + * + * These cannot be masked -- that would flatten a real palette -- and they cannot + * be left alone either: measured against the dark tile they land between 1.04:1 + * and 1.59:1, which is invisible. `zai` is 1.04, `bizrouter` 1.08, `baseten` 1.59. + * + * The fix belongs to the tile rather than the file. A vendor's artwork is drawn + * unchanged on a constant light plate, which is what a favicon assumes anyway: + * every one of these was designed to sit on a page, not on a #303030 chip. + * + * `digitalocean.svg` looks like it belongs here and must not: its file carries + * its own `@media (prefers-color-scheme: dark)` rule that repaints the glyph + * #F4F5F5. A constant plate defeats that -- the file goes light-on-light and + * measures 1.01:1 -- so the one mark that solves this problem itself is left + * alone to do it. Check for an embedded media query before plating anything. + */ +const PLATED_PROVIDER_ICONS: ReadonlySet = new Set([ + "baseten.svg", + "kilo.svg", + "sambanova.svg", + "venice.svg", + "zai.svg", +]); + +/** + * The same problem pointing the other way: colour artwork whose dominant ink is + * near-WHITE, drawn for a dark header and invisible on the light tile. + * + * Measured dominant luminance: `parallel` 1.00, `bizrouter` 0.98, `nebius` 0.87, + * `featherless` 0.87, `umans` 0.84, `hyperbolic` 0.84 -- all of which land near + * 1.0:1 against #f4f4f4. A light plate would make them worse, so they get a dark + * one, which is the surface their own designers assumed. + * + * Two plates rather than one theme-following plate on purpose: a plate that + * followed the theme would put light-ink art back on a light tile in light mode, + * which is the exact failure being fixed. + */ +const DARK_PLATED_PROVIDER_ICONS: ReadonlySet = new Set([ + "bizrouter.svg", + "featherless.svg", + "hyperbolic.svg", + "nebius.svg", + "parallel.svg", + "umans.svg", +]); + +/** How a provider mark must be painted so it survives both themes. */ +export type ProviderIconPaint = "mask" | "plate" | "dark-plate" | "image"; + +export function providerIconPaint(src: string | undefined): ProviderIconPaint { + if (!src) return "image"; + const file = src.split("/").pop() ?? ""; + if (MASKED_PROVIDER_ICONS.has(file)) return "mask"; + if (PLATED_PROVIDER_ICONS.has(file)) return "plate"; + if (DARK_PLATED_PROVIDER_ICONS.has(file)) return "dark-plate"; + return "image"; +} + /** Display label with proper brand casing when known; otherwise original name. */ export function formatProviderDisplayName(provider: string, t: TFn): string { const key = provider.toLowerCase(); diff --git a/gui/src/styles-integrations.css b/gui/src/styles-integrations.css index e75ad10e66..f1b59d9751 100644 --- a/gui/src/styles-integrations.css +++ b/gui/src/styles-integrations.css @@ -47,6 +47,56 @@ .integration-client-head { display: flex; align-items: center; gap: 10px; } .integration-client-head h3 { margin: 0; } + +/* One brand mark, four surfaces. Sized from a custom property so the same three + rules serve the 14px tab strip, the 20px overview card and the 24px client + page header without a variant class per size. */ +.client-mark { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: var(--client-mark-size, 20px); + height: var(--client-mark-size, 20px); +} + +.client-mark--img img { + width: 100%; + height: 100%; + border-radius: 2px; +} + +/* A single-ink mark is painted with the surrounding text color instead of its + own, so it follows the theme. Without this a white-on-transparent logo is + invisible in light mode and a near-black one invisible in dark -- which is + not hypothetical, it shipped that way for prime, opencode and kimi. */ +.client-mark--mask { + background: var(--text); + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; + -webkit-mask-size: contain; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; +} + +/* Placeholder for a client whose vendor publishes no usable asset. Honest about + being one: muted, and the slot a real mark drops into later. */ +.client-mark--monogram { + font-size: var(--text-label); + font-weight: var(--weight-semibold); + color: var(--muted); + line-height: 1; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--raised); +} + +/* The card head is space-between, so without this the badge would land between + the mark and the title. The title takes the free space instead. */ +.integration-card-head h4 { flex: 1 1 auto; min-width: 0; } + +.page-tab > .client-mark { margin-right: 6px; vertical-align: -2px; } .integration-client-head .switch { margin-left: auto; } /* A config path is the thing a user copies when a refusal tells them to diff --git a/gui/src/styles-models-workspace.css b/gui/src/styles-models-workspace.css index e9ea063d01..ab00d41402 100644 --- a/gui/src/styles-models-workspace.css +++ b/gui/src/styles-models-workspace.css @@ -309,6 +309,69 @@ max-width: 100%; } +/* ── Cap cluster (040) ── + The provider-wide context window is one setting wearing three sibling controls. + This groups them visually: the gap is tighter than the row own var(--space-2), + which is what makes the trio read as one control instead of three. + + Grouping only, never a functional merge. The per-model overrides button opens a + different SCOPE than the switch and select, which are the provider-wide default, + so all three keep their own tab stop. + + flex-wrap is load-bearing, not tidiness. As one flex item of a wrapping actions + row the cluster can be squeezed, and its children do not shrink: the labeled + switch is flex: 0 0 auto, .btn is white-space: nowrap, the Select is inline-block. + A nowrap cluster would overflow, and .models-provider-card is overflow: hidden, so + the clip would be swallowed silently while a page-level scrollWidth === clientWidth + assertion still passed. Wrapping prevents that; a min-width floor would be the + child-floor design 011 killed. */ +.models-cap-cluster { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-1); +} + +/* ── Alias-edit emphasis (050) ── + The user asked for a hover affordance. Two independent audits rejected the first + reading of that — hover that GATES, controls hidden until the pointer arrives — + because it deletes the pencil, the alias-defaults switch and custom-add from the + default visual inventory and worsens the exact opacity 020 exists to fix. What is + left is emphasis on the ONE genuinely secondary control: a provider alias is set + once and then left alone. + + Every state below is written at the SAME 0,3,0 specificity as the resting rule. + The audit caught the earlier draft writing the overrides at 0,2,0, which meant a + (hover: none) device kept the dim forever and :focus-visible was a no-op — the two + failures those rules exist to prevent. + + Rest is 0.75, not the drafted 0.65: .btn:disabled is opacity 0.55, so a deeper dim + reads as maybe-disabled. Contrast is not the constraint — 0.65 already cleared + 1.4.11's 3:1 in both themes — legibility of INTENT is. */ +.models-provider-actions .btn-ghost.models-alias-edit { + opacity: 0.75; + transition: opacity var(--motion-fast); +} +.models-provider-head:hover .models-provider-actions .btn-ghost.models-alias-edit, +.models-provider-head:focus-within .models-provider-actions .btn-ghost.models-alias-edit, +.models-provider-actions .btn-ghost.models-alias-edit:focus-visible { + opacity: 1; +} +/* A pointer that cannot hover never triggers the emphasis rule, so the resting dim + would be permanent. Full contrast is the resting state there. */ +@media (hover: none) { + .models-provider-actions .btn-ghost.models-alias-edit { + opacity: 1; + } +} +/* Reduced motion removes the ANIMATION, never the emphasis: the contrast change is + information, the fade is decoration. */ +@media (prefers-reduced-motion: reduce) { + .models-provider-actions .btn-ghost.models-alias-edit { + transition: none; + } +} + /* ── Spacing rhythm (tight within clusters, loose between sections) ── Keep spacing in CSS classes — inline px floods Impeccable’s page scan. */ diff --git a/gui/src/styles.css b/gui/src/styles.css index 4bd3568f70..df63631504 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -1178,6 +1178,45 @@ select.input { appearance: none; } .switch.on { background: var(--toggle-on-bg); border-color: var(--toggle-on-bg); } .switch.mixed { background: var(--amber-soft); border-color: var(--amber); } .switch:disabled { opacity: 0.6; cursor: default; } +/* A labeled switch is ONE atomic flex item, which is what the bare 34px button was + before the label existed. Without flex: 0 0 auto the knob and its text become + separate shrinkable items in .models-provider-actions and the text collapses to a + min-content column. + + The label lives INSIDE the button so the visible words are clickable, which means + the button can no longer be the 34x20 pill: that geometry moves to the ::before + track and the button becomes a transparent, borderless row wrapping track + text. */ +.switch.switch-labeled { + width: auto; + height: auto; + border: 0; + background: none; + border-radius: var(--radius-sm); + padding: 0; + gap: var(--space-1); + flex: 0 0 auto; + line-height: normal; +} +.switch.switch-labeled::before { + content: ""; + width: 34px; + height: 20px; + border-radius: var(--radius-pill); + border: 1px solid var(--border); + background: var(--raised); + flex-shrink: 0; + box-sizing: border-box; + transition: background var(--motion-normal), border-color var(--motion-normal); +} +.switch.switch-labeled.on::before { background: var(--toggle-on-bg); border-color: var(--toggle-on-bg); } +.switch.switch-labeled.mixed::before { background: var(--amber-soft); border-color: var(--amber); } +/* The knob is absolutely positioned over the ::before track rather than laid out in + the button's flow, because the flow now also carries the label text. */ +.switch.switch-labeled { position: relative; } +.switch.switch-labeled .knob { position: absolute; left: 3px; } +.switch.switch-labeled.on .knob { transform: translateX(14px); } +.switch.switch-labeled.mixed .knob { transform: translateX(7px); } +.switch-labeled-text { white-space: nowrap; } .switch .knob { width: 14px; height: 14px; @@ -2114,6 +2153,35 @@ table.logs-table { .prov-title { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; flex-wrap: wrap; min-width: 0; } .provider-icon { width: 31px; height: 31px; border-radius: var(--radius-xs); flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; background: var(--raised); border: 1px solid var(--border-soft); color: var(--text); } .provider-icon img { width: 19px; height: 19px; object-fit: contain; display: block; } + +/* A single-ink mark painted with the surrounding text colour, so it follows the + theme instead of vanishing into one of the two tiles. The tile already sets + `color: var(--text)`, so this needs no property of its own. */ +.provider-icon-mask { + width: 19px; + height: 19px; + display: block; + background: currentColor; + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; + -webkit-mask-size: contain; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; +} +.provider-icon-sm .provider-icon-mask { width: 15px; height: 15px; } + +/* Artwork that carries real colour but is dominantly near-black: measured between + 1.04:1 and 1.59:1 against the dark tile, which is invisible. Masking would + flatten the palette, so the vendor's own colours are kept and given the light + plate every favicon already assumes it will sit on. */ +.provider-icon--plate { background: #f4f4f4; border-color: rgba(0, 0, 0, 0.12); } + +/* The mirror case: artwork drawn in near-white for a dark header, which is + invisible on the light tile at roughly 1.0:1. It gets the dark surface its own + designers assumed. A theme-following plate would not work -- it would put + light-ink art back on a light tile in light mode. */ +.provider-icon--plate-dark { background: #1c1c1c; border-color: rgba(255, 255, 255, 0.14); } .prov-meta { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; min-width: 0; } .prov-meta > span { min-width: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .provider-quota { padding-left: 58px; } diff --git a/gui/src/styles/provider-catalog.css b/gui/src/styles/provider-catalog.css index c6c5618a41..6d87c59c5b 100644 --- a/gui/src/styles/provider-catalog.css +++ b/gui/src/styles/provider-catalog.css @@ -60,6 +60,16 @@ text-overflow: ellipsis; } +/* Both row kinds are space-between, so a mark placed before the title would push + the badges into the middle of the row. The text block takes the free space + instead, and `min-width: 0` lets a long provider label ellipsize rather than + widen the row past the modal. + + Scoped to the catalog on purpose: `.list-row` is shared, and the other lists + that use it have no mark to lay out. */ +.provider-catalog-rows .provider-icon { flex: 0 0 auto; } +.provider-catalog-rows .provider-icon + div { flex: 1 1 auto; min-width: 0; } + /* An account row is normally one horizontal line (`.list-row` is a row flex). While a login for that row is in flight it grows a second line for the authorization URL, device code, and paste field, so the head keeps the diff --git a/gui/src/ui.tsx b/gui/src/ui.tsx index b5db510953..96ef56f188 100644 --- a/gui/src/ui.tsx +++ b/gui/src/ui.tsx @@ -5,11 +5,37 @@ import { IconCheck, IconAlert } from "./icons"; import { IconChevron } from "./icons"; import { computeSelectMenuStyle } from "./select-position"; -export function Switch({ on, mixed = false, onClick, disabled, label }: { on: boolean; mixed?: boolean; onClick: () => void; disabled?: boolean; label?: string }) { +/** + * `label` is the accessible name. It is NOT rendered by default, which is correct + * for a switch inside an already-labeled row and was also a defect: the two + * provider-header switches passed a label and rendered a bare knob, so a sighted + * user could not tell what they toggled (devlog/_plan/260830_models_provider_header/ + * 020_control_affordances.md). + * + * `showLabel` renders the label as visible text INSIDE the button, so the words + * both name the control and toggle it. An earlier revision put that text in an + * `aria-hidden` sibling span; the audit caught that clicking the visible label did + * nothing, because the hit target stayed the 34x20 knob. Text inside the button is + * the element's own content, so it supplies the accessible name on its own and + * `aria-label` must be dropped — otherwise `aria-label` would override the visible + * words and break Label-in-Name. + * + * `title` stays strictly opt-in. Defaulting it to `label` was rejected for the same + * reason: HTML-AAM maps `title` to the accessible description when + * `aria-describedby` is absent, so every untouched bare `Switch` would have been + * announced twice with a description that repeats its own name. + */ +export function Switch({ on, mixed = false, onClick, disabled, label, showLabel = false, title }: { on: boolean; mixed?: boolean; onClick: () => void; disabled?: boolean; label?: string; showLabel?: boolean; title?: string }) { + const labeled = showLabel && !!label; return ( - ); } diff --git a/gui/tests/client-config-panel.test.tsx b/gui/tests/client-config-panel.test.tsx index 6915b6c680..8acc44e9ee 100644 --- a/gui/tests/client-config-panel.test.tsx +++ b/gui/tests/client-config-panel.test.tsx @@ -274,10 +274,12 @@ test("dialog closes on Escape and returns focus to its trigger", async () => { }); test("each client row shows its own brand mark, never a borrowed one", async () => { - // Nine clients ship a real first-party asset; gajae and hermes have none that - // qualifies and fall back to a monogram tile. The rule this guards is that no - // client ever borrows another product's logo — not that every client has an - // asset. Uniqueness is the teeth: a borrowed logo would show up twice. + // Every client now ships a real first-party asset, two of them traced from the + // vendor's own raster. The rule this guards is that no client ever borrows + // another product's logo; uniqueness is the teeth, because a borrowed logo + // would show up twice. Completeness of the map is guarded separately in + // client-marks-assets.test.ts, and the monogram branch stays for the next + // client that arrives without an asset. // // A mark reaches the DOM one of two ways. A plated brand SVG is an ; a // single-ink silhouette is a masked span so the theme supplies its color. Both @@ -287,7 +289,7 @@ test("each client row shows its own brand mark, never a borrowed one", async () const { root, container } = await mountPanel(); // OpenCode is monochrome, so its mark is masked rather than an . - const opencodeMark = row(container, "OpenCode").querySelector(".awi-clientconfig-mark-mask"); + const opencodeMark = row(container, "OpenCode").querySelector(".client-mark--mask"); expect(opencodeMark).not.toBeNull(); expect(opencodeMark!.style.maskImage || opencodeMark!.style.webkitMaskImage) .toContain("/provider-icons/opencode.svg"); @@ -297,7 +299,7 @@ test("each client row shows its own brand mark, never a borrowed one", async () // rendering paths. const imgSources = [...container.querySelectorAll("img")] .map(img => img.getAttribute("src")); - const maskSources = [...container.querySelectorAll(".awi-clientconfig-mark-mask")] + const maskSources = [...container.querySelectorAll(".client-mark--mask")] .map(node => { const raw = node.style.maskImage || node.style.webkitMaskImage; return raw.replace(/^url\(["']?/, "").replace(/["']?\)$/, ""); @@ -312,7 +314,7 @@ test("each client row shows its own brand mark, never a borrowed one", async () } // A masked mark is a bare span, so it must not announce itself either; the // slot around it already carries aria-hidden. - for (const node of container.querySelectorAll(".awi-clientconfig-mark-mask")) { + for (const node of container.querySelectorAll(".client-mark--mask")) { expect(node.textContent).toBe(""); } diff --git a/gui/tests/client-marks-assets.test.ts b/gui/tests/client-marks-assets.test.ts index 59388cc21b..d7367df59d 100644 --- a/gui/tests/client-marks-assets.test.ts +++ b/gui/tests/client-marks-assets.test.ts @@ -1,10 +1,16 @@ import { expect, test } from "bun:test"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import { CLIENT_MARKS } from "../src/components/apikeys-workspace/client-config-clients"; +import { CLIENT_MARKS, CLIENTS, MONOCHROME_CLIENT_MARKS } from "../src/components/apikeys-workspace/client-config-clients"; const PUBLIC_DIR = join(import.meta.dir, "..", "public"); +/** Every literal color a mark paints with, lowercased; `currentColor` is not one. */ +function inksOf(body: string): Set { + const matches = body.match(/(?:fill|stop-color)\s*[:=]\s*"?#[0-9a-fA-F]{3,8}/g) ?? []; + return new Set(matches.map(raw => raw.split(/[:=]/).pop()!.replace(/"/g, "").trim().toLowerCase())); +} + /* * A mark that 404s renders as a broken image, which is worse than the monogram * it replaced. CLIENT_MARKS is a plain string map, so nothing else checks that @@ -31,3 +37,134 @@ test("every client mark is drawn geometry, not text or an embedded raster", () = .toMatch(/<(path|circle|rect|polygon|ellipse|line|polyline)[\s>]/); } }); + +/* + * Membership in MONOCHROME_CLIENT_MARKS decides whether a mark draws as an + * or as a themed mask, and the wrong answer is invisible in code review. Masking + * a multi-color mark silently flattens its palette into one ink; that direction + * is what this guards, and it is the destructive one, because the mark still + * renders and still looks deliberate. + * + * The other direction is not symmetric, so it is not asserted here: a single-ink + * mark is only a candidate for masking. Whether it SHOULD be masked depends on + * whether that ink is neutral or is the brand itself, which no property of the + * file can answer -- see the dsh case below. + */ +test("no multi-color mark is masked, which would flatten its palette", () => { + const flattened: string[] = []; + for (const [clientId, src] of Object.entries(CLIENT_MARKS)) { + if (!MONOCHROME_CLIENT_MARKS.has(clientId as never)) continue; + const body = readFileSync(join(PUBLIC_DIR, src!.replace(/^\//, "")), "utf8"); + const inks = inksOf(body); + const gradient = /<(linearGradient|radialGradient)[\s>]/.test(body); + if (gradient || inks.size > 1) { + flattened.push(`${clientId}: ${inks.size} ink(s)${gradient ? " + gradient" : ""}`); + } + } + expect(flattened).toEqual([]); +}); + +/* + * The failure that shipped: prime is white-on-transparent and was invisible in + * light mode, opencode (#211E1E) and kimi (#1A1A1A) invisible in dark. Each has + * exactly one ink and no way to follow the theme, so each must be masked. Pinned + * by name because the general rule cannot express it -- dsh is also single-ink + * and must NOT be masked. + */ +test("the marks that were invisible against a theme are masked", () => { + for (const clientId of ["prime", "opencode", "kimi", "aside"] as const) { + expect(MONOCHROME_CLIENT_MARKS.has(clientId), `${clientId} must be masked`).toBe(true); + } +}); + +/* + * A masked mark is painted with the row's text color, so whatever ink the file + * carries is discarded. For `dsh` that would be wrong in the other direction -- + * it is a single ink, but that ink is DeepSeek blue and part of the brand, so it + * stays an . Recorded here because "single ink" alone would have masked it. + */ +test("a single-ink mark whose ink is a brand color is not masked", () => { + expect(MONOCHROME_CLIENT_MARKS.has("dsh")).toBe(false); + const body = readFileSync(join(PUBLIC_DIR, CLIENT_MARKS.dsh!.replace(/^\//, "")), "utf8"); + expect([...inksOf(body)]).toEqual(["#4d6bfe"]); +}); + +/* + * Every mark here is somebody else's trademark, used on the strength of being + * that vendor's own published asset. The README is where that claim lives -- the + * source it came from and when -- and it is the only record of it. A mark added + * without an entry is one nobody can later confirm the provenance of, which is + * the state this directory is specifically trying not to be in. + * + * Prose is checked here rather than a manifest because prose is what the README + * is; the assertion is only that each committed mark is named somewhere in it. + */ +test("every mark's provenance is recorded in the README", () => { + const readme = readFileSync(join(PUBLIC_DIR, "provider-icons", "README.md"), "utf8"); + const undocumented = Object.values(CLIENT_MARKS) + .map(src => src!.split("/").pop()!) + .filter(file => !readme.includes(file)); + expect(undocumented).toEqual([]); +}); + +/* + * Every export client now has a mark, and that is a property worth pinning + * rather than a coincidence of the current map. The monogram branch in + * ClientConfigRow stays -- it is the correct fallback and a future client will + * arrive without an asset -- but a client SILENTLY losing its mark, because a + * key was renamed or an entry dropped in a merge, looks identical to a client + * that never had one. This is the difference. + */ +test("every export client has a mark", () => { + const monogram = CLIENTS.filter(clientId => CLIENT_MARKS[clientId] === undefined); + expect(monogram).toEqual([]); +}); + +/* + * Provenance is documented; how a mark is PAINTED was not, and that is the fact + * that actually broke. `grok.svg` sat unmasked at roughly 1.9:1 on the dark card + * because the reasoning for leaving it an image lived only in a code comment that + * was wrong about what masking does. + * + * This pins the README section rather than the decision itself -- the decision is + * enforced in integration-marks.test.ts. What it prevents is the next person + * re-litigating a case that was already measured, which is how grok stayed broken + * through two passes over the same file. + */ +test("the README explains how marks are painted, not only where they came from", () => { + const readme = readFileSync(join(PUBLIC_DIR, "provider-icons", "README.md"), "utf8"); + expect(readme).toContain("## How a mark is painted"); + + /* + * Every masked mark has to be argued for by name. A mark added to the mask set + * without a line here is a decision nobody can review, and "it looked + * monochrome" is precisely the reasoning that needs to be written down. + */ + const section = readme.slice(readme.indexOf("## How a mark is painted")); + const unexplained = [...MONOCHROME_CLIENT_MARKS] + .map(clientId => CLIENT_MARKS[clientId]!.split("/").pop()!) + .filter(file => !section.includes(file)); + expect(unexplained).toEqual([]); + + // The two cases a reader is most likely to get backwards. + expect(section).toContain("grok.svg"); + expect(section).toContain("openai.svg"); +}); + +/* + * Two of the newest marks are traced from raster sources, which is a different + * provenance claim from "fetched" and the one a reader is most likely to doubt. + * The README has to carry the reproduction detail: the source file, and the + * tracer parameters. Named files rather than a general rule because only these + * two are traced -- a fetched mark has nothing to reproduce. + */ +test("a traced mark records the source it was traced from", () => { + const readme = readFileSync(join(PUBLIC_DIR, "provider-icons", "README.md"), "utf8"); + for (const [file, source] of [ + ["hermes-agent.svg", "apps/desktop/assets/icon.png"], + ["gajae-code.svg", "assets/character.png"], + ] as const) { + expect(readme, `${file} should name its raster source`).toContain(source); + } + expect(readme, "a traced mark should record its tracer invocation").toContain("potrace"); +}); diff --git a/gui/tests/integration-marks.test.ts b/gui/tests/integration-marks.test.ts new file mode 100644 index 0000000000..b964bc4ce1 --- /dev/null +++ b/gui/tests/integration-marks.test.ts @@ -0,0 +1,195 @@ +import { expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { INTEGRATION_MARKS, MASKED_MARKS } from "../src/components/integration-marks"; +import { CLIENT_MARKS, MONOCHROME_CLIENT_MARKS } from "../src/components/apikeys-workspace/client-config-clients"; +import { TABS } from "../src/pages/integrations/integration-tabs"; + +const PUBLIC_DIR = join(import.meta.dir, "..", "public"); + +function bodyOf(src: string): string { + return readFileSync(join(PUBLIC_DIR, src.replace(/^\//, "")), "utf8"); +} + +function inksOf(body: string): Set { + const matches = body.match(/(?:fill|stop-color)\s*[:=]\s*"?#[0-9a-fA-F]{3,8}/g) ?? []; + return new Set(matches.map(raw => raw.split(/[:=]/).pop()!.replace(/"/g, "").trim().toLowerCase())); +} + +/* + * The page renders a mark for every row it draws, so a row whose id is missing + * from the map renders nothing where a logo should be. The map is a Record over + * OverviewClientId, so the compiler catches an omission -- but only for ids that + * type already knows. This asserts the values are real. + */ +test("every Integrations row has a mark that resolves to a committed file", () => { + const broken = Object.entries(INTEGRATION_MARKS) + .filter(([, src]) => src !== null && !existsSync(join(PUBLIC_DIR, src.replace(/^\//, "")))); + expect(broken).toEqual([]); +}); + +/* + * A null value is a legitimate answer -- it renders a monogram -- but none of the + * current rows should be taking it. If one does, either an asset was dropped or a + * client was added without a mark decision, and both look like a rendering bug + * from the outside. + */ +test("no Integrations row falls back to a monogram today", () => { + const monogram = Object.entries(INTEGRATION_MARKS) + .filter(([, src]) => src === null) + .map(([id]) => id); + expect(monogram).toEqual([]); +}); + +/* + * Masking paints the artwork with the theme's text color, discarding whatever + * the file carries. Doing that to a multi-color mark flattens a brand palette + * into one ink, and the result still renders and still looks deliberate, which is + * why this needs a test rather than review attention. + */ +test("no multi-color asset is masked", () => { + const flattened: string[] = []; + for (const src of MASKED_MARKS) { + const body = bodyOf(src); + const gradient = /<(linearGradient|radialGradient)[\s>]/.test(body); + const inks = inksOf(body); + if (gradient || inks.size > 1) flattened.push(`${src}: ${inks.size} ink(s)${gradient ? " + gradient" : ""}`); + } + expect(flattened).toEqual([]); +}); + +/* + * The inverse rule, and the one that cannot be derived from the file: a mark may + * be a single ink and still not be a masking candidate, because that ink is the + * brand. openai.svg is #10A37F and deepseek-harness.svg is #4d6bfe; masking + * either repaints a trademark in the theme's text color. Pinned with their inks + * so a vendor changing its asset shows up here rather than silently satisfying + * the assertion. + */ +test("a single-ink asset whose ink is a brand color is not masked", () => { + for (const [src, ink] of [ + ["/provider-icons/openai.svg", "#10a37f"], + ["/provider-icons/deepseek-harness.svg", "#4d6bfe"], + ] as const) { + expect(MASKED_MARKS.has(src), `${src} must not be masked`).toBe(false); + expect([...inksOf(bodyOf(src))], `${src} ink changed upstream`).toEqual([ink]); + } +}); + +/* + * MASKED_MARKS is derived from MONOCHROME_CLIENT_MARKS rather than restated, and + * this is what makes that derivation observable: the two must describe the same + * assets. A second hand-maintained list would drift, and the drift would be a +* mark masked on one surface and not on another. +*/ +test("the masked set is the monochrome client marks plus the native exceptions", () => { + const fromClients = [...MONOCHROME_CLIENT_MARKS].map(id => CLIENT_MARKS[id]).filter(Boolean) as string[]; + /* + * The only additions allowed are marks belonging to rows with no export client, + * since MONOCHROME_CLIENT_MARKS is keyed by ExportClientId and structurally + * cannot hold them. Named here so a mask added for any OTHER reason -- which is + * how the two lists would start drifting -- fails. + */ + const nativeExceptions = ["/provider-icons/grok.svg"]; + expect([...MASKED_MARKS].sort()).toEqual([...fromClients, ...nativeExceptions].sort()); + + // Every export client's masking decision still comes from the derived set only. + const clientMarks = new Set(Object.values(CLIENT_MARKS).filter(Boolean) as string[]); + const maskedClientMarks = [...MASKED_MARKS].filter(src => clientMarks.has(src)).sort(); + expect(maskedClientMarks).toEqual([...fromClients].sort()); +}); + +/* + * The tab strip draws a mark per tab, and two tabs have no client behind them: + * overview is the page and keys is a credential surface. Every OTHER tab must be + * in the map, or its tab renders bare while its neighbours carry logos. + */ +test("every client tab has a mark", () => { + const bare = TABS + .filter(tab => tab.id !== "overview" && tab.id !== "keys") + .filter(tab => !(tab.id in INTEGRATION_MARKS)); + expect(bare.map(tab => tab.id)).toEqual([]); +}); + +/** + * Relative luminance, so "would this vanish?" is a measurement rather than a + * judgement about a hex string. + */ +function luminance(hex: string): number { + const raw = hex.replace("#", ""); + const full = raw.length === 3 ? [...raw].map(c => c + c).join("") : raw.slice(0, 6); + const channel = (pair: string): number => { + const v = parseInt(pair, 16) / 255; + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * channel(full.slice(0, 2)) + + 0.7152 * channel(full.slice(2, 4)) + + 0.0722 * channel(full.slice(4, 6)); +} + +/* + * The rule the other direction, and the one that actually shipped broken. + * + * `no multi-color asset is masked` stops a brand palette being flattened. Nothing + * stopped the opposite: a mark that is one near-black ink drawn as an , which + * is invisible against a dark surface. That is not hypothetical -- prime, opencode + * and kimi each shipped that way, and the fix was to mask them. Removing one from + * MONOCHROME_CLIENT_MARKS today reintroduces it silently, because an invisible + * still renders, still has layout, and every existing assertion still holds. + * + * A near-neutral ink is one whose channels are close together: #0d1117 qualifies, + * OpenAI's #10a37f does not, which is what keeps this from arguing with the + * brand-color test above. + */ +test("a single-ink mark that would vanish against one theme is masked", () => { + const vanishing: string[] = []; + for (const src of new Set(Object.values(INTEGRATION_MARKS))) { + if (src === null) continue; + const body = bodyOf(src); + if (/<(linearGradient|radialGradient)[\s>]/.test(body)) continue; + const inks = [...inksOf(body)]; + if (inks.length !== 1) continue; + const hex = inks[0]!.replace("#", ""); + const full = hex.length === 3 ? [...hex].map(c => c + c).join("") : hex.slice(0, 6); + const channels = [full.slice(0, 2), full.slice(2, 4), full.slice(4, 6)].map(p => parseInt(p, 16)); + const neutral = Math.max(...channels) - Math.min(...channels) <= 24; + if (!neutral) continue; + // Only the extremes vanish. A mid grey is legible on both surfaces. + const l = luminance(inks[0]!); + if (l > 0.12 && l < 0.75) continue; + if (!MASKED_MARKS.has(src)) vanishing.push(`${src}: ${inks[0]} would be invisible in one theme`); + } + expect(vanishing).toEqual([]); +}); + +/* + * The three marks added last, pinned by how they are PAINTED rather than by + * existing. Rendered measurement at 1440px: hermes flips ink with the theme + * (17.67:1 on the light card, 11.17:1 on the dark one), while gajae and minimax + * keep their own colors in both. This is the cheap version of that check. + */ +test("the three newest marks are painted the way their artwork requires", () => { + // A traced single-ink silhouette: invisible as an image on a dark surface. + expect(MASKED_MARKS.has("/provider-icons/hermes-agent.svg")).toBe(true); + // Multi-color artwork: masking would flatten a mascot and a gradient wave. + expect(MASKED_MARKS.has("/provider-icons/gajae-code.svg")).toBe(false); + expect(MASKED_MARKS.has("/provider-icons/minimax.svg")).toBe(false); + expect(inksOf(bodyOf("/provider-icons/gajae-code.svg")).size).toBeGreaterThan(1); + expect(/]/.test(bodyOf("/provider-icons/minimax.svg"))).toBe(true); +}); + +/* + * The stylesheet rule the mobile dialog depends on. + * + * A config path is one long unbroken token and the dialog is 370px wide at a 390px + * viewport, so without an in-word break opportunity the path overflows and the one + * fact the user needs -- WHICH file is about to change -- goes off screen. This is + * a CSS declaration with no type or render coverage in a DOM-less suite, so it is + * asserted as text. + */ +test("the consequence dialog lets a long path break mid-token", () => { + const css = readFileSync(join(import.meta.dir, "..", "src", "styles-integrations.css"), "utf8"); + const rule = css.match(/\.integration-consequence-body code \{[^}]*\}/); + expect(rule).not.toBeNull(); + expect(rule![0]).toMatch(/overflow-wrap:\s*anywhere/); +}); diff --git a/gui/tests/integrations-rollback-history.test.tsx b/gui/tests/integrations-rollback-history.test.tsx index 5cdce4fee9..57446d2044 100644 --- a/gui/tests/integrations-rollback-history.test.tsx +++ b/gui/tests/integrations-rollback-history.test.tsx @@ -93,6 +93,17 @@ async function mount( }); } +/** Re-render the same tree with a new journal, the way a refresh does. */ +async function rerender(journal: IntegrationJournalRow[]) { + await act(async () => { + root!.render( + + {}} /> + , + ); + }); +} + function visibleRows(): HTMLElement[] { return Array.from(container.querySelectorAll(".integration-history-row")) as unknown as HTMLElement[]; } @@ -175,6 +186,31 @@ test("an expired snapshot offers no control anywhere in the list", async () => { expect(expired!.querySelector("button")).toBeNull(); }); +test("restoring from inside the fold passes that row, not the newest one", async () => { + /* + * The newest row's Undo was covered; a folded row's control was not, and the + * two are wired separately -- the fold maps over a sliced copy. Passing the + * wrong row here is the most destructive defect this component could have: the + * user asks to roll back to a specific point in their history and silently + * gets a different one, with a confirmation dialog that names the operation + * they chose. Nothing downstream can catch it, because the request is + * well-formed and the server has no way to know it was not what was meant. + */ + let restored: IntegrationJournalRow | null = null; + const journal = rows(12); + await mount(journal, { onRestore: value => { restored = value; } }); + await act(async () => { disclosure()!.open = true; }); + + const folded = visibleRows().filter(node => node.closest(".integration-history-older")); + const third = folded[2]!; + await act(async () => { third.querySelector("button")!.click(); }); + + // Row 0 is outside the fold, so the third folded row is journal[3]. + expect(restored).not.toBeNull(); + expect(restored!.opId).toBe(journal[3]!.opId); + expect(restored!.opId).not.toBe(journal[0]!.opId); +}); + test("the overview names the client on every row; a client tab does not", async () => { /* * The overview is the only surface showing one chronology across clients, so @@ -226,3 +262,35 @@ test("the disclosure is keyboard-operable and its summary is the only added tab .filter(node => node.getAttribute("tabindex") !== "-1"); expect(extraStops).toHaveLength(0); }); + +test("a refresh that prepends an entry does not re-collapse what was expanded", async () => { + /* + * Every restore refreshes the journal, and the refresh prepends the operation + * the user just performed. If the reveal count reset on that re-render, a user + * paging back through history would be thrown to the top by their own undo -- + * the one moment they are certainly reading older rows. The count lives in + * component state and the component is not remounted, so it survives; this + * pins that, because moving the state up or keying the element on the newest + * row would silently break it. + */ + await mount(rows(20)); + const details = disclosure()!; + await act(async () => { details.open = true; }); + await act(async () => { buttonByText("Show 6 more")!.click(); }); + const revealed = visibleRows().length; + expect(revealed).toBeGreaterThan(7); + + // The restore lands and the journal comes back one row longer. + const refreshed = [row({ opId: "op-new", at: new Date(Date.UTC(2026, 7, 31, 11, 0, 0)).toISOString() }), ...rows(20)]; + await rerender(refreshed); + + expect(disclosure()!.open).toBe(true); + // Same count, not a reset to one visible row plus a fresh page. The reveal + // budget applies to the older list, so prepending shifts what fills it rather + // than adding to it -- what matters is that the budget itself survived. + expect(visibleRows().length).toBe(revealed); + // And the row the user just created is the one now sitting outside the fold. + const outside = Array.from(container.querySelectorAll(".integration-history-row")) + .filter(node => !(node as unknown as HTMLElement).closest(".integration-history-older")); + expect(outside).toHaveLength(1); +}); diff --git a/gui/tests/integrations-surfaces.test.tsx b/gui/tests/integrations-surfaces.test.tsx index c4f7b4b736..2b9600af59 100644 --- a/gui/tests/integrations-surfaces.test.tsx +++ b/gui/tests/integrations-surfaces.test.tsx @@ -184,6 +184,24 @@ async function mountClient( await act(async () => { await new Promise(resolve => testWindow.setTimeout(resolve, 30)); }); } +/** + * Mount again inside ONE test, against a fresh fixture. + * + * `useDataSurface` caches by `apiBase`, so a second mount on the same base + * replays the first response and the new `stateResponse` has no effect. Rotating + * the base is what makes a state sweep in a single test possible at all. + */ +async function remountClient(client: "hermes" | "dsh" = "hermes"): Promise { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + mountCount += 1; + apiBase = `http://ocx-test-${mountCount}.invalid`; + await mountClient(true, client); +} + test("the DSH surface uses localized ownership semantics and its own API route", async () => { stateResponse = () => json(status({ clientId: "dsh", @@ -273,6 +291,102 @@ test("conflict locks the switch instead of guessing", async () => { expect(toggleSwitch().disabled).toBe(true); }); +/* + * The overwrite escape hatch. + * + * Conflict was a dead end before it existed: the switch locks and the only way + * forward was hand-editing the file. These pin the two halves of the deal -- + * the button appears for exactly one state, and it costs a confirmation. + */ +test("a conflict offers an overwrite, and no other state does", async () => { + for (const state of ["absent", "current", "stale", "unsafe"] as const) { + stateResponse = () => json(status({ state })); + await remountClient(); + expect(buttonByText("Replace")).toBeUndefined(); + } + + stateResponse = () => json(status({ state: "conflict", reason: "unowned-key" })); + await remountClient(); + expect(buttonByText("Replace")).toBeDefined(); +}); + +test("a client with no config on disk is never offered an overwrite", async () => { + // installed:false means there is nothing to replace; the server refuses it as + // not_installed, so offering the button would only produce an error dialog. + stateResponse = () => json(status({ state: "conflict", reason: "unowned-key", installed: false })); + await mountClient(); + expect(buttonByText("Replace")).toBeUndefined(); +}); + +test("the overwrite button mutates nothing until the dialog is confirmed", async () => { + stateResponse = () => json(status({ state: "conflict", reason: "unowned-key" })); + await mountClient(); + + await act(async () => { buttonByText("Replace")!.click(); }); + // Opening the dialog is not the operation. + expect(requests.some(request => request.method === "PUT")).toBe(false); + + // The dialog names the file the user is about to lose a block from, and says + // the change is recoverable. + const dialog = container.querySelector(".integration-consequence-dialog")!; + expect(dialog.textContent).toContain("/tmp/home/.hermes/config.yaml"); + expect(dialog.textContent).toContain("rollback list"); + + const confirm = Array.from(dialog.querySelectorAll("button")).find( + button => (button.textContent ?? "").trim() === "Replace", + ) as HTMLButtonElement; + await act(async () => { confirm.click(); }); + + const put = requests.find(request => request.method === "PUT"); + expect(put?.body).toEqual({ enabled: true, overwriteConflict: true }); +}); + +test("a foreign edit and an unowned block get different dialog copy", async () => { + stateResponse = () => json(status({ state: "conflict", reason: "foreign-edit" })); + await remountClient(); + await act(async () => { buttonByText("Replace")!.click(); }); + // The user's own edit is what is discarded, and the copy has to say so. + expect(container.querySelector(".integration-consequence-dialog")!.textContent) + .toContain("Your edit inside the opencodex block"); + + stateResponse = () => json(status({ state: "conflict", reason: "unowned-key" })); + await remountClient(); + await act(async () => { buttonByText("Replace")!.click(); }); + expect(container.querySelector(".integration-consequence-dialog")!.textContent) + .toContain("A block we did not write"); +}); + +test("the dialog's config path can break mid-string, so it cannot overflow a phone", async () => { + /* + * The dialog is 370px wide at a 390px viewport and the path it names is a long + * unbroken token -- a real one is `~/.zcode/v2/config.json` and worse. Without a + * break opportunity inside the word that token overflows its own container, + * which is how the one piece of information the user needs (WHICH file) ends up + * off screen. + * + * happy-dom does no layout, so measured geometry is not available here; what is + * checkable is that the path renders inside an element the stylesheet allows to + * break. Rendered geometry was measured separately at 390px in both themes + * (dialog 370px wide at left:10, code element 212px, no overflow). + */ + // A synthetic home, not a real one: privacy:scan rejects a committed /Users//. + const longPath = "/home/dev/Library/Application Support/SomeVendor/deeply/nested/config.json"; + stateResponse = () => json(status({ + state: "conflict", + reason: "unowned-key", + configPath: longPath, + })); + await remountClient(); + await act(async () => { buttonByText("Replace")!.click(); }); + + const dialog = container.querySelector(".integration-consequence-dialog")!; + const code = dialog.querySelector("code"); + // A element, not bare text: `.integration-consequence-body code` is what + // carries `overflow-wrap: anywhere`. + expect(code).not.toBeNull(); + expect(code!.textContent).toBe(longPath); +}); + test("unsafe locks the switch instead of guessing", async () => { stateResponse = () => json(status({ state: "unsafe", reason: "unparseable" })); await mountClient(); @@ -559,6 +673,116 @@ test("a drifted restore asks a second time instead of failing", async () => { expect((posts[1] as { confirmDrift?: boolean }).confirmDrift).toBe(true); }); +/** + * #3059: a successful restore starts an asynchronous history refresh before closing + * the dialog. That means normal focus restoration first finds the trigger still in + * the tree, and only later does the refresh consume its snapshot and remove the + * trigger. The region must receive focus on the successful close, before that later + * removal can send focus to . + */ +test("a successful restore keeps focus on the stable region after refresh removes its trigger", async () => { + const [{ createRoot }, { LanguageProvider }, { default: RestoreDialog }] = await Promise.all([ + import("react-dom/client"), + import("../src/i18n/provider"), + import("../src/pages/integrations/RestoreDialog"), + ]); + + // The shape RollbackHistory renders: a stable region holding the row trigger. + const region = testWindow.document.createElement("section"); + const trigger = testWindow.document.createElement("button"); + region.appendChild(trigger); + testWindow.document.body.appendChild(region); + trigger.focus(); + expect(testWindow.document.activeElement).toBe(trigger); + + const row = { + opId: "op-consumed", + clientId: "hermes" as const, + kind: "apply" as const, + at: "2026-08-02T09:00:00.000Z", + configPath: "/tmp/home/.hermes/config.yaml", + snapshot: "stored" as const, + undoable: false, + }; + let resolveRestore: ((response: Response) => void) | undefined; + const restoreFetch = ((input: RequestInfo | URL) => { + if (String(input).includes("/restore")) { + return new Promise(resolve => { resolveRestore = resolve; }); + } + return Promise.resolve(json({ operations: [] })); + }) as typeof fetch; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: restoreFetch }); + Object.defineProperty(testWindow, "fetch", { configurable: true, value: restoreFetch }); + + await act(async () => { + root = createRoot(container); + root.render( + + {}} + onClose={() => { + root!.unmount(); + root = null; + }} + /> + , + ); + }); + + await act(async () => { buttonByText("Restore")!.click(); }); + expect(resolveRestore).toBeDefined(); + + // Restore succeeds and closes while the trigger is still connected. + await act(async () => { resolveRestore!(json({ ok: true })); }); + expect(trigger.isConnected).toBe(true); + expect(testWindow.document.activeElement).toBe(region); + + // The asynchronous history refresh then consumes the snapshot and its trigger. + trigger.remove(); + expect(testWindow.document.activeElement).toBe(region); + expect(testWindow.document.activeElement).not.toBe(testWindow.document.body); + region.remove(); +}); + +test("focus returns to the trigger itself when it survived", async () => { + const [{ createRoot }, { LanguageProvider }, { default: RestoreDialog }] = await Promise.all([ + import("react-dom/client"), + import("../src/i18n/provider"), + import("../src/pages/integrations/RestoreDialog"), + ]); + + const region = testWindow.document.createElement("section"); + const trigger = testWindow.document.createElement("button"); + region.appendChild(trigger); + testWindow.document.body.appendChild(region); + trigger.focus(); + + const row = { + opId: "op-kept", + clientId: "hermes" as const, + kind: "apply" as const, + at: "2026-08-02T09:00:00.000Z", + configPath: "/tmp/home/.hermes/config.yaml", + snapshot: "stored" as const, + undoable: true, + }; + await act(async () => { + root = createRoot(container); + root.render( + + {}} onRestored={() => {}} /> + , + ); + }); + await act(async () => { root!.unmount(); root = null; }); + + // The fallback must not preempt a trigger that is still there. + expect(testWindow.document.activeElement).toBe(trigger); + region.remove(); +}); + test("a card toggles its own client without a trip to the sub-page", async () => { // Same rule as the client page: off means disable, for `stale` too. stateResponse = () => json({ clients: [status({ state: "stale" })] }); @@ -750,3 +974,108 @@ test("a populated overview journal collapses instead of flooding the page", asyn await act(async () => { details.open = true; }); expect(container.querySelectorAll(".integration-history-older .integration-history-row").length).toBeGreaterThan(1); }); + +/* + * Adding a file client means editing three hand-maintained lists that no type + * relates to each other: CLIENTS in client-config-clients.ts, INTEGRATION_TABS, + * and FILE_CLIENTS. Miss one and the client half-ships -- it exports from the + * API tab but has no Integrations tab to toggle from, or it owns a tab that + * renders a page for a client the file surface does not recognize. Both compile, + * and both look complete from whichever half you happen to open. + * + * Aside is the reason this exists: it needed all three, and nothing would have + * failed if it had landed in two. + */ +test("every export client has both an Integrations tab and a file-surface entry", async () => { + const { CLIENTS } = await import("../src/components/apikeys-workspace/client-config-clients"); + const { TABS, FILE_CLIENTS } = await import("../src/pages/integrations/integration-tabs"); + + const tabIds = new Set(TABS.map(tab => tab.id as string)); + const missing = CLIENTS.filter(id => !tabIds.has(id) || !FILE_CLIENTS.has(id as never)); + expect(missing).toEqual([]); + + // And no tab claims a client that does not exist, which would render a page + // for an id the config surface cannot answer for. + const clientIds = new Set(CLIENTS); + const orphaned = [...FILE_CLIENTS].filter(id => !clientIds.has(id)); + expect(orphaned).toEqual([]); +}); + +/* + * The mark has to reach every surface, not just the API tab it started on. Three + * of them are checked here; the fourth is client-config-panel.test.tsx. + * + * These assert on the rendered DOM rather than on the map, because the map being + * right and the component never being called is exactly the failure a map-only + * test cannot see -- and it is the failure that would ship, since the marks were + * correct in data long before any surface drew them. + */ +test("a client page header draws its client's mark", async () => { + stateResponse = () => json(status({ + clientId: "dsh", + configPath: "/tmp/home/.dsh/settings.yaml", + })); + await mountClient(true, "dsh"); + + const head = container.querySelector(".integration-client-head")!; + const mark = head.querySelector(".client-mark"); + expect(mark, "the client page header should carry a mark").not.toBeNull(); + // dsh is single-ink but its ink is DeepSeek blue, so it renders as an image. + expect(mark!.querySelector("img")?.getAttribute("src")).toBe("/provider-icons/deepseek-harness.svg"); + // Decoration beside a heading that already names the client. + expect(mark!.getAttribute("aria-hidden")).toBe("true"); +}); + +test("every overview card draws a mark, and none of them names itself", async () => { + await mountOverview(); + + const cards = [...container.querySelectorAll(".integration-card")]; + expect(cards.length).toBeGreaterThan(4); + const bare = cards + .filter(card => card.querySelector(".client-mark") === null) + .map(card => card.getAttribute("data-client")); + expect(bare).toEqual([]); + + // A mark next to a visible label must not join the accessible name, or a + // screen reader says the client twice. + for (const mark of container.querySelectorAll(".client-mark")) { + expect(mark.getAttribute("aria-hidden")).toBe("true"); + } + for (const img of container.querySelectorAll(".client-mark img")) { + expect(img.getAttribute("alt")).toBe(""); + } + + // The card head is space-between; the mark must sit with the title rather than + // after the badge, so it is the first child. + const head = cards[0]!.querySelector(".integration-card-head")!; + expect(head.firstElementChild?.classList.contains("client-mark")).toBe(true); +}); + +test("the tab strip marks every client tab and leaves the two non-client tabs bare", async () => { + const [{ createRoot }, { LanguageProvider }, { default: Integrations }] = await Promise.all([ + import("react-dom/client"), + import("../src/i18n/provider"), + import("../src/pages/Integrations"), + ]); + await act(async () => { + root = createRoot(container); + root.render( + + + , + ); + }); + await act(async () => { await new Promise(resolve => testWindow.setTimeout(resolve, 30)); }); + + const tabs = [...container.querySelectorAll(".page-tab")]; + expect(tabs.length).toBeGreaterThan(10); + const marked = tabs.filter(tab => tab.querySelector(".client-mark") !== null); + // overview and keys carry no client, so they carry no mark. + expect(tabs.length - marked.length).toBe(2); + + const codexTab = tabs.find(tab => tab.id === "integrations-tab-codex")!; + expect(codexTab.querySelector(".client-mark img")?.getAttribute("src")).toBe("/provider-icons/openai.svg"); + // The label lost its "CLI": the mark carries that identity now, and the row + // covers the app and SDK too. + expect(codexTab.textContent).toBe("Codex"); +}); diff --git a/gui/tests/locale-parity.test.ts b/gui/tests/locale-parity.test.ts index 047702d324..0f2924bd91 100644 --- a/gui/tests/locale-parity.test.ts +++ b/gui/tests/locale-parity.test.ts @@ -248,3 +248,30 @@ test("every locale carries the exact DSH label and ownership semantics", async ( expect(dict.get("integrations.semantics.dsh")).toBe(expected[2]); } }); + +/* + * Aside's ownership sentence carries three facts a user acts on, and each is + * wrong in a different way if a translation drops it: which key OpenCodex + * touches (`providers.opencodex`, so the rest of the file is untouched), where + * the file lives (`~/.aside/u/`, which is per-account and not the bare + * `~/.aside`), and that Aside rewrites the file while running, so a change does + * not take until the app is fully quit and reopened. A translator working from + * the English can render the prose naturally and still lose one. + * + * The identifiers are asserted rather than the sentence, unlike the DSH case + * above: pinning full translated strings freezes wording, and these three tokens + * are the part that must survive translation unchanged. + */ +test("every locale keeps the three facts Aside's ownership sentence carries", async () => { + for (const locale of LOCALES) { + const dict = await readDict(locale); + expect(dict.get("api.clientConfig.clientAside"), locale).toBe("Aside"); + expect(dict.get("integrations.tab.aside"), locale).toBe("Aside"); + + const semantics = dict.get("integrations.semantics.aside") ?? ""; + expect(semantics, `${locale} names the managed key`).toContain("providers.opencodex"); + expect(semantics, `${locale} names the per-account root`).toContain("~/.aside/u/"); + // Aside rewrites models.json as it runs, so a restart hint is not optional. + expect(semantics.length, `${locale} keeps the restart warning`).toBeGreaterThan(80); + } +}); diff --git a/gui/tests/logs-model-filter.test.ts b/gui/tests/logs-model-filter.test.ts new file mode 100644 index 0000000000..663686980f --- /dev/null +++ b/gui/tests/logs-model-filter.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test"; +import { logMatchesModelQuery } from "../src/pages/logs-model-filter"; + +/** + * #3070: an operator running custom providers watched their OpenAI monthly window + * shrink and had no way to find the responsible turns in the dashboard. `?model=` + * filtering landed for the CLI and the API in b68edc077 but never reached Logs.tsx, + * and the page's only related control — intercepted-helpers — keys on + * `shadowCallRewrittenFrom`, which an ordinary account-gated turn does not carry. + */ +describe("logs model filter", () => { + test("an empty or whitespace query matches everything", () => { + const log = { model: "gpt-5.6-terra" }; + expect(logMatchesModelQuery(log, "")).toBe(true); + expect(logMatchesModelQuery(log, " ")).toBe(true); + // Inert until used: a blank control must not hide rows. + expect(logMatchesModelQuery({}, "")).toBe(true); + }); + + test("matches a substring of the requested model, case-insensitively", () => { + const log = { model: "gpt-5.6-terra" }; + expect(logMatchesModelQuery(log, "terra")).toBe(true); + expect(logMatchesModelQuery(log, "TERRA")).toBe(true); + expect(logMatchesModelQuery(log, "luna")).toBe(false); + }); + + test("matches the resolved model even when the requested one differs", () => { + // The case the filter exists for: routing redirected the turn, and the model + // that got billed is the resolved one. Matching only `model` would hide it. + const redirected = { model: "combo/free", resolvedModel: "gpt-5.6-terra" }; + expect(logMatchesModelQuery(redirected, "terra")).toBe(true); + expect(logMatchesModelQuery(redirected, "combo")).toBe(true); + }); + + test("matches the provider and model of a winning failover attempt", () => { + const failedOver = { + provider: "primary", + model: "combo/reliable", + attempts: [ + { provider: "anthropic", model: "claude-sonnet-4.6" }, + { provider: "xai", model: "grok-4.6" }, + ], + }; + expect(logMatchesModelQuery(failedOver, "XAI")).toBe(true); + expect(logMatchesModelQuery(failedOver, "GROK-4.6")).toBe(true); + }); + + test("ignores malformed attempt containers and entries", () => { + expect(logMatchesModelQuery({ + model: "primary", + attempts: "not-an-array" as unknown as Array<{ provider?: string; model?: string }>, + }, "xai")).toBe(false); + expect(logMatchesModelQuery({ + attempts: [null as unknown as { provider?: string; model?: string }, { provider: "xai" }], + }, "xai")).toBe(true); + }); + + test("matches the provider", () => { + expect(logMatchesModelQuery({ provider: "xai", model: "grok-4.6" }, "xai")).toBe(true); + expect(logMatchesModelQuery({ provider: "xai", model: "grok-4.6" }, "anthropic")).toBe(false); + }); + + test("a row missing every field matches nothing but the empty query", () => { + expect(logMatchesModelQuery({}, "terra")).toBe(false); + }); + + test("a non-string field cannot throw or match", () => { + // Log rows arrive from the API; the page validates shape but this helper is + // called per row on every render and must not be the thing that crashes it. + const malformed = { model: 42 as unknown as string, resolvedModel: "gpt-5.6-terra" }; + expect(logMatchesModelQuery(malformed, "terra")).toBe(true); + expect(logMatchesModelQuery(malformed, "42")).toBe(false); + }); +}); diff --git a/gui/tests/models-alias-edit-emphasis.test.ts b/gui/tests/models-alias-edit-emphasis.test.ts new file mode 100644 index 0000000000..da617d2862 --- /dev/null +++ b/gui/tests/models-alias-edit-emphasis.test.ts @@ -0,0 +1,107 @@ +import { expect, test } from "bun:test"; +import { effectiveDeclaration, withoutComments } from "./helpers/css-declarations"; + +/** + * WP2 (devlog/_plan/260830_models_provider_header/050_hover_affordance_and_column_gate.md). + * + * The user's requirement was a hover affordance. The independent audit rejected the + * first reading of it — hover that GATES, i.e. controls hidden until the pointer + * arrives — because that deletes the alias pencil, the alias-defaults switch and + * custom-add from the default visual inventory and worsens the exact opacity that + * 020 exists to fix. What survives is emphasis: the control never leaves layout, + * tab order, or the accessibility tree, and only its resting contrast changes. + * + * These assertions exist to stop a later "tidy up" from quietly turning emphasis + * back into gating, and to stop the keyboard and touch paths from being dropped + * while the hover path survives. + */ + +const read = async () => withoutComments(await Bun.file(new URL("../src/styles-models-workspace.css", import.meta.url)).text()); + +/** The body of an at-rule block, which `ruleBodies` cannot reach: it matches an exact selector. */ +function atRuleBody(css: string, condition: string): string { + const at = css.indexOf(condition); + if (at < 0) throw new Error("at-rule not found: " + condition); + const open = css.indexOf("{", at); + let depth = 0; + for (let i = open; i < css.length; i++) { + if (css[i] === "{") depth++; + else if (css[i] === "}") { depth--; if (depth === 0) return css.slice(open + 1, i); } + } + throw new Error("unbalanced at-rule: " + condition); +} + +test("the alias-edit control is de-emphasized at rest, not hidden", async () => { + const css = await read(); + + // 0.75, not lower: .btn:disabled is opacity 0.55, so a deeper dim reads as + // maybe-disabled rather than secondary. + // + // Read the RESTING rule only. `effectiveDeclaration` takes the last textual match + // across every body of an exact selector and does not model @-rules, so run against + // the whole file it would report the `(hover: none)` override's `opacity: 1` and + // pass whatever the resting value happened to be. + const restingOnly = css.slice(0, css.indexOf("@media (hover: none)")); + expect(effectiveDeclaration(restingOnly, ".models-provider-actions .btn-ghost.models-alias-edit", "opacity")).toBe("0.75"); + + // Gating, in any of its spellings, would remove the control from the resting + // layout — the design 011 rejected and 030 re-rejected. + const rule = css.slice(css.indexOf(".models-alias-edit")); + const block = rule.slice(0, rule.indexOf("@media") < 0 ? rule.length : rule.indexOf("@media")); + expect(block).not.toContain("display: none"); + expect(block).not.toContain("visibility: hidden"); + expect(block).not.toContain("content-visibility: hidden"); +}); + +/** + * The audit's blocker: the first draft wrote every override at 0,2,0 against a 0,3,0 + * resting rule. Same origin, lower specificity — so `(hover: none)` never won and the + * dim was permanent on touch, and `:focus-visible` was a silent no-op. A rule that + * exists but cannot win is worse than a missing one, because it reads as covered. + */ +test("every emphasis state is written at the resting rule's specificity", async () => { + const css = await read(); + const REST = ".models-provider-actions .btn-ghost.models-alias-edit"; + + // Each state must carry the full .btn-ghost + descendant chain, not a shorter one. + expect(css).toContain(`.models-provider-head:hover ${REST}`); + expect(css).toContain(`.models-provider-head:focus-within ${REST}`); + expect(css).toContain(`${REST}:focus-visible`); + + // And the at-rule overrides must use the same chain. + expect(atRuleBody(css, "@media (hover: none)")).toContain(REST); + expect(atRuleBody(css, "@media (prefers-reduced-motion: reduce)")).toContain(REST); + + // A bare .models-alias-edit override would be 0,1,0 or 0,2,0 and could not win. + expect(css).not.toMatch(/(^|[\n,])\s*\.models-alias-edit\s*\{/); +}); + +test("emphasis has a keyboard path, not only a pointer one", async () => { + const css = await read(); + + // :hover alone would mean a keyboard user reaching the pencil by Tab never sees + // the emphasis a mouse user gets by proximity. + expect(css).toContain(".models-provider-head:focus-within"); + expect(css).toContain(":focus-visible"); + + const emphasized = css.slice(css.indexOf(".models-provider-head:hover .models-provider-actions")); + expect(emphasized.slice(0, emphasized.indexOf("}"))).toContain("opacity: 1"); +}); + +test("a pointer that cannot hover gets full contrast permanently", async () => { + const css = await read(); + // Without this the resting dim is permanent on touch: no pointer ever arrives to + // trigger the emphasis rule. + const body = atRuleBody(css, "@media (hover: none)"); + expect(body).toContain(".models-alias-edit"); + expect(body).toContain("opacity: 1"); +}); + +test("reduced motion removes the transition rather than the emphasis", async () => { + const css = await read(); + const body = atRuleBody(css, "@media (prefers-reduced-motion: reduce)"); + expect(body).toContain(".models-alias-edit"); + expect(body).toContain("transition: none"); + // The emphasis itself must survive: reduced motion is about animation, not contrast. + expect(body).not.toContain("opacity: 1"); +}); diff --git a/gui/tests/models-cap-cluster.test.ts b/gui/tests/models-cap-cluster.test.ts new file mode 100644 index 0000000000..431603c37d --- /dev/null +++ b/gui/tests/models-cap-cluster.test.ts @@ -0,0 +1,69 @@ +import { expect, test } from "bun:test"; +import { effectiveDeclaration, withoutComments } from "./helpers/css-declarations"; + +/** + * WP3 (devlog/_plan/260830_models_provider_header/040_cap_cluster_and_occupied_slot.md). + * + * The defect the user actually reported: openai showed a 1.05M cap value and + * anthropic showed nothing, because the cap Select was rendered only when the cap + * was on. Two cards therefore started their control row at different left edges. + * The slot is now always occupied — disabled with its value when the cap is off, + * which is an honest rendering of "no opinion". + */ + +const readPage = () => Bun.file(new URL("../src/pages/Models.tsx", import.meta.url)).text(); +const readCss = async () => withoutComments(await Bun.file(new URL("../src/styles-models-workspace.css", import.meta.url)).text()); + +test("the cap Select is no longer conditional on the cap being on", async () => { + const page = await readPage(); + + // The guard that caused the reported misalignment. + expect(page).not.toContain("{(capOn || nativeProviderGroup) && ("); + + // Occupancy without inertness would be a lie: the control must still refuse input + // while the cap is off. + expect(page).toContain("disabled={busy || !capOn}"); +}); + +/** + * Found by the independent audit. `providerCapCustomOpen` is per-provider state that + * nothing clears when the cap is switched off. While the Select disappeared with the + * cap this was unreachable for routed providers; always-rendering it would have left + * the custom input and its Apply button standing under an off cluster — and Apply + * sends `enabled: true`, so the field would silently turn the cap back on. + */ +test("the custom-cap editor cannot outlive the cap being switched off", async () => { + const page = await readPage(); + expect(page).toContain("{capOn && providerCapCustomOpen[provider] && ("); +}); + +test("the cap trio is one visual cluster that can wrap", async () => { + const css = await readCss(); + + // Tighter than the row own var(--space-2): that difference is what makes the trio + // read as one control rather than three peers. + expect(effectiveDeclaration(css, ".models-cap-cluster", "gap")).toBe("var(--space-1)"); + expect(effectiveDeclaration(css, ".models-provider-actions", "gap")).toBe("var(--space-2)"); + + // Load-bearing: the cluster is one flex item of a wrapping row and its children do + // not shrink, so a nowrap cluster would overflow into .models-provider-card own + // overflow: hidden and be clipped silently. + expect(effectiveDeclaration(css, ".models-cap-cluster", "flex-wrap")).toBe("wrap"); + + // A min-width floor here would be the child-floor design 011 rejected. + expect(() => effectiveDeclaration(css, ".models-cap-cluster", "min-width")).toThrow(); +}); + +test("grouping did not become a functional merge", async () => { + const page = await readPage(); + const start = page.indexOf('
    '); + expect(start).toBeGreaterThan(-1); + const cluster = page.slice(start, page.indexOf("
    ", start)); + + // Three separate controls, three tab stops. The per-model button opens a different + // SCOPE than the provider-wide switch and select, so collapsing them would be a + // lie about what the surface does. + expect(cluster).toContain("on={capOn}"); + expect(cluster).toContain("onChange={v => onSelectProviderCap(provider, v)}"); + expect(cluster).toContain('t("models.contextSettings")'); +}); diff --git a/gui/tests/models-control-affordances.test.ts b/gui/tests/models-control-affordances.test.ts new file mode 100644 index 0000000000..3dd2290b70 --- /dev/null +++ b/gui/tests/models-control-affordances.test.ts @@ -0,0 +1,85 @@ +import { expect, test } from "bun:test"; +import { effectiveDeclaration, withoutComments } from "./helpers/css-declarations"; + +/** + * WP1 (devlog/_plan/260830_models_provider_header/020_control_affordances.md): + * three controls in the provider header were operable but carried no visible + * meaning. `Switch` accepted a `label` and spent it entirely on `aria-label`, so + * every switch in the app was, to a sighted user, an unlabeled knob. + * + * These are source-text assertions; happy-dom performs no layout. The rendered + * behaviour — that the visible label actually toggles the switch — is proven in + * gui/tests/switch-labeled-dom.test.tsx, and the geometry proof lives in the + * unit's evidence directory. + */ + +const read = (p: string) => Bun.file(new URL(p, import.meta.url)).text(); + +test("Switch renders its label as visible text inside the control", async () => { + const ui = await read("../src/ui.tsx"); + + // The visible-label path exists... + expect(ui).toContain("showLabel"); + expect(ui).toContain("switch-labeled-text"); + + // ...and the visible words are the accessible name, not a decorative copy of it. + // aria-hidden here would leave the button nameless; aria-label would override the + // words and break Label-in-Name. + expect(ui).not.toMatch(/switch-labeled-text[^>]*aria-hidden/); + expect(ui).toContain("aria-label={labeled ? undefined :"); + + // `title` is strictly opt-in: defaulting it to `label` gave every untouched + // Switch in the app an accessible description that merely repeats its name. + expect(ui).toContain("title={title}"); + expect(ui).not.toMatch(/title=\{title \?\? /); +}); + +test("a labeled switch stays one atomic flex item", async () => { + const css = withoutComments(await read("../src/styles.css")); + + // Without this the knob and its text become two shrinkable items inside + // .models-provider-actions and the text collapses to a min-content column — + // the same class of defect 010 fixed for the toggle's children. + expect(effectiveDeclaration(css, ".switch.switch-labeled", "flex")).toBe("0 0 auto"); + expect(effectiveDeclaration(css, ".switch-labeled-text", "white-space")).toBe("nowrap"); + + // The label lives inside the button, so the pill geometry moves to ::before — + // otherwise the text would sit on the coloured track. + expect(effectiveDeclaration(css, ".switch.switch-labeled", "width")).toBe("auto"); + expect(effectiveDeclaration(css, ".switch.switch-labeled::before", "width")).toBe("34px"); +}); + +test("the three opaque provider-header controls now carry visible meaning", async () => { + const page = await read("../src/pages/Models.tsx"); + + // 1. alias-defaults switch: label was already correct, it was merely discarded. + const aliasSwitch = page.slice( + page.indexOf('label={t("models.useDefaultAliases")}') - 300, + page.indexOf('label={t("models.useDefaultAliases")}') + 120, + ); + expect(aliasSwitch).toContain("showLabel"); + + // 2. custom-add: a visible text label, not a tooltip. `title` is undiscoverable + // on touch, and wrapping it in Tooltip would nest '); + + // 3. cap switch: the label must name the FUNCTION. It used to be + // models.capValue ("기본 128k"), a value masquerading as a name. + expect(page).toMatch(/on=\{capOn\}[\s\S]{0,200}label=\{t\("models\.contextCapLabel"\)\}[\s\S]{0,40}showLabel/); + expect(page).not.toMatch(/on=\{capOn\}[\s\S]{0,200}label=\{t\("models\.capValue"/); + + // The cap Select says what its number means; the value itself stays its name. + const capSelect = page.slice(page.indexOf("onSelectProviderCap(provider, v)"), page.indexOf("onSelectProviderCap(provider, v)") + 400); + expect(capSelect).toContain('title={t("models.contextCapLabel")}'); +}); + +test("no new i18n key was invented: every string used already exists in all locales", async () => { + const { DICTS } = await import("../src/i18n/shared"); + const keys = ["models.useDefaultAliases", "models.customAdd", "models.contextCapLabel"] as const; + for (const [locale, dict] of Object.entries(DICTS)) { + for (const key of keys) { + expect((dict as Record)[key], `${locale} is missing ${key}`).toBeTruthy(); + } + } +}); diff --git a/gui/tests/models-native-group-controls.test.ts b/gui/tests/models-native-group-controls.test.ts index e54190ea93..ffd27ad17f 100644 --- a/gui/tests/models-native-group-controls.test.ts +++ b/gui/tests/models-native-group-controls.test.ts @@ -63,11 +63,15 @@ test("fmtK renders past a million as M, not as four-digit k", () => { expect(fmtK(350_000)).toBe("350k"); }); -test("the native group keeps its window readable with the cap switched off", async () => { +test("every provider keeps its window readable with the cap switched off", async () => { const src = await Bun.file(new URL("../src/pages/Models.tsx", import.meta.url)).text(); - // Routed providers hide the value when the cap is off (off = "no opinion"), but a native - // row always advertises some window, so the number stays on screen for that group. - expect(src).toContain("{(capOn || nativeProviderGroup) && ("); + // This used to be the native group ALONE: routed providers hid the value when the cap + // was off, and only a native row kept its number on screen. That asymmetry is the defect + // the user reported — openai showed 1.05M while anthropic showed nothing, so the two + // cards started their control rows at different left edges. The guard is gone and the + // slot is occupied on every card (040_cap_cluster_and_occupied_slot.md), which makes the + // property this test protects strictly wider than it was. + expect(src).not.toContain("{(capOn || nativeProviderGroup) && ("); // With the cap off the stored value is only what a future toggle would apply — the 350k // default — so the display falls back to the widest window the rows actually advertise. // Matched as separate fragments because the expression is wrapped across lines now, and diff --git a/gui/tests/provider-catalog-marks.test.tsx b/gui/tests/provider-catalog-marks.test.tsx new file mode 100644 index 0000000000..21a5b8c672 --- /dev/null +++ b/gui/tests/provider-catalog-marks.test.tsx @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import AddProviderModal from "../src/components/AddProviderModal"; + +/** + * The Add-Provider catalog is the list a user reads to CHOOSE a provider, and it + * was the only provider surface with no marks at all -- the rail, the details + * panel and the dashboard rows have drawn them for a while. + * + * These pin the two properties that make the marks correct rather than merely + * present: every row has one, and none of them is announced. + */ + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record<(typeof globals)[number], unknown>; +let win: Window; +let host: HTMLElement; +let root: Root | null = null; +let originalFetch: typeof globalThis.fetch; + +/** Two presets whose ids resolve a mark, and one that cannot. */ +const PRESETS = [ + { id: "cerebras", label: "Cerebras", adapter: "openai-completions", baseUrl: "https://api.cerebras.ai/v1", auth: "key" }, + { id: "together", label: "Together", adapter: "openai-completions", baseUrl: "https://api.together.xyz/v1", auth: "key" }, + // No asset upstream: this row must still draw the fallback tile. + { id: "chutes", label: "Chutes", adapter: "openai-completions", baseUrl: "https://llm.chutes.ai/v1", auth: "key" }, +]; + +beforeEach(() => { + previous = Object.fromEntries(globals.map(k => [k, Reflect.get(globalThis, k)])) as typeof previous; + originalFetch = globalThis.fetch; + win = new Window({ url: "http://localhost/" }); + Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + localStorage: { configurable: true, value: win.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (input: RequestInfo | URL) => { + const url = new URL(String(input), "http://localhost"); + if (url.pathname === "/api/provider-presets") return Response.json({ providers: PRESETS }); + if (url.pathname === "/api/oauth/providers") return Response.json({ providers: [] }); + if (url.pathname === "/api/usage") return Response.json({ providers: [] }); + return Response.json({}); + }, + }); + + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + } + Object.defineProperty(globalThis, "fetch", { configurable: true, value: originalFetch }); + await win.happyDOM?.close?.(); +}); + +async function mountCatalog() { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( + + {/* `paid` is where an API-key preset buckets; the default `free` tab would + render an empty list and make these assertions vacuous. */} + {}} onAdded={() => {}} /> + , + ); + }); + await act(async () => { await new Promise(r => setTimeout(r, 60)); }); +} + +function catalogRows(): HTMLElement[] { + return [...host.querySelectorAll(".provider-catalog-rows .list-row")]; +} + +/* + * Every row, including the one whose vendor publishes nothing. A provider with no + * asset must still render the fallback tile: the row that draws neither is + * indistinguishable from a broken lookup, and misaligned against its neighbours. + */ +test("every catalog row draws a mark, including a provider with no asset", async () => { + await mountCatalog(); + const rows = catalogRows(); + expect(rows.length).toBeGreaterThan(0); + const bare = rows.filter(row => row.querySelector(".provider-icon") === null); + expect(bare.map(row => row.textContent?.slice(0, 24))).toEqual([]); +}); + +/* + * The mark sits beside a label that already names the provider, so it must add + * nothing to the accessible name -- an `Cerebras` next to the word + * Cerebras makes a screen reader say it twice. + */ +test("a catalog mark is decoration, not a second announcement of the name", async () => { + await mountCatalog(); + for (const img of host.querySelectorAll(".provider-catalog-rows .provider-icon img")) { + expect(img.getAttribute("alt")).toBe(""); + expect(img.getAttribute("aria-hidden")).toBe("true"); + } +}); + +/* + * The mark comes first, before the title block. `.list-row` is space-between, so + * a mark inserted anywhere else pushes the badge strip into the middle of the row. + */ +test("the mark leads the row so the badges stay right-aligned", async () => { + await mountCatalog(); + for (const row of catalogRows()) { + expect(row.firstElementChild?.classList.contains("provider-icon")).toBe(true); + } +}); diff --git a/gui/tests/provider-icons.test.ts b/gui/tests/provider-icons.test.ts new file mode 100644 index 0000000000..5a0495c453 --- /dev/null +++ b/gui/tests/provider-icons.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from "bun:test"; +import { existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { PROVIDER_REGISTRY } from "../../src/providers/registry"; +import { providerIconSrc } from "../src/provider-icons"; + +const PUBLIC_DIR = join(import.meta.dir, "..", "public", "provider-icons"); + +/** + * Asset filenames that could plausibly belong to a provider id. + * + * A plan variant is a billing arrangement, not a brand -- `alibaba-token-plan` + * wears the Alibaba mark -- so the first dash-segment is probed too. + */ +function candidateAssets(providerId: string): string[] { + const stem = providerId.split("-")[0]!; + return [ + `${providerId}.svg`, + `${providerId}-color.svg`, + `${stem}.svg`, + `${stem}-color.svg`, + ]; +} + +/* + * The gap this exists for, stated plainly: `minimax.svg` was committed for the + * MiniMax Code CLIENT and the MiniMax PROVIDER kept rendering an initial tile for + * weeks. Nothing could have told anyone. `CLIENT_MARKS` is keyed by + * `ExportClientId` and `PROVIDER_ICON_ALIASES` by provider id, so adding artwork + * on one side leaves no signal on the other, and the fallback tile is a designed + * state that looks identical to a mistake. + * + * This is the only check that closes that loop, and it keeps closing it as new + * assets land: a mark added for any reason is immediately owed a provider row. + */ +test("a provider whose brand asset is already committed is actually wired to it", () => { + const files = new Set(readdirSync(PUBLIC_DIR).filter(name => name.endsWith(".svg"))); + const unwired: string[] = []; + for (const entry of PROVIDER_REGISTRY) { + if (providerIconSrc(entry.id)) continue; + const found = candidateAssets(entry.id).find(name => files.has(name)); + if (found) unwired.push(`${entry.id}: ${found} is committed but PROVIDER_ICON_ALIASES has no row`); + } + expect(unwired).toEqual([]); +}); + +/* + * A mistyped filename renders a broken image, which is strictly worse than the + * fallback tile it replaced: the tile is deliberate and legible, the broken image + * is a visual defect. The map is plain strings, so nothing else checks this. + */ +test("every wired provider icon names a file that exists", () => { + const broken = PROVIDER_REGISTRY + .map(entry => [entry.id, providerIconSrc(entry.id)] as const) + .filter((pair): pair is readonly [string, string] => pair[1] !== undefined) + .filter(([, src]) => !existsSync(join(PUBLIC_DIR, src.split("/").pop()!))) + .map(([id, src]) => `${id} -> ${src}`); + expect(broken).toEqual([]); +}); + +/* + * The four rows this phase added, pinned by intent rather than by count. + * + * MiniMax is one brand on two endpoints (`api.minimax.io` and `api.minimaxi.com`), + * the same shape as the three Alibaba ids that already share one asset. Xiaomi's + * MiMo ids are the same brand as `mimo-free`, which was already wired -- the + * inconsistency was the bug. + */ +test("the MiniMax and Xiaomi MiMo provider ids resolve to their brand's mark", () => { + expect(providerIconSrc("minimax")).toBe("/provider-icons/minimax.svg"); + expect(providerIconSrc("minimax-cn")).toBe("/provider-icons/minimax.svg"); + expect(providerIconSrc("xiaomi-mimo")).toBe("/provider-icons/xiaomi-color.svg"); + expect(providerIconSrc("mimo")).toBe("/provider-icons/xiaomi-color.svg"); + // The precedent that makes the two above consistent rather than novel. + expect(providerIconSrc("mimo-free")).toBe("/provider-icons/xiaomi-color.svg"); +}); diff --git a/gui/tests/provider-marks-assets.test.ts b/gui/tests/provider-marks-assets.test.ts new file mode 100644 index 0000000000..ebe143af44 --- /dev/null +++ b/gui/tests/provider-marks-assets.test.ts @@ -0,0 +1,153 @@ +import { expect, test } from "bun:test"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { PROVIDER_REGISTRY } from "../../src/providers/registry"; +import { providerIconPaint, providerIconSrc } from "../src/provider-icons"; + +const PUBLIC_DIR = join(import.meta.dir, "..", "public", "provider-icons"); + +function bodyOf(src: string): string { + return readFileSync(join(PUBLIC_DIR, src.replace(/^\/provider-icons\//, "")), "utf8"); +} + +/** Every asset a provider resolves to, deduplicated -- several ids share one file. */ +function wiredAssets(): string[] { + const seen = new Set(); + for (const entry of PROVIDER_REGISTRY) { + const src = providerIconSrc(entry.id); + if (src) seen.add(src); + } + return [...seen].sort(); +} + +/* + * The three ways a mark passes an SVG parse and still fails as artwork. + * + * A `` glyph renders per-machine and blank where the font lacks the + * character -- that is what disqualified the Hermes repo favicon, 113 bytes whose + * entire body was one text element. An `` or a base64 payload is a raster + * wearing an SVG costume: it will not scale into the 19px tile and cannot be + * masked. Neither is visible in review; both are visible here. + */ +test("every wired provider mark is drawn geometry, not text or a wrapped raster", () => { + const broken: string[] = []; + for (const src of wiredAssets()) { + const body = bodyOf(src); + if (/]/.test(body)) broken.push(`${src}: renders a glyph`); + if (/]/.test(body)) broken.push(`${src}: embeds a raster`); + if (/;base64,/.test(body)) broken.push(`${src}: carries a base64 payload`); + if (!/<(path|circle|rect|polygon|ellipse|line|polyline)[\s>]/.test(body)) { + broken.push(`${src}: carries no vector geometry`); + } + } + expect(broken).toEqual([]); +}); + +/* + * A wordmark in a square slot. + * + * The rail draws a 19px box. A horizontal lockup scaled into it is an illegible + * smear, which is what disqualified the MiniMax docs asset (129x32) in the + * previous unit and several vendor logos in this one. The viewBox is the only + * thing that says which shape a file is, and it is not something review catches. + * + * The threshold is deliberately loose: 2.5 admits a slightly wide mark and still + * rejects the 4:1-and-up lockups that actually caused the problem. + */ +test("no wired provider mark is a horizontal wordmark", () => { + const lockups: string[] = []; + for (const src of wiredAssets()) { + const box = bodyOf(src).match(/viewBox="[-\d.eE]+[ ,]+[-\d.eE]+[ ,]+([\d.eE]+)[ ,]+([\d.eE]+)"/); + if (!box) continue; + const ratio = Number(box[1]) / Number(box[2]); + if (Number.isFinite(ratio) && ratio > 2.5) lockups.push(`${src}: ${ratio.toFixed(2)}:1`); + } + expect(lockups).toEqual([]); +}); + +/** Relative luminance, so "would this vanish?" is measured rather than judged. */ +function luminance(hex: string): number { + const raw = hex.replace("#", ""); + const full = raw.length === 3 ? [...raw].map(c => c + c).join("") : raw.slice(0, 6); + const channel = (pair: string): number => { + const v = parseInt(pair, 16) / 255; + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * channel(full.slice(0, 2)) + + 0.7152 * channel(full.slice(2, 4)) + + 0.0722 * channel(full.slice(4, 6)); +} + +function inksOf(body: string): string[] { + const matches = body.match(/(?:fill|stop-color)\s*[:=]\s*"?(#[0-9a-fA-F]{3,8})/g) ?? []; + return [...new Set(matches.map(raw => raw.split(/[:=]/).pop()!.replace(/"/g, "").trim().toLowerCase()))]; +} + +/* + * The rule that has now shipped broken five times. + * + * `prime` was white-on-transparent and invisible in light mode; `opencode` + * (#211E1E) and `kimi` (#1A1A1A) invisible in dark; `grok` (#000000) sat at + * 1.9:1 on the dark card through two passes because a comment argued it away. + * Every one of those is a single fill of a near-neutral ink drawn as a plain + * image, which is the one combination that cannot survive both themes. + * + * The provider tile resolves to #f4f4f4 light and #303030 dark, so an ink at + * either extreme disappears into one of them. Such a mark must be masked (the ink + * comes from the theme) or plated (the tile is pinned to the surface the artwork + * assumes). What it must not be is `image`. + */ +test("a single-ink near-neutral mark is never left to be drawn plain", () => { + const vanishing: string[] = []; + for (const src of wiredAssets()) { + const body = bodyOf(src); + if (/<(linearGradient|radialGradient)[\s>]/.test(body)) continue; + if (/prefers-color-scheme/.test(body)) continue; // solves it itself, see digitalocean + const inks = inksOf(body); + if (inks.length !== 1) continue; + const hex = inks[0]!.replace("#", ""); + const full = hex.length === 3 ? [...hex].map(c => c + c).join("") : hex.slice(0, 6); + const channels = [full.slice(0, 2), full.slice(2, 4), full.slice(4, 6)].map(p => parseInt(p, 16)); + if (Math.max(...channels) - Math.min(...channels) > 24) continue; // a brand colour, not a neutral + const l = luminance(inks[0]!); + if (l > 0.12 && l < 0.75) continue; // a mid grey reads on both tiles + if (providerIconPaint(src) === "image") { + vanishing.push(`${src}: ${inks[0]} is drawn plain and vanishes against one tile`); + } + } + expect(vanishing).toEqual([]); +}); + +/* + * The inverse, and the more destructive direction. Masking discards every ink in + * the file and repaints the silhouette in one colour, so applying it to a palette + * flattens a brand -- and the result still renders, still looks deliberate, and is + * invisible in review. + */ +test("no multi-colour provider mark is masked", () => { + const flattened: string[] = []; + for (const src of wiredAssets()) { + if (providerIconPaint(src) !== "mask") continue; + const body = bodyOf(src); + const gradient = /<(linearGradient|radialGradient)[\s>]/.test(body); + const inks = inksOf(body); + if (gradient || inks.length > 1) { + flattened.push(`${src}: ${inks.length} ink(s)${gradient ? " + gradient" : ""}`); + } + } + expect(flattened).toEqual([]); +}); + +/* + * A mark that adapts to the theme in its own file must be left alone. A constant + * plate defeats its media query: `digitalocean.svg` repaints itself #F4F5F5 under + * dark, so plating it light produced light-on-light at 1.01:1 -- worse than doing + * nothing, and only visible by measuring the rendered result. + */ +test("a self-adapting mark is not plated", () => { + const overridden = wiredAssets() + .filter(src => /prefers-color-scheme/.test(bodyOf(src))) + .filter(src => providerIconPaint(src) !== "image") + .map(src => `${src}: carries its own media query but is painted ${providerIconPaint(src)}`); + expect(overridden).toEqual([]); +}); diff --git a/gui/tests/switch-labeled-dom.test.tsx b/gui/tests/switch-labeled-dom.test.tsx new file mode 100644 index 0000000000..42c62f95b0 --- /dev/null +++ b/gui/tests/switch-labeled-dom.test.tsx @@ -0,0 +1,92 @@ +import { afterEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { Switch } from "../src/ui"; + +/** + * WP1 rendered-DOM contract + * (devlog/_plan/260830_models_provider_header/020_control_affordances.md). + * + * The sibling .ts file pins source invariants. This file proves the property the + * audit said a substring check cannot reach: the visible label ACTIVATES the + * switch. An earlier revision rendered the label as an `aria-hidden` sibling span, + * which read correctly and looked correct but left the hit target as the 34x20 + * knob, so clicking the words did nothing. + */ + +const globals = ["document", "window", "navigator", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record<(typeof globals)[number], unknown>; +let win: Window; +let root: Root | null = null; +let host: HTMLElement; + +function mount(node: React.ReactElement) { + win = new Window({ url: "http://localhost/" }); + previous = Object.fromEntries(globals.map(k => [k, Reflect.get(globalThis, k)])) as typeof previous; + // Plain assignment fails in a whole-suite run: an earlier DOM test leaves + // `document` installed as a non-writable global, so only defineProperty works. + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + }); + host = win.document.createElement("div") as never as HTMLElement; + win.document.body.appendChild(host as never); + act(() => { root = createRoot(host); root.render(node); }); + return host; +} + +afterEach(() => { + if (root) act(() => root!.unmount()); + root = null; + for (const k of globals) { + Object.defineProperty(globalThis, k, { configurable: true, value: previous?.[k] }); + } +}); + +test("clicking the visible label toggles the switch", () => { + let clicks = 0; + const el = mount( { clicks += 1; }} label="Use default aliases" showLabel />); + + const text = el.querySelector(".switch-labeled-text"); + expect(text?.textContent).toBe("Use default aliases"); + + // The words must be INSIDE the button, otherwise they are decoration. + const button = el.querySelector("button.switch"); + expect(button?.contains(text as never)).toBe(true); + + act(() => { (text as never as HTMLElement).dispatchEvent(new win.MouseEvent("click", { bubbles: true }) as never); }); + expect(clicks).toBe(1); +}); + +test("a labeled switch is named by its visible words, and exactly once", () => { + const el = mount( {}} label="Default window / cap" showLabel />); + const button = el.querySelector("button.switch")!; + + // aria-label would OVERRIDE the visible text and break Label-in-Name. + expect(button.getAttribute("aria-label")).toBeNull(); + // aria-hidden on the text would leave the button with no name at all. + expect(el.querySelector(".switch-labeled-text")?.getAttribute("aria-hidden")).toBeNull(); + expect(button.textContent).toContain("Default window / cap"); + expect(button.getAttribute("aria-pressed")).toBe("true"); +}); + +test("an unlabeled switch keeps aria-label and gains no redundant title", () => { + const el = mount( {}} label="Keep native" />); + const button = el.querySelector("button.switch")!; + + expect(button.getAttribute("aria-label")).toBe("Keep native"); + // HTML-AAM maps title to the accessible DESCRIPTION when aria-describedby is + // absent. Copying the name into it announces the control twice. + expect(button.getAttribute("title")).toBeNull(); + expect(button.querySelector(".switch-labeled-text")).toBeNull(); +}); + +test("title stays opt-in and does not displace the accessible name", () => { + const el = mount( {}} label="Shadow call" title="Explains the shadow lane" />); + const button = el.querySelector("button.switch")!; + expect(button.getAttribute("title")).toBe("Explains the shadow lane"); + expect(button.getAttribute("aria-label")).toBe("Shadow call"); +}); diff --git a/package.json b/package.json index 5fc493b829..9da46cb61c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.38.0", + "version": "2.39.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/src/adapters/cursor/live-models.ts b/src/adapters/cursor/live-models.ts index 4e4c07a236..df5fbf14b6 100644 --- a/src/adapters/cursor/live-models.ts +++ b/src/adapters/cursor/live-models.ts @@ -262,6 +262,7 @@ async function fetchCursorUsableModelsHttp2Once(opts: CursorUsableModelsOptions) req.on("error", () => close({ ok: false, error: "transport", detail: "HTTP/2 request failed" })); req.on("end", () => { if (bodyRejected) return; + if (status === 0) return close({ ok: false, error: "transport", detail: "HTTP/2 response ended before headers" }); if (status === 401 || status === 403) { return close({ ok: false, error: "auth", detail: `HTTP ${status}` }); } diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 047c60a6a3..0d91807617 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -236,6 +236,30 @@ function stripCanonicalOnlyToolFields(body: unknown, includeCapabilityGated: boo return input ? { ...rewrittenBody, input } : rewrittenBody; } +/** + * Codex keeps this ChatGPT-internal item metadata when its configured provider name is `openai`. + * Loopback OpenCodex injection intentionally retains that provider identity for history continuity, + * even when the proxy ultimately routes the request to a public Responses destination. Those + * destinations reject the private field as an unknown `input[*]` parameter, so remove it at the + * noncanonical boundary without mutating the caller-owned raw body. + */ +function stripInternalChatMessageMetadataPassthrough(body: unknown): unknown { + if (!isPlainObject(body) || !Array.isArray(body.input)) return body; + + let changed = false; + const input = body.input.map(item => { + if (!isPlainObject(item) || !Object.hasOwn(item, "internal_chat_message_metadata_passthrough")) { + return item; + } + changed = true; + const next = { ...item }; + delete next.internal_chat_message_metadata_passthrough; + return next; + }); + + return changed ? { ...body, input } : body; +} + /** * When `store` is false, the upstream API does not persist response items. Any item ID * forwarded in `input` is then interpreted as a reference to a stored item that does not @@ -901,13 +925,23 @@ function annotateEmptyResponsesToolOutputs(body: unknown, enabled: boolean): unk * Runs on every forward request; with intact pairs it returns the original reference. */ /** - * Backfill `queries` on a replayed single-query `web_search_call`. + * Repair a replayed `web_search_call` action that is missing either key. * * `webSearchAction()` in the bridge now emits both keys, but that only helps items * created after the fix. A conversation that already recorded - * `{type:"search", query:"..."}` replays that stored item on every subsequent turn, and - * DeepSeek's native Responses parser requires `queries` — so upgrading alone leaves - * those threads permanently 400ing with `missing field 'queries'` (#930). + * `{type:"search", query:"..."}` or `{type:"search", queries:[...]}` replays that stored + * item on every subsequent turn. DeepSeek's native Responses parser requires `queries` + * (#930) and Console Go's validator requires `query` (#3071), so upgrading alone leaves + * those threads permanently 400ing in one direction or the other. The repair runs both + * ways. + * + * Input items carry a loose schema, so a stored `queries` is not necessarily an array of + * strings. A partly- or wholly-malformed array is left alone rather than used as a source + * for the singular field: writing `query: 123` would satisfy the presence check and still + * fail the validator this repair exists to satisfy, and deriving `query` from + * `["a", 42]` would satisfy Console Go while leaving DeepSeek to reject the same replay. + * An empty `queries: []` canonicalizes to the shape the bridge emits for an empty search, + * keeping an existing `query` when the item has one. * * Runs on every Responses request, on both `input` items and the `action` nested inside * them. Returns the original reference when nothing needs repair, so the common path @@ -920,9 +954,35 @@ function backfillWebSearchQueries(body: unknown): unknown { if (!isPlainObject(item) || item.type !== "web_search_call") return item; const action = item.action; if (!isPlainObject(action) || action.type !== "search") return item; - if (typeof action.query !== "string" || Array.isArray(action.queries)) return item; - changed = true; - return { ...item, action: { ...action, queries: [action.query] } }; + // Repair whichever side is missing so both strict parsers pass: + // DeepSeek native Responses requires `queries`; Console Go requires `query`. + const rep: Record = { ...action }; + let itemChanged = false; + const hasQuery = typeof action.query === "string"; + const queries = Array.isArray(action.queries) ? action.queries : undefined; + if (queries !== undefined && queries.length === 0) { + // An empty array satisfies neither validator. Canonicalize to the empty-search + // shape the bridge emits, keeping an existing query rather than discarding it. + const query = hasQuery ? action.query as string : ""; + rep.query = query; + rep.queries = [query]; + itemChanged = true; + } else if (!hasQuery && queries !== undefined) { + // A plural array is only a usable source for the singular field when EVERY member + // is a string: deriving `query` from a partly-malformed array would satisfy Console + // Go while leaving DeepSeek to reject the same replay. Wholly malformed arrays are + // left untouched — coercing or dropping members would invent semantics the stored + // item never had. + if (queries.every(entry => typeof entry === "string")) { + rep.query = queries[0]; // multi-query item recorded before the fix + itemChanged = true; + } + } else if (hasQuery && queries === undefined) { + rep.queries = [action.query]; // single-query item recorded before the fix + itemChanged = true; + } + if (itemChanged) changed = true; + return itemChanged ? { ...item, action: rep } : item; }); return changed ? { ...body, input } : body; } @@ -2097,11 +2157,13 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = repairOversizedReplayCallIds(outBody); } outBody = stripUnsupportedReasoningSummaryDelivery(outBody, parsed.modelId); - // Repair stored history from before the bridge emitted both keys: a conversation - // that already recorded a single-query web_search_call replays it every turn, and - // a strict parser rejects the whole request over it (#930). + // Repair stored history from before the bridge emitted both keys, in either + // direction: a conversation that already recorded a web_search_call replays it + // every turn, and a strict parser rejects the whole request over the missing key — + // `queries` for DeepSeek (#930), `query` for Console Go (#3071). outBody = backfillWebSearchQueries(outBody); if (!isCanonicalOpenAiForwardProvider(provider)) { + outBody = stripInternalChatMessageMetadataPassthrough(outBody); outBody = promoteClientLoadedTools(outBody); } if (!isCanonicalOpenAiForwardProvider(provider)) { diff --git a/src/bridge.ts b/src/bridge.ts index dcb163553c..145913ddab 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -146,27 +146,27 @@ export { adapterFailureFromMessage } from "./lib/errors"; /** * Build the native `WebSearchAction::Search` payload from the queries that ran. * - * Single query → `{ query, queries: [query] }`. Batch → `{ queries }` with NO singular - * `query`. Empty → `{ query: "", queries: [""] }`. + * Every action carries BOTH keys: `{ query, queries }`, where `query` is the first + * member. Empty → `{ query: "", queries: [""] }`. * - * The asymmetry is load-bearing in both directions. codex-rs prefers a non-empty `query` - * for the cell label and renders " ..." only when `query` is ABSENT and - * `queries.len() > 1`, so adding `query` to a batch would collapse the plural ellipsis. - * Meanwhile DeepSeek's native Responses parser makes `queries` a required field, so a - * replayed one-term `web_search_call` — carried in the history of every subsequent turn - * — fails deserialization with `missing field 'queries'` and 400s the rest of the - * conversation (#930). Carrying both keys in the single case satisfies the strict parser - * without changing what codex-rs displays. + * Carrying both is load-bearing in both directions. DeepSeek's native Responses parser + * makes `queries` a required field, and Console Go's upstream validator makes `query` a + * required field — so a replayed `web_search_call` carried in the history of every + * subsequent turn fails deserialization with `missing field 'queries'` (#930) or 400s + * with `missing required field 'query'` unless both keys are present. Carrying both keys + * in every case satisfies both strict parsers; the trade-off is that a multi-query batch + * loses the " ..." ellipsis in codex-rs and shows the first query as the label. + * + * That trade is deliberate: a cosmetic label against a conversation that 400s on every + * subsequent turn. Do not restore the old batch-omits-`query` shape to win the ellipsis + * back — it reopens #3071. * * This fixes items created from here on. History recorded before it is repaired at the * replay boundary by `backfillWebSearchQueries()` in the Responses adapter. */ function webSearchAction(queries: string[]): Record { - if (queries.length <= 1) { - const query = queries[0] ?? ""; - return { type: "search", query, queries: [query] }; - } - return { type: "search", queries }; + const first = queries[0] ?? ""; + return { type: "search", query: first, queries: queries.length > 0 ? queries : [first] }; } interface OutputItem { diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index e4aa52f465..3edd53ffb0 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -12,7 +12,7 @@ import { CLI_COMMANDS } from "./registry"; import { isValidProviderName } from "../config/provider-name"; import type { CliHead } from "./root"; import type { ReadyArgs } from "./ready"; -import type { LiveProxy } from "../server/proxy-liveness"; +import type { LivenessIo, LiveProxy } from "../server/proxy-liveness"; import type { OcxConfig } from "../types"; import { hasHelpFlag, printSubcommandUsage, printUsage } from "./help"; import { setIntegrationEnabled, shouldSyncCodexOnStart } from "../codex/desired-state"; @@ -29,7 +29,7 @@ export interface CliDispatchDeps { command: string | undefined; head: CliHead; loadConfig: () => OcxConfig; - findLiveProxy: () => Promise; + findLiveProxy: (io?: LivenessIo) => Promise; probeHostname: (hostname: string | undefined) => string; waitForProxy: (timeoutMs?: number) => Promise; startArgv: (port?: number) => string[]; @@ -121,14 +121,31 @@ const commandRunners: Record = { if (desired.status === "unchanged") { const { classifyNativeRoutedResidue } = await import("../codex/native-residue"); if (classifyNativeRoutedResidue().kind === "clean") { - const alreadyOff = "Codex integration is already OFF and native; no Codex files changed."; + // The Codex half being a no-op says nothing about the Grok half. Returning here + // without stripping the fence meant `ocx restore` could report success while Grok + // still pointed at a stopped proxy — and the deferred-teardown recovery path + // (#3008) tells operators to run exactly this command before deleting a receipt, + // so the incomplete teardown would be signed off and the obligation erased. + let grokNote = ""; + let grokCode = 0; + try { + const g = stripGrokConfig(); + if (g.changed) grokNote = ` ${g.message}`; + else if (!g.ok) { grokNote = ` Grok config cleanup failed: ${g.message}`; grokCode = 1; } + } catch (err) { + grokNote = ` Grok config cleanup failed: ${err instanceof Error ? err.message : String(err)}`; + grokCode = 1; + } + const alreadyOff = `Codex integration is already OFF and native; no Codex files changed.${grokNote}`; if (restoreJson) { const { skippedRestoreEnvelope } = await import("../codex/inject"); - console.log(JSON.stringify(skippedRestoreEnvelope(true, alreadyOff))); - } else { + console.log(JSON.stringify(skippedRestoreEnvelope(grokCode === 0, alreadyOff))); + } else if (grokCode === 0) { console.log(alreadyOff); + } else { + console.error(alreadyOff); } - return 0; + return grokCode; } } let r: { success: boolean; message: string }; @@ -137,26 +154,40 @@ const commandRunners: Record = { } catch (err) { r = { success: false, message: err instanceof Error ? err.message : String(err) }; } + // Grok BEFORE either output. The JSON path used to return here, so `ocx restore --json` + // (and `ocx eject --json`, the same runner) could report success while the fence still + // pointed at the stopped proxy — and the deferred-teardown recovery on this branch + // tells operators to run exactly this before deleting a receipt (#3008). + let grokFailure: string | null = null; + let grokChangedMessage: string | null = null; + try { + const g = stripGrokConfig(); + if (g.changed) grokChangedMessage = g.message; + else if (!g.ok) grokFailure = g.message; + } catch (err) { + grokFailure = err instanceof Error ? err.message : String(err); + } if (restoreJson) { // Spawned callers need the artifact-level result to distinguish a busy // history worker from a successful native restore. Keep stdout machine - // readable; human framing remains the default command contract. - console.log(JSON.stringify(r)); - return r.success ? 0 : 1; + // readable — the Codex artifact schema is unchanged; the Grok outcome is + // folded into success/message so a caller cannot read a half teardown as done. + const message = grokFailure + ? `${r.message} Grok config cleanup failed: ${grokFailure}` + : grokChangedMessage ? `${r.message} ${grokChangedMessage}` : r.message; + console.log(JSON.stringify({ ...r, success: r.success && !grokFailure, message })); + return r.success && !grokFailure ? 0 : 1; } if (r.success) console.log(`✅ ${r.message}`); else { console.error(`⚠️ ${r.message}`); } let code = r.success ? 0 : 1; - try { - const g = stripGrokConfig(); - if (g.changed) console.log(`✅ ${g.message}`); - else if (!g.ok) { - console.error(`⚠️ ${g.message}`); - code = 1; - } - } catch { /* best-effort */ } + if (grokChangedMessage) console.log(`✅ ${grokChangedMessage}`); + if (grokFailure) { + console.error(`⚠️ ${grokFailure}`); + code = 1; + } if (r.success) { console.log("Codex integration is OFF and plain `codex` now runs natively. Switch back with: ocx restore back"); } else { @@ -542,7 +573,12 @@ const commandRunners: Record = { health: async deps => { const healthArgs = deps.args.slice(1); const wantsHealthJson = healthArgs.includes("--json"); - const live = await deps.findLiveProxy(); + // A proxy that has only just bound can miss a single probe while its event loop + // is still settling startup work — the same just-started race the stop paths + // already retry for (#764, SERVICE_STOP_LIVENESS). Without this, `ocx health` + // run seconds after a service restart reports a false negative on a proxy that + // is in fact serving. + const live = await deps.findLiveProxy({ attempts: 3 }); if (wantsHealthJson) { console.log(JSON.stringify({ ok: !!live, pid: live?.pid ?? null, port: live?.port ?? null })); } else { diff --git a/src/cli/index.ts b/src/cli/index.ts index 56abb5d05a..ca1a3a0fa9 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -2,6 +2,7 @@ import { spawn } from "node:child_process"; import { currentExternalCodexModelProvider, restoreNativeCodex, restoreNativeCodexAsync, shouldInjectApiAuthHeader } from "../codex/inject"; import { stripGrokConfig } from "../grok/inject"; +import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../update/stop-contract.mjs"; import { describeHistoryJobFailure, resolveCodexHistoryJobTarget, @@ -25,7 +26,16 @@ import { writePid, writeRuntimePort, } from "../config/process-state"; +import { + claimPendingTeardown, + clearPendingTeardown, + isPendingTeardownAbandoned, + listPendingTeardowns, + pendingTeardownPathFor, + quarantinePendingTeardown, +} from "../config/pending-teardown"; import { collectStatus, unusedProxyWarningLines } from "./status"; +import { endpointsToProve, everyEndpointProvenDown, sharedTeardownAuthorized, type UninstallObservation } from "./uninstall-plan"; import { takeFlag } from "./runtime-api"; import { @@ -44,9 +54,9 @@ import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-li import { createReadinessGate } from "../server/readiness"; import { runReady, type ReadyArgs } from "./ready"; import { runCli } from "./root"; -import { ProxyOwnershipRefusedError, stopProxy } from "../lib/process-control"; +import { isProcessAlive, ProxyOwnershipRefusedError, stopProxy } from "../lib/process-control"; import { loadServiceTokenFromFile } from "../lib/service-secrets"; -import { assertNotAdminToken, diagnoseService, isServiceOwnershipError, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "../service"; +import { assertNotAdminToken, diagnoseService, isServiceOwnershipError, proxyStillLiveAfterStop, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalledDetailed, uninstallServiceIfInstalled, uninstallServiceDetailed } from "../service"; import { formatStartupRoutingDetail, startupHealthSummary } from "../codex/autostart-health"; import { drainAndShutdown, isRecyclingForExit, startServer } from "../server"; import { injectSystemEnv, reconcileShellHook, revertSystemEnv, uninstallShellHook } from "../server/system-env"; @@ -230,7 +240,14 @@ async function handleStart(options: { block?: boolean } = {}) { const present = process.env.OPENCODEX_API_AUTH_TOKEN?.trim(); if (present) assertNotAdminToken(present); const requestedPort = parsePortOption(); - const owner = await findProxyOwnerBeforeJournalRecovery(); + // Always probe the configured port, even when both state files are absent. A + // fallback-port sibling overwrites the pid/runtime records when it starts and + // removes them on its own shutdown, so their absence proves nothing about the + // configured port. Without the probe, `start` shadowed a healthy proxy with an + // ephemeral-port copy and re-pointed client config at the copy; the next sibling + // shutdown then left no runtime record for discovery at all. `handleEnsure` + // already passes this; `handleStart` is the path that did not. + const owner = await findProxyOwnerBeforeJournalRecovery({ probeConfiguredPort: true }); if (owner.live) { // Service-wrapper context (opencodex-service.cmd `:loop`): a healthy proxy from // ANY source means the requested port is already served. Exit 0 so the wrapper's @@ -631,17 +648,35 @@ async function handleRestartStartWhenStopped(): Promise { return handleEnsure({ existingIsSuccess: false }); } -async function restoreSharedClientStateAfterStop(): Promise { - let restored = true; +/** + * Restore shared client state after a stop. + * + * Returns the two failure kinds separately. `historyOnly` means teardown succeeded and + * only Codex history metadata could not be finalized: the proxy is down, the service is + * stopped, and a manifest is waiting for review. `other` means something that actually + * removes state a client depends on. + * + * The distinction exists because `ocx update` must proceed for the first and abort for the + * second, and it can only see an exit code (#3008). + */ +async function restoreSharedClientStateAfterStop(): Promise<{ historyOnly: boolean; other: boolean }> { + let historyOnly = false; + let other = false; try { const result = await restoreNativeCodexAsync(); if (result.success) console.log(`↩️ ${result.message}`); else { - restored = false; + // Codex history is the one restore whose failure leaves the runtime consistent: the + // manifest is retained and the routed metadata is untouched. Config and catalog are + // not — a client reads those, so their failure is a real teardown failure. + const artifacts = result.artifacts; + const configOrCatalogFailed = artifacts.config.state === "failed" || artifacts.catalog.state === "failed"; + if (!configOrCatalogFailed && artifacts.history.state === "failed") historyOnly = true; + else other = true; console.error(`⚠️ ${result.message}`); } } catch (error) { - restored = false; + other = true; console.error(`⚠️ Native Codex restore failed: ${error instanceof Error ? error.message : String(error)}`); } @@ -649,16 +684,58 @@ async function restoreSharedClientStateAfterStop(): Promise { try { const grok = stripGrokConfig(); if (grok.changed) console.log(`↩️ ${grok.message}`); - else if (!grok.ok) { restored = false; console.error(`⚠️ ${grok.message}`); } + else if (!grok.ok) { other = true; console.error(`⚠️ ${grok.message}`); } } catch (error) { - restored = false; + other = true; console.error(`⚠️ Grok config restore failed: ${error instanceof Error ? error.message : String(error)}`); } - return restored; + return { historyOnly, other }; } async function handleStop() { + // The receipt must name the endpoint the owner was stopping — an obligation nobody can + // locate cannot be proven discharged. Only the runtime record knows it; a proxy started + // with an explicit --port is not on the configured one. + const endpointOf = (runtime: { port: number; hostname?: string } | null): { hostname: string; port: number } | null => + runtime?.port ? { hostname: runtime.hostname ?? "127.0.0.1", port: runtime.port } : null; + // Last-resort endpoint for a receipt: the address this home is configured to serve on, + // which is what a later recovery probe would ask about anyway. + const configuredEndpoint = (): { hostname: string; port: number } => { + try { + const config = loadConfig(); + return { + hostname: config.hostname ?? "127.0.0.1", + port: typeof config.port === "number" && config.port > 0 ? config.port : 10100, + }; + } catch { + return { hostname: "127.0.0.1", port: 10100 }; + } + }; + // Only a definitive "nothing is answering" authorizes finishing somebody else's + // abandoned teardown. The tri-state probe distinguishes that from "we could not tell" + // (timeout, a listener that withholds /healthz), which `findLiveProxy` collapses into + // the same null (#3008). + const abandonedTeardownIsSafeToFinish = async ( + endpoint: { hostname: string; port: number } | null, + ): Promise => { + // The endpoint has to come from the receipt. A crashed owner usually leaves no + // runtime-port record, and the configured port is the wrong question for a proxy + // started with an explicit --port: it refuses while the live one keeps serving. + // An obligation that cannot name its endpoint cannot be proven discharged. + if (!endpoint) return false; + try { + const { probeProxyLiveness } = await import("../update/proxy-liveness-probe.mjs"); + return probeProxyLiveness(endpoint.port, endpoint.hostname) === "dead"; + } catch { + // A probe that could not run is not evidence of absence. + return false; + } + }; let stopFailed = false; + let historyOnlyFailure = false; + // Only Task Scheduler respawns after a successful stop (#764), so only it earns the + // restart-window wait; launchd, systemd and WinSW are down when they say so. + let schedulerCanRespawn = false; let stoppedService = false; // An ownership mismatch means the service manager was never even contacted: the installed // service is still live and will respawn the proxy. Tearing down SHARED state in that @@ -666,9 +743,81 @@ async function handleStop() { // service — the exact failure this flag prevents. A plain stop failure is different: we // tried, so local teardown still proceeds. let ownershipBlocked = false; + // Deferring shared teardown to this process is an obligation, so record it on disk + // before asking for it (#3008). A parent that dies mid-stop would otherwise leave the + // client config routed at a proxy that is already gone, with nothing to find later. + // + // `inheritedTeardowns` is the inverse case: PREVIOUS stops that left obligations + // unfinished. Snapshot them BEFORE this run claims anything, so this run's own receipt + // is never mistaken for one it inherited. + const inheritedTeardowns = listPendingTeardowns() + .filter(read => isPendingTeardownAbandoned(read, isProcessAlive)); + let teardownNonce: string | undefined; + const claimTeardown = (endpoint: { hostname: string; port: number }, endpointSource: "exact" | "guessed") => { + if (teardownNonce) return; + try { + teardownNonce = claimPendingTeardown(endpoint, endpointSource).nonce; + } catch (err) { + // Without a receipt the proxy performs its own teardown, which is the pre-#3008 + // behaviour: correct for every backend that cannot respawn, and merely early for + // Task Scheduler. Losing the deferral is far better than losing the stop. + console.warn(`⚠️ Could not record the deferred-teardown receipt: ${err instanceof Error ? err.message : String(err)}`); + } + }; + /** + * One stop target, used for BOTH the receipt and the request. + * + * Deriving them separately meant the receipt could name a different endpoint than the + * one actually contacted, and a proxy with no runtime record got no receipt at all — + * silently reopening the parent-crash window on the path where the stop is a hard kill + * and no child teardown runs at all. + * + * So the caller supplies whatever endpoint it already discovered: the orphan path knows + * one from `findLiveProxy` even when the runtime record is gone. + * + * When nothing resolves, the graceful request cannot be made at all — `stopProxy` goes + * straight to the kill ladder, no child teardown runs, and there is no receipt to leave + * behind. A warning does not make that durable, so the receipt is claimed FIRST against + * the endpoint this process would restore anyway. It is the configured listen address, + * which is the same address every recovery probe would ask about, and an obligation + * recorded against it is strictly better than none: at worst the probe cannot confirm + * A guessed endpoint is NOT evidence, so the receipt records which kind it holds: a + * guessed one fails closed into manual recovery rather than letting a later probe read + * "the configured port refuses" as proof that the right proxy is down. + */ + const stopWithDeferral = async (pid: number, discovered?: { hostname: string; port: number } | null): Promise => { + // Resolve ONCE. Reading the runtime record twice let the receipt name the configured + // guess while the request went to a runtime endpoint that appeared in between. + const exact = discovered ?? endpointOf(readRuntimePort(pid)); + claimTeardown(exact ?? configuredEndpoint(), exact ? "exact" : "guessed"); + await stopProxy(pid, { + deferSharedTeardownNonce: teardownNonce, + // Only an exact endpoint may direct the request; the configured fallback is a guess + // good enough to record an obligation against, not to POST a stop to. + runtimeEndpoint: exact ?? undefined, + }); + }; try { - stoppedService = stopServiceIfInstalled(); - if (stoppedService) console.log("🛑 Service manager stopped (won't respawn)."); + const serviceStop = stopServiceIfInstalledDetailed(); + stoppedService = serviceStop === "stopped" || serviceStop === "stopped-respawnable"; + schedulerCanRespawn = serviceStop === "stopped-respawnable"; + // No "won't respawn" claim here: a stopped Task Scheduler can still respawn through + // its wrapper, which the verification below is what actually settles. + if (stoppedService) console.log("🛑 Service manager stopped."); + if (serviceStop === "failed") { + // A manager that would not stop can respawn the proxy. That is a real stop failure, + // not a history-only one, and an update must not replace files over it (#3008). + stopFailed = true; + console.error("❌ The installed service manager did not stop; it may respawn the proxy."); + } + if (serviceStop === "state-unknown") { + // Nothing refused to stop — the scheduler state could not be READ. Saying "did not + // stop" sends the operator looking for the wrong problem, and `/api/stop` answers + // the same case with service_state_unknown. + stopFailed = true; + console.error("❌ The Windows Task Scheduler state could not be read, so this stop cannot tell whether a wrapper would respawn the proxy."); + console.error(" Run 'ocx service status' to see the query error, repair Task Scheduler access, then retry."); + } } catch (err) { if (isServiceOwnershipError(err)) { ownershipBlocked = true; @@ -685,7 +834,11 @@ async function handleStop() { try { // Graceful-first (management-API drain) — on Windows this is the only path where // the proxy's shutdown handlers actually run; taskkill /F is the fallback inside. - await stopProxy(pid); + // Shared teardown is deferred to this process: it happens after the respawn + // verification below, so a survivor does not get its client config pulled first. + // The receipt goes down first — the proxy honours the deferral only when it can + // see one, so an unrecordable claim degrades to the child doing its own teardown. + await stopWithDeferral(pid); console.log(`✅ Proxy (PID ${pid}) stopped.`); removePid(pid); removeRuntimePort(pid); @@ -713,7 +866,9 @@ async function handleStop() { const live = await findLiveProxy(); if (live?.pid) { try { - await stopProxy(live.pid); + // The probe already found where it answers, and on this path the runtime record is + // typically what went missing in the first place. + await stopWithDeferral(live.pid, { hostname: live.hostname ?? "127.0.0.1", port: live.port }); console.log(`✅ Proxy (PID ${live.pid}) stopped.`); } catch (err) { stopFailed = true; @@ -725,6 +880,17 @@ async function handleStop() { console.error(" Skipping shared teardown (native Codex restore, Grok config): the foreign proxy is still running."); } } + } else if (live) { + // Identity-confirmed live, but no PID this process can kill: a legacy /healthz that + // reports no pid, or a pid that failed verification. Treating that as "nothing is + // running" purges the state records and then restores shared client config out from + // under a proxy that is still serving — the exact failure the deferral exists to + // prevent, arrived at from the other direction. + stopFailed = true; + ownershipBlocked = true; + console.error(`❌ A proxy is answering on port ${live.port}, but no process id could be resolved for it, so it cannot be stopped from here.`); + console.error(" Skipping shared teardown: restoring client config while it serves would leave both pointing at each other."); + console.error(" Stop it from the home that started it, or end the process manually, then rerun 'ocx stop'."); } else if (!stoppedService) { console.log("No running proxy found."); } @@ -739,16 +905,159 @@ async function handleStop() { // Environment ownership is independent from service ownership. Always roll back // current-home variables; the helper refuses foreign markers on its own. try { revertSystemEnv(); } catch { /* best-effort */ } - if (!ownershipBlocked) { - if (!await restoreSharedClientStateAfterStop()) stopFailed = true; + // A stopped Windows scheduler is not a proven-down proxy. `killWindowsSchedulerWrappers` + // is explicitly best-effort and the `:loop` wrapper respawns its child after ~5s, so an + // immediate probe can see a dead interval and an update can start replacing files right + // before the proxy comes back. Poll across the restart window before this stop is allowed + // to report anything but failure (#3008) — and ONLY for that backend, since making every + // launchd and systemd stop wait seven seconds would be a regression in ordinary use. + if (schedulerCanRespawn && !ownershipBlocked) { + const survivor = await proxyStillLiveAfterStop({ canRespawn: true }); + if (survivor) { + stopFailed = true; + console.error(`❌ A proxy is still listening on port ${survivor.port} after the service stop; it is being respawned.`); + console.error(" Skipping shared teardown: restoring client config while the proxy runs leaves both pointing at each other."); + ownershipBlocked = true; + } } - // Set the code rather than exiting inline: `restart` and the tray coordinator call this - // function and need it to RETURN so they can decide what to do next. + // Recovering somebody else's abandoned obligation is not the same act as finishing this + // run's own. This run stopped a proxy and verified the result; the inherited case has no + // such evidence, and `findLiveProxy` returning null covers a timeout and a malformed + // answer as well as a genuinely dead port. Restoring client config under a proxy that is + // merely unresponsive is exactly the failure the deferral exists to prevent. + // + // So an inherited obligation this run did not claim GATES the restore itself, rather + // than only labelling it: without a definitive "dead" from the tri-state probe, the + // restore does not run, the receipt stays for the next stop, and the stop fails. A + // warning that lets the restore happen anyway is not a gate. + // + // An UNREADABLE obligation is a third case. It names no endpoint, so nothing can ever + // prove its proxy down. It is NOT waved through: it fails this stop and is set aside + // only afterwards, so the operator gets an explicit manual step instead of a silent + // restore backed by no evidence. Setting it aside is still necessary — left in place it + // makes both updater gates run a stop that fails on it every time, which is an update + // that can never proceed. + // + // Inherited obligations are evaluated whether or not this run claimed its own. A stop + // that finds a live proxy used to skip them entirely, so older abandoned receipts + // accumulated forever while each run cleared only its own nonce. + const recoveredNonces: string[] = []; + const unreadable: { nonce: string }[] = []; + let inheritedBlocks = false; + if (inheritedTeardowns.length > 0 && !ownershipBlocked) { + for (const read of inheritedTeardowns) { + if (read.state === "unscannable") { + // No file, no nonce: nothing to quarantine and nothing to remove. The home itself + // may be hiding an obligation, so block and ask for the directory to be fixed. + inheritedBlocks = true; + stopFailed = true; + console.error(`❌ ${read.detail}, so this stop cannot tell whether a shared teardown is still owed.`); + console.error(" Skipping shared teardown. Fix access to the opencodex home, then rerun 'ocx stop'."); + continue; + } + if (read.state === "invalid") { + unreadable.push(read); + inheritedBlocks = true; + stopFailed = true; + console.error(`❌ A pending-teardown receipt could not be read (${read.detail}).`); + console.error(" It names no endpoint, so this stop cannot prove the proxy it belonged to is down."); + console.error(" Confirm no proxy is running, then rerun 'ocx stop' to complete the teardown."); + continue; + } + if (read.receipt.endpointSource === "guessed") { + // The recorded address is the configured one, not the one that stop contacted. A + // proxy on an explicit --port can be respawned there while this address refuses, + // so "dead" here proves nothing and must not authorize a restore. + inheritedBlocks = true; + stopFailed = true; + console.error("❌ A shared teardown from an earlier stop is outstanding, but that stop could not record the address it was stopping."); + console.error(` Only the configured address (${read.receipt.endpoint.hostname}:${read.receipt.endpoint.port}) was recorded, which cannot prove the right proxy is down.`); + console.error(` Confirm no proxy is running, then run 'ocx restore' and remove ${pendingTeardownPathFor(read.receipt.nonce)}.`); + continue; + } + if (await abandonedTeardownIsSafeToFinish(read.receipt.endpoint)) { + recoveredNonces.push(read.receipt.nonce); + continue; + } + inheritedBlocks = true; + stopFailed = true; + console.error(`❌ A shared teardown from an earlier stop is still outstanding, and the proxy on ${read.receipt.endpoint.hostname}:${read.receipt.endpoint.port} could not be confirmed down.`); + console.error(" Skipping shared teardown: restoring client config under a proxy that may still be running is what the deferral exists to prevent."); + console.error(" The obligation is preserved; retry once the proxy is confirmed stopped."); + } + } + const restoreBlocked = ownershipBlocked || inheritedBlocks; + if (!restoreBlocked) { + if (recoveredNonces.length > 0) { + // A previous deferred stop died before restoring, and the probe says its endpoint is + // not answering. That is the whole point of leaving the receipt behind. + console.log("↩️ Finishing a shared teardown left unfinished by an earlier stop."); + } + const restore = await restoreSharedClientStateAfterStop(); + if (restore.other) stopFailed = true; + else if (restore.historyOnly) historyOnlyFailure = true; + // The obligation is discharged whether or not history metadata finalized: config and + // catalog are what a client reads, and `restore.other` already fails the stop. + // + // Each nonce names its own file, so a clear can only ever remove the obligation it + // names — never one a concurrent stop wrote. Both this run's claim and every inherited + // receipt it proved discharged are released together. + if (!restore.other) { + const discharged = teardownNonce ? [teardownNonce, ...recoveredNonces] : recoveredNonces; + for (const nonce of discharged) { + // A receipt that survives its discharge re-triggers recovery forever, so a failed + // removal is surfaced rather than swallowed. + if (!clearPendingTeardown(nonce)) { + stopFailed = true; + console.error(`❌ The shared teardown finished, but its receipt could not be removed: ${pendingTeardownPathFor(nonce)}`); + console.error(" Remove it manually; otherwise every later stop and update will try to recover it again."); + } + } + } + } + // Set an unreadable receipt aside only AFTER the outcome is known. Renaming it earlier + // would take it out of the recovery loop while the restore it stood for had not run. + // + // Setting aside is NOT discharging. The renamed file still counts as an outstanding + // obligation (`isAnyTeardownObligationFileName`), so both updaters keep refusing to + // install until an operator removes it — the rename only stops every later stop from + // re-reading the same garbage. Skipped under `ownershipBlocked` because a foreign + // service still owns this state and none of it is ours to move. + if (unreadable.length > 0 && !ownershipBlocked) { + for (const read of unreadable) { + const moved = quarantinePendingTeardown(read.nonce); + if (moved) { + console.error(`⚠️ That unreadable receipt was set aside at ${moved}. It still blocks 'ocx update', and 'ocx stop' has NOT restored on its behalf.`); + console.error(" To clear it: confirm no proxy is running, run 'ocx restore', then delete that file."); + } else { + console.error(`❌ It could not be set aside either: ${pendingTeardownPathFor(read.nonce)}. Remove it manually after running 'ocx restore'.`); + } + } + } + // Set the code rather than exiting inline: this function returns a value its dispatcher + // reads, so exiting here would take that decision away from the caller. + // + // A history-only failure gets its own code so `ocx update` can tell "the proxy is down + // and a manifest needs review" from "the proxy would not stop" (#3008). Ordinary failure + // still wins: it is the stronger signal. if (stopFailed) process.exitCode = 1; + else if (historyOnlyFailure) process.exitCode = STOP_HISTORY_INCOMPLETE_EXIT_CODE; return !stopFailed; } async function handleUninstall() { + /** Definitive "nothing is answering" on the endpoint this home would serve. */ + const proxyEndpointProvenDown = async (): Promise => { + try { + const { probeProxyLiveness } = await import("../update/proxy-liveness-probe.mjs"); + // Every candidate, not just the preferred one: a stale runtime record pointing at a + // closed port would otherwise "prove" a live proxy on the configured port is gone. + const endpoints = endpointsToProve(readRuntimePort(), loadConfig()); + return everyEndpointProvenDown(endpoints, e => probeProxyLiveness(e.port, e.hostname)); + } catch { + return false; + } + }; const failures: string[] = []; const runStep = async (label: string, step: () => void | boolean | Promise) => { @@ -762,18 +1071,89 @@ async function handleUninstall() { } }; - await runStep("service stopped", () => stopServiceIfInstalled()); + // Consume the DETAILED outcome. The boolean helper returns false for "not installed", + // "refused to stop" and "state could not be read" alike, so this step used to print + // "not installed" for a manager that might still be running and then tear down shared + // config underneath it (#3008). + // The authorization rule lives in `uninstall-plan` so it can be exercised for every + // failure permutation by calling it, rather than by reading this function's source. + const observed: UninstallObservation = { + serviceStop: null, + proxyProvenDown: false, + serviceRemoval: null, + respawnWindowVerified: false, + }; + await runStep("service stopped", () => { + const outcome = stopServiceIfInstalledDetailed(); + observed.serviceStop = outcome; + if (outcome === "absent") return false; + if (outcome === "failed") { + throw new Error("the installed service manager did not stop; it may respawn the proxy"); + } + if (outcome === "state-unknown") { + throw new Error("the Windows Task Scheduler state could not be read, so this uninstall cannot tell whether a manager is still running. Run 'ocx service status' to see the query error"); + } + return true; + }); await runStep("proxy stopped", async () => { const pid = readPid(); - if (!pid) return false; + if (!pid) { + // A missing pid file is not proof that nothing is serving: a proxy can outlive its + // record (crash, manual delete, corrupt file), which is exactly why `ocx stop` falls + // back to identity-checked discovery. Without this, uninstall restored shared config + // and reported success while that proxy kept running (#3008). + const live = await findLiveProxy(); + if (!live) { + // A miss is not proof: `findLiveProxy` collapses a timeout and a transport failure + // into the same null as a dead endpoint. Ask the tri-state probe, which only says + // "dead" for a refused connection or a definitive non-OpenCodex answer (#3008). + observed.proxyProvenDown = await proxyEndpointProvenDown(); + if (!observed.proxyProvenDown) { + throw new Error("no proxy could be found, but its endpoint could not be confirmed down either; confirm nothing is serving, then rerun"); + } + return false; + } + if (!live.pid) { + throw new Error(`a proxy is answering on port ${live.port} but no process id could be resolved for it; stop it from the home that started it, then rerun`); + } + await stopProxy(live.pid); + observed.proxyProvenDown = true; + return true; + } await stopProxy(pid); removePid(pid); removeRuntimePort(pid); + observed.proxyProvenDown = true; return true; }); - await runStep("service removed", () => uninstallServiceIfInstalled()); + await runStep("service removed", () => { + const outcome = uninstallServiceDetailed(); + observed.serviceRemoval = outcome; + // "absent" and "removed" are both fine; a failure is not, and it used to look like + // absence on darwin and linux. + if (outcome === "failed") throw new Error("the installed service could not be removed"); + return outcome === "removed"; + }); + + // Only Task Scheduler can respawn through a surviving wrapper, and removing the + // registration does not prove the running one died. Poll the same window `ocx stop` does + // before shared config is allowed down (#764, #3008). + if (observed.serviceStop === "stopped-respawnable") { + await runStep("respawn window verified", async () => { + const survivor = await proxyStillLiveAfterStop({ canRespawn: true }); + if (survivor) throw new Error(`a proxy is still listening on port ${survivor.port} after the service was removed; it is being respawned`); + // A null from that poll is not proof either: its identity probe returns null on a + // timeout, so a respawned-but-unresponsive proxy looks the same as none. Require the + // tri-state probe to say dead on every candidate before calling the window verified. + if (!await proxyEndpointProvenDown()) { + throw new Error("no survivor answered after the service was removed, but the endpoint could not be confirmed down either; confirm nothing is serving, then rerun"); + } + observed.respawnWindowVerified = true; + return true; + }); + } if (process.platform === "win32") { await runStep("Windows tray removed", async () => { @@ -784,16 +1164,26 @@ async function handleUninstall() { }); } - await runStep("native Codex restored", async () => { - const r = await restoreNativeCodexAsync(); - if (!r.success) throw new Error(r.message); - }); + // Shared client config comes down only once nothing that could still be serving is + // unaccounted for. Restoring it under a live, still-managed proxy leaves both pointing + // at each other — the same failure `ocx stop` refuses (#3008). + if (sharedTeardownAuthorized(observed)) { + await runStep("native Codex restored", async () => { + const r = await restoreNativeCodexAsync(); + if (!r.success) throw new Error(r.message); + }); - await runStep("Grok Build config restored", () => { - const r = stripGrokConfig(); - if (!r.ok) throw new Error(r.message); - return r.changed; - }); + await runStep("Grok Build config restored", () => { + const r = stripGrokConfig(); + if (!r.ok) throw new Error(r.message); + return r.changed; + }); + } else { + failures.push("native Codex restored", "Grok Build config restored"); + console.error("⚠️ Skipping shared teardown (native Codex restore, Grok config): a service or proxy could not be proven stopped."); + console.error(" Resolve the failures above and rerun 'ocx uninstall' — service removal and local state cleanup are also unfinished."); + console.error(" 'ocx restore' is an interim step if you need native routing back before then."); + } await runStep("system env vars reverted", () => { const r = revertSystemEnv(); diff --git a/src/cli/integrations.ts b/src/cli/integrations.ts index a654bf8cac..3b417632ff 100644 --- a/src/cli/integrations.ts +++ b/src/cli/integrations.ts @@ -29,7 +29,7 @@ const GROK_USAGE = `Usage: const CLIENT_USAGE = `Usage: ocx integration client [status] [--client ] [--json] - ocx integration client --client [--json] + ocx integration client --client [--overwrite-conflict] [--json] ocx integration client history [--client ] [--json] ocx integration client restore --op [--confirm-drift] [--json]`; @@ -212,11 +212,33 @@ export async function handleClientIntegrationCommand( throw new CliUsageError(`unknown client integration command ${action}`, CLIENT_USAGE); } const client = takeOption(args, "--client"); + /* + * The conflict escape hatch, spelled the way `restore --confirm-drift` is: the + * refusal is the default and the waiver has to be typed. + * + * Without it the dashboard could resolve a conflict and the CLI could not, + * which strands exactly the user who cannot open a browser -- an SSH session, + * or an agent driving the proxy. That dead end is the reason the overwrite + * path exists at all. + */ + const overwriteConflict = takeFlag(args, "--overwrite-conflict"); rejectArgs(args, CLIENT_USAGE); if (!client) throw new CliUsageError("--client is required", CLIENT_USAGE); + /* + * Refused here rather than forwarded. The route answers 400 for this pair, but + * a local usage error names the flag that is wrong, where the route's reply + * arrives as a generic failed request. + */ + if (overwriteConflict && action === "disable") { + throw new CliUsageError("--overwrite-conflict applies only to enable", CLIENT_USAGE); + } const result = await runtimeRequest(`/api/client-integrations/${encodeURIComponent(client)}`, { method: "PUT", - body: JSON.stringify({ enabled: action === "enable" }), + // Sent only when asked for, so a proxy on an older build sees the request it + // has always seen rather than an unknown field. + body: JSON.stringify(overwriteConflict + ? { enabled: true, overwriteConflict: true } + : { enabled: action === "enable" }), }, deps); printData(result, wantsJson, [String((result as Record).message ?? `${client} ${action}d.`)]); }); diff --git a/src/cli/uninstall-plan.ts b/src/cli/uninstall-plan.ts new file mode 100644 index 0000000000..0e1df1cd2d --- /dev/null +++ b/src/cli/uninstall-plan.ts @@ -0,0 +1,86 @@ +/** + * Whether an uninstall may take shared client config down (#3008). + * + * Extracted from `handleUninstall` because the rule is a decision, and a decision that + * only exists inside a long imperative command can only be tested by reading its source — + * which is how this shipped wrong twice: first trusting a boolean that collapsed "not + * installed" with "still running", then trusting a missing pid file as proof no proxy was + * serving. + * + * Native Codex and the Grok fence are SHARED. Restoring them while something may still be + * serving leaves the client and the proxy pointing at each other, so every step that could + * leave a live proxy behind has to be accounted for first. + */ +export type UninstallObservation = { + /** Detailed service-stop outcome, or null when the step threw. */ + serviceStop: "absent" | "stopped" | "stopped-respawnable" | "failed" | "state-unknown" | null; + /** + * Did the proxy step PROVE nothing is serving? + * + * A `findLiveProxy` miss is not that proof: it collapses a timeout and a transport + * failure into the same null as a dead endpoint, so an unresponsive proxy read as absent. + */ + proxyProvenDown: boolean; + /** Service removal outcome, or null when the step threw. */ + serviceRemoval: "absent" | "removed" | "failed" | null; + /** + * For a Task Scheduler backend: was the restart window verified AFTER removal? + * + * Deleting the registration does not prove an already-running `:loop` wrapper died — + * killing it is best-effort (#764). `ocx stop` polls across the window; uninstall has + * to do the same before it may take shared config down. + */ + respawnWindowVerified: boolean; +}; + +export function sharedTeardownAuthorized(o: UninstallObservation): boolean { + if (o.serviceStop === null) return false; + // "absent" and a clean stop are the only service states that prove nothing is managing + // the proxy. + if (o.serviceStop === "failed" || o.serviceStop === "state-unknown") return false; + // Removing the registration is not the same as proving the running wrapper is gone. + if (o.serviceStop === "stopped-respawnable" && !o.respawnWindowVerified) return false; + if (o.serviceRemoval === null || o.serviceRemoval === "failed") return false; + return o.proxyProvenDown; +} + +/** An endpoint an uninstall must account for before shared config comes down. */ +export type ProbeEndpoint = { hostname: string; port: number }; + +/** + * Every DISTINCT endpoint this home could be serving on. + * + * A runtime record and the configured port can disagree — a stale record pointing at a + * closed port while the live proxy sits on the configured one. Probing only the runtime + * candidate then reports "dead" for a port nobody is using and authorizes the teardown + * (#3008). `findLiveProxy` already probes both; the proof has to cover both too. + */ +export function endpointsToProve( + runtime: { port?: number; hostname?: string } | null, + config: { port?: number; hostname?: string }, +): ProbeEndpoint[] { + const out: ProbeEndpoint[] = []; + const push = (port: number | undefined, hostname: string | undefined) => { + if (!port || port <= 0 || port > 65535) return; + const endpoint = { hostname: hostname ?? "127.0.0.1", port }; + if (out.some(e => e.port === endpoint.port && e.hostname === endpoint.hostname)) return; + out.push(endpoint); + }; + push(runtime?.port, runtime?.hostname); + push(typeof config.port === "number" && config.port > 0 ? config.port : 10100, config.hostname); + return out; +} + +/** + * Proof requires EVERY candidate to be definitively dead. + * + * "unknown" is not absence: a listener that accepts connections but withholds /healthz, or + * one that times out, is exactly the state where restoring shared config is most harmful. + */ +export function everyEndpointProvenDown( + endpoints: readonly ProbeEndpoint[], + probe: (e: ProbeEndpoint) => "live" | "dead" | "unknown", +): boolean { + if (endpoints.length === 0) return false; + return endpoints.every(e => probe(e) === "dead"); +} diff --git a/src/codex/account-store.ts b/src/codex/account-store.ts index 6774637257..952ac048b8 100644 --- a/src/codex/account-store.ts +++ b/src/codex/account-store.ts @@ -13,6 +13,7 @@ import { import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; import type { CodexAccountCredentialRecord, CodexAccountCredentials } from "../types"; import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch"; +import { CODEX_REFRESH_FLIGHT_CEILING_MS } from "./quota-recovery-timing"; type LegacyCodexAccountStore = Record; type CodexAccountStore = Record; @@ -407,7 +408,29 @@ type CodexRefreshResult = CodexTokenResult & { * refresh of the one the caller was holding, not somebody else's replacement. */ selfRefreshed?: boolean; + /** + * Three-way form of {@link selfRefreshed}, kept alongside it so existing callers are + * unaffected (#3019). `selfRefreshed` is `provenance === "self-refresh"`. + */ + provenance?: CodexRefreshProvenance; }; + +/** + * How THIS caller arrived at the credential it is returning (#3019). + * + * `selfRefreshed` is a boolean, and a boolean cannot carry three cases. Its `false` means + * both "somebody else replaced the credential" and "I joined an in-flight refresh of the + * same grant and adopted its result" — and a recovery budget has to treat those opposite + * ways. Joining is the same lineage getting its one refresh; replacement is a NEW lineage + * that has not had one yet, and charging it for somebody else's attempt would deny the + * fresh credential the recovery this exists to grant. + */ +export type CodexRefreshProvenance = "self-refresh" | "joined-lineage" | "external-replacement"; + +/** Terminal outcome of one forced refresh, as seen by the caller that requested it. */ +export type ForcedRefreshOutcome = + | { kind: "resolved"; provenance: CodexRefreshProvenance; generation: number; rotated: boolean } + | { kind: "failed"; error: unknown }; const MAX_CODEX_REFRESH_FLIGHTS = 32; const CODEX_REFRESH_FLIGHT_STALE_MS = 120_000; interface RefreshFlight { @@ -587,22 +610,81 @@ function awaitOwnCancellation(work: Promise, callerSignal?: AbortSignal): */ export async function forceRefreshCodexPoolToken( id: string, - options: { rejectedGeneration: number; rejectedAccessToken: string; signal?: AbortSignal }, -): Promise { - const result = await resolveCodexToken( + options: { + rejectedGeneration: number; + rejectedAccessToken: string; + signal?: AbortSignal; + /** + * Fires with THIS caller's classified outcome, regardless of `signal` (#3019). + * + * Cancellation rejects what the caller awaits; the shared flight keeps running and + * commits. A recovery budget claimed before the refresh therefore has no one left to + * settle it — the claim expires and the already-refreshed lineage gets a second + * refresh, which is the loop the budget exists to close. This callback is attached to + * the resolution itself, so it fires with no waiter present. + * + * It is called exactly once per call, for both success and failure, and its own + * failures are swallowed: settlement bookkeeping must never reject a credential the + * caller successfully obtained, nor disturb another waiter on the same flight. + */ + onSettled?: (outcome: ForcedRefreshOutcome) => void | Promise; + }, +): Promise { + const settle = (outcome: ForcedRefreshOutcome) => { + // Both halves matter: a synchronous throw and a rejected thenable are equally capable + // of turning settlement bookkeeping into an unhandled rejection that fails the process. + try { void Promise.resolve(options.onSettled?.(outcome)).catch(() => {}); } catch { /* ignore */ } + }; + const classify = (result: CodexRefreshResult): CodexRefreshProvenance => + // Default to the conservative reading. A path that did not classify itself is not + // assumed to be this caller's own lineage: charging a replacement for somebody else's + // attempt is the failure mode, so an unlabelled path leaves the returned lineage its + // own budget. + result.provenance ?? (result.selfRefreshed === true ? "self-refresh" : "external-replacement"); + + // The completion is NOT the caller's await. + // + // `options.signal` cancels what this function returns, while the shared flight keeps + // running and commits. Settling from the cancelled await therefore reported "failed" for + // a refresh that was about to succeed — releasing the budget, and letting the newly + // refreshed lineage claim again moments later. So the settlement rides an uncancelled + // resolution and the caller's cancellation is layered on top of it. + // A caller that is already gone must not start work. `resolveCodexToken` is called + // without the caller signal below, which bypasses its own pre-abort guard, so a + // pre-aborted request would otherwise rotate a credential nobody is waiting for. + if (options.signal?.aborted) { + settle({ kind: "failed", error: options.signal.reason }); + throw options.signal.reason; + } + const completion = resolveCodexToken( id, { rejectedGeneration: options.rejectedGeneration, rejectedAccessToken: options.rejectedAccessToken }, - options.signal, + // Deliberately no caller signal: the flight is shared and this settlement speaks for + // the credential, not for whoever happened to be waiting. + undefined, ); + completion.then( + resolved => settle({ + kind: "resolved", + provenance: classify(resolved), + generation: resolved.generation, + rotated: resolved.accessToken !== options.rejectedAccessToken, + }), + error => settle({ kind: "failed", error }), + ); + const result = await awaitOwnCancellation(completion, options.signal); + const provenance = classify(result); + const rotated = result.accessToken !== options.rejectedAccessToken; return { accessToken: result.accessToken, chatgptAccountId: result.chatgptAccountId, generation: result.generation, - rotated: result.accessToken !== options.rejectedAccessToken, + rotated, // Only a CAS this call performed itself proves the new credential descends from the // rejected one; anything else is somebody else's replacement and must not be treated // as this request's own lineage. - selfRefreshed: result.selfRefreshed === true, + selfRefreshed: provenance === "self-refresh", + provenance, }; } @@ -633,7 +715,15 @@ async function resolveCodexToken( // correct again and refreshing would burn a rotation for nothing. const forcedTargetsStoredCredential = forced !== undefined && !forcedFenceSuperseded(record.generation, forced); if (cred.expiresAt > Date.now() + REFRESH_SKEW_MS && !forcedTargetsStoredCredential) { - return { accessToken: cred.accessToken, chatgptAccountId: cred.chatgptAccountId, generation: record.generation }; + // The freshness shortcut: nothing was refreshed and nothing was adopted. A forced + // caller reaches it only once its fence was superseded, which is a replacement by + // definition; an ordinary caller does not read this field. + return { + accessToken: cred.accessToken, + chatgptAccountId: cred.chatgptAccountId, + generation: record.generation, + provenance: "external-replacement", + }; } const existing = refreshLocks.get(refreshGrantFingerprint); @@ -658,6 +748,14 @@ async function resolveCodexToken( accessToken: currentCred.accessToken, chatgptAccountId: currentCred.chatgptAccountId, generation: current.generation, + // Adopted the stored result of a flight this caller joined: same grant, same + // lineage. Not a replacement — that distinction is the whole point of #3019. + // + // Only `external-replacement` is inherited. The flight's own success is tagged + // `self-refresh` for the caller that performed the CAS, and copying that here + // would tell a caller that did no CAS that the credential is its own lineage. + // Everything this branch adopts is, by definition, a join. + provenance: refreshed.provenance === "external-replacement" ? "external-replacement" : "joined-lineage", }; } } @@ -693,6 +791,9 @@ async function resolveCodexToken( accessToken: currentCred.accessToken, chatgptAccountId: currentCred.chatgptAccountId, generation: current.generation, + // `forcedFenceSuperseded` is exactly "somebody else moved this credential past + // the generation I was holding" — a new lineage, entitled to its own budget. + provenance: "external-replacement", }; } if ( @@ -720,6 +821,7 @@ async function resolveCodexToken( // This joiner performed its own CAS onto its own record, so the resulting // generation is its own lineage even though another caller drove the fetch. selfRefreshed: true, + provenance: "self-refresh", resolvedGrantFingerprint: refreshGrantFingerprint, }; } @@ -747,7 +849,7 @@ async function resolveCodexToken( * eviction) and the 30s ceiling remain, because those bound the flight itself. */ const abort = new AbortController(); - const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(30_000)]); + const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(CODEX_REFRESH_FLIGHT_CEILING_MS)]); let flight!: RefreshFlight; const fetchPromise = withCodexRefreshFileLock(refreshGrantFingerprint, signal, async (): Promise => { const current = readCodexAccountRecord(id); @@ -765,6 +867,9 @@ async function resolveCodexToken( credential: lockedCred, // This credential belongs to a DIFFERENT grant than the flight was opened // for. Tagging it keeps a joiner from adopting it as its own. + // It is also somebody else's credential by definition, so a joiner that ends up + // adopting it must not charge it to this lineage's budget (#3019). + provenance: "external-replacement", ...(lockedRefreshGrantFingerprint !== undefined ? { resolvedGrantFingerprint: lockedRefreshGrantFingerprint } : {}), @@ -783,6 +888,9 @@ async function resolveCodexToken( chatgptAccountId: lockedCred.chatgptAccountId, generation: startGeneration, credential: lockedCred, + // The stored credential is fresh and no forced fence still targets it: whoever + // wrote it, it was not this call. A joiner adopting it inherits that provenance. + provenance: "external-replacement", resolvedGrantFingerprint: refreshGrantFingerprint, }; } @@ -803,6 +911,7 @@ async function resolveCodexToken( credential: sameGrantFreshCredential, resolvedGrantFingerprint: refreshGrantFingerprint, selfRefreshed: true, + provenance: "self-refresh", }; } const res = await fetch(CHATGPT_TOKEN_URL, { @@ -882,6 +991,7 @@ async function resolveCodexToken( // token — tagging the new grant would make every legitimate joiner look foreign. resolvedGrantFingerprint: refreshGrantFingerprint, selfRefreshed: true, + provenance: "self-refresh", }; }); /* @@ -923,6 +1033,9 @@ async function resolveCodexToken( // produced this generation, and a forced caller needs that to know whether the new // credential descends from the one it was holding. ...(result.selfRefreshed !== undefined ? { selfRefreshed: result.selfRefreshed } : {}), + // Provenance rides out with the rest: a joiner that adopts this result needs the + // flight's own classification, not a guess made at the adoption site (#3019). + ...(result.provenance !== undefined ? { provenance: result.provenance } : {}), ...(result.resolvedGrantFingerprint !== undefined ? { resolvedGrantFingerprint: result.resolvedGrantFingerprint } : {}), diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index c70ebf79a7..91734a3229 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -10,6 +10,7 @@ import { getCodexAccountCredential, getValidCodexToken, isCodexAccountGenerationLive, + forceRefreshCodexPoolToken, markCodexAccountValidated, readCodexAccountRecord, saveCodexAccountCredential, @@ -124,6 +125,14 @@ import { tryAcquireNativeMainProfileClaim } from "./native-main-admission"; import { withNativeMainSharedClaim } from "./native-main-claim"; import { resolveNativeProfileContext } from "./native-profile-store"; import { NativeProfileError } from "./native-profile-types"; +import { WHAM_REQUEST_TIMEOUT_MS } from "./quota-recovery-timing"; +import { + claimQuotaRecovery, + quotaRecoveryTerminalFor, + releaseQuotaRecovery, + settleQuotaRecovery, + settleQuotaRecoveryTerminal, +} from "./quota-401-recovery"; function isNativeMainClaimUnavailable(error: unknown): error is NativeProfileError { return error instanceof NativeProfileError @@ -766,7 +775,7 @@ async function fetchMainAccountInfoWhileOwned( try { const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", { headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id }, - signal: AbortSignal.timeout(8000), + signal: AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS), }); if (!resp.ok) { const terminalAuthFailure = await isTerminalMainAuthResponse(resp, isMainAccountTokenVerifiablyLive()); @@ -959,6 +968,180 @@ function reconcileFreshPoolAccountPlans(runtimeConfig: OcxConfig, updates: Fresh } } + + +/** + * One refresh-and-replay for a pool account whose WHAM request came back 401 (#3019). + * + * The account list used to convert any 401 straight into `needsReauth`, and a bare 401 is + * exactly what a stale-but-refreshable bearer produces after a plan change — so a healthy + * credential was thrown away and the operator was told to log in again. + * + * Bounded by the recovery store: one attempt per credential lineage. An unbounded retry + * against an upstream 401 is a self-inflicted credential-stuffing loop, which is why the + * claim is taken BEFORE the refresh and settled by the flight rather than by this caller. + */ +async function recoverPoolQuotaFrom401(ctx: { + accountId: string; + existing: StoredAccountQuota | null; + configuredPlan: string | undefined; + rejectedAccessToken: string; + rejectedGeneration: number; + resp: Response; + onCredentialGeneration?: (generation: number) => void; +}): Promise { + const { accountId, existing, configuredPlan, rejectedAccessToken, rejectedGeneration, resp } = ctx; + + // Structured terminal evidence short-circuits everything: the same allowlist and bounded + // parser the main account uses, because it is the same endpoint answering. + if (await isTerminalPoolAuthResponse(resp)) { + // Durable, not just this response: the account list re-polls, and without a recorded + // mark the next bare 401 finds nothing terminal and reports the account healthy. + // + // Scoped to the generation this evidence is ABOUT. An account-wide mark would outlive + // the credential it condemned, so a late terminal response arriving after the operator + // re-authenticated would quarantine the replacement. + markAccountNeedsReauth(accountId, captureConfigGeneration(), rejectedGeneration); + return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration }; + } + + const claim = claimQuotaRecovery(accountId, rejectedGeneration); + if (!claim.granted) { + // A lineage fenced by a TERMINAL refresh failure stays terminal. Without this, the + // budget being used would make the next bare 401 report a dead credential as healthy. + if (quotaRecoveryTerminalFor(accountId, rejectedGeneration)) { + return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration }; + } + // Otherwise: this lineage spent its attempt, another caller is mid-refresh, or a + // transient failure is backing off. Report transient and let the next poll try — + // quarantining here would undo the whole point of the budget. + return { quota: existing ?? null, needsReauth: false, credentialGeneration: rejectedGeneration }; + } + + let refreshed: Awaited>; + try { + refreshed = await forceRefreshCodexPoolToken(accountId, { + rejectedGeneration, + rejectedAccessToken, + // Settlement rides the flight, not this await: a cancelled caller would otherwise + // leave the claim to expire while the shared refresh commits, and the already + // refreshed lineage would get a second attempt. + onSettled: outcome => { + if (outcome.kind === "resolved") { + settleQuotaRecovery(accountId, claim.claimId, outcome); + } else if (outcome.error instanceof TokenRefreshError && isTerminalRefreshError(outcome.error)) { + // A revoked or expired grant does not become valid on the next poll. Releasing it + // into backoff would let the following bare 401 find a non-terminal record and + // report a dead credential as healthy. + settleQuotaRecoveryTerminal(accountId, claim.claimId); + } else { + releaseQuotaRecovery(accountId, claim.claimId, QUOTA_RECOVERY_BACKOFF_MS); + } + }, + }); + } catch (e) { + // A refresh that failed terminally is the one case where the credential really is gone. + // Everything else is unknown, and unknown is not proof. + if (e instanceof TokenRefreshError && isTerminalRefreshError(e)) { + markAccountNeedsReauth(accountId, captureConfigGeneration(), rejectedGeneration); + return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration }; + } + return { quota: existing ?? null, needsReauth: false, credentialGeneration: rejectedGeneration }; + } + + // A byte-identical access token means replaying earns the same 401. Report transient + // rather than burning the replay; the fence already moved to the returned generation. + if (!refreshed.rotated) { + return { quota: existing ?? null, needsReauth: false, credentialGeneration: refreshed.generation }; + } + + // The flight may have moved the generation while this request was in the air. Tell the + // coalescing layer where the credential actually is, or a late caller joins on a stale + // generation and opens a redundant flight. + ctx.onCredentialGeneration?.(refreshed.generation); + + const writerGeneration = captureConfigGeneration(); + const replay = await fetch("https://chatgpt.com/backend-api/wham/usage", { + headers: { + Authorization: `Bearer ${refreshed.accessToken}`, + "ChatGPT-Account-Id": refreshed.chatgptAccountId, + }, + signal: AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS), + }); + if (!replay.ok) { + if (replay.status === 401 && await isTerminalPoolAuthResponse(replay)) { + // The refresh already settled this claim non-terminally, so the record alone would + // let the next poll call a dead credential healthy. The evidence is about the + // REFRESHED credential, which is what the replay used. + markAccountNeedsReauth(accountId, writerGeneration, refreshed.generation); + return { quota: existing ?? null, needsReauth: true, credentialGeneration: refreshed.generation }; + } + return { quota: existing ?? null, needsReauth: false, credentialGeneration: refreshed.generation }; + } + return await commitPoolQuotaResponse(replay, { + accountId, existing, configuredPlan, generation: refreshed.generation, writerGeneration, + }); +} + +/** Backoff after a refresh failure that proved nothing about the credential. */ +const QUOTA_RECOVERY_BACKOFF_MS = 60_000; + +/** Same allowlist and bounded parser as the main account: it is the same endpoint. */ +async function isTerminalPoolAuthResponse(resp: Response): Promise { + // Consume the original rather than a clone. `resp.clone()` tees the body, and the + // bounded parser's timeout cancels only its own reader — the unread original branch + // keeps buffering. Nothing needs this response afterwards, so there is nothing to tee. + const code = await readMainAuthErrorCode(resp); + return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code); +} + +/** A revoked or expired grant is terminal; an unknown or transport failure is not. */ +function isTerminalRefreshError(error: TokenRefreshError): boolean { + // Read the discriminator, not the message. TokenRefreshError carries `reason`, and + // matching on human text would let a durable quarantine decision change the next time + // somebody rewords an error string. + return error.reason === "revoked" || error.reason === "expired"; +} + +/** Parse and store a successful WHAM response. Shared by the first attempt and the replay. */ +async function commitPoolQuotaResponse( + resp: Response, + ctx: { + accountId: string; + existing: StoredAccountQuota | null; + configuredPlan: string | undefined; + generation: number; + writerGeneration: number; + }, +): Promise { + const { accountId, existing, configuredPlan, generation, writerGeneration } = ctx; + const data = (await resp.json()) as WhamUsageResponse; + const freshPlan = nonEmptyPlan(data.plan_type) ?? undefined; + const quota = parseUsageQuota({ ...data, plan_type: freshPlan ?? configuredPlan }); + const freshResetCredits = quota?.resetCredits; + if (!quota) { + return { + quota: isCodexAccountGenerationLive(accountId, generation) ? existing ?? null : getAccountQuota(accountId), + needsReauth: false, + credentialGeneration: generation, + ...(freshPlan !== undefined ? { freshPlan, freshCredentialGeneration: generation } : {}), + }; + } + if (!isCodexAccountGenerationLive(accountId, generation)) { + return { quota: null, needsReauth: false, credentialGeneration: generation }; + } + setAccountQuotaFromParsed(accountId, quota, writerGeneration); + return { + quota: getAccountQuota(accountId), + needsReauth: false, + credentialGeneration: generation, + freshQuota: quota, + freshCredentialGeneration: generation, + ...(freshPlan !== undefined ? { freshPlan } : {}), + ...(freshResetCredits !== undefined ? { freshResetCredits } : {}), + }; +} + async function fetchFreshPoolAccountQuota( accountId: string, existing: StoredAccountQuota | null, @@ -976,39 +1159,25 @@ async function fetchFreshPoolAccountQuota( signal: AbortSignal.timeout(8000), }); if (!resp.ok) { - return { - quota: existing ?? null, - needsReauth: resp.status === 401, - credentialGeneration: generation, - }; - } - const data = (await resp.json()) as WhamUsageResponse; - const freshPlan = nonEmptyPlan(data.plan_type) ?? undefined; - const quota = parseUsageQuota({ ...data, plan_type: freshPlan ?? configuredPlan }); - const freshResetCredits = quota?.resetCredits; - if (!quota) { - return { - quota: isCodexAccountGenerationLive(accountId, generation) ? existing ?? null : getAccountQuota(accountId), - needsReauth: false, - credentialGeneration: generation, - ...(freshPlan !== undefined - ? { freshPlan, freshCredentialGeneration: generation } - : {}), - }; - } - if (!isCodexAccountGenerationLive(accountId, generation)) { - return { quota: null, needsReauth: false, credentialGeneration: generation }; + if (resp.status !== 401) { + return { quota: existing ?? null, needsReauth: false, credentialGeneration: generation }; + } + // A bare 401 is what a stale-but-refreshable bearer produces after a plan change, so + // quarantining on it tells the operator to re-authenticate an account that was fine + // (#3019). Refresh once, replay once, and only then decide. + return await recoverPoolQuotaFrom401({ + accountId, + existing, + configuredPlan, + rejectedAccessToken: accessToken, + rejectedGeneration: generation, + resp, + onCredentialGeneration, + }); } - setAccountQuotaFromParsed(accountId, quota, writerGeneration); - return { - quota: getAccountQuota(accountId), - needsReauth: false, - credentialGeneration: generation, - freshQuota: quota, - freshCredentialGeneration: generation, - ...(freshPlan !== undefined ? { freshPlan } : {}), - ...(freshResetCredits !== undefined ? { freshResetCredits } : {}), - }; + return await commitPoolQuotaResponse(resp, { + accountId, existing, configuredPlan, generation, writerGeneration, + }); } catch (e) { if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError || e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError) { diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 87afd0bb12..5ffc357cb1 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -30,6 +30,7 @@ import { import type { OcxConfig, OcxProviderConfig } from "../../types"; import { modelInList } from "../../types"; import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; +import { isModelVisionSidecarConsumer } from "../../vision/eligibility"; import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata"; import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; import { @@ -670,11 +671,14 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, const configuredMaxInput = configuredMaxInputTokens(prov, model.id); const configuredAutoCompact = configuredAutoCompactTokenLimit(prov, model.id); let inputModalities = configuredInputModalities(prov, model.id); - // Vision-sidecar coverage: `noVisionModels` marks models whose images the PROXY describes - // (src/vision/index.ts). The catalog must still advertise image input for them — the Codex app + // The shared vision-sidecar consumer predicate keeps catalog advertisement and request-time + // planning aligned. The catalog must still advertise image input — the Codex app // gates attachments client-side on input_modalities, and a text-only entry would block images - // before the sidecar ever runs ("This model does not support image inputs"). - if (modelInList(prov.noVisionModels, model.id)) { + // before the sidecar ever runs ("This model does not support image inputs"). Discovery-derived + // text-only rows stay untouched: the runtime predicate only reads these two config sources, so + // it would not convert those. + const sidecarCovered = isModelVisionSidecarConsumer(prov, model.id); + if (sidecarCovered) { const base = inputModalities ?? model.inputModalities ?? ["text"]; inputModalities = base.includes("image") ? [...base] : [...base, "image"]; } @@ -936,9 +940,70 @@ export function resolveComboCatalogMember( }; } +const DATED_VARIANT_YYYYMMDD = /^(\d{4})(\d{2})(\d{2})$/; +const DATED_VARIANT_YYMMDD = /^(2\d)(\d{2})(\d{2})$/; +const DATED_VARIANT_MMDD_OR_YYMM = /^(\d{2})(\d{2})$/; + +/** Whether a Gregorian year contains February 29th. */ +function isLeapYear(year: number): boolean { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +} + +/** + * Whether a month/day pair exists in the given year. Without a year, February 29th is + * accepted because it occurs in at least one calendar year. + */ +function isValidCalendarDate(year: number | undefined, month: number, day: number): boolean { + if (year !== undefined && (year < 1 || year > 9999)) return false; + if (month < 1 || month > 12 || day < 1) return false; + const daysInMonth = [ + 31, year === undefined || isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, + 31, 31, 30, 31, 30, 31, + ]; + return day <= daysInMonth[month - 1]!; +} + +/** + * Release-date suffixes providers actually publish: `YYYYMMDD` (`-20251001`), `YYMMDD` + * (`-260806`), `MMDD` (`-0813`) and `YYMM` (`-2512`). A `\d{8}`-only rule matched none of + * the dated ids on a real multi-provider install, so DeepSeek, Kimi, Mistral, Qwen and + * Solar aliases all fell through to `droppedConfiguredIds` (#3024). + * + * Calendar validation rejects impossible month-end and leap-day values as well as ordinary + * numeric suffixes such as `-2048`, `-4096` and `-8192`. `-1024` is the one irreducible + * collision — it is a valid `MMDD` (October 24th) — so it reads as dated. That is a known, + * accepted cost; the test table pins it so it cannot become a surprise later. + * + * Hyphenated ISO suffixes (`-2024-08-06`, `-05-06`) are deliberately out of scope: a + * hyphenated suffix is ambiguous against ordinary name segments and needs its own call. + */ +function isDatedVariantSuffix(suffix: string): boolean { + const yyyyMmDd = DATED_VARIANT_YYYYMMDD.exec(suffix); + if (yyyyMmDd) { + return isValidCalendarDate( + Number(yyyyMmDd[1]), Number(yyyyMmDd[2]), Number(yyyyMmDd[3]), + ); + } + + const yyMmDd = DATED_VARIANT_YYMMDD.exec(suffix); + if (yyMmDd) { + return isValidCalendarDate( + 2000 + Number(yyMmDd[1]), Number(yyMmDd[2]), Number(yyMmDd[3]), + ); + } + + const mmDdOrYyMm = DATED_VARIANT_MMDD_OR_YYMM.exec(suffix); + if (!mmDdOrYyMm) return false; + const first = Number(mmDdOrYyMm[1]); + const second = Number(mmDdOrYyMm[2]); + return isValidCalendarDate(undefined, first, second) + || (first >= 20 && first <= 29 && second >= 1 && second <= 12); +} + +/** Whether `liveId` is a supported dated release of the configured base id. */ export function isDatedVariantId(liveId: string, configuredId: string): boolean { if (!liveId.startsWith(`${configuredId}-`)) return false; - return /^\d{8}$/.test(liveId.slice(configuredId.length + 1)); + return isDatedVariantSuffix(liveId.slice(configuredId.length + 1)); } export const lastDropWarnSignature = new Map(); @@ -2113,9 +2178,10 @@ async function gatherRoutedModelsUncached( ...(base.codexToolMode === undefined && replaced.codexToolMode !== undefined ? { codexToolMode: replaced.codexToolMode } : {}), ...(base.capabilities === undefined && replaced.capabilities !== undefined ? { capabilities: replaced.capabilities } : {}), } : base; - // Vision-sidecar coverage ONLY: if the custom model is in the enriched provider's - // noVisionModels, advertise image input so the Codex app lets images reach the sidecar - // (#349/#344). Deliberately NOT the full applyProviderConfigHints pass — custom rows are a + // Vision-sidecar coverage only: when the enriched provider's shared predicate matches + // noVisionModels or text-without-image modelInputModalities, advertise image input so the + // Codex app lets images reach the sidecar (#349/#344). Deliberately NOT the full + // applyProviderConfigHints pass — custom rows are a // user override, so their explicit contextWindow / inputModalities / reasoning fields must be // preserved verbatim (the hint pass would cap context and overwrite modalities from registry). const mergedContext = typeof merged.contextWindow === "number" && merged.contextWindow > 0 @@ -2141,7 +2207,8 @@ async function gatherRoutedModelsUncached( } : mergedWithHardBounds; const enrichedProvider = enrichedByName.get(cm.provider) ?? rawProvider; - if (enrichedProvider && modelInList(enrichedProvider.noVisionModels, mergedWithAutoCompact.id)) { + // Reuse the request-time consumer predicate so custom rows cannot drift from catalog hints. + if (enrichedProvider && isModelVisionSidecarConsumer(enrichedProvider, mergedWithAutoCompact.id)) { const current = mergedWithAutoCompact.inputModalities ?? ["text"]; if (!current.includes("image")) { return { ...mergedWithAutoCompact, inputModalities: [...current, "image"] }; diff --git a/src/codex/history-job.ts b/src/codex/history-job.ts index b52e57dccc..1f4e174c51 100644 --- a/src/codex/history-job.ts +++ b/src/codex/history-job.ts @@ -110,6 +110,8 @@ export type CodexHistoryJobOutcome = | { readonly kind: "blocked"; readonly reason: "busy" | "database" | "unsafe-path" | "desired_disabled" | "desired_enabled" } | { readonly kind: "failed"; readonly reason: "worker-error" | "worker-died" | "timeout"; readonly message: string; readonly historyFailureReason?: CodexHistoryFailureReason; + /** Specific integrity condition when `historyFailureReason` is `"integrity"`. */ + readonly historyIntegrityCode?: string; readonly rows?: number; readonly files?: number }; /** @@ -258,6 +260,13 @@ export function describeHistoryJobFailure( return "permission was denied while writing Codex history; this is not a Codex app lock. Run 'ocx doctor'."; } if (outcome.historyFailureReason === "integrity") { + // Not every integrity stop is a retry. An ambiguous reroute means two histories + // produced the same row and no durable fact separates them, so retrying reaches the + // same refusal - the manifest needs a person, and saying "run doctor" sends them the + // wrong way. + if (outcome.historyIntegrityCode === "history_apply_ambiguous_reroute") { + return "a Codex history entry could not be re-routed because its manifest cannot prove whether an earlier relabel was undone; nothing was changed and the manifest was kept. Resolve it manually rather than retrying."; + } return partiallyChanged ? "the history backup or its restore target changed after a partial restore; the manifest was retained for review and safe retry. Run 'ocx doctor'." : "the history backup or its restore target failed integrity checks; no unverified provider metadata was applied. Run 'ocx doctor'."; @@ -298,6 +307,7 @@ function classifyWorkerResult(result: HistoryWorkerResult): CodexHistoryJobOutco reason: "worker-error", message: redactWorkerMessage(result.message), ...(result.reason ? { historyFailureReason: result.reason } : {}), + ...(result.integrityCode ? { historyIntegrityCode: result.integrityCode } : {}), ...(result.rows !== undefined && result.files !== undefined ? { rows: result.rows, files: result.files } : {}), diff --git a/src/codex/history-manifest.ts b/src/codex/history-manifest.ts index 3f5f0c2af8..01e649b306 100644 --- a/src/codex/history-manifest.ts +++ b/src/codex/history-manifest.ts @@ -10,10 +10,37 @@ export interface CodexHistoryBackupEntry { modelProvider: string; source: string; hasUserEvent: 0 | 1; + /** + * Whether the row had a non-empty `first_user_message` when the snapshot was taken. + * + * Routing derives the post-image `has_user_event` from the message AT SNAPSHOT TIME + * (`history-provider.ts` `routeOpenai`), so a restore that recomputes it from the + * message as it is NOW will mistake the user's first message for OpenCodex's own write + * and erase it. Only the emptiness is recorded, never the text: this manifest is a file + * on disk and the message is user content. + * + * Optional because manifests written before this field exists cannot be given one. An + * entry without it falls back to the current-row reading, which is exactly as imprecise + * as the behaviour it replaces and no worse. + */ + hadFirstUserMessage?: boolean; + /** + * Whether OpenCodex's routing relabel is known to have landed for this entry. + * + * `pending` is written before the routing write and resolved after it, so a crash + * between the two leaves an honest "unknown" rather than a confident wrong answer. The + * observed row resolves it: the recorded original means the write did not land, the + * expected post-image means it did. + * + * Absent on entries written before the field existed. Those refuse only in the one + * genuinely undecidable case — original tuple with `has_user_event` moved 0 to 1 — + * which `dev` already refuses today. + */ + relabel?: "pending" | "committed" | "none"; } export interface CodexHistoryBackupManifest { - version: 1; + version: 1 | 2; stateDbPath: string; entries: Record; } @@ -76,7 +103,7 @@ export function validateCodexHistoryBackupManifest( expectedStateDbPath: string, ): CodexHistoryManifestValidation { if (!isRecord(raw) - || raw.version !== 1 + || (raw.version !== 1 && raw.version !== 2) || typeof raw.stateDbPath !== "string" || !raw.stateDbPath.trim() || !isAbsolute(raw.stateDbPath) @@ -103,6 +130,12 @@ export function validateCodexHistoryBackupManifest( || typeof value.hasUserEvent !== "number" || !Number.isSafeInteger(value.hasUserEvent) || (value.hasUserEvent !== 0 && value.hasUserEvent !== 1) + // Optional, but not unvalidated: a truthy `hadFirstUserMessage: "false"` would select + // the wrong restore verdict, and an unrecognized `relabel` would be read as a state + // the classifier does not have. + || (value.hadFirstUserMessage !== undefined && typeof value.hadFirstUserMessage !== "boolean") + || (value.relabel !== undefined + && value.relabel !== "pending" && value.relabel !== "committed" && value.relabel !== "none") || !hasAllowedProvenance(value)) { return { ok: false, reason: "schema", scope: "entry-provenance" }; } diff --git a/src/codex/history-provider.ts b/src/codex/history-provider.ts index 4ee8bac87d..d7f1832e8f 100644 --- a/src/codex/history-provider.ts +++ b/src/codex/history-provider.ts @@ -227,6 +227,11 @@ function integrityFailureResult(error: CodexHistoryIntegrityError): CodexHistory files: error.progress.files, failed: true, failureReason: "integrity", + // The specific code, so an operator sees WHICH integrity condition stopped the + // transition rather than a generic "run doctor". `history_apply_ambiguous_reroute` in + // particular needs manual resolution: the manifest is intact and the safe move is to + // inspect it, not to retry. + integrityCode: error.message, }; } @@ -239,6 +244,15 @@ export interface CodexHistorySyncResult { failed?: true; /** Why the retry budget was exhausted when `failed` is set. */ failureReason?: CodexHistoryFailureReason; + /** + * The specific integrity condition, when `failureReason` is `"integrity"`. + * + * `failureReason` alone tells an operator only that something was inconsistent, which + * reads as "retry or run doctor". Some of these are not retryable — + * `history_apply_ambiguous_reroute` means two histories produced the same row and the + * manifest needs a human — so the code travels with the result. + */ + integrityCode?: string; } interface ThreadRow { @@ -361,7 +375,10 @@ function readBackupStrict(path: string, stateDbPath: string): StrictBackupRead { return { kind: "known", present: false, - manifest: { version: 1, stateDbPath, entries: {} }, + // New manifests carry the snapshot and relabel fields, so they are v2. v1 stays + // readable: an entry written before those fields existed falls back to the + // current-row reading, which is the behaviour it was written under. + manifest: { version: 2, stateDbPath, entries: {} }, fingerprint: "absent", }; } @@ -462,14 +479,80 @@ function writeBackup(path: string, manifest: CodexHistoryBackupManifest, stateDb atomicWriteFile(path, JSON.stringify({ ...manifest, stateDbPath: manifest.stateDbPath ?? stateDbPath }, null, 2) + "\n"); } -function rememberOriginal(manifest: CodexHistoryBackupManifest, row: ThreadRow): void { - if (manifest.entries[row.id]) return; +function rememberOriginal(manifest: CodexHistoryBackupManifest, row: ApplyRowSnapshot): void { + const existing = manifest.entries[row.id]; + if (existing) { + // A surviving entry means a previous route/restore cycle did not consume its manifest. + // Its `relabel` describes THAT attempt, and this one has not written yet, so a stale + // `committed` would let a later restore treat the marker as proof that OpenCodex + // authored an event flag the user had since set. + // + // The provenance tuple stays — it is the ORIGINAL, and a routed row must never + // overwrite it. But `hadFirstUserMessage` is not provenance: it describes the input to + // one routing write, and this attempt has its own. Leaving the previous attempt's value + // makes the new routed row match the expected post-image and erases activity that + // arrived in between. Re-record it, and promote the manifest so the field is covered by + // the schema that declares it. + const previousRelabel = existing.relabel; + const previousHadFirstUserMessage = existing.hadFirstUserMessage; + existing.relabel = "pending"; + existing.hadFirstUserMessage = hasFirstUserMessage(row.first_user_message); + // `hasUserEvent` is the value restore returns to, and the previous attempt's can be two + // events stale — a restore that already landed, plus whatever the user did afterwards. + // Refreshing it needs proof that the previous relabel was UNDONE, because the original + // tuple alone is not proof: route-then-legacy-recovery lands on that same tuple, so + // refreshing there would adopt OpenCodex's own write as the user's baseline. + // + const atOriginalTuple = row.model_provider === existing.modelProvider + && row.source === existing.source; + const observedEvent: 0 | 1 = Number(row.has_user_event) === 1 ? 1 : 0; + if (observedEvent !== existing.hasUserEvent) { + // The row's event disagrees with the recorded baseline. Whether that is decidable is + // a function of DIRECTION and ORIGIN, not of one flag: + // + // - `1 -> 0` is always foreign. Nothing in this system clears the flag: routing only + // ever sets it, the user only ever sets it, and legacy recovery sets it to 1. A + // baseline that moved down is a decision this manifest does not own. + // - `0 -> 1` on an exec-origin entry is the user's. `routeExec` moves `source` to + // `cli` and legacy recovery does not move it back, so an exec-origin row wearing + // its original tuple was never routed away and back. + // - `0 -> 1` with `relabel: "none"` is the user's: a restore landed and undid the + // previous relabel, so the observed row is the honest pre-route state. + // - `0 -> 1` where the previous route would have written 0 is the user's, because + // OpenCodex could not have authored a 1 it never writes. + // - `0 -> 1` where the previous route WOULD have written 1, or where a legacy entry + // records nothing about it, is undecidable: routing-never-landed-plus-activity and + // routing-landed-then-legacy-recovery produce the same row. Refuse rather than pick. + const reverseDrift = observedEvent === 0; + const execOrigin = existing.modelProvider !== "openai"; + const priorRouteWroteZero = previousHadFirstUserMessage === false; + const decidable = !reverseDrift + && atOriginalTuple + && (execOrigin || previousRelabel === "none" || priorRouteWroteZero); + if (!decidable) { + throw new CodexHistoryIntegrityError("history_apply_ambiguous_reroute"); + } + existing.hasUserEvent = observedEvent; + } + manifest.version = 2; + return; + } + manifest.version = 2; manifest.entries[row.id] = { id: row.id, rolloutPath: row.rollout_path, modelProvider: row.model_provider, source: row.source, hasUserEvent: Number(row.has_user_event) === 1 ? 1 : 0, + // Emptiness only, never the text: the manifest is a file on disk and the message is + // user content. Routing derives the post-image event flag from the message as it was + // HERE, so a restore that re-reads the current message would mistake later user + // activity for OpenCodex's own write. + hadFirstUserMessage: hasFirstUserMessage(row.first_user_message), + // The routing write has not happened yet. Resolved to "committed" after it lands, or + // left pending if the process dies between the two - in which case the observed row + // is what decides. + relabel: "pending", }; } @@ -486,7 +569,13 @@ function rowMatchesRestoreTuple( function rowMatchesExpectedPostImage(row: RestoreRowSnapshot, entry: CodexHistoryBackupEntry): boolean { if (entry.modelProvider === "openai") { - const postHasUserEvent = hasFirstUserMessage(row.first_user_message) ? 1 : entry.hasUserEvent; + // Routing derived this from the message AT SNAPSHOT TIME (`routeOpenai`), so read the + // recorded flag when the manifest has one. Recomputing from the row's CURRENT message + // mistakes a first message the user sent after routing for OpenCodex's own write, and + // restore then erases it. Manifests written before the flag existed fall back to the + // current reading, which is exactly the behaviour this replaces and no worse. + const hadMessage = entry.hadFirstUserMessage ?? hasFirstUserMessage(row.first_user_message); + const postHasUserEvent = hadMessage ? 1 : entry.hasUserEvent; return rowMatchesRestoreTuple(row, "opencodex", entry.source, postHasUserEvent); } return hasFirstUserMessage(row.first_user_message) @@ -499,6 +588,60 @@ function rowMatchesExpectedPostImage(row: RestoreRowSnapshot, entry: CodexHistor ); } +/** + * What `has_user_event` should read after restore, or `null` when the row is not one this + * manifest owns. + * + * The field has two writers, so a final state cannot establish authorship on its own. Four + * shapes cover every row reachable in practice, and the tuple the row wears says which: + * + * - **A** exactly the recorded original: untouched, or already restored. + * - **B** the expected post-image: OpenCodex wrote it, so the recorded value is authoritative. + * - **C** the original tuple with the flag moved 0 to 1: either Codex-side user activity, or + * OpenCodex routing that legacy recovery has since pulled back to the original provider. + * - **D** the post-image tuple with the flag moved 0 to 1: a routed row the user then touched. + * No provenance needed - a row wearing the routed tuple was written by OpenCodex, so drift + * on top of it can only be what followed. + * + * Only C is ambiguous, and only when the route's own expected event was 1: then "routing + * never landed and the user typed" and "routing landed and legacy recovery pulled it back" + * produce an identical row, and nothing durable separates them. That one cell refuses. A + * guess there either erases real activity or fabricates it. + */ +export function restoredUserEventFor(row: RestoreRowSnapshot, entry: CodexHistoryBackupEntry): 0 | 1 | null { + if (rowMatchesRestoreTuple(row, entry.modelProvider, entry.source, entry.hasUserEvent)) { + return entry.hasUserEvent; // A + } + if (rowMatchesExpectedPostImage(row, entry)) return entry.hasUserEvent; // B + + const drifted = Number(row.has_user_event) === 1 && entry.hasUserEvent === 0; + if (!drifted) return null; + + const routeExpectedEvent = entry.hadFirstUserMessage ?? hasFirstUserMessage(row.first_user_message) ? 1 : 0; + + // D: wearing the routed tuple, so the 1 arrived after OpenCodex wrote the row. The tuple + // is the one routing actually produces — `routeOpenai` keeps the source, `routeExec` + // moves exec to cli — so D and C cannot both match rather than merely being ordered. + const routedSource = entry.modelProvider === "openai" ? entry.source : "cli"; + if (rowMatchesRestoreTuple(row, "opencodex", routedSource, 1)) return 1; + + // C: wearing the original tuple. + if (rowMatchesRestoreTuple(row, entry.modelProvider, entry.source, 1)) { + // An exec-origin entry cannot reach here by legacy recovery: routeExec moves source to + // cli and recovery does not move it back, so the original tuple is unreachable that way. + if (entry.modelProvider !== "openai") return 1; + if (entry.relabel === "none") return 1; + if (entry.relabel === "committed") { + // OpenCodex authored the 1 only if its own routing write would have produced one. + return routeExpectedEvent === 1 ? 0 : 1; + } + if (entry.relabel === undefined) return null; // legacy manifest: the pre-existing refusal + // pending: two histories reach this exact row and nothing durable tells them apart. + return routeExpectedEvent === 1 ? null : 1; + } + return null; +} + interface RestoreRolloutSnapshot { readonly identity: string; readonly latestProvider: string; @@ -549,7 +692,7 @@ function snapshotRolloutForRestore(entry: CodexHistoryBackupEntry): RestoreRollo if (identityBefore === null) { throw new CodexHistoryIntegrityError("history_backup_rollout_unrestorable"); } - const latest = readLatestSessionMeta(entry.rolloutPath); + const latest = readLatestSessionMetaForId(entry.rolloutPath, entry.id); if (!latest || (!rolloutMatchesRestoreTuple(latest, entry, entry.modelProvider, entry.source) && !rolloutMatchesExpectedPostImage(latest, entry))) { @@ -608,8 +751,7 @@ function preflightRestoreRows( if (!row || typeof row.rollout_path !== "string" || !sameCodexHistoryPath(row.rollout_path, entry.rolloutPath)) { throw new CodexHistoryIntegrityError("history_backup_target_mismatch"); } - if (!rowMatchesRestoreTuple(row, entry.modelProvider, entry.source, entry.hasUserEvent) - && !rowMatchesExpectedPostImage(row, entry)) { + if (restoredUserEventFor(row, entry) === null) { throw new CodexHistoryIntegrityError("history_backup_postimage_mismatch"); } snapshots.set(entry.id, row); @@ -625,10 +767,11 @@ function assertRestoreReadback( const row = getCurrent(entry.id); if (!row || !sameCodexHistoryPath(row.rollout_path, entry.rolloutPath) - || !rowMatchesRestoreTuple(row, entry.modelProvider, entry.source, entry.hasUserEvent)) { + || !rowMatchesRestoreTuple(row, entry.modelProvider, entry.source, Number(row.has_user_event) === 1 ? 1 : 0) + || restoredUserEventFor(row, entry) === null) { throw new CodexHistoryIntegrityError("history_backup_database_readback_mismatch"); } - const latest = readLatestSessionMeta(entry.rolloutPath); + const latest = readLatestSessionMetaForId(entry.rolloutPath, entry.id); if (inspectFirstLineProvider(entry.rolloutPath, entry.id, entry.modelProvider) !== "current" || !latest || !rolloutMatchesRestoreTuple(latest, entry, entry.modelProvider, entry.source)) { @@ -665,6 +808,19 @@ export function readLatestSessionMeta(path: string): ParsedSessionMeta | null { return readLatestSessionMetaFromText(raw); } +/** + * Same fold as {@link readLatestSessionMeta}, restricted to this thread's own metadata. + * + * A forked/branched rollout appends the SOURCE thread's `session_meta` after its own, and the + * app discards any record whose payload id is not the canonical thread id (codex-rs + * `apply_session_meta_from_item`). Reading the last line regardless of id therefore answers + * with a foreign thread's provider, which is neither what the app honors nor what we may patch. + */ +function readLatestSessionMetaForId(path: string, expectedId: string): ParsedSessionMeta | null { + const raw = readFileSync(path, "utf8"); + return readLatestSessionMetaForIdFromText(raw, expectedId); +} + function readLatestSessionMetaFromText(raw: string): ParsedSessionMeta | null { const lines = raw.split("\n"); for (let i = lines.length - 1; i >= 0; i--) { @@ -875,18 +1031,15 @@ function updateSessionMeta( return { changed: false, durableProvider: false, conflict: true }; } - const latest = readLatestSessionMeta(path); + // Resolve by id. The app ignores `session_meta` lines whose payload id != the canonical + // thread id (codex-rs `apply_session_meta_from_item`), and a forked rollout trails the source + // session's metadata, so the last line is not necessarily this thread's. Patching that record + // would clone the wrong thread's meta into a line the app discards; skipping the file entirely + // left forked threads unroutable and, once routed, unrestorable. + const latest = readLatestSessionMetaForId(path, expectedId); if (!latest) return { changed: false, durableProvider: false }; const record = latest.record; - // The app ignores `session_meta` lines whose payload id != the canonical thread id - // (codex-rs `apply_session_meta_from_item`). Forked rollouts can embed a source session's - // metadata, so an id-mismatched latest line means we'd be cloning the wrong thread's meta and - // appending a line the app would discard. Skip rather than write a no-op/misleading line. - const payloadId = record.payload.id; - if (typeof payloadId !== "string" || payloadId !== expectedId) { - return { changed: false, durableProvider: false }; - } const latestProvider = typeof record.payload.model_provider === "string" && record.payload.model_provider ? record.payload.model_provider : "openai"; @@ -1236,6 +1389,15 @@ function syncCodexHistoryProviderUnsafe(provider: CodexHistoryProvider, stateDbP throw error; } + // The routing writes landed. Resolve every pending marker and rewrite the manifest, so a + // later restore knows the relabel is OpenCodex's rather than having to infer it. A crash + // before this point leaves `pending`, which the observed row resolves at restore time. + for (const row of [...openaiRows, ...execRows]) { + const entry = manifest.entries[row.id]; + if (entry?.relabel === "pending") entry.relabel = "committed"; + } + writeBackup(backupPath, manifest, stateDbPath); + return { rows: openaiRows.length + execRows.length, files }; } finally { db.close(); @@ -1277,10 +1439,13 @@ function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): C for (const entry of entries) { const before = snapshots.get(entry.id); if (!before) throw new CodexHistoryIntegrityError("history_backup_snapshot_missing"); + // Codex-side activity that arrived after OpenCodex wrote the row is the user's, and + // restoring the manifest's snapshot over it would erase it. + const restoredEvent = restoredUserEventFor(before, entry) ?? entry.hasUserEvent; const result = update.run( entry.modelProvider, entry.source, - entry.hasUserEvent, + restoredEvent, entry.id, before.rollout_path, before.model_provider, @@ -1345,6 +1510,18 @@ function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): C if (error instanceof CodexHistoryIntegrityError) { throw new CodexHistoryIntegrityError(error.message, { rows: entries.length, files }); } + // The restore landed and its readback passed; only finalization failed, so the + // manifest survives on disk. Record that its relabel is undone, or a later routing + // attempt cannot tell this entry from one still mid-route and has to keep a baseline + // that is now stale. Best-effort: a failure here leaves exactly the prior state. + try { + for (const entry of entries) { + const stored = manifest.entries[entry.id]; + if (stored) stored.relabel = "none"; + } + manifest.version = 2; + writeBackup(backupPath, manifest, stateDbPath); + } catch { /* the surviving manifest keeps its previous marker */ } const failureReason = classifyRecoverableHistoryError(error); if (failureReason) { return { diff --git a/src/codex/history-worker.ts b/src/codex/history-worker.ts index 52151a89fb..81718b73d2 100644 --- a/src/codex/history-worker.ts +++ b/src/codex/history-worker.ts @@ -75,6 +75,8 @@ export type HistoryWorkerResult = readonly reason: "busy" | "database" | "unsafe-path" | "desired_disabled" | "desired_enabled" } | { readonly type: "error"; readonly requestId: string; readonly jobId: string; readonly message: string; readonly reason?: CodexHistoryFailureReason; + /** Specific integrity condition, so a non-retryable one can be named as such. */ + readonly integrityCode?: string; readonly rows?: number; readonly files?: number }; const OPERATIONS: ReadonlySet = new Set([ @@ -181,6 +183,7 @@ export function runHistoryUnitUnderLock( jobId, message: "history_transition_failed", ...(result.failureReason ? { reason: result.failureReason } : {}), + ...(result.integrityCode ? { integrityCode: result.integrityCode } : {}), ...(result.rows > 0 || result.files > 0 ? { rows: result.rows, files: result.files } : {}), }; } diff --git a/src/codex/quota-401-recovery.ts b/src/codex/quota-401-recovery.ts new file mode 100644 index 0000000000..7de4fda232 --- /dev/null +++ b/src/codex/quota-401-recovery.ts @@ -0,0 +1,190 @@ +import { randomUUID } from "node:crypto"; +import { isCodexAccountGenerationLive, type CodexRefreshProvenance } from "./account-store"; +import { QUOTA_RECOVERY_LEASE_MS } from "./quota-recovery-timing"; + +/** + * One refresh-and-replay per credential lineage, for a WHAM 401 (#3019). + * + * A bare 401 from `backend-api/wham/usage` is what a stale-but-refreshable bearer produces + * after a plan change, so quarantining on it tells the operator to re-authenticate an + * account that was fine. The fix is to refresh and replay once — and `once` is the whole + * problem, because an unbounded retry against an upstream 401 is a self-inflicted + * credential-stuffing loop. + * + * ## Why a claim id rather than the lineage + * + * Keying the budget on the lineage cannot separate an old claimant from a later retry on + * the same lineage: claim L, a transient failure releases L, another poll claims L, and + * the first caller's late settlement spends the second caller's claim. The claim id makes + * every mutation a compare-and-set on `(accountId, lineage, claimId)`, so a stale + * completion is a no-op instead of somebody else's budget. + * + * ## Why `spent` never expires and `claimed` does + * + * Expiring a spent record would hand the same lineage another refresh, which is exactly + * the property this module exists to hold. But a caller can be cancelled or die between + * claim and settle, so `claimed` carries a bounded lease: an expired lease is reclaimable, + * and is never promoted to `spent` because the refresh may not have happened. + */ + +export type RecoveryRecord = + | { state: "claimed"; lineage: number; claimId: string; expiresAt: number } + /** + * `terminal` marks a lineage whose refresh proved the grant is dead. It is spent AND the + * account must keep reporting needs-reauth: without it, the next bare 401 finds the + * budget used, reports transient, and a dead credential looks healthy. + */ + | { state: "spent"; lineage: number; terminal?: true } + | { state: "backoff"; lineage: number; nextAttemptAt: number }; + +export type ClaimResult = + | { granted: true; claimId: string } + | { granted: false; reason: "spent" | "in-flight" | "backoff" }; + +const records = new Map(); + +function leaseExpired(record: RecoveryRecord, now: number): boolean { + return record.state === "claimed" && record.expiresAt <= now; +} + +/** + * Claim the one refresh this lineage is entitled to. + * + * A live claim blocks EVERY lineage for the account, not just its own. The refresh it + * fences commits `G -> G+1` before the claimant can settle, so during that window a + * `G+1` claim would otherwise be granted and the late settlement would land on nothing. + */ +export function claimQuotaRecovery( + accountId: string, + lineage: number, + now: number = Date.now(), +): ClaimResult { + const existing = records.get(accountId); + if (existing && !leaseExpired(existing, now)) { + if (existing.state === "claimed") return { granted: false, reason: "in-flight" }; + if (existing.state === "spent") { + // Only this lineage is fenced. A different one is a new grant with its own budget. + if (existing.lineage === lineage) return { granted: false, reason: "spent" }; + } else if (existing.lineage === lineage && existing.nextAttemptAt > now) { + return { granted: false, reason: "backoff" }; + } + } + const claimId = randomUUID(); + records.set(accountId, { state: "claimed", lineage, claimId, expiresAt: now + QUOTA_RECOVERY_LEASE_MS }); + return { granted: true, claimId }; +} + +function heldClaim(accountId: string, claimId: string): Extract | null { + const existing = records.get(accountId); + if (!existing || existing.state !== "claimed" || existing.claimId !== claimId) return null; + return existing; +} + +/** + * Record the outcome of a claimed refresh. + * + * Which lineage gets fenced depends on the outcome, and getting this backwards is how a + * fresh credential loses the recovery it is entitled to: + * + * - `self-refresh` and `joined-lineage` are this lineage's own attempt, so the RETURNED + * generation is spent. The returned one, never the rejected one: a successful token + * response can rotate only the refresh grant, leaving the access token byte-identical, + * and the credential has already moved by then. + * - `external-replacement` means somebody else's credential is now stored. The claimed + * OLD lineage is spent — this caller did use its attempt — and the returned generation + * is left untouched so it can claim on its own next 401. + */ +export function settleQuotaRecovery( + accountId: string, + claimId: string, + outcome: { provenance: CodexRefreshProvenance; generation: number }, +): void { + const held = heldClaim(accountId, claimId); + if (!held) return; // superseded or already settled: never disturb a newer claimant + const fenced = outcome.provenance === "external-replacement" ? held.lineage : outcome.generation; + records.set(accountId, { state: "spent", lineage: fenced }); +} + +/** + * Record a refresh that failed with proof the grant itself is dead. + * + * Distinct from {@link releaseQuotaRecovery}: a revoked or expired grant will not become + * valid on the next poll, so backing off would let a later 401 report the account healthy + * while it is not. The lineage is fenced durably and the caller quarantines. + */ +export function settleQuotaRecoveryTerminal(accountId: string, claimId: string): void { + const held = heldClaim(accountId, claimId); + if (!held) return; + records.set(accountId, { state: "spent", lineage: held.lineage, terminal: true }); +} + +/** Did this lineage's one refresh prove the grant dead? */ +export function quotaRecoveryTerminalFor(accountId: string, lineage: number): boolean { + const record = records.get(accountId); + return record?.state === "spent" && record.lineage === lineage && record.terminal === true; +} + +/** + * Release a claim whose refresh failed without proving anything about the credential. + * + * Into BACKOFF, not into eligibility: a failed quota request does not refresh the quota + * timestamp, so successive dashboard and background polls would each issue another token + * refresh — the loop, reopened from the other side. + */ +export function releaseQuotaRecovery( + accountId: string, + claimId: string, + backoffMs: number, + now: number = Date.now(), +): void { + const held = heldClaim(accountId, claimId); + if (!held) return; + records.set(accountId, { state: "backoff", lineage: held.lineage, nextAttemptAt: now + backoffMs }); +} + +/** + * Drop records for accounts that no longer exist, and fences whose credential has moved. + * + * A live claim is exempt. Its refresh commits the generation forward, so "the fenced + * generation is no longer stored" is the EXPECTED state mid-flight, and removing it there + * would let a second claim in before the first settles. + */ +export function reconcileQuotaRecovery(liveAccountIds: ReadonlySet, now: number = Date.now()): number { + let removed = 0; + for (const [accountId, record] of [...records]) { + if (!liveAccountIds.has(accountId)) { + records.delete(accountId); + removed += 1; + continue; + } + if (record.state === "claimed" && !leaseExpired(record, now)) continue; + if (!isCodexAccountGenerationLive(accountId, record.lineage)) { + records.delete(accountId); + removed += 1; + } + } + return removed; +} + +/** Drop expired backoff windows and abandoned leases. */ +export function sweepExpiredQuotaRecovery(now: number = Date.now()): number { + let removed = 0; + for (const [accountId, record] of [...records]) { + // A spent record is durable: expiring it would grant the same lineage another refresh. + if (record.state === "spent") continue; + const stale = record.state === "backoff" ? record.nextAttemptAt <= now : leaseExpired(record, now); + if (stale) { + records.delete(accountId); + removed += 1; + } + } + return removed; +} + +export function quotaRecoveryRecordForTests(accountId: string): RecoveryRecord | undefined { + return records.get(accountId); +} + +export function resetQuotaRecoveryForTests(): void { + records.clear(); +} diff --git a/src/codex/quota-recovery-timing.ts b/src/codex/quota-recovery-timing.ts new file mode 100644 index 0000000000..f1a992e0b7 --- /dev/null +++ b/src/codex/quota-recovery-timing.ts @@ -0,0 +1,28 @@ +/** + * Timing contract shared by the credential refresh, the WHAM quota request, and the + * 401-recovery budget (#3019). + * + * A leaf on purpose: `auth-api.ts` imports the recovery store to claim and settle, so the + * recovery store cannot import `auth-api.ts` back for the WHAM timeout. Both of them, and + * `account-store.ts`, import this instead. + * + * These were three inline literals in three files. The lease has to outlast the operations + * it fences — a lease that expires mid-refresh admits a second claim for a lineage that is + * already being refreshed — so it is derived here rather than restated as a round number + * that drifts away from what it covers. + */ + +/** The refresh flight's own `AbortSignal.timeout` ceiling. */ +export const CODEX_REFRESH_FLIGHT_CEILING_MS = 30_000; + +/** Deadline for one `backend-api/wham/usage` request. */ +export const WHAM_REQUEST_TIMEOUT_MS = 8_000; + +/** + * How long a recovery claim stays valid without settlement. + * + * The sequence a claim covers is: WHAM request → refresh → WHAM replay. Both quota legs sit + * inside the lease, which is why the request timeout is counted twice. + */ +export const QUOTA_RECOVERY_LEASE_MS = + CODEX_REFRESH_FLIGHT_CEILING_MS + WHAM_REQUEST_TIMEOUT_MS * 2; diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 3248186375..87e5766b84 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -110,6 +110,12 @@ function mayCommitAccountQuota(accountId: string, writerGeneration: number): boo // Valid upstream percentages are normalized to 0..100. Keep "unknown" outside that domain so an // actually exhausted account is still eligible for threshold rotation. export const CODEX_UNKNOWN_USAGE_SCORE = 101; +/** + * A window reading at or above this is a measured refusal, not a position on a scale. + * + * Separate from `CODEX_UNKNOWN_USAGE_SCORE` because they mean opposite things: unknown is + * "we have not observed this account", 100 is "we observed it and it is full". + */ export const CODEX_EXHAUSTED_USAGE_PERCENT = 100; export function isCodexQuotaExhausted( diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 250aac9636..9890e14046 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -17,7 +17,7 @@ import { seedPoolRotationAccount, selectPriorityTier, } from "./pool-rotation"; -import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota"; +import { CODEX_EXHAUSTED_USAGE_PERCENT, CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota"; import { isThirtyDayOnlyCodexPlan } from "./plan"; import { MAIN_CODEX_ACCOUNT_ID, @@ -364,7 +364,8 @@ export function computeCodexUsageScore(quota: { weeklyPercent?: number; monthlyPercent?: number; shortPercent?: number; -} | null, plan?: unknown): number { + shortResetAt?: number; +} | null, plan?: unknown, now: number = Date.now()): number { if (!quota) return CODEX_UNKNOWN_USAGE_SCORE; const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value); const longWindows = isThirtyDayOnlyCodexPlan(plan) @@ -376,11 +377,50 @@ export function computeCodexUsageScore(quota: { // account whose weekly/monthly usage is entirely unverified look like the emptiest in the // pool, so `pickLowestUsageAmong` would send every request to it. Unknown has to stay // unknown until a governing window is actually observed. - if (knownLong.length === 0) return CODEX_UNKNOWN_USAGE_SCORE; + // + // A FULL burst window is the exception (#3029). It is not an optimistic guess about an + // unobserved window — it is a direct observation that the account cannot serve a request + // right now, whatever its monthly position turns out to be. Unknown-means-selectable is + // correct for uncertainty and wrong for a measured refusal: the account stays selected, + // `applyQuotaAutoSwitch` never fires, and the pool wedges on an exhausted credential. + if (knownLong.length === 0) { + return isTerminalShortWindow(quota, now) ? CODEX_EXHAUSTED_USAGE_PERCENT : CODEX_UNKNOWN_USAGE_SCORE; + } const values = finite(quota.shortPercent) ? [...knownLong, quota.shortPercent] : knownLong; return Math.max(...values); } +/** + * A short-only reading that proves the account is blocked NOW. + * + * Freshness is not optional. `getAccountQuota` performs no expiry check, partial updates + * carry the old short tuple forward, and disk hydration accepts a persisted reading for + * hours — so scoring 100 from `shortPercent` alone would keep excluding an account whose + * five-hour window has since reset. That is #3029 pointed the other way: the issue is that + * an exhausted account stays selected, and "a recovered account stays excluded" trades one + * unusable pool for another. + * + * A reading with no `shortResetAt` cannot be aged, so it stays unknown. The conservative + * direction here is the one that keeps an account selectable: a wrongly-selected account + * fails one request, while a wrongly-excluded one is invisible until someone reads the pool + * by hand. + */ +function isTerminalShortWindow( + quota: { shortPercent?: number; shortResetAt?: number }, + now: number, +): boolean { + if (typeof quota.shortPercent !== "number" || !Number.isFinite(quota.shortPercent)) return false; + if (quota.shortPercent < CODEX_EXHAUSTED_USAGE_PERCENT) return false; + const resetAt = quota.shortResetAt; + if (typeof resetAt !== "number" || !Number.isFinite(resetAt) || resetAt <= 0) return false; + // Both units reach storage: `normalizeResetAt` does not scale, and the GUI disambiguates + // by magnitude at read time. A comparison written against one assumption is off by 1000x + // against the other, and in the seconds-read-as-milliseconds direction every terminal + // reading looks like it reset in 1970 — a fix that passes its own test and does nothing. + const resetAtMs = resetAt < 10_000_000_000 ? resetAt * 1000 : resetAt; + return resetAtMs > now; +} + export function classifyCodexUpstreamOutcome( outcome: CodexUpstreamOutcome, denial?: "workspace" | "entitlement", @@ -1131,7 +1171,7 @@ function getEligiblePoolAccounts( return selectPriorityTier( ids, codexAccountPriorityLookup(config), - id => hasCodexQuotaHeadroom(config, id, selectionOptions), + id => hasCodexQuotaHeadroom(config, id, selectionOptions, now), pinnedCodexAccountId(config), ); } @@ -1163,12 +1203,14 @@ function hasCodexQuotaHeadroom( config: OcxConfig, accountId: string, selectionOptions?: CodexAccountUsabilityOptions, + now: number = Date.now(), ): boolean { const threshold = config.autoSwitchThreshold ?? 80; if (threshold <= 0) return true; const usage = computeCodexUsageScore( getAccountQuota(accountId), getPoolAccountPlanForSelection(config, accountId, selectionOptions), + now, ); if (isUnknownUsage(usage)) return true; return usage < threshold; @@ -1188,7 +1230,7 @@ function pickFillFirstCodexAccount( if (eligible.length === 0) return null; const active = getEffectiveActiveCodexAccountId(config); - if (active && eligible.includes(active) && hasCodexQuotaHeadroom(config, active, selectionOptions)) { + if (active && eligible.includes(active) && hasCodexQuotaHeadroom(config, active, selectionOptions, now)) { return active; } @@ -1200,7 +1242,7 @@ function pickNextFillFirstCodexAccount( config: OcxConfig, afterId: string | null, eligible: readonly string[] = listEligibleCodexAccountIds(config, Date.now()), - _now = Date.now(), + now = Date.now(), selectionOptions?: CodexAccountUsabilityOptions, ): string | null { if (eligible.length === 0) return null; @@ -1208,7 +1250,7 @@ function pickNextFillFirstCodexAccount( if (!afterId) { // Prefer an under-threshold account when starting with no active cursor. for (const id of ordered) { - if (hasCodexQuotaHeadroom(config, id, selectionOptions)) return id; + if (hasCodexQuotaHeadroom(config, id, selectionOptions, now)) return id; } return ordered[0] ?? null; } @@ -1223,7 +1265,7 @@ function pickNextFillFirstCodexAccount( const startIdx = stableAll.indexOf(afterId); if (startIdx < 0) { for (const id of ordered) { - if (hasCodexQuotaHeadroom(config, id, selectionOptions)) return id; + if (hasCodexQuotaHeadroom(config, id, selectionOptions, now)) return id; } return ordered[0] ?? null; } @@ -1234,7 +1276,7 @@ function pickNextFillFirstCodexAccount( const candidate = stableAll[(startIdx + step) % stableAll.length]!; if (!eligible.includes(candidate)) continue; if (!fallback) fallback = candidate; - if (hasCodexQuotaHeadroom(config, candidate, selectionOptions)) return candidate; + if (hasCodexQuotaHeadroom(config, candidate, selectionOptions, now)) return candidate; } return fallback ?? ordered[0] ?? null; } @@ -1356,6 +1398,7 @@ function pickLowerUsageAccount( const usage = computeCodexUsageScore( getAccountQuota(id), getPoolAccountPlanForSelection(config, id, selectionOptions), + now, ); if (usage < bestUsage) { best = id; @@ -1370,6 +1413,7 @@ function pickLowestUsageAmong( config: OcxConfig, ids: readonly string[], selectionOptions?: CodexAccountUsabilityOptions, + now: number = Date.now(), ): string | null { let best: string | null = null; let bestUsage = Number.POSITIVE_INFINITY; @@ -1377,6 +1421,7 @@ function pickLowestUsageAmong( const usage = computeCodexUsageScore( getAccountQuota(id), getPoolAccountPlanForSelection(config, id, selectionOptions), + now, ); if (usage < bestUsage) { best = id; @@ -1397,6 +1442,7 @@ export function pickLowestUsageCodexAccount( config, getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions), selectionOptions, + now, ); } @@ -1540,7 +1586,7 @@ function pickPriorityPreemption( if ( pinned !== undefined && eligible.includes(pinned) - && hasCodexQuotaHeadroom(config, pinned, selectionOptions) + && hasCodexQuotaHeadroom(config, pinned, selectionOptions, now) ) return null; const priorityOf = codexAccountPriorityLookup(config); if (priorityOf(eligible[0]!) <= priorityOf(active)) return null; @@ -1548,8 +1594,9 @@ function pickPriorityPreemption( // picking one would hand the request straight back to a drained account. return pickLowestUsageAmong( config, - eligible.filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions)), + eligible.filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now)), selectionOptions, + now, ); } @@ -1566,6 +1613,7 @@ function releaseDrainedCodexAccountPin( CodexAccountUsabilityOptions, "nativeMainSelectionOnly" | "isMainAccountTokenLive" >, + now: number = Date.now(), ): void { const pinned = pinnedCodexAccountId(config); if (pinned === undefined) return; @@ -1580,7 +1628,7 @@ function releaseDrainedCodexAccountPin( // is readable. Cached reauth and configured pause state were handled above. if (pinned === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.nativeMainSelectionOnly === true) return; const drained = !isCodexAccountUsable(config, pinned, selectionOptions) - || !hasCodexQuotaHeadroom(config, pinned, selectionOptions); + || !hasCodexQuotaHeadroom(config, pinned, selectionOptions, now); if (!drained) return; clearCodexAccountPin(config); saveConfigPreservingClaudeCode(config); @@ -1600,6 +1648,7 @@ function applyQuotaAutoSwitch( const activeUsage = computeCodexUsageScore( quota, getPoolAccountPlanForSelection(config, active, selectionOptions), + now, ); // Unknown usage is not evidence that a user's explicit selection crossed the // threshold. Wait for quota priming instead of rotating among guesses. @@ -1633,7 +1682,7 @@ function isHealthySharedCodexSelection( selectionOptions: CodexAccountUsabilityOptions | undefined, ): boolean { return isCodexAccountSelectable(config, accountId, now, quotaScope, selectionOptions) - && hasCodexQuotaHeadroom(config, accountId, selectionOptions) + && hasCodexQuotaHeadroom(config, accountId, selectionOptions, now) && !shouldFailover(config, accountId, now); } @@ -1720,6 +1769,7 @@ function previewReusableAffinityAccount( const usage = computeCodexUsageScore( getAccountQuota(entry.accountId), getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions), + now, ); if (!isUnknownUsage(usage) && usage >= threshold) { const best = pickLowerUsageAccount( @@ -1755,6 +1805,7 @@ function reevaluateAffinityQuota( ? computeCodexUsageScore( getAccountQuota(entry.accountId), getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions), + now, ) : 0; const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold; @@ -1848,6 +1899,7 @@ export function previewCodexAccountForRequest( const usage = computeCodexUsageScore( getAccountQuota(active), getPoolAccountPlanForSelection(config, active, selectionOptions), + now, ); if (!isUnknownUsage(usage) && usage >= threshold) { active = pickLowerUsageAccount(config, active, usage, now, quotaScope, selectionOptions); @@ -1887,7 +1939,7 @@ export function resolveCodexAccountForThreadDetailed( // revive after quota resets. Independent model scopes must never persist a // change to shared routing state. if (!isIndependentCodexQuotaScope(quotaScope)) { - releaseDrainedCodexAccountPin(config, sharedStateSelectionOptions(selectionOptions)); + releaseDrainedCodexAccountPin(config, sharedStateSelectionOptions(selectionOptions), now); } const sharedActiveBeforeSelection = getEffectiveActiveCodexAccountId(config); const preserveSharedSelectionForModelDetour = modelScopedSelection && ( @@ -1945,7 +1997,7 @@ export function resolveCodexAccountForThreadDetailed( && isCodexAccountSelectable(config, entry.accountId, now, quotaScope, selectionOptions); const failoverReady = shouldFailover(config, entry.accountId, now); const healthyForSharedAffinity = selectableForSharedState - && hasCodexQuotaHeadroom(config, entry.accountId, sharedSelectionOptions) + && hasCodexQuotaHeadroom(config, entry.accountId, sharedSelectionOptions, now) && !failoverReady; if ( selectableForRequest @@ -2041,7 +2093,7 @@ export function resolveCodexAccountForThreadDetailed( sharedSelectionOptions, ); const activeHealthyForSharedSelection = activeSelectableForSharedState - && hasCodexQuotaHeadroom(config, active, sharedSelectionOptions) + && hasCodexQuotaHeadroom(config, active, sharedSelectionOptions, now) && !shouldFailover(config, active, now); if (!isCodexAccountSelectable(config, active, now, quotaScope, selectionOptions)) { const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope, selectionOptions); diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index 5b838f9493..27eea1e53d 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -226,7 +226,10 @@ export function isNativeModelQuotaExhausted( const resolvedAccountId = resolveRouteFallbackAccountId(route, config, accountId); if (!resolvedAccountId) return false; const quota = getAccountQuota(resolvedAccountId); - const usage = computeCodexUsageScore(quota, getPoolAccountPlan(config, resolvedAccountId)); + // Subagent fallback reads the same score, so a stale terminal reading would push + // subagents off a native model whose window has already reset. Thread the caller's clock + // rather than letting the scorer read wall time - the two would silently diverge. + const usage = computeCodexUsageScore(quota, getPoolAccountPlan(config, resolvedAccountId), now); if (usage >= CODEX_UNKNOWN_USAGE_SCORE) return false; return usage >= quotaThreshold(config); } diff --git a/src/config/pending-teardown-names.d.mts b/src/config/pending-teardown-names.d.mts new file mode 100644 index 0000000000..a5e540d55c --- /dev/null +++ b/src/config/pending-teardown-names.d.mts @@ -0,0 +1,8 @@ +export declare const PENDING_TEARDOWN_PREFIX: string; +export declare const PENDING_TEARDOWN_SUFFIX: string; +export declare const PENDING_TEARDOWN_UNREADABLE_SUFFIX: string; +export declare function isPendingTeardownFileName(name: unknown): boolean; +export declare function isQuarantinedTeardownFileName(name: unknown): boolean; +export declare function isAnyTeardownObligationFileName(name: unknown): boolean; +export declare function pendingTeardownNonceFromFileName(name: string): string | null; +export declare function hasPendingTeardownIn(readdir: (dir: string) => string[], dir: string): boolean; diff --git a/src/config/pending-teardown-names.mjs b/src/config/pending-teardown-names.mjs new file mode 100644 index 0000000000..6d3f33f6f5 --- /dev/null +++ b/src/config/pending-teardown-names.mjs @@ -0,0 +1,69 @@ +/** + * Naming rules for pending-teardown receipts, shared by both update lanes (#3008). + * + * Plain ESM because `bin/ocx.mjs` runs under Node before Bun exists and cannot import the + * TypeScript module. It lives here rather than being spelled out twice because that is + * exactly how this broke: the launcher kept checking the retired singleton filename after + * the receipts moved to one file per claim, so the npm lane silently stopped seeing every + * outstanding obligation. + */ + +export const PENDING_TEARDOWN_PREFIX = "pending-teardown-"; +export const PENDING_TEARDOWN_SUFFIX = ".json"; +/** + * Suffix for an obligation that could not be read. + * + * It is still an obligation. Quarantine renames the file so the ordinary recovery loop + * stops re-reading garbage, but it must NOT stop counting: an update that proceeds + * because the evidence was filed away is exactly the outcome the receipt exists to + * prevent. Both lanes treat this as outstanding until an operator removes it. + */ +export const PENDING_TEARDOWN_UNREADABLE_SUFFIX = ".unreadable.json"; +const NONCE_RE = /^[0-9a-f]{32}$/; + +/** A receipt the recovery loop should read and try to discharge. */ +export function isPendingTeardownFileName(name) { + if (typeof name !== "string") return false; + if (isQuarantinedTeardownFileName(name)) return false; + if (!name.startsWith(PENDING_TEARDOWN_PREFIX) || !name.endsWith(PENDING_TEARDOWN_SUFFIX)) return false; + return NONCE_RE.test(name.slice(PENDING_TEARDOWN_PREFIX.length, name.length - PENDING_TEARDOWN_SUFFIX.length)); +} + +/** A receipt that could not be read and is waiting on a human. */ +export function isQuarantinedTeardownFileName(name) { + if (typeof name !== "string") return false; + if (!name.startsWith(PENDING_TEARDOWN_PREFIX) || !name.endsWith(PENDING_TEARDOWN_UNREADABLE_SUFFIX)) return false; + return NONCE_RE.test(name.slice( + PENDING_TEARDOWN_PREFIX.length, + name.length - PENDING_TEARDOWN_UNREADABLE_SUFFIX.length, + )); +} + +/** Any obligation at all — readable or quarantined. Both block an update. */ +export function isAnyTeardownObligationFileName(name) { + return isPendingTeardownFileName(name) || isQuarantinedTeardownFileName(name); +} + +export function pendingTeardownNonceFromFileName(name) { + if (!isPendingTeardownFileName(name)) return null; + return name.slice(PENDING_TEARDOWN_PREFIX.length, name.length - PENDING_TEARDOWN_SUFFIX.length); +} + +/** + * Does the given config directory hold any outstanding obligation? + * + * Quarantined receipts count. Filing one away to unblock an update would let the very + * next `ocx update` install over a teardown that never ran — the enforcement has to + * survive until a human removes the file. + */ +export function hasPendingTeardownIn(readdir, dir) { + try { + return readdir(dir).some(isAnyTeardownObligationFileName); + } catch (error) { + // "There is no home yet" is the only honest empty answer. Any other failure — + // permissions, I/O, a file where the directory should be — means an obligation may be + // sitting there unread, and reporting "none" would let an update install over a + // teardown that never ran. Absence of proof is not proof of absence. + return error?.code !== "ENOENT"; + } +} diff --git a/src/config/pending-teardown.ts b/src/config/pending-teardown.ts new file mode 100644 index 0000000000..31082b0fbb --- /dev/null +++ b/src/config/pending-teardown.ts @@ -0,0 +1,286 @@ +import { createHash, randomBytes } from "node:crypto"; +import { existsSync, readdirSync, readFileSync, renameSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; +import { atomicWriteFile } from "./atomic-write"; +import { getConfigDir } from "./paths"; + +/** + * Ownership receipt for a deferred shared teardown (#3008). + * + * `ocx stop` asks the proxy NOT to restore native Codex and the Grok fence, because a + * stopped Task Scheduler can respawn the proxy and a survivor must keep its client + * config. That hands one obligation to the parent — and a bare query flag cannot express + * an obligation: if the parent dies between the child's exit and its own restore, the + * shared config keeps pointing at a proxy that is gone, with nothing on disk saying so. + * + * The receipt is that missing state. The parent writes it BEFORE asking for a deferred + * stop and removes it only after its own restore, so a later `ocx stop`/`ocx update` can + * see the abandoned obligation and finish it once that proxy is proven down. + * + * ## Why the nonce is the FILENAME + * + * One shared file cannot be cleared safely. Read-compare-unlink is three syscalls, and a + * concurrent stop replacing the file between the compare and the unlink means this run + * deletes an obligation it never owned — the check passed against bytes that are already + * gone. Giving each claim its own path removes the race rather than serializing it: + * `unlink` names one specific obligation, so it can only ever delete that one. Two + * concurrent stops hold two receipts, which is the truth of the situation. + */ +export type PendingTeardownReceipt = { + /** Process that accepted the obligation, so a live owner is distinguishable from a dead one. */ + ownerPid: number; + /** Identity of this claim; also its filename, which is what makes a clear a single-syscall delete. */ + nonce: string; + /** ISO timestamp, for diagnostics only; recovery is decided by liveness, not by age. */ + createdAt: string; + /** + * Endpoint the owner was stopping. + * + * Recovery has to prove THAT proxy is down, and after a crash the runtime-port record + * is usually gone. Falling back to the configured port asks the wrong question for a + * proxy started with an explicit `--port`: the configured port refuses while the live + * one keeps serving, and its client config gets torn out from under it. + */ + endpoint: { hostname: string; port: number }; + /** + * How the endpoint was obtained. + * + * `exact` came from the runtime record or a successful liveness probe — the address the + * stop actually contacted. `guessed` is the configured listen address, recorded because + * an obligation with a weak address beats no obligation at all, but it is NOT evidence: + * a proxy on an explicit `--port` can be respawned there while the configured port + * refuses, and treating that refusal as proof would restore under a live proxy. A + * guessed receipt therefore fails closed into manual recovery. + */ + endpointSource: "exact" | "guessed"; +}; + +/** + * What is on disk, kept distinct from what it means. + * + * Collapsing a malformed file into "no receipt" loses the one fact recovery needs: an + * obligation may still be outstanding, and its owner can no longer be identified. That + * state must not silently authorize a deferral, and it must not wedge every later stop + * either — see {@link quarantinePendingTeardown}. + */ +export type PendingTeardownRead = + | { state: "missing" } + | { state: "valid"; receipt: PendingTeardownReceipt } + | { state: "invalid"; nonce: string; detail: string }; + +/** + * The home itself could not be listed. + * + * Distinct from an invalid receipt: there is no file to quarantine and no nonce to name, + * so it must never be fed to the receipt machinery. It blocks like any obligation, but the + * remedy is to fix the directory and retry, not to remove something. + */ +export type TeardownScanFailure = { state: "unscannable"; detail: string }; + +import { + isPendingTeardownFileName, + isAnyTeardownObligationFileName, + PENDING_TEARDOWN_PREFIX as PREFIX, + PENDING_TEARDOWN_SUFFIX as SUFFIX, + PENDING_TEARDOWN_UNREADABLE_SUFFIX as UNREADABLE_SUFFIX, + pendingTeardownNonceFromFileName, +} from "./pending-teardown-names.mjs"; + +const NONCE_RE = /^[0-9a-f]{32}$/; + +export function pendingTeardownPathFor(nonce: string): string { + return join(getConfigDir(), `${PREFIX}${nonce}${SUFFIX}`); +} + +function isReceipt(value: unknown, nonce: string): value is PendingTeardownReceipt { + if (!value || typeof value !== "object") return false; + const receipt = value as Record; + const endpoint = receipt.endpoint as Record | undefined; + const endpointOk = !!endpoint + && typeof endpoint === "object" + && typeof endpoint.hostname === "string" + && endpoint.hostname.trim() !== "" + && Number.isInteger(endpoint.port) + && Number(endpoint.port) > 0 + && Number(endpoint.port) <= 65535; + return Number.isSafeInteger(receipt.ownerPid) + && Number(receipt.ownerPid) > 0 + // The body must agree with the name: a receipt whose nonce was edited to name a + // different claim would let a request authorize a deferral it does not own. + && receipt.nonce === nonce + && typeof receipt.createdAt === "string" + && (receipt.endpointSource === "exact" || receipt.endpointSource === "guessed") + && endpointOk; +} + +/** Claim a deferred teardown for this process. Returns the receipt that was written. */ +export function claimPendingTeardown( + endpoint: { hostname: string; port: number }, + endpointSource: "exact" | "guessed", + ownerPid: number = process.pid, +): PendingTeardownReceipt { + const dir = getConfigDir(); + assertNotRealHomeUnderTest(dir); + const nonce = randomBytes(16).toString("hex"); + const receipt: PendingTeardownReceipt = { ownerPid, nonce, createdAt: new Date().toISOString(), endpoint, endpointSource }; + atomicWriteFile(pendingTeardownPathFor(nonce), JSON.stringify(receipt, null, 2) + "\n"); + return receipt; +} + +export function readPendingTeardown(nonce: string): PendingTeardownRead { + if (!NONCE_RE.test(nonce)) return { state: "missing" }; + let raw: string; + try { + raw = readFileSync(pendingTeardownPathFor(nonce), "utf-8"); + } catch (error) { + // Only "there is no file" is absence. A permission error, or a directory sitting where + // the receipt belongs, means something IS there and cannot be read; calling that + // missing hides an obligation that may still be outstanding. + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") return { state: "missing" }; + return { state: "invalid", nonce, detail: `unreadable (${code ?? "unknown"})` }; + } + try { + const parsed: unknown = JSON.parse(raw); + if (isReceipt(parsed, nonce)) return { state: "valid", receipt: parsed }; + const digest = createHash("sha256").update(raw).digest("hex").slice(0, 12); + return { state: "invalid", nonce, detail: `malformed receipt (sha256 ${digest})` }; + } catch { + return { state: "invalid", nonce, detail: "unparseable JSON" }; + } +} + +/** An obligation that exists on disk — the "missing" case cannot occur in a listing. */ +export type OutstandingTeardown = Exclude | TeardownScanFailure; + +/** + * Every obligation currently on disk, attributable or not. + * + * A scan that FAILS is not an empty scan. Swallowing a permission or I/O error into `[]` + * would let `handleStop` restore client config with an unread obligation sitting right + * there, so anything but a missing home surfaces as one unreadable obligation the caller + * must treat like any other: blocking, and needing a human. + */ +export function listPendingTeardowns(): OutstandingTeardown[] { + let names: string[]; + try { + names = readdirSync(getConfigDir()); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + // Not an invalid RECEIPT: there is no file here and no nonce to name. Synthesizing one + // would hand a fabricated identity to the quarantine and clear paths, which could then + // rename or delete a real receipt that happened to carry it. + return [{ state: "unscannable", detail: `the opencodex home could not be listed (${(error as NodeJS.ErrnoException).code ?? "unknown"})` }]; + } + const out: OutstandingTeardown[] = []; + for (const name of names) { + // One naming rule, shared with the npm launcher: the two lanes drifting apart is + // exactly how the Node updater stopped seeing receipts at all. + if (!isPendingTeardownFileName(name)) continue; + const nonce = pendingTeardownNonceFromFileName(name)!; + const read = readPendingTeardown(nonce); + if (read.state !== "missing") out.push(read); + } + return out; +} + +/** + * Is any obligation outstanding, whether or not it can still be attributed? + * + * Quarantined receipts count. Filing an unreadable one away must not let the next update + * install over a teardown that never ran — that would turn "we could not tell" into "it + * is fine", which is the failure this whole mechanism exists to prevent. + */ +export function pendingTeardownOutstanding(): boolean { + try { + return readdirSync(getConfigDir()).some(isAnyTeardownObligationFileName); + } catch (error) { + // Only a missing home is empty. Any other scan failure may be hiding an obligation, + // and reporting "none" would unblock an update over a teardown that never ran. + return (error as NodeJS.ErrnoException).code !== "ENOENT"; + } +} + +/** Paths of quarantined obligations awaiting a human. */ +export function listQuarantinedTeardowns(): string[] { + try { + return readdirSync(getConfigDir()) + .filter(name => name.startsWith(PREFIX) && name.endsWith(UNREADABLE_SUFFIX)) + .map(name => join(getConfigDir(), name)); + } catch { + return []; + } +} + +/** + * Remove exactly one obligation. + * + * The nonce is the filename, so this is a compare-and-delete in one syscall: it can never + * remove a receipt another process wrote, because that receipt lives at a different path. + * Returns whether the obligation is gone — a failed unlink is reported rather than + * swallowed, since a receipt that survives its discharge re-triggers recovery forever. + */ +export function clearPendingTeardown(nonce: string): boolean { + try { + unlinkSync(pendingTeardownPathFor(nonce)); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ENOENT"; + } +} + +/** + * Move an unattributable obligation aside. + * + * An invalid receipt names no endpoint, so nothing can prove its proxy is down, so it can + * never be discharged the normal way. Left in place it is not merely useless: both + * updater gates treat an outstanding receipt as a reason to run the stop, and that stop + * would fail on the same receipt every time — an update that can never proceed. + * + * Renaming stops the recovery loop from re-reading garbage on every stop, but it + * deliberately does NOT stop the obligation from counting: `pendingTeardownOutstanding` + * still sees it, so both updaters keep refusing to install over a teardown that never + * ran. Only a human removing the file ends the enforcement. + * + * Returns the path it was moved to, or null when it could not be moved. + */ +export function quarantinePendingTeardown(nonce: string): string | null { + const from = pendingTeardownPathFor(nonce); + if (!existsSync(from)) return null; + const to = join(getConfigDir(), `${PREFIX}${nonce}${UNREADABLE_SUFFIX}`); + try { + renameSync(from, to); + return to; + } catch { + return null; + } +} + +/** + * True when a previous deferred stop left its obligation unfinished. + * + * A receipt whose owner is still alive belongs to a stop that is still running: leave it + * alone. Only an abandoned obligation is a candidate, and a VALID one still has to prove + * its endpoint is down before anything is restored — an invalid one never can, which is + * what {@link quarantinePendingTeardown} exists for. + */ +export function isPendingTeardownAbandoned( + read: PendingTeardownRead | TeardownScanFailure, + isAlive: (pid: number) => boolean, + selfPid: number = process.pid, +): boolean { + if (read.state === "missing") return false; + // A home that cannot be listed may be hiding an obligation. It is not recoverable and + // not removable; the caller blocks on it and asks for the directory to be fixed. + if (read.state === "unscannable") return true; + if (read.state === "invalid") return true; + if (read.receipt.ownerPid === selfPid) return false; + return !isAlive(read.receipt.ownerPid); +} + +/** Does this request name an obligation that exists and is readable? */ +export function deferralMatchesReceipt(nonce: string | null): boolean { + if (!nonce || !NONCE_RE.test(nonce)) return false; + return readPendingTeardown(nonce).state === "valid"; +} diff --git a/src/integrations/journal.ts b/src/integrations/journal.ts index 090204eafe..b5d341ac17 100644 --- a/src/integrations/journal.ts +++ b/src/integrations/journal.ts @@ -19,7 +19,18 @@ import { atomicWriteFile } from "../config"; import { ensureDir, fingerprint, integrationsDir, type OwnershipRecord } from "./ownership"; import { isIntegrationClientId, type IntegrationClientId } from "./registry"; -export type OperationKind = "apply" | "disable" | "refresh" | "restore"; +/** + * `overwrite` is deliberately distinct from `apply`. Both write our block, but + * only one of them replaced something the user or another tool had put there, + * and the rollback list is exactly where that distinction matters. + * + * This union is re-declared, not imported, in two other places -- the management + * route envelope and the GUI adapter -- because neither imports across that + * boundary. `tests/integrations-journal.test.ts` asserts the three agree, since + * nothing else can: a kind persisted here and missing there renders as a raw + * key with no type error anywhere. + */ +export type OperationKind = "apply" | "disable" | "refresh" | "restore" | "overwrite"; /** * Tagged so "the file did not exist" and "the snapshot was collected" stay diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index 831fa80c21..422aa680ea 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -263,7 +263,24 @@ function preflight(input: IntegrationWriteInput) { return { failed: undefined, store, io, clientId, spec, exportSpec, configPath, detectDir, before, parsed, contribution, record, classified } as const; } -function applyOrRefreshIntegration(input: IntegrationWriteInput, allowAbsent: boolean): WriteOutcome { +/** + * How a conflicted document is treated. + * + * `refuse` is the default and the only behavior that existed: a conflict means + * something we did not write occupies our paths, or our own block was edited, + * and guessing which one the user meant to keep is how a toggle deletes work. + * + * `overwrite` is the explicit escape hatch. It is never reached by a plain + * apply -- the caller has to ask for it by name -- because the whole value of + * the refusal is that it cannot be triggered by accident. + */ +type ConflictPolicy = "refuse" | "overwrite"; + +function applyOrRefreshIntegration( + input: IntegrationWriteInput, + allowAbsent: boolean, + conflictPolicy: ConflictPolicy = "refuse", +): WriteOutcome { const pre = preflight(input); if (pre.failed) return pre.failed; const { store, io, clientId, spec, exportSpec, configPath, detectDir, before, parsed, contribution, record, classified } = pre; @@ -278,10 +295,20 @@ function applyOrRefreshIntegration(input: IntegrationWriteInput, allowAbsent: bo `The generated ${clientId} integration is loopback-only and does not emit the admission header a non-loopback bind requires. Give it loopback access instead, through a tunnel or a local forwarder.`); } if (classified.state === "conflict") { - return refuse(clientId, "conflict", "conflict", - classified.reason === "foreign-edit" - ? `${configPath} changed after opencodex wrote it` - : `${configPath} already contains an opencodex block we did not write`); + if (conflictPolicy === "refuse") { + return refuse(clientId, "conflict", "conflict", + classified.reason === "foreign-edit" + ? `${configPath} changed after opencodex wrote it` + : `${configPath} already contains an opencodex block we did not write`); + } + /* + * The caller asked for the overwrite explicitly, so the merge below runs + * against the document as it stands and our block replaces whatever holds + * our paths. Everything that makes it recoverable is shared with apply -- + * the snapshot, the atomic write, the compare-before-commit recheck and the + * journal row all come from the same commit() call -- which is why this is a + * policy flag on one code path rather than a second implementation. + */ } /* * `unsafe` from the classifier means the document is not one we may write @@ -327,7 +354,22 @@ function applyOrRefreshIntegration(input: IntegrationWriteInput, allowAbsent: bo */ const base = classified.state === "stale" && record ? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc - : parsed; + : classified.state === "conflict" && record + /* + * A forced overwrite of a `foreign-edit` conflict drops what the previous + * record owned for the same reason a stale refresh does: the replacement + * record covers the paths we are about to write, so a path the old record + * owned and the new one does not would be stranded forever, unremovable by + * any later disable. + * + * With NO record -- an `unowned-key` conflict -- there is nothing to drop and + * the merge runs against the user's document directly. That is correct: + * createdContainerPaths then attributes every container they already had to + * them, so a later disable removes our leaves and leaves their structure + * standing. + */ + ? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc + : parsed; // Computed against the document as it stands BEFORE the merge: afterwards // every container exists and "did we create this?" is unanswerable. const created = createdContainerPaths(base, contribution); @@ -375,7 +417,16 @@ function applyOrRefreshIntegration(input: IntegrationWriteInput, allowAbsent: bo const snapshot = store.captureSnapshot(clientId, opId, before); const at = new Date(io.now()).toISOString(); const entry: JournalEntry = { - opId, clientId, kind: classified.state === "stale" ? "refresh" : "apply", at, configPath, + /* + * `overwrite` is its own kind rather than reusing `apply`. The rollback list + * is the one place a user goes after a mistake, and "applied" is a lie about + * an operation that replaced a block somebody else wrote. + */ + opId, clientId, + kind: classified.state === "conflict" + ? "overwrite" + : classified.state === "stale" ? "refresh" : "apply", + at, configPath, snapshot, resultFingerprint: fingerprint(text), resultAbsent: false, priorRecord: record, }; const refreshablePaths = refreshablePathsOf(contribution); @@ -406,6 +457,23 @@ export function applyIntegration(input: IntegrationWriteInput): WriteOutcome { return applyOrRefreshIntegration(input, true); } +/** + * Apply over a conflicted config, replacing whatever holds our paths. + * + * Separate from `applyIntegration` and never a flag on it: a caller has to name + * this function to get the behavior, so no existing call site can acquire it by + * passing a default through. + * + * What it does NOT relax. `unsafe` still refuses -- a blocked container means the + * merge would replace a value it cannot reason about, and a snapshot is not a + * licence for that. `not_installed` and `non_loopback` still refuse; neither is a + * conflict. And a non-conflict state behaves exactly as apply does, so calling + * this on a clean file is not a way to skip any other check. + */ +export function overwriteIntegration(input: IntegrationWriteInput): WriteOutcome { + return applyOrRefreshIntegration(input, true, "overwrite"); +} + /** Refresh an owned stale block, but never create or reconnect an absent one. */ export function refreshIntegration(input: IntegrationWriteInput): WriteOutcome { return applyOrRefreshIntegration(input, false); @@ -698,6 +766,13 @@ export function refreshIntegrationCoordinated( return coordinatedWrite(input, refreshIntegration, options); } +export function overwriteIntegrationCoordinated( + input: IntegrationWriteInput, + options?: CoordinatedIntegrationOptions, +): Promise { + return coordinatedWrite(input, overwriteIntegration, options); +} + export function disableIntegrationCoordinated( input: IntegrationWriteInput, options?: CoordinatedIntegrationOptions, diff --git a/src/lib/process-control.ts b/src/lib/process-control.ts index 6c0f7082f9..3e296d6c72 100644 --- a/src/lib/process-control.ts +++ b/src/lib/process-control.ts @@ -29,6 +29,24 @@ export interface GracefulStopIo { waitExit?: (pid: number, timeoutMs: number) => boolean; env?: Record; exitTimeoutMs?: number; + /** + * Nonce of the pending-teardown receipt this caller claimed. + * + * `ocx stop` sets it because it restores shared client config itself, only after + * proving a stopped Task Scheduler did not respawn the proxy (#3008). The nonce is what + * makes the deferral an owned obligation rather than a flag anyone can set: the proxy + * honours it only when it names the receipt actually on disk. Direct callers omit it + * and keep the self-contained behaviour. + */ + deferSharedTeardownNonce?: string; + /** + * Endpoint the caller already resolved for this pid. + * + * `ocx stop` records this same snapshot in its pending-teardown receipt. Re-reading the + * runtime file here could pick up a different one, which would make the receipt name an + * endpoint the stop never contacted — and recovery probes exactly that endpoint. + */ + runtimeEndpoint?: { hostname: string; port: number }; } /** @@ -67,7 +85,7 @@ export class ProxyOwnershipRefusedError extends Error {} */ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): Promise { const readRuntime = io.readRuntime ?? readRuntimePort; - const runtime = readRuntime(pid); + const runtime = io.runtimeEndpoint ?? readRuntime(pid); if (!runtime?.port) return false; const env = io.env ?? process.env; const headers: Record = {}; @@ -75,7 +93,14 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): if (token) headers["x-opencodex-api-key"] = token; const fetchFn = io.fetchFn ?? fetch; try { - const res = await fetchFn(`http://${gracefulStopHost(runtime.hostname)}:${runtime.port}/api/stop`, { + // `ocx stop` asks the proxy NOT to restore shared client config: it does that itself, + // after verifying a stopped Task Scheduler did not respawn the proxy (#3008). Letting + // the child do it means a survivor found seconds later has already lost its config. + const stopUrl = `http://${gracefulStopHost(runtime.hostname)}:${runtime.port}/api/stop` + + (io.deferSharedTeardownNonce + ? `?deferSharedTeardown=1&teardownNonce=${encodeURIComponent(io.deferSharedTeardownNonce)}` + : ""); + const res = await fetchFn(stopUrl, { method: "POST", headers, // Hung proxies with many CLOSE_WAIT clients can be slow to accept; give them @@ -107,10 +132,10 @@ function drainDeadlineMs(): number { } /** Graceful-first stop: management-API drain, then the platform kill ladder. */ -export async function stopProxy(pid: number): Promise { +export async function stopProxy(pid: number, io: GracefulStopIo = {}): Promise { if (!isProcessAlive(pid)) return; - const runtime = readRuntimePort(pid); - const graceful = await stopProxyGracefully(pid); + const runtime = io.runtimeEndpoint ?? readRuntimePort(pid); + const graceful = await stopProxyGracefully(pid, io); if (graceful === "refused") { // The proxy refused on purpose (foreign service owns it). Forcing would strip shared // config while that service keeps the proxy alive. diff --git a/src/lib/state-store-registrations.ts b/src/lib/state-store-registrations.ts index 61aad5f7b9..433a8989b4 100644 --- a/src/lib/state-store-registrations.ts +++ b/src/lib/state-store-registrations.ts @@ -9,6 +9,7 @@ import { reconcileProviderFetchWarnings } from "../codex/catalog/provider-fetch" import { reconcileModelCacheGeneration } from "../codex/model-cache"; import { reconcilePoolRotationState } from "../codex/pool-rotation"; import { reconcileCodexQuotaAccounts } from "../codex/quota"; +import { reconcileQuotaRecovery, sweepExpiredQuotaRecovery } from "../codex/quota-401-recovery"; import { listLiveCodexAccountIds, reconcileCodexRoutingHealth, @@ -84,6 +85,13 @@ export const STATE_STORE_REGISTRATIONS = [ }, { name: "anthropic-routing-health", sweepExpired: sweepExpiredAnthropicRoutingHealth }, { name: "xai-refresh-verdicts", sweepExpired: sweepExpiredXaiPermanentFailureVerdicts }, + { + name: "codex-quota-401-recovery", + // Only backoff windows and abandoned leases expire. A spent fence is durable: expiring + // it would grant the same credential lineage a second refresh (#3019). + sweepExpired: sweepExpiredQuotaRecovery, + reconcileGeneration: context => reconcileQuotaRecovery(context.codexAccountIds), + }, { name: "responses-continuation", sweepExpired: sweepExpiredResponseStates, diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 7a356df8c8..c4a682ee3f 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -1,10 +1,34 @@ import type { KiroOAuthMetadata, OAuthController, OAuthCredentials } from "./types"; import { parseCallbackInput } from "./callback-server"; import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types"; -import { loadConfig, resolveEnvValue, saveConfig } from "../config"; +import { ConfigMutationLockError, loadConfig, resolveEnvValue, saveConfig } from "../config"; import { maskEmail } from "../lib/privacy"; import { KiroTokenRefreshError, environmentKiroRoutingMetadata, loginKiro, refreshKiroToken, settleKiroLoginTransaction } from "./kiro"; -import { getAccountCredential, getAccountCredentialWithStatus, getAccountSet, removeAccount, saveAccountCredential, saveCredential, setActiveAccount, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, markOAuthRefreshIntentStaleOwner, clearOAuthRefreshIntent, normalizeAuthStoreBuffer, OAuthMutationBusyError } from "./store"; +import { + OAuthMutationBusyError, + OAuthRefreshIntentIOError, + clearOAuthRefreshIntent, + clearOAuthRefreshIntentIfMatch, + createOAuthRefreshIntentLock, + credentialGeneration, + getAccountCredential, + getAccountCredentialWithStatus, + getAccountSet, + getCredential, + markAccountNeedsReauthIfGeneration, + markOAuthRefreshIntentCleanupPending, + markOAuthRefreshIntentStaleOwner, + mergeAccountCredential, + normalizeAuthStoreBuffer, + readOAuthRefreshIntent, + removeAccount, + saveAccountCredential, + saveCredential, + setActiveAccount, + writeOAuthRefreshIntent, + type OAuthRefreshIntent, + type OAuthRefreshIntentCleanupPending, +} from "./store"; import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenRequestError } from "./xai"; import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthropicToken } from "./anthropic"; import { loginKimi, refreshKimiToken } from "./kimi"; @@ -559,9 +583,152 @@ function terminal(error:unknown):boolean{ // Local durable-write/read/cleanup failures are operational, not credential // death: the provider credential was never rejected or consumed. Never mark // the account needsReauth for broken local persistence infrastructure. - if (error instanceof RefreshIntentIOError) return false; + if (error instanceof RefreshIntentIOError || error instanceof OAuthRefreshIntentIOError) return false; return isTerminalRefreshError(error); } + +/** + * True when the token endpoint definitively answered and rejected the request. + * + * The Anthropic adapter attaches an HTTP status only to an explicit non-success response, + * which is the retryable rejection this PR handles. Everything else (timeout, dropped + * connection, a body that could not be read or parsed, or a local persistence fault) leaves + * the outcome unknown: the server may already have rotated the token, and a blind replay + * could trip refresh-token-reuse revocation. Those cases must keep the refresh intent. + * + * Deliberately narrower than `terminal()`, which asks whether the CREDENTIAL is dead. + * This asks the different question of whether the ATTEMPT is known to have failed. + */ +function definitivelyAnswered(error: unknown): boolean { + if (error instanceof AnthropicTokenError) return error.httpStatus !== undefined; + return false; +} + +/** + * Intent cleanup is secondary to the refresh outcome it protects. + * + * Once a credential is already durable, cleanup remains secondary and best-effort. A known + * failed attempt takes the stricter path below: its retry-safe marker must become durable before + * the original provider error can be returned. + */ +function clearAnthropicRefreshIntentBestEffort( + provider: string, + accountId: string, + expected: OAuthRefreshIntent, +): boolean { + try { + return expected.attemptId + ? clearOAuthRefreshIntentIfMatch(provider, accountId, expected) + : clearOAuthRefreshIntent(provider, accountId, expected.generation); + } catch { + console.warn( + "[opencodex] Anthropic refresh intent cleanup failed; preserving the durable replay guard.", + ); + return false; + } +} + +const ANTHROPIC_INTENT_MARK_RETRY_DELAYS_MS = [10, 25, 50] as const; + +function isConfigMutationLockContention(error: unknown): boolean { + if (!(error instanceof ConfigMutationLockError)) return false; + const cause = error.cause; + const code = cause && typeof cause === "object" && "code" in cause + ? String((cause as { code?: unknown }).code) + : ""; + return code === "SQLITE_BUSY" || code === "SQLITE_LOCKED"; +} + +async function clearAnthropicRefreshIntentForKnownFailure( + provider: string, + accountId: string, + expected: OAuthRefreshIntent, + cleanupPending: OAuthRefreshIntentCleanupPending, + refreshError: unknown, +): Promise { + let marked: OAuthRefreshIntent | undefined; + for (let attempt = 0; attempt <= ANTHROPIC_INTENT_MARK_RETRY_DELAYS_MS.length; attempt += 1) { + try { + marked = markOAuthRefreshIntentCleanupPending( + provider, + accountId, + expected, + cleanupPending, + ); + break; + } catch (cause) { + const retryDelay = ANTHROPIC_INTENT_MARK_RETRY_DELAYS_MS[attempt]; + if (!isConfigMutationLockContention(cause) || retryDelay === undefined) { + throw new OAuthRefreshIntentIOError( + "mark-cleanup-pending", + cause, + refreshError, + ); + } + // The provider has definitively answered, so caller cancellation no longer changes the + // settlement obligation. Yield briefly while retaining the per-account refresh lock, then + // rerun the existing compare-and-swap marker against current disk state. + await Bun.sleep(retryDelay); + } + } + if (!marked) { + throw new OAuthRefreshIntentIOError( + "mark-cleanup-pending", + new Error("Anthropic refresh intent changed before safe cleanup"), + refreshError, + ); + } + + let cleared: boolean; + try { + cleared = clearOAuthRefreshIntentIfMatch(provider, accountId, marked); + } catch { + console.warn( + "[opencodex] Anthropic refresh intent cleanup failed; retry-safe cleanup remains pending.", + ); + return false; + } + if (!cleared) { + throw new OAuthRefreshIntentIOError( + "clear-cleanup-pending", + new Error("Anthropic refresh intent changed during safe cleanup"), + refreshError, + ); + } + return true; +} + +function resumeAnthropicRefreshIntentCleanup( + provider: string, + accountId: string, + pendingIntent: OAuthRefreshIntent, +): void { + let cleared: boolean; + try { + cleared = clearOAuthRefreshIntentIfMatch(provider, accountId, pendingIntent); + } catch (cause) { + throw new OAuthRefreshIntentIOError( + "resume-cleanup", + cause, + ); + } + if (!cleared) { + throw new OAuthRefreshIntentIOError( + "resume-cleanup", + new Error("Pending Anthropic refresh intent changed before cleanup"), + ); + } +} + +function clearObservedAnthropicRefreshIntent( + provider: string, + accountId: string, + pendingIntent: OAuthRefreshIntent, +): boolean { + return pendingIntent.attemptId + ? clearOAuthRefreshIntentIfMatch(provider, accountId, pendingIntent) + : clearOAuthRefreshIntent(provider, accountId, pendingIntent.generation); +} function authoritative(stored:OAuthCredentials,active:boolean,now:()=>number):OAuthCredentials{if(stored.source!=="local-cli")return stored;const disk=detectGrokCliToken();if(!disk)return stored;const allowed=isSameGrokIdentity(stored,disk)||(active&&!hasComparableGrokIdentity(stored,disk));return allowed&&shouldAdoptGrokGeneration(stored,disk,now(),REFRESH_SKEW_MS)?disk:stored;} function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCredentials { return { @@ -647,13 +814,19 @@ export async function refreshAnthropicAccountWithLock( afterPrePersistRead: deps.afterPrePersistRead, }); if (outcome.superseded) { - if (pendingIntent) clearOAuthRefreshIntent(provider, accountId, pendingIntent.generation); + // The disk credential is already durable here, so cleanup is secondary: an unlink + // failure must not mask a committed credential by throwing over the return below. + if (pendingIntent) clearAnthropicRefreshIntentBestEffort(provider, accountId, pendingIntent); if (outcome.stored.expires > now() + REFRESH_SKEW_MS) return outcome.stored.access; throw new OAuthLoginRequiredError(provider); } - if (pendingIntent) clearOAuthRefreshIntent(provider, accountId, pendingIntent.generation); + if (pendingIntent) clearAnthropicRefreshIntentBestEffort(provider, accountId, pendingIntent); return disk.access; } + if (pendingIntent?.cleanupPending && pendingIntent.generation === generation) { + resumeAnthropicRefreshIntentCleanup(provider, accountId, pendingIntent); + pendingIntent = undefined; + } if (!pendingIntent?.uncertain && pendingIntent?.generation === generation) { if (pendingIntent.staleOwner) throw new OAuthTokenRefreshStaleError(); if (deps.replacedStaleFlight && pendingIntent.flightId === deps.replacedStaleFlight.flightId) { @@ -661,7 +834,9 @@ export async function refreshAnthropicAccountWithLock( markOAuthRefreshIntentStaleOwner(provider, accountId, generation, deps.replacedStaleFlight.flightId); throw new OAuthTokenRefreshStaleError(); } - clearOAuthRefreshIntent(provider, accountId, generation); + if (!clearObservedAnthropicRefreshIntent(provider, accountId, pendingIntent)) { + throw new OAuthTokenRefreshStaleError(); + } pendingIntent = undefined; } } @@ -669,7 +844,9 @@ export async function refreshAnthropicAccountWithLock( await markAccountNeedsReauthIfGeneration(provider, accountId, generation, writerGeneration); throw new OAuthLoginRequiredError(provider); } - if (pendingIntent) clearOAuthRefreshIntent(provider, accountId, pendingIntent.generation); + if (pendingIntent && !clearObservedAnthropicRefreshIntent(provider, accountId, pendingIntent)) { + throw new OAuthTokenRefreshStaleError(); + } if (account?.needsReauth) { throw new OAuthLoginRequiredError(provider); } @@ -677,9 +854,15 @@ export async function refreshAnthropicAccountWithLock( return stored.access; } + let refreshMayHaveReachedProvider = false; + let attemptIntent: OAuthRefreshIntent | undefined; try { - writeOAuthRefreshIntent(provider, accountId, generation, now(), deps.flight?.flightId); + attemptIntent = writeOAuthRefreshIntent(provider, accountId, generation, now(), deps.flight?.flightId); if (deps.signal?.aborted) throw deps.signal.reason; + // From this point on, even a synchronous client error is conservatively post-dispatch: + // the provider may have received and rotated the refresh token before the caller learned + // the outcome. + refreshMayHaveReachedProvider = true; if (deps.flight) deps.flight.dispatched = true; const fresh = merged(await def.refresh(stored.refresh, deps.signal), stored); const outcome = await mergeAccountCredential(provider, accountId, fresh, { @@ -687,17 +870,41 @@ export async function refreshAnthropicAccountWithLock( afterPrePersistRead: deps.afterPrePersistRead, }); if (outcome.superseded) { - clearOAuthRefreshIntent(provider, accountId, generation); + if (attemptIntent) clearAnthropicRefreshIntentBestEffort(provider, accountId, attemptIntent); if (outcome.stored.expires > now() + REFRESH_SKEW_MS) return outcome.stored.access; throw new OAuthLoginRequiredError(provider); } - clearOAuthRefreshIntent(provider, accountId, generation); + // The rotated credential is durable now. A cleanup failure must not turn that committed + // success into a refresh failure; the old-generation intent remains a conservative guard. + if (attemptIntent) clearAnthropicRefreshIntentBestEffort(provider, accountId, attemptIntent); return fresh.access; } catch (error) { - if (error instanceof OAuthMutationBusyError) throw error; - if (!terminal(error)) throw error; + if (error instanceof OAuthMutationBusyError || error instanceof OAuthTokenRefreshStaleError) throw error; + if (!terminal(error)) { + // A non-terminal failure tells the caller to retry, but the intent outlived it, so + // the next attempt hit the pending-intent branch above and raised + // OAuthLoginRequiredError. One 503 locked the account out of refresh until manual + // re-auth even after upstream recovered. + // + // Only clear the intent when the server DEFINITIVELY answered and rejected the + // request. The adapter attaches an HTTP status only to that explicit non-success + // response. A timeout, a dropped connection, or an unreadable/unparseable body + // carries no status: the server may already have + // rotated the token, and replaying it could trip refresh-token-reuse revocation. + // Those outcomes keep the intent so the guard still refuses a blind replay. + if ((!refreshMayHaveReachedProvider || definitivelyAnswered(error)) && attemptIntent) { + await clearAnthropicRefreshIntentForKnownFailure( + provider, + accountId, + attemptIntent, + refreshMayHaveReachedProvider ? "definitive-rejection" : "pre-dispatch", + error, + ); + } + throw error; + } await markAccountNeedsReauthIfGeneration(provider, accountId, generation, writerGeneration); - clearOAuthRefreshIntent(provider, accountId, generation); + if (attemptIntent) clearAnthropicRefreshIntentBestEffort(provider, accountId, attemptIntent); throw new OAuthLoginRequiredError(provider); } } finally { diff --git a/src/oauth/store.ts b/src/oauth/store.ts index 755d86f3e2..bc9e0e11c1 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -19,7 +19,7 @@ import { createHash, randomUUID } from "node:crypto"; import { chmodSync, closeSync, copyFileSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { getConfigDir, atomicWriteFile, backupInvalidConfig, hardenConfigDir, hardenExistingSecret } from "../config"; +import { getConfigDir, atomicWriteFile, backupInvalidConfig, hardenConfigDir, hardenExistingSecret, withConfigMutationLockSync } from "../config"; import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; import { recordOwnedConfigPath } from "../lib/config-ownership"; import { MAX_PENDING_OAUTH_MUTATIONS } from "../lib/translator-budget"; @@ -67,23 +67,93 @@ export function getAuthRefreshIntentLockPath(provider: string, accountId: string export function getAuthRefreshIntentPath(provider: string, accountId: string): string { return `${getAuthRefreshIntentLockPath(provider, accountId)}.json`; } -export interface OAuthRefreshIntent { version: 1; provider: string; accountId: string; generation: string; createdAt: number; flightId?: string; staleOwner?: true; uncertain?: true } +export type OAuthRefreshIntentCleanupPending = "pre-dispatch" | "definitive-rejection"; +export interface OAuthRefreshIntent { + version: 1; + provider: string; + accountId: string; + generation: string; + createdAt: number; + attemptId?: string; + flightId?: string; + staleOwner?: true; + uncertain?: true; + cleanupPending?: OAuthRefreshIntentCleanupPending; +} + +export class OAuthRefreshIntentIOError extends Error { + readonly code = "OAUTH_REFRESH_INTENT_IO"; + constructor( + readonly operation: "write-intent" | "mark-cleanup-pending" | "clear-cleanup-pending" | "resume-cleanup", + cause?: unknown, + readonly refreshError?: unknown, + ) { + super(`OAuth refresh intent ${operation} failed`, cause ? { cause } : undefined); + this.name = "OAuthRefreshIntentIOError"; + } +} + +// Both compare-and-swap sites in this file — the OAuth file lock and the refresh-intent +// file — must agree on what "the same file" means, so they share one snapshot shape and +// one identity check (`snapshot`/`sameSnapshot` below) rather than two copies that can drift. +type OAuthRefreshIntentFileSnapshot = LockSnapshot; + +export interface OAuthRefreshIntentMutationHooks { + beforeMarkCommit?: () => void; + beforeClearRecheck?: () => void; +} + +class OAuthRefreshIntentChangedError extends Error {} + +function uncertainOAuthRefreshIntent(provider: string, accountId: string): OAuthRefreshIntent { + return { version: 1, provider, accountId, generation: "", createdAt: 0, uncertain: true }; +} + function parseOAuthRefreshIntent( provider: string, accountId: string, raw: string, ): OAuthRefreshIntent { - const value = JSON.parse(raw) as Partial; + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return uncertainOAuthRefreshIntent(provider, accountId); + } + const value = parsed as Partial; if ( value.version !== 1 || value.provider !== provider || value.accountId !== accountId || typeof value.generation !== "string" + || !/^[0-9a-f]{64}$/.test(value.generation) || typeof value.createdAt !== "number" - || (value.flightId !== undefined && typeof value.flightId !== "string") + || !Number.isFinite(value.createdAt) + || !Number.isInteger(value.createdAt) + || value.createdAt < 0 + || ( + value.attemptId !== undefined + && (typeof value.attemptId !== "string" || value.attemptId.length === 0 || value.attemptId.length > 128) + ) + || ( + value.flightId !== undefined + && (typeof value.flightId !== "string" || value.flightId.length === 0 || value.flightId.length > 128) + ) || (value.staleOwner !== undefined && value.staleOwner !== true) + || (value.uncertain !== undefined && value.uncertain !== true) + || ( + value.cleanupPending !== undefined + && value.cleanupPending !== "pre-dispatch" + && value.cleanupPending !== "definitive-rejection" + ) + || ( + value.cleanupPending !== undefined + && ( + value.attemptId === undefined + || value.uncertain === true + || value.staleOwner === true + ) + ) ) { - return { version: 1, provider, accountId, generation: "", createdAt: 0, uncertain: true }; + return uncertainOAuthRefreshIntent(provider, accountId); } return value as OAuthRefreshIntent; } @@ -96,7 +166,7 @@ export function readOAuthRefreshIntent(provider: string, accountId: string): OAu return parseOAuthRefreshIntent(provider, accountId, readFileSync(path, "utf8")); } catch (error) { if (errorCode(error) === "ENOENT") return undefined; - return { version: 1, provider, accountId, generation: "", createdAt: 0, uncertain: true }; + return uncertainOAuthRefreshIntent(provider, accountId); } } @@ -107,27 +177,159 @@ export function peekOAuthRefreshIntent(provider: string, accountId: string): OAu return parseOAuthRefreshIntent(provider, accountId, readFileSync(path, "utf8")); } catch (error) { if (errorCode(error) === "ENOENT") return undefined; - return { version: 1, provider, accountId, generation: "", createdAt: 0, uncertain: true }; + return uncertainOAuthRefreshIntent(provider, accountId); } } -export function writeOAuthRefreshIntent(provider: string, accountId: string, generation: string, createdAt = Date.now(), flightId?: string): void { +export function writeOAuthRefreshIntent(provider: string, accountId: string, generation: string, createdAt = Date.now(), flightId?: string): OAuthRefreshIntent { const dir = getConfigDir(); if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); hardenConfigDir(); - const intent: OAuthRefreshIntent = { version: 1, provider, accountId, generation, createdAt, ...(flightId ? { flightId } : {}) }; - atomicWriteFile(getAuthRefreshIntentPath(provider, accountId), `${JSON.stringify(intent)}\n`); + const intent: OAuthRefreshIntent = { + version: 1, + provider, + accountId, + generation, + createdAt, + attemptId: randomUUID(), + ...(flightId ? { flightId } : {}), + }; + return withConfigMutationLockSync(() => { + if (readOAuthRefreshIntent(provider, accountId) !== undefined) { + throw new OAuthRefreshIntentIOError( + "write-intent", + new Error("An OAuth refresh intent already exists; refusing to overwrite its replay guard"), + ); + } + atomicWriteFile(getAuthRefreshIntentPath(provider, accountId), `${JSON.stringify(intent)}\n`); + return intent; + }); +} + +function sameOAuthRefreshIntentAttempt(current: OAuthRefreshIntent, expected: OAuthRefreshIntent): boolean { + return current.provider === expected.provider + && current.accountId === expected.accountId + && current.generation === expected.generation + && current.createdAt === expected.createdAt + && current.attemptId === expected.attemptId + && current.flightId === expected.flightId; +} + +function exactOAuthRefreshIntentFile( + provider: string, + accountId: string, +): { intent: OAuthRefreshIntent; snapshot: OAuthRefreshIntentFileSnapshot } | undefined { + const path = getAuthRefreshIntentPath(provider, accountId); + try { + hardenConfigDir(); + hardenExistingSecret(path); + const file = snapshot(path); + let intent: OAuthRefreshIntent; + try { intent = parseOAuthRefreshIntent(provider, accountId, file.bytes); } + catch { intent = uncertainOAuthRefreshIntent(provider, accountId); } + return { intent, snapshot: file }; + } catch (error) { + if (errorCode(error) === "ENOENT") return undefined; + throw error; + } +} + +export function markOAuthRefreshIntentCleanupPending( + provider: string, + accountId: string, + expected: OAuthRefreshIntent, + cleanupPending: OAuthRefreshIntentCleanupPending, + hooks: OAuthRefreshIntentMutationHooks = {}, +): OAuthRefreshIntent | undefined { + return withConfigMutationLockSync(() => { + const path = getAuthRefreshIntentPath(provider, accountId); + const observed = exactOAuthRefreshIntentFile(provider, accountId); + const current = observed?.intent; + if ( + !observed + || !current + || expected.attemptId === undefined + || current.uncertain + || current.staleOwner + || !sameOAuthRefreshIntentAttempt(current, expected) + || (current.cleanupPending !== undefined && current.cleanupPending !== cleanupPending) + ) return undefined; + const marked: OAuthRefreshIntent = { ...current, cleanupPending }; + try { + atomicWriteFile(path, `${JSON.stringify(marked)}\n`, undefined, { + beforeRename: hooks.beforeMarkCommit, + validateBeforeRename: targetPath => { + let latest: OAuthRefreshIntentFileSnapshot; + try { latest = snapshot(targetPath); } + catch (error) { + throw new OAuthRefreshIntentChangedError("OAuth refresh intent changed before marker commit", { cause: error }); + } + if (!sameSnapshot(observed.snapshot, latest)) { + throw new OAuthRefreshIntentChangedError("OAuth refresh intent changed before marker commit"); + } + }, + }); + } catch (error) { + if (error instanceof OAuthRefreshIntentChangedError) return undefined; + throw error; + } + return marked; + }); } + +export function clearOAuthRefreshIntentIfMatch( + provider: string, + accountId: string, + expected: OAuthRefreshIntent, + hooks: OAuthRefreshIntentMutationHooks = {}, +): boolean { + return withConfigMutationLockSync(() => { + const path = getAuthRefreshIntentPath(provider, accountId); + const observed = exactOAuthRefreshIntentFile(provider, accountId); + if (!observed) return true; + const current = observed.intent; + if ( + current.uncertain + || expected.uncertain + || current.attemptId === undefined + || expected.attemptId === undefined + || current.uncertain !== expected.uncertain + || current.staleOwner !== expected.staleOwner + || current.cleanupPending !== expected.cleanupPending + || !sameOAuthRefreshIntentAttempt(current, expected) + ) return false; + hooks.beforeClearRecheck?.(); + let latest: OAuthRefreshIntentFileSnapshot; + try { latest = snapshot(path); } + catch (error) { + if (errorCode(error) === "ENOENT") return true; + throw error; + } + if (!sameSnapshot(observed.snapshot, latest)) return false; + try { unlinkSync(path); return true; } + catch (error) { if (errorCode(error) === "ENOENT") return true; throw error; } + }); +} + export function markOAuthRefreshIntentStaleOwner(provider: string, accountId: string, generation: string, flightId: string): boolean { - const current = readOAuthRefreshIntent(provider, accountId); - if (current?.uncertain || current?.generation !== generation || current.flightId !== flightId) return false; - atomicWriteFile(getAuthRefreshIntentPath(provider, accountId), `${JSON.stringify({ ...current, staleOwner: true })}\n`); - return true; + return withConfigMutationLockSync(() => { + const current = readOAuthRefreshIntent(provider, accountId); + if ( + current?.uncertain + || current?.cleanupPending + || current?.generation !== generation + || current.flightId !== flightId + ) return false; + atomicWriteFile(getAuthRefreshIntentPath(provider, accountId), `${JSON.stringify({ ...current, staleOwner: true })}\n`); + return true; + }); } export function clearOAuthRefreshIntent(provider: string, accountId: string, generation: string): boolean { - const current = readOAuthRefreshIntent(provider, accountId); - if (!current || current.generation !== generation) return false; - try { unlinkSync(getAuthRefreshIntentPath(provider, accountId)); return true; } - catch (error) { if (errorCode(error) === "ENOENT") return false; throw error; } + return withConfigMutationLockSync(() => { + const current = readOAuthRefreshIntent(provider, accountId); + if (!current || current.generation !== generation) return false; + try { unlinkSync(getAuthRefreshIntentPath(provider, accountId)); return true; } + catch (error) { if (errorCode(error) === "ENOENT") return false; throw error; } + }); } export function credentialGeneration(cred: OAuthCredentials): string { return createHash("sha256").update(JSON.stringify([cred.refresh, cred.access, cred.expires])).digest("hex"); diff --git a/src/responses/spill-store.ts b/src/responses/spill-store.ts index f61d475c5a..ce063dfd8b 100644 --- a/src/responses/spill-store.ts +++ b/src/responses/spill-store.ts @@ -470,6 +470,26 @@ function serializedSpill( }; } +/** + * Exact on-disk payload size this spill WOULD occupy, measured before publication. + * + * Callers that reserve disk against a cap need the real envelope, not the resident + * measurement: the resident figure omits the `version` field the published payload + * carries, so pricing an admission by it undercounts and lets a request that sits exactly + * at the cap still exceed it. Shares `serializedSpill` rather than describing it, so the + * two cannot drift. + */ +export function prospectiveResponseSpillBytes( + responseId: string, + state: Omit, +): number | null { + try { + return serializedSpill(responseId, state).bytes.byteLength; + } catch { + return null; + } +} + function responseSpillWriteError(cause: unknown): NodeJS.ErrnoException { const error = new Error("Response spill write failed", { cause }) as NodeJS.ErrnoException; if (cause && typeof cause === "object" && "code" in cause) { diff --git a/src/responses/state.ts b/src/responses/state.ts index 35540a0ee7..b95a1fa2c6 100644 --- a/src/responses/state.ts +++ b/src/responses/state.ts @@ -16,6 +16,7 @@ import { responseSpillDirectory, responseSpillPayloadCap, markResponseSpillPublicationSuperseded, + prospectiveResponseSpillBytes, type ResponseSpillPublicationControl, type ResponseSpillRef, writeResponseSpillDurably, @@ -35,6 +36,30 @@ const SNAPSHOT_DEBOUNCE_MAX_MS = 30_000; * continuation chains) stores the full expanded input each turn — ~quadratic bytes per chain — * so a count cap alone cannot bound memory. Oldest-first eviction applies past this mark. */ export const MAX_STORED_RESPONSE_BYTES = 64 * 1024 * 1024; +/** + * Aggregate ceiling for the durable spill directory: the disk-side counterpart to + * the RAM ceiling above. Without it the spilled set is bounded only per-file + * (MAX_RESPONSE_SPILL_PAYLOAD_BYTES, 256 MiB) and per-entry (MAX_STORED_RESPONSES, + * 1000), whose product is 250 GiB — larger than the disk of any host this runs on. + * The only effective bound was therefore RESPONSE_TTL_MS, which makes disk use a + * function of client request rate rather than of anything this process controls. + * + * Measured on one macOS host, 2026-08-30: a client spilling ~150 MB payloads at + * ~1.4/min held 6.8 GB after 44 minutes, still climbing toward the ~12 GB an + * hour-long window implies, and filled the volume. Retention itself was correct + * throughout — the TTL evicted that whole cohort an hour later — so what was + * missing is a budget, not a sweep. + * + * 1 GiB comes from the same sample (n=31), whose spilled sizes are strongly + * bimodal: median 1.1 MiB against a p90 of 198.7 MiB, near the per-file ceiling. + * At that median the count cap and this ceiling bind within 8% of each other + * (1000 x 1.1 MiB = 1.07 GiB), so ordinary traffic sees no eviction it would not + * already have seen and only the large tail is cut. Erring small is the safe + * direction: too low costs a replay miss, an already-handled path surfaced as + * previous_response_not_found, while too high costs the host's disk and every + * unrelated process on it. + */ +export const MAX_SPILLED_RESPONSE_BYTES = 1024 * 1024 * 1024; /** Legacy snapshot selection only. Spill demotion is governed solely by the RAM cap above. */ const SNAPSHOT_ENTRY_MAX_BYTES = 2 * 1024 * 1024; const SNAPSHOT_TOTAL_MAX_BYTES = 24 * 1024 * 1024; @@ -187,12 +212,79 @@ interface PendingResponseSpill { cancelled: boolean; released: boolean; sizeBytes: number; + /** Peak on-disk bytes reserved for this publication; released exactly once on settle. */ + reservedBytes: number; publicationControl: ResponseSpillPublicationControl; } const pendingResponseSpills = new Set(); const pendingResponseSpillById = new Map(); let pendingResponseSpillBytes = 0; +/** + * On-disk bytes a queued publication is about to occupy but has not yet installed into + * `states`. + * + * `spilledResponseBytes()` walks installed spills and deferred unlinks — files that + * already exist. It cannot see one that `writeResponseSpillDurablyAsync` is in the + * middle of creating, and on Windows that middle can last as long as `icacls` takes. + * Without a reservation the cap holds only when writes are fast, which is not a cap. + * + * The reserved figure is the PEAK footprint, not the payload: publication can fall back + * from hard-linking to an exclusive copy, and during that fallback the destination copy + * and the temp file exist simultaneously. Reserving one envelope would leave the overshoot + * intact at half its magnitude. + * + * Ownership is single: a job holds its reservation from queue until + * `releasePendingResponseSpill`, which every exit from the publication path reaches + * through the `finally` in `runPendingResponseSpill` and through cancellation of a + * not-yet-running job. A leaked reservation is monotonic — it would ratchet the usable + * cap toward zero — so the release must stay on the settlement path rather than in a + * parallel bookkeeping pass. + */ +let reservedResponseSpillBytes = 0; +/** + * Paths a failed cleanup left on the volume, with the bytes each one occupies. + * + * A failed unlink leaves a real file behind, so the cap has to keep seeing it. But a + * never-decremented total would be phantom debt: a Windows lock that clears a moment + * later, or the async writer's own retry, can remove the file while the charge stays + * forever — and with 256 MiB payloads two conservative charges consume the whole default + * cap, after which nothing can spill for the life of the process. + * + * So the debt is per PATH, priced at what that path actually holds, and settled the + * moment the path is gone. `reconcileUnreclaimableSpillPaths` re-checks on every read of + * the accounted total, which is the same tick that would otherwise refuse an admission. + */ +const unreclaimableSpillPaths = new Map(); + +function chargeUnreclaimableSpillPath(path: string | null | undefined, bytes: number): void { + if (!path || bytes <= 0) return; + unreclaimableSpillPaths.set(path, bytes); +} + +/** Drop charges for paths that have since disappeared; returns the surviving total. */ +function reconcileUnreclaimableSpillPaths(): number { + let total = 0; + for (const [path, bytes] of [...unreclaimableSpillPaths]) { + if (existsSync(path)) total += bytes; + else unreclaimableSpillPaths.delete(path); + } + return total; +} + +/** + * Peak on-disk footprint of publishing this candidate: temp plus destination copy. + * + * Measured from the production serializer rather than from `candidate.sizeBytes`. The + * resident measurement omits the `version` field the published envelope carries, so + * pricing an admission by it undercounts and lets a request sitting exactly at the cap + * still exceed it. Falls back to the resident figure only when serialization fails, which + * is the same condition that will fail the publication itself. + */ +function publicationFootprintBytes(id: string, candidate: ResidentResponseState): number { + const exact = prospectiveResponseSpillBytes(id, spillPayloadForResident(candidate)); + return (exact ?? candidate.sizeBytes) * 2; +} let responseSpillPublicationTail: Promise = Promise.resolve(); let responseSpillShutdownBudgetOverride: { totalMs: number; fallbackReserveMs: number } | null = null; let responseSpillShutdownTerminalizationPassLimitOverride: number | null = null; @@ -210,6 +302,7 @@ function releasePendingResponseSpill(job: PendingResponseSpill): void { if (job.released) return; job.released = true; pendingResponseSpillBytes = Math.max(0, pendingResponseSpillBytes - job.sizeBytes); + reservedResponseSpillBytes = Math.max(0, reservedResponseSpillBytes - job.reservedBytes); pendingResponseSpills.delete(job); if (pendingResponseSpillById.get(job.id) === job) pendingResponseSpillById.delete(job.id); job.candidate = null; @@ -222,6 +315,11 @@ function cancelPendingResponseSpill(id: string): ResponseSpillRef | undefined { job.cancelled = true; markResponseSpillPublicationSuperseded(job.publicationControl); const superseded = job.supersededSpill; + // Ownership TRANSFERS to the caller. Leaving the ref on the cancelled job would let the + // accounting walk count the same physical file twice — once here and once on the + // replacement — and an overcount evicts live continuations to make room for bytes that + // are not there. + delete job.supersededSpill; // A queued job has not captured the candidate in an async frame yet, so release it now. // A running job retains its accounting until settlement and will discard its stale file. if (!job.running) releasePendingResponseSpill(job); @@ -313,6 +411,25 @@ function queuePendingResponseSpill( deferSupersededSpill(inheritedSpill); return; } + // Enforce the disk cap BEFORE the temp or destination file is created. Deleting the + // overflow afterwards is not equivalent: on Windows the file can outlive the decision + // by as long as ACL hardening takes, which is the window the measured 6.8 GiB + // accumulated in. Reclaim first, and only refuse if the peak footprint still does not + // fit — an eviction pass can free a live continuation's worth of room. + const footprint = publicationFootprintBytes(id, candidate); + // The superseded generation this job is about to own is already off `states` and not + // yet on the job, so it is invisible to the walk. Price it here or admission decides + // against a total that is short by a whole envelope. + const inheritedBytes = inheritedSpill?.payloadBytes ?? 0; + if (accountedResponseSpillBytes() + footprint + inheritedBytes > spillByteCap()) { + enforceSpilledResponseBudget(); + if (accountedResponseSpillBytes() + footprint + inheritedBytes > spillByteCap()) { + spillCounters.writeFailures += 1; + replaceWithSpillFailure(id, candidate); + deferSupersededSpill(inheritedSpill); + return; + } + } const job: PendingResponseSpill = { id, candidate, @@ -322,11 +439,13 @@ function queuePendingResponseSpill( cancelled: false, released: false, sizeBytes: candidate.sizeBytes, + reservedBytes: footprint, publicationControl: createResponseSpillPublicationControl(), }; pendingResponseSpills.add(job); pendingResponseSpillById.set(id, job); pendingResponseSpillBytes += job.sizeBytes; + reservedResponseSpillBytes += job.reservedBytes; recomputeOldestResident(); responseSpillPublicationTail = responseSpillPublicationTail .then(() => runPendingResponseSpill(job), () => runPendingResponseSpill(job)); @@ -418,7 +537,36 @@ function installShutdownFallbackSpill( aclBudgetMs: number, ): void { let ref: ResponseSpillRef | null = null; + // Supersession released this job's reservation, but the synchronous write below is the + // largest publication of the shutdown path and has its own link-then-copy fallback + // holding a temp and a destination at once. Re-reserve for its duration so the cap is + // not blind exactly where the drain does its heaviest work, and settle in `finally` so + // every return, throw and mismatch releases it. + const footprint = publicationFootprintBytes(job.id, candidate); + reservedResponseSpillBytes += footprint; try { + // Supersession released this job, so its superseded generation is no longer visible + // to the accounting walk — but the file is still on the volume until + // `deferSupersededSpill` or a delete takes it. Price it here or the fallback decides + // against a total short by that whole envelope, which is exactly the gap that lets + // `debt + footprint <= cap < old + debt + footprint` publish over budget. + const supersededBytes = job.supersededSpill?.payloadBytes ?? 0; + // The drain must not publish over the cap either. Reclaim first; if the footprint + // still does not fit — which is what unreclaimable cleanup debt looks like — the + // honest close-out is a tombstone, not another file on a volume that is already + // over budget. `replaceWithSpillFailure` is the same fail-closed ending the budget + // exhaustion path uses, so replay reports `spill_failed` and the client resends. + if (accountedResponseSpillBytes() + supersededBytes > spillByteCap()) { + enforceSpilledResponseBudget(); + if (accountedResponseSpillBytes() + supersededBytes > spillByteCap()) { + if (states.get(job.id) === candidate) { + spillCounters.writeFailures += 1; + replaceWithSpillFailure(job.id, candidate); + deferSupersededSpill(job.supersededSpill); + } + throw Object.assign(new Error("Response spill shutdown fallback exceeds the durable disk cap"), { code: "ENOSPC" }); + } + } ref = writeResponseSpillDurably(job.id, spillPayloadForResident(candidate), { aclBudgetMs }); if (ref.payloadBytes > responseSpillPayloadCap()) { deleteResponseSpill(ref); @@ -445,6 +593,8 @@ function installShutdownFallbackSpill( deferSupersededSpill(job.supersededSpill); } throw error; + } finally { + reservedResponseSpillBytes = Math.max(0, reservedResponseSpillBytes - footprint); } } @@ -477,7 +627,20 @@ function supersedeShutdownFallbackBatch( } for (const { job } of pending) { const cleanupFailure = cleanupSupersededResponseSpillPublication(job.publicationControl); - if (cleanupFailure) failures.push(cleanupFailure); + if (cleanupFailure) { + failures.push(cleanupFailure); + // Cleanup failed, so an async temp or destination is STILL on the volume. Releasing + // the reservation would un-account a file that exists, and the fallback write that + // follows reserves only its own footprint — three envelopes on disk priced as two. + // + // Charge the surviving PATHS rather than a flat two envelopes: `clearOwnedPath` + // nulls whichever it managed to remove, so one failure is one file, not two. The + // charge is settled automatically once the path disappears, which a retried unlink + // or a released Windows lock can still do. + const perPath = Math.max(1, Math.floor(job.reservedBytes / 2)); + chargeUnreclaimableSpillPath(job.publicationControl.tempPath, perPath); + chargeUnreclaimableSpillPath(job.publicationControl.destinationPath, perPath); + } releasePendingResponseSpill(job); } } @@ -603,6 +766,69 @@ export function getStoredResponseBytesForTests(): number { return storedResponseBytes; } +let spillByteCapOverride: number | null = null; + +function spillByteCap(): number { + return spillByteCapOverride ?? MAX_SPILLED_RESPONSE_BYTES; +} + +/** + * Live total of durable spill payloads. Recomputed per call rather than carried as + * a running counter: spilled entries reach `states` through several insertion paths + * (demotion swap, direct oversized admission, snapshot reload), and one missed + * increment there would silently disable the cap, where an O(MAX_STORED_RESPONSES) + * walk cannot drift. + */ +function spilledResponseBytes(): number { + let total = 0; + for (const entry of states.values()) { + if (entry.kind === "spill") total += entry.spill.payloadBytes; + } + // Superseded generations awaiting a durable snapshot are still files on disk. + // Counting only `states` would let PENDING_SPILL_UNLINKS_MAX of them sit outside + // the budget while it reports itself satisfied. + for (const ref of pendingSpillUnlinks) total += ref.payloadBytes; + return total; +} + +/** + * Accounted on-disk bytes: files that exist, plus the peak footprint of publications + * already in flight. + * + * The cap is enforced against this rather than against `spilledResponseBytes()` alone, + * because a publication that has not finished is still consuming the volume. On Windows + * the gap between "queued" and "installed" is however long `icacls` takes, and the + * measured incident this cap answers accumulated 6.8 GiB in 44 minutes. + */ +function accountedResponseSpillBytes(): number { + // Superseded generations a pending job still owns are files on disk too. A same-id + // replacement removes the old spill from `states` and hands its ref to the job, so + // counting only `states` plus `pendingSpillUnlinks` loses it for the whole publication + // — during a copy fallback that is old generation + new temp + new destination, three + // envelopes priced as two. + let ownedBySpillJobs = 0; + for (const job of pendingResponseSpills) { + if (job.supersededSpill) ownedBySpillJobs += job.supersededSpill.payloadBytes; + } + return spilledResponseBytes() + reservedResponseSpillBytes + ownedBySpillJobs + + reconcileUnreclaimableSpillPaths(); +} + +/** Test-only: lower/restore the durable spill cap (null restores the default). */ +export function setSpilledResponseByteCapForTests(bytes: number | null): void { + spillByteCapOverride = bytes; +} + +/** Test-only: current durable spill accounting (proves evictions unlink their files). */ +export function getSpilledResponseBytesForTests(): number { + return spilledResponseBytes(); +} + +/** Test-only: on-disk bytes plus in-flight publication reservations. */ +export function getAccountedResponseSpillBytesForTests(): number { + return accountedResponseSpillBytes(); +} + function serializedBytes(value: unknown): number | null { try { const serialized = JSON.stringify(value); @@ -1495,6 +1721,60 @@ export function replayOverlapSkipsForTests(): number { return replayOverlapSkips; } +/** + * Bring the durable spill set inside MAX_SPILLED_RESPONSE_BYTES, and report the + * bytes released. + * + * One owner, three callers: mutation pruning, the lazy load that follows a + * restart, and the periodic sweep. The periodic caller is not redundant — the + * mutation path only runs when traffic arrives, and a process can come up over + * budget from a snapshot written under a larger ceiling and then sit idle. That + * was observed in production at 1.8 GiB against a 1 GiB cap, held until the first + * request. + * + * NOT covered here: spill files orphaned by a crash. They are absent from + * `states`, so this function can neither see nor price them, and they stay with + * recoverOrphanedResponseSpills and its RESPONSE_SPILL_ORPHAN_GRACE_MS window. + * This ceiling therefore bounds what the store owns, which is every file it can + * account for, and not the directory as a whole. + */ +function enforceSpilledResponseBudget(): number { + // Price in-flight publications too: a file being created by + // `writeResponseSpillDurablyAsync` occupies the volume before it reaches `states`. + let spilledBytes = accountedResponseSpillBytes(); + if (spilledBytes <= spillByteCap()) return 0; + const before = spilledBytes; + // Deferred generations go first. They are already superseded, so releasing one + // costs only the crash window the queue exists to cover — the same trade + // PENDING_SPILL_UNLINKS_MAX already makes against unbounded disk. Evicting a + // live continuation to make room for a dead file would be the wrong order. + while (spilledBytes > spillByteCap() && pendingSpillUnlinks.length > 0) { + const ref = pendingSpillUnlinks.shift()!; + spilledBytes -= ref.payloadBytes; + deleteResponseSpill(ref); + } + // Ordered by createdAt, not by map order. `states` is not an age index: + // demotion and spill replacement delete and reinsert entries, and + // writeBoundedSnapshot serializes the map reversed, so map order can put a + // newer continuation first — and evicting that one spends a resume the older + // entry would not have cost. Sorting is O(k log k) over the spilled subset and + // runs only on a tick already over budget. + const spilled = [...states] + .filter((pair): pair is [string, SpilledResponseState] => pair[1].kind === "spill") + // createdAt is millisecond-resolution, so ties are ordinary under load. A + // stable sort would then fall back to insertion order — the very order this + // is avoiding — so break ties on the response id. Not localeCompare: the + // order must not depend on the host locale. + .sort((a, b) => a[1].createdAt - b[1].createdAt + || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); + for (const [id, entry] of spilled) { + if (spilledBytes <= spillByteCap()) break; + spilledBytes -= entry.spill.payloadBytes; + deleteEntry(id); + } + return before - spilledBytes; +} + function pruneResponses(at = now()): void { for (const [id, state] of states) { if (at - state.createdAt > RESPONSE_TTL_MS) deleteEntry(id); @@ -1537,6 +1817,7 @@ function pruneResponses(at = now()): void { replaceWithSpillFailure(oldestId, entry); } } + enforceSpilledResponseBudget(); } /** Periodic TTL-only sweep; count/byte eviction remains owned by mutation paths. */ @@ -1547,7 +1828,10 @@ export function sweepExpiredResponseStates(at = now()): number { deleteEntry(id); removed += 1; } - if (removed > 0) schedulePersist(); + // The disk ceiling needs a caller that does not depend on traffic. The return + // value stays the TTL count so this function's existing contract is unchanged. + const reclaimed = enforceSpilledResponseBudget(); + if (removed > 0 || reclaimed > 0) schedulePersist(); return removed; } @@ -1995,6 +2279,8 @@ export function clearResponseStateMemoryForTests(): void { export function clearResponseStateForTests(): void { for (const entry of states.values()) deleteOwnedSpills(entry); clearResponseStateMemoryForTests(); + reservedResponseSpillBytes = 0; + unreclaimableSpillPaths.clear(); try { unlinkSync(snapshotPath()); } catch { diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 9f19831576..9e188c03ee 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -256,10 +256,46 @@ export async function handleManagementAPI( if (routed) return routed; if (url.pathname === "/api/stop" && req.method === "POST") { - const { restoreNativeCodexAsync } = await import("../codex/inject"); - const { stopServiceIfInstalled, isServiceOwnershipError } = await import("../service"); + const { installedServiceRespawnRisk, stopServiceIfInstalledDetailed, isServiceOwnershipError } = await import("../service"); + // `ocx stop` performs its own shared teardown AFTER verifying the scheduler did not + // respawn the proxy (#3008). Without this the child restores native Codex and strips + // the Grok fence here, so a survivor found moments later has already had the shared + // config pulled out from under it — and the parent's `ownershipBlocked` guard can + // only prevent a second, redundant teardown. A direct caller sends nothing and keeps + // the self-contained behaviour. + // + // The query flag alone is not enough to hand over the obligation: any authenticated + // caller could set it and simply exit, leaving client config pointed at a proxy that + // no longer exists. Honour the deferral only when the caller left a pending-teardown + // receipt on disk, which a later stop/update can find and finish. + // Decide BEFORE touching the manager. Stopping the Task Scheduler task and then + // refusing left the proxy running with its manager stopped — worse than either + // outcome. This process cannot verify its own post-exit respawn window; only the + // receipt-backed parent `ocx stop` can, which is what the deferral exists for. + const { deferralMatchesReceipt } = await import("../config/pending-teardown"); + const { deferralHonored, performStopTeardown } = await import("./stop-teardown"); + const holdsReceipt = deferralHonored(url, deferralMatchesReceipt); + const respawnRisk = holdsReceipt ? "none" : installedServiceRespawnRisk(); + if (respawnRisk === "respawnable") { + return jsonResponse({ + success: false, + code: "respawnable_service", + message: "This proxy is managed by a Task Scheduler wrapper that can respawn it, so the stop must be run by `ocx stop`, which verifies the respawn window. Nothing was changed.", + }, 409, req, config); + } + if (respawnRisk === "unknown") { + // Do NOT send them to `ocx stop`: it maps the same unanswerable probe to a stop + // failure, so that advice would be a loop. The scheduler query itself is what needs + // fixing (#3008). + return jsonResponse({ + success: false, + code: "service_state_unknown", + message: "The Windows Task Scheduler state could not be read, so this proxy cannot tell whether a wrapper would respawn it. Nothing was changed. Run `ocx service status` to see the query error, repair Task Scheduler access, then retry.", + }, 409, req, config); + } + let serviceStop: import("../service").ServiceStopOutcome; try { - stopServiceIfInstalled(); + serviceStop = stopServiceIfInstalledDetailed(); } catch (err) { if (isServiceOwnershipError(err)) { // The installed service belongs to another CODEX_HOME/OPENCODEX_HOME: it would respawn @@ -269,12 +305,30 @@ export async function handleManagementAPI( } throw err; } - const restore = await restoreNativeCodexAsync(); + // The boolean helper collapses "failed" into the same false as "no service installed", + // so this route used to tear down shared config and exit while a manager that refused + // to stop was still there to respawn the proxy (#3008). + if (serviceStop === "failed") { + return jsonResponse({ + success: false, + message: "The installed service manager did not stop; it may respawn the proxy. Shared client config was left alone. Run `ocx stop` from the home that owns the service.", + }, 409, req, config); + } + if (serviceStop === "state-unknown") { + // Same case, same remedy as the pre-check: the query is what needs fixing. + return jsonResponse({ + success: false, + code: "service_state_unknown", + message: "The Windows Task Scheduler state could not be read, so this proxy cannot tell whether a wrapper would respawn it. Shared client config was left alone. Run `ocx service status` to see the query error, repair Task Scheduler access, then retry.", + }, 409, req, config); + } + // The pre-check above already refused the respawnable case without a receipt, so + // reaching here with one means the parent owns the verification. // Both managed configs come down together on an explicit teardown. The daemon's own // syncCleanup skips this when OCX_SERVICE is set (so a crash/respawn keeps the fence), - // which is exactly why an intentional stop has to do it here. - const { stripGrokConfig } = await import("../grok/inject"); - const grok = stripGrokConfig(); + // which is exactly why an intentional stop has to do it here — unless the caller is + // `ocx stop`, which does it itself once the proxy is proven down. + const teardown = await performStopTeardown(url, { ownsReceipt: deferralMatchesReceipt }); setTimeout(async () => { let shutdownSucceeded = false; try { @@ -282,12 +336,12 @@ export async function handleManagementAPI( } catch { console.warn("[opencodex] shutdown drain failed"); } - process.exit(shutdownSucceeded ? 0 : 1); + // A drained proxy whose shared teardown failed did not finish the job. Exiting 0 + // told a supervisor the stop was clean while native Codex or the Grok fence was + // still pointed at this process (#3008). + process.exit(shutdownSucceeded && teardown.success ? 0 : 1); }, 200); - const grokNote = grok.ok ? "" : ` Grok config cleanup failed: ${grok.message}`; - return jsonResponse(restore.success - ? { success: true, message: `Proxy stopping, native Codex restored.${grokNote}` } - : { success: false, message: `Proxy stopping, but native Codex restore failed: ${restore.message}. Run \`ocx restore\`.${grokNote}` }); + return jsonResponse(teardown); } if (url.pathname.startsWith("/api/native-main-profiles")) { diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts index ff0b562bfd..bc635b9743 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -21,6 +21,7 @@ import { createIntegrationStateStore, type IntegrationStateStore } from "../../i import { applyIntegrationCoordinated, disableIntegrationCoordinated, + overwriteIntegrationCoordinated, restoreIntegrationCoordinated, type IntegrationRestoreInput, type IntegrationWriteInput, @@ -70,7 +71,7 @@ export interface IntegrationJournalEnvelope { export interface IntegrationJournalRow { opId: string; clientId: IntegrationClientId; - kind: "apply" | "disable" | "refresh" | "restore"; + kind: "apply" | "disable" | "refresh" | "restore" | "overwrite"; at: string; configPath: string; snapshot: "none" | "stored" | "expired"; @@ -79,6 +80,14 @@ export interface IntegrationJournalRow { export interface IntegrationToggleBody { enabled: boolean; + /** + * Opt in to replacing a conflicted block with the one opencodex would write. + * + * Absent and `false` behave identically and are the only states a caller + * reaches by accident, which is the point: the conflict refusal protects work + * we did not author, so it can only be waived by asking for it by name. + */ + overwriteConflict?: boolean; } export interface IntegrationRestoreBody { @@ -472,16 +481,38 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise parsed.enabled - ? applyIntegrationCoordinated(input, { lockSeams: integrationMutationTestHooks?.lockSeams }) - : disableIntegrationCoordinated(input, { lockSeams: integrationMutationTestHooks?.lockSeams }), + () => { + const options = { lockSeams: integrationMutationTestHooks?.lockSeams }; + if (!parsed.enabled) return disableIntegrationCoordinated(input, options); + return parsed.overwriteConflict === true + ? overwriteIntegrationCoordinated(input, options) + : applyIntegrationCoordinated(input, options); + }, ); if (!result.ok) return writerFailureResponse(requestedClient, result, ctx); return jsonResponse(result satisfies IntegrationToggleEnvelope, 200, req, ctx.config); diff --git a/src/server/responses/encrypted-payload.ts b/src/server/responses/encrypted-payload.ts index 9de44cd2a2..3df72f769a 100644 --- a/src/server/responses/encrypted-payload.ts +++ b/src/server/responses/encrypted-payload.ts @@ -175,7 +175,24 @@ function textWithoutFernetRuns(payload: string, runs: readonly FernetTokenRun[]) return `${text}${payload.slice(last)}`; } -export const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*NEW_TASK[^\n]*\nTask name\s*:[^\n]*\nSender\s*:[^\n]*\nPayload\s*:\s*(?:\n|$)/gi; +/** + * The routing header codex-rs writes above a delegated agent payload. + * + * `MESSAGE` is matched as well as `NEW_TASK`, and only for the unreadability CHECK -- + * recovery stays NEW_TASK-only. #3021 reported a subagent `MESSAGE` arriving in the + * parent conversation as raw `gAAAA...` ciphertext after an `adapter_eof`. The detector + * decides "unreadable" by stripping the envelope and asking whether any plaintext + * survives, so an envelope shape it does not recognise counts as surviving text: a + * `MESSAGE` whose entire body is one Fernet token measured as READABLE and was forwarded + * verbatim. + * + * Widening the strip is not the same as widening recovery. Recovery decrypts, and + * decrypting a `MESSAGE` on the parent's behalf would build a plaintext oracle out of a + * payload the parent's session may have no right to read. This only lets the proxy + * NOTICE that what it is about to forward is unreadable ciphertext, which is what the + * report asks for: fail closed with a structured error rather than paste the token. + */ +export const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*(?:NEW_TASK|MESSAGE)[^\n]*\nTask name\s*:[^\n]*\nSender\s*:[^\n]*\nPayload\s*:\s*(?:\n|$)/gi; // CXC is the compatibility-hook control namespace. Strip only the tagged paragraph: // later untagged paragraphs may be genuine task text. Repeated CXC paragraphs are diff --git a/src/server/stop-teardown.ts b/src/server/stop-teardown.ts new file mode 100644 index 0000000000..e4aadc4996 --- /dev/null +++ b/src/server/stop-teardown.ts @@ -0,0 +1,84 @@ +import type { CodexNativeRestoreResult } from "../codex/inject"; +import { deferralMatchesReceipt } from "../config/pending-teardown"; + +/** + * Shared-teardown decision and execution for `POST /api/stop` (#3008). + * + * Lives outside the route handler because the handler schedules `process.exit` 200ms + * after it answers, which makes it uncallable from a test. The part worth testing is + * exactly this: whether the deferral is honoured, whether the restores actually run, and + * whether the response says what happened. + */ + +export type GrokStripResult = { ok: boolean; changed: boolean; message: string }; + +export type StopTeardownIo = { + /** Does the nonce this request carries name a readable obligation on disk? */ + ownsReceipt?: (nonce: string | null) => boolean; + restoreNativeCodex?: () => Promise; + stripGrok?: () => GrokStripResult; +}; + +export type StopTeardownBody = { + success: boolean; + message: string; + sharedTeardown: "deferred" | "performed"; +}; + +/** + * A deferral is honoured only when the caller proves it owns the obligation. + * + * The query flag names an intention; the receipt is the obligation. Without the second + * half any authenticated caller could ask the proxy to skip teardown and then exit, + * leaving native Codex and the Grok fence pointed at a proxy that no longer exists. + * + * "A receipt exists" is not that proof either: it would let any caller ride on another + * stop's outstanding obligation and get a deferral it never owns. The request has to name + * the receipt's nonce, which only the process that wrote it (and anything that can read + * the 0700 config directory, which is already the trust boundary for the admin token) + * can know. + */ +export function deferralHonored(url: URL, ownsReceipt: (nonce: string | null) => boolean): boolean { + if (url.searchParams.get("deferSharedTeardown") !== "1") return false; + return ownsReceipt(url.searchParams.get("teardownNonce")); +} + +/** Run (or skip) the shared teardown and describe the outcome truthfully. */ +export async function performStopTeardown(url: URL, io: StopTeardownIo = {}): Promise { + const ownsReceipt = io.ownsReceipt ?? deferralMatchesReceipt; + if (deferralHonored(url, ownsReceipt)) { + // Not "native Codex restored": nothing was restored here, and claiming otherwise + // would be a success message the operator cannot verify. + return { + success: true, + message: "Proxy stopping; shared teardown deferred to the stopping client.", + sharedTeardown: "deferred", + }; + } + const restore = io.restoreNativeCodex + ? await io.restoreNativeCodex() + : await (await import("../codex/inject")).restoreNativeCodexAsync(); + const grok = io.stripGrok + ? io.stripGrok() + : (await import("../grok/inject")).stripGrokConfig(); + // Success means BOTH halves came down. Deciding it from the native restore alone and + // appending the Grok text let a caller read `success: true` while the fence still + // pointed at a proxy that was exiting — the teardown reported done with half of it + // undone (#3008). + const grokNote = grok.ok ? "" : ` Grok config cleanup failed: ${grok.message}`; + if (restore.success && grok.ok) { + return { success: true, message: "Proxy stopping, native Codex restored.", sharedTeardown: "performed" }; + } + if (restore.success) { + return { + success: false, + message: `Proxy stopping, native Codex restored, but the Grok fence was not removed:${grokNote} Run \`ocx restore\`.`, + sharedTeardown: "performed", + }; + } + return { + success: false, + message: `Proxy stopping, but native Codex restore failed: ${restore.message}. Run \`ocx restore\`.${grokNote}`, + sharedTeardown: "performed", + }; +} diff --git a/src/service.ts b/src/service.ts index ab780711e8..67138d79fc 100644 --- a/src/service.ts +++ b/src/service.ts @@ -2998,6 +2998,22 @@ export function stopWindows(): void { if (isWindowsSchedulerEndBenign(error)) return; } } + +/** + * `stopWindows` for callers that need to know whether it worked. + * + * The void form swallows a non-benign `/end` failure, which is right for best-effort + * teardown and wrong for deciding whether an update may replace files: a scheduler that + * refused to stop can respawn the proxy on top of a half-written install (#3008). + */ +export function stopWindowsChecked(): boolean { + try { + schtasks(["/end", "/tn", TASK]); + return true; + } catch (error) { + return isWindowsSchedulerEndBenign(error); + } +} function statusWindows(): string { try { return schtasks(["/query", "/tn", TASK]); } catch { return ""; } } function statusWindowsXml(): string { try { return schtasks(["/query", "/tn", TASK, "/xml"]); } catch { return ""; } } @@ -3604,36 +3620,129 @@ export async function installFreshWindowsSchedulerSafely( } } +// `stopServiceIfInstalled` (boolean) is deliberately gone. It collapsed "not installed", +// "refused to stop" and "state could not be read" into the same `false`, and every caller +// that trusted it eventually read a live manager as absence — the route, then uninstall +// (#3008). Callers take `stopServiceIfInstalledDetailed` and handle the outcomes. /** - * If a service is installed, stop it so the process manager doesn't respawn after `ocx stop`. - * Returns true if a service was found and stopped. + * Would stopping the installed manager leave something that can respawn the proxy? + * + * Answered WITHOUT stopping anything, because a caller that must refuse the stop has to + * refuse before it acts: `POST /api/stop` briefly ended the Task Scheduler task and then + * returned 409, which left the proxy running with its manager stopped — worse than either + * outcome it was choosing between. + * + * Task Scheduler only. `schtasks /end` ends the task instance while the `cmd :loop` + * wrapper survives and respawns its child (#764); launchd, systemd and WinSW are down when + * they report stopped. */ -export function stopServiceIfInstalled(): boolean { +export function installedServiceRespawnRisk( + probe: () => WindowsSchedulerTaskProbe = probeWindowsSchedulerTask, + platform: NodeJS.Platform = process.platform, +): "none" | "respawnable" | "unknown" { + // launchd, systemd and WinSW are down when they report stopped; only the Task Scheduler + // wrapper survives its task ending (#764). + if (platform !== "win32") return "none"; + try { + // `probeWindowsSchedulerTask` returns "unknown" as an ordinary value when its queries + // fail — it does not throw — so testing for "present" let an unanswerable probe + // through, and the route then killed scheduler wrappers before refusing. + // + // "unknown" is kept SEPARATE from "respawnable" because the remedies differ. Telling + // an operator whose schtasks query is broken to run `ocx stop` is circular: that + // command maps the same unknown to a stop failure, so it cannot finish either. + const status = probe().status; + if (status === "absent") return "none"; + return status === "present" ? "respawnable" : "unknown"; + } catch { + // A probe that cannot answer is not evidence of absence either. + return "unknown"; + } +} + +/** + * Outcome of stopping an installed process manager. + * + * `stopServiceIfInstalled` collapses "no service was installed" and "a service was + * installed and would not stop" into the same `false`, which is fine for a caller that + * only wants to log. It is not fine for one deciding whether an update may replace package + * files: a manager that refused to stop can respawn the proxy on top of a half-written + * install (#3008). + */ +/** + * `stopped-respawnable` is Task Scheduler specifically: `schtasks /end` ends the task + * instance while the `cmd :loop` wrapper survives and respawns its child seconds later + * (#764). Only that backend needs the restart-window wait — launchd, systemd and WinSW + * are down when they report stopped, and making them pay a seven-second poll would be a + * regression in every ordinary `ocx stop`. + */ +/** + * `state-unknown` is kept apart from `failed` because the remedies differ. A manager that + * refused to stop is a stop failure the operator can retry; a scheduler whose state cannot + * be READ is a broken query, and telling that operator "the manager did not stop" sends + * them looking for the wrong thing (#3008). + */ +export type ServiceStopOutcome = "absent" | "stopped" | "stopped-respawnable" | "failed" | "state-unknown"; + +/** + * Collapse the Windows backend observations into one outcome. + * + * Extracted so the precedence is testable by calling it. The rule that matters: a readable + * failure outranks an unreadable state, and an unreadable state outranks success — a + * scheduler we cannot see may still respawn the proxy. + */ +export function classifyWindowsServiceStop(o: { + stopped: boolean; + failed: boolean; + schedulerStopped: boolean; + stateUnknown: boolean; +}): ServiceStopOutcome { + if (o.failed) return "failed"; + if (o.stateUnknown) return "state-unknown"; + if (o.stopped) return o.schedulerStopped ? "stopped-respawnable" : "stopped"; + return "absent"; +} + +export function stopServiceIfInstalledDetailed(): ServiceStopOutcome { assertServiceEnvironmentMatchesInstall(); if (process.platform === "darwin") { if (existsSync(plistPath())) { - try { stopLaunchd(); return true; } catch { return false; } + try { stopLaunchd(); return "stopped"; } catch { return "failed"; } } } else if (process.platform === "win32") { // Query BOTH backends regardless of state: a failed switch or stale state can leave // two managers installed, and either one would respawn the proxy after `ocx stop`. let stopped = false; - try { - const q = schtasks(["/query", "/tn", TASK]); - if (q.includes(TASK)) { stopWindows(); stopped = true; } - } catch { /* task not found */ } + let failed = false; + let schedulerStopped = false; + let stateUnknown = false; + // `probeWindowsSchedulerTask` is tri-state on purpose: a query that THROWS is not the + // same as a task that is absent, and treating it as absent lets a live scheduler + // survive a "successful" stop. + const probe = probeWindowsSchedulerTask(); + if (probe.status === "present") { + if (stopWindowsChecked()) { stopped = true; schedulerStopped = true; } + else failed = true; + } else if (probe.status === "unknown") { + // Not "failed": nothing refused to stop. The query itself could not answer, which is + // a different problem with a different fix. + stateUnknown = true; + } if (statusWinswRaw() !== "nonexistent") { - try { stopWinswService(); stopped = true; } catch { /* best-effort */ } + try { stopWinswService(); stopped = true; } catch { failed = true; } } // `schtasks /end` ends the task instance but the cmd `:loop` wrapper survives and // respawns its child seconds later (issue #764), resurrecting the proxy during a // stop or a tray restart. Kill the launcher/wrapper processes outright. killWindowsServiceWrapperProcesses(); - if (stopped) return true; + // A failure on either backend wins: the other one stopping does not make the live one + // safe to update over. + const outcome = classifyWindowsServiceStop({ stopped, failed, schedulerStopped, stateUnknown }); + if (outcome !== "absent") return outcome; } else if (process.platform === "linux" && isSystemd() && existsSync(unitPath())) { - try { stopSystemd(); return true; } catch { return false; } + try { stopSystemd(); return "stopped"; } catch { return "failed"; } } - return false; + return "absent"; } /** Delete install-state files; stale state would make `ocx update` "reinstall" a service that no longer exists. */ @@ -3666,13 +3775,22 @@ export function setUninstallServiceHooksForTests(hooks: UninstallServiceHooksFor * service or scheduler task that cannot be removed throws so the caller cannot erase state and * report success. */ -export function uninstallServiceIfInstalled(): boolean { +/** + * Outcome of removing an installed manager. + * + * `false` used to mean both "nothing was installed" and "removal failed" on darwin and + * linux, so a failed removal was reported as absence and authorized the shared teardown + * while the service assets were still there (#3008). + */ +export type ServiceUninstallOutcome = "absent" | "removed" | "failed"; + +export function uninstallServiceDetailed(): ServiceUninstallOutcome { const hooks = uninstallServiceHooksForTests; (hooks?.assertEnvironment ?? assertServiceEnvironmentMatchesInstall)(); const platform = hooks?.platform ?? process.platform; if (platform === "darwin") { if (existsSync(plistPath())) { - try { uninstallLaunchd(); removeServiceInstallState(); return true; } catch { return false; } + try { uninstallLaunchd(); removeServiceInstallState(); return "removed"; } catch { return "failed"; } } } else if (platform === "win32") { let removed = false; @@ -3688,13 +3806,20 @@ export function uninstallServiceIfInstalled(): boolean { (hooks?.uninstallNative ?? uninstallWinswService)(); removed = true; } - if (removed) { (hooks?.removeInstallState ?? removeServiceInstallState)(); return true; } + if (removed) { (hooks?.removeInstallState ?? removeServiceInstallState)(); return "removed"; } } else if (platform === "linux" && existsSync(unitPath())) { - try { uninstallSystemd(); removeServiceInstallState(); return true; } catch { - try { unlinkSync(unitPath()); removeServiceInstallState(); return true; } catch { return false; } + try { uninstallSystemd(); removeServiceInstallState(); return "removed"; } catch { + try { unlinkSync(unitPath()); removeServiceInstallState(); return "removed"; } catch { return "failed"; } } } - return false; + return "absent"; +} + +/** Boolean form for callers that only distinguish "something was removed". */ +export function uninstallServiceIfInstalled(): boolean { + const outcome = uninstallServiceDetailed(); + if (outcome === "failed") throw new Error("the installed service could not be removed"); + return outcome === "removed"; } /** True if a background service (launchd/systemd/Task Scheduler) is installed. */ @@ -4204,11 +4329,17 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise { // modules after startup, so an in-place update leaves it executing mixed old/new code. // Gate on the service and the runtime-port record too, not just the pid file — a // service-managed or orphaned proxy can be live while ocx.pid is stale/missing. + // + // An outstanding pending-teardown receipt is a fourth reason to run the stop. After a + // parent crashed mid-deferral all three of the other signals can be absent while the + // shared client config still points at a proxy that is gone; installing over that + // silently skips the recovery the receipt was written to trigger (#3008). // Full `ocx stop` semantics (drain, service stop, restore). - if (serviceWasInstalled || readPid() || readRuntimePort()) { + if (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()) { console.log("⏹ Stopping the running proxy before updating..."); const stopStdio = updateChildStdio(); const stop = spawnSync(process.execPath, selfLaunchArgv(["stop"]), { @@ -256,17 +266,39 @@ export async function runUpdate(): Promise { windowsHide: true, }); if (stopStdio === "pipe") logSpawnOutput("", stop); - if (stop.status !== 0 || readPid() || readRuntimePort()) { + // One decision, shared with the npm launcher (#3008). The two lanes disagreeing about + // the same situation is how this shipped fixed on one side only. Absent PID and runtime + // files are weak evidence - a crashed-but-listening proxy leaves none - so the captured + // endpoint is asked, and `null` from proxyIdentityAt covers refusal AND timeout alike. + const identity = await proxyIdentityAt(capturedListen.port, { hostname: capturedListen.hostname }); + const decision = decidePostStopUpdate({ + status: stop.status, + hasRuntimeState: !!(readPid() || readRuntimePort()), + // Re-checked AFTER the stop: a quarantined receipt lets the stop itself succeed + // (there is nothing left to stop), so a pre-stop check alone let the retry install + // over a teardown that never ran. + teardownOutstanding: pendingTeardownOutstanding(), + liveness: identity ? "live" : probeProxyLiveness(capturedListen.port, capturedListen.hostname), + }); + const historyOnlyStop = decision.reason === "history-only"; + if (!decision.proceed) { if (trayWasRunning) { try { const { startWindowsTray } = await import("../tray/windows"); startWindowsTray(); } catch { /* preserve the proxy stop failure */ } } - console.error("⚠️ Could not stop the running proxy; aborting the update. Run 'ocx stop' and retry."); + if (decision.reason === "teardown-outstanding") { + console.error("⚠️ A shared teardown from an earlier stop is still outstanding and needs manual review; aborting the update."); + console.error(" Confirm no proxy is running, run 'ocx restore', then remove the pending-teardown file in your opencodex home."); + } else { + console.error(decision.reason === "proxy-unknown" + ? `⚠️ Could not confirm the proxy on ${capturedListen.hostname}:${capturedListen.port} is stopped; aborting the update. Run 'ocx stop' and retry.` + : "⚠️ Could not stop the running proxy; aborting the update. Run 'ocx stop' and retry."); + } process.exit(1); } - if (historyRestoreIncomplete()) { + if (historyOnlyStop || historyRestoreIncomplete()) { console.warn( "⚠️ Codex resume-history metadata restore is incomplete (a backup manifest remains).\n" + " The DB may be busy or the manifest/target may need review; untracked routed history is intentionally unchanged.\n" + diff --git a/src/update/proxy-liveness-probe.d.mts b/src/update/proxy-liveness-probe.d.mts new file mode 100644 index 0000000000..a72c5fe028 --- /dev/null +++ b/src/update/proxy-liveness-probe.d.mts @@ -0,0 +1,6 @@ +/** Declaration for the plain-ESM liveness probe shared with `bin/ocx.mjs`. */ +export declare function probeProxyLiveness( + port: number, + hostname?: string, + timeoutMs?: number, +): "live" | "dead" | "unknown"; diff --git a/src/update/proxy-liveness-probe.mjs b/src/update/proxy-liveness-probe.mjs new file mode 100644 index 0000000000..6179156a32 --- /dev/null +++ b/src/update/proxy-liveness-probe.mjs @@ -0,0 +1,84 @@ +import { spawnSync } from "node:child_process"; + +/** + * Is something still answering `/healthz` as an opencodex proxy on this endpoint? + * + * Absent PID and runtime-port files are weak evidence that the proxy is gone: a crashed + * but still-listening process, or one supervised outside our records, leaves no files and + * keeps the port. Replacing package files under it leaves a server running a mix of old + * and new modules, which is the hazard `ocx update` stops the proxy to avoid (#3008). + * + * Synchronous and dependency-free because it runs inside the plain-Node launcher's + * `runNpmSelfUpdate`, which is not async and cannot import the TypeScript liveness module. + * A separate Node child does the fetch so the caller keeps its straight-line control flow. + * + * Returns `"live" | "dead" | "unknown"`, and the caller treats `unknown` as a reason to + * stop. Fail-open was wrong here: a listener that accepts connections but withholds + * `/healthz`, or a probe that times out, is exactly the state where replacing package + * files is most dangerous, and "we could not tell" is not evidence the proxy is gone. + * Only a refused connection or a definitive non-OpenCodex answer earns `"dead"`. + */ +export function probeProxyLiveness(port, hostname = "127.0.0.1", timeoutMs = 1500) { + // An unusable port is not an ambiguous probe: there is nothing to ask. + if (!Number.isFinite(port) || port <= 0 || port > 65535) return "dead"; + // Normalize HERE rather than at each call site. Leaving it to the callers put the fix in + // one lane and not the other, and a bracketed IPv6 literal handed to node:http answers + // nothing - which the tri-state correctly reports as "unknown" and the updater correctly + // treats as a reason to abort, turning a healthy stop into a refused update. + let host = typeof hostname === "string" && hostname.trim() !== "" ? hostname.trim() : "127.0.0.1"; + // A wildcard bind answers on loopback; `node:http` cannot dial the wildcard itself. + if (host === "0.0.0.0" || host === "*") host = "127.0.0.1"; + if (host === "::" ) host = "::1"; + // `[::1]` is a URL spelling; the socket layer wants the bare address. + if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1); + // `node:http` rather than `fetch`: the child inherits a parent whose event loop is + // blocked on `spawnSync`, and an aborted-before-dispatch fetch reports the same "not + // live" as a genuinely dead port. A request emitted on the socket cannot be confused + // with one that never left. + const script = [ + "const http = require('node:http');", + "const [host, port, timeout] = process.argv.slice(1);", + "const req = http.get({ host, port: Number(port), path: '/healthz', timeout: Number(timeout) }, res => {", + " let body = '';", + " res.setEncoding('utf8');", + " res.on('data', chunk => { body += chunk; });", + " res.on('end', () => {", + " try {", + " const parsed = JSON.parse(body);", + " // Mirrors isOpencodexHealthz in src/server/proxy-liveness.ts. A foreign server", + " // that happens to expose /healthz must not be read as our proxy, and a", + " // pre-identity build of ours must not be read as foreign.", + " const isOpencodex = parsed && typeof parsed === 'object'", + " && (parsed.service === 'opencodex'", + " || (parsed.service === undefined", + " && parsed.status === 'ok'", + " && typeof parsed.version === 'string'", + " && typeof parsed.uptime === 'number'));", + " // Only a clean 200 decides anything. Any other status means the endpoint is", + " // answering but not telling us what it is, which is not evidence of absence.", + " if (res.statusCode !== 200) process.stdout.write('UNKNOWN');", + " else process.stdout.write(isOpencodex ? 'LIVE' : 'DEAD');", + " } catch { process.stdout.write('UNKNOWN'); }", + " });", + "});", + "req.on('timeout', () => { process.stdout.write('UNKNOWN'); req.destroy(); });", + "// ECONNREFUSED is the one error that proves nothing is listening. Everything else -", + "// reset, unreachable host, TLS confusion - leaves the question open.", + "req.on('error', err => process.stdout.write(err && err.code === 'ECONNREFUSED' ? 'DEAD' : 'UNKNOWN'));", + ].join("\n"); + try { + const probe = spawnSync( + process.execPath, + ["-e", script, host, String(port), String(timeoutMs)], + { encoding: "utf8", timeout: timeoutMs + 1500, windowsHide: true }, + ); + const out = probe.stdout ?? ""; + if (out.includes("LIVE")) return "live"; + if (out.includes("DEAD")) return "dead"; + // A child that produced nothing, was killed by its own timeout, or failed to spawn + // leaves the question open rather than answering it. + return "unknown"; + } catch { + return "unknown"; + } +} diff --git a/src/update/stop-contract.d.mts b/src/update/stop-contract.d.mts new file mode 100644 index 0000000000..b077eb21b3 --- /dev/null +++ b/src/update/stop-contract.d.mts @@ -0,0 +1,2 @@ +/** Declaration for the plain-ESM stop contract shared with `bin/ocx.mjs`. */ +export declare const STOP_HISTORY_INCOMPLETE_EXIT_CODE: 79; diff --git a/src/update/stop-contract.mjs b/src/update/stop-contract.mjs new file mode 100644 index 0000000000..c72548b777 --- /dev/null +++ b/src/update/stop-contract.mjs @@ -0,0 +1,15 @@ +/** + * The exit code `ocx stop` uses to say "teardown succeeded, history cleanup did not". + * + * This is plain ESM rather than TypeScript because it has two consumers on opposite sides + * of a process boundary: `src/update/index.ts` and the Node launcher `bin/ocx.mjs`, which + * cannot import a `.ts` module. A TypeScript union would not survive `spawnSync` anyway — + * the value has to be on the wire, and an exit code is the wire. + * + * 79 is deliberate. It sits above the `sysexits.h` block (64-78), below `128 + signal`, + * and outside every code this CLI already uses: `src/cli/index.ts` emits 0, 1 and 130, + * and `src/cli/dispatch.ts` adds 2, 4 and 64. Picking one of those would have made a + * history-only stop indistinguishable from a config conflict, and `bin/ocx.mjs` mirrors + * the child's code faithfully enough to propagate the confusion. + */ +export const STOP_HISTORY_INCOMPLETE_EXIT_CODE = 79; diff --git a/src/update/stop-decision.d.mts b/src/update/stop-decision.d.mts new file mode 100644 index 0000000000..f773e786e6 --- /dev/null +++ b/src/update/stop-decision.d.mts @@ -0,0 +1,10 @@ +/** Declaration for the plain-ESM post-stop decision shared with `bin/ocx.mjs`. */ +export declare function decidePostStopUpdate(input: { + status: number | null; + hasRuntimeState: boolean; + liveness: "live" | "dead" | "unknown"; + teardownOutstanding?: boolean; +}): { + proceed: boolean; + reason: "stop-failed" | "runtime-state" | "teardown-outstanding" | "proxy-live" | "proxy-unknown" | "history-only" | "ok"; +}; diff --git a/src/update/stop-decision.mjs b/src/update/stop-decision.mjs new file mode 100644 index 0000000000..e96c11ae17 --- /dev/null +++ b/src/update/stop-decision.mjs @@ -0,0 +1,34 @@ +import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "./stop-contract.mjs"; + +/** + * May an update replace package files after `ocx stop` returned? + * + * Both updaters ask this: `src/update/index.ts` on the Bun path and `bin/ocx.mjs` on the + * npm path the dashboard uses. It lives here as plain ESM so the Node launcher can import + * it, and so the two lanes cannot drift into disagreeing about the same situation — which + * is how #3008 shipped in the first place, with the fix on one side only. + * + * Returns `{ proceed, reason }`. The reasons are: + * + * - `stop-failed` — a nonzero status other than the history-only code, or a signal kill. + * A signal kill carries no evidence the teardown finished, so it is not a maybe. + * - `runtime-state` — a PID or runtime-port record survived the stop. + * - `teardown-outstanding` — a shared-teardown obligation survived the stop. That is a + * quarantined receipt awaiting a human: the stop itself can succeed (there was nothing + * left to stop), so checking only BEFORE the stop let the retry sail straight through + * and install over a teardown that never ran. + * - `proxy-live` — something is still answering as our proxy on the captured endpoint. + * - `proxy-unknown` — the probe could not answer. Absence of proof is not proof of + * absence, and replacing files under a live server leaves it running a mix of old and + * new modules. + * - `ok` / `history-only` — proceed; the second also prints the manifest warning. + */ +export function decidePostStopUpdate({ status, hasRuntimeState, liveness, teardownOutstanding = false }) { + const historyOnly = status === STOP_HISTORY_INCOMPLETE_EXIT_CODE; + if (status !== 0 && !historyOnly) return { proceed: false, reason: "stop-failed" }; + if (hasRuntimeState) return { proceed: false, reason: "runtime-state" }; + if (teardownOutstanding) return { proceed: false, reason: "teardown-outstanding" }; + if (liveness === "live") return { proceed: false, reason: "proxy-live" }; + if (liveness !== "dead") return { proceed: false, reason: "proxy-unknown" }; + return { proceed: true, reason: historyOnly ? "history-only" : "ok" }; +} diff --git a/src/vision/eligibility.ts b/src/vision/eligibility.ts index 06a4ecce02..09a83d44db 100644 --- a/src/vision/eligibility.ts +++ b/src/vision/eligibility.ts @@ -21,6 +21,7 @@ * explicit modalities. */ import { modelInList, type OcxConfig, type OcxProviderConfig } from "../types"; +import { modelRecordValue } from "../reasoning-effort"; import { getModelMetadataCaseInsensitive, resolveMetadataProvider } from "../generated/model-metadata"; import { nativeInputModalities } from "../codex/catalog/metadata"; import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../codex/catalog/native-models"; @@ -68,6 +69,22 @@ export interface VisionModelOption { type EnrichedProviderCache = Map; +/** + * Whether the proxy must describe images for this model before dispatching its main request. + * + * `noVisionModels` is an explicit override. A modality declaration is only evidence for this + * path when it describes a text model that excludes image input: an audio-only declaration is + * not a text-only model and must not be widened to image through the vision sidecar. + */ +export function isModelVisionSidecarConsumer( + provider: Pick, + modelId: string, +): boolean { + if (modelInList(provider.noVisionModels, modelId)) return true; + const modalities = modelRecordValue(provider.modelInputModalities, modelId); + return Array.isArray(modalities) && modalities.includes("text") && !modalities.includes("image"); +} + function advertisesImageInput(modalities: readonly string[] | undefined): boolean | undefined { if (!modalities || modalities.length === 0) return undefined; return modalities.includes("image"); @@ -105,7 +122,8 @@ function isVisionSidecarConsumerWithCache( modelId: string, cache: EnrichedProviderCache, ): boolean { - return modelInList(enrichedProviderForVision(config, providerName, cache)?.noVisionModels, modelId); + const provider = enrichedProviderForVision(config, providerName, cache); + return provider !== undefined && isModelVisionSidecarConsumer(provider, modelId); } /** diff --git a/src/vision/index.ts b/src/vision/index.ts index b945620ca6..3f85258624 100644 --- a/src/vision/index.ts +++ b/src/vision/index.ts @@ -1,12 +1,10 @@ import { createHash } from "node:crypto"; import type { OcxConfig, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent } from "../types"; -import { modelInList } from "../types"; -import { modelRecordValue } from "../reasoning-effort"; import type { VisionReasoningEffort } from "../reasoning-effort"; import { describeImage, type DescribeOutcome, type VisionSettings } from "./describe"; import { describeImageAnthropic } from "./anthropic-describe"; import { describeImageRouted } from "./routed-describe"; -import { modelAcceptsImageInput } from "./eligibility"; +import { isModelVisionSidecarConsumer as isModelTextOnly, modelAcceptsImageInput } from "./eligibility"; import { normalizeVisionReasoningForModel } from "./reasoning"; import type { CodexAuthContext } from "../codex/auth-context"; import { resolveSidecarAuth } from "../sidecar/auth"; @@ -22,24 +20,12 @@ import { export { describeImage } from "./describe"; -/** - * True when the model is explicitly known to be text-only — either listed in - * `noVisionModels` or declared with `modelInputModalities` that exclude "image". - * Returns false for unknown models (no evidence either way) so they fall through - * to native image passthrough, which is the safe default for an unclassified model. - */ -export function isModelTextOnly( - provider: OcxProviderConfig, - modelId: string, -): boolean { - if (modelInList(provider.noVisionModels, modelId)) return true; - const modalities = modelRecordValue(provider.modelInputModalities, modelId); - if (Array.isArray(modalities) && modalities.length > 0 && !modalities.includes("image")) return true; - return false; -} +/** Backward-compatible request-time name for the shared vision-sidecar consumer predicate. */ +export { isModelVisionSidecarConsumer as isModelTextOnly } from "./eligibility"; export { describeImageAnthropic, parseAnthropicVisionSSE } from "./anthropic-describe"; export { BASELINE_VISION_MODELS, + isModelVisionSidecarConsumer, isVisionEligibleModel, isVisionSidecarConsumer, modelAcceptsImageInput, diff --git a/structure/00_overview.md b/structure/00_overview.md index d489fb293b..d1f864c4c6 100644 --- a/structure/00_overview.md +++ b/structure/00_overview.md @@ -84,7 +84,7 @@ opencodex state root does not undo those writes. Putting native Codex back is th | `~/.opencodex/ocx.pid`, `runtime-port.json`, `system-env-port` | opencodex runtime | Live process identity and the port a client should reach; rewritten on start. `runtime-port.json` also carries the protected per-process listener-attestation key used before CLI diagnostics attach a management bearer. | | `~/.opencodex/codex-runtime.json`, `codex-runtime-clamp.json` | opencodex Codex runtime | Selected Codex executable/version state and effort-clamp diagnostics. Not process identity: these persist a resolved choice and a diagnostic, so losing them changes behavior until re-resolved. | | `~/.opencodex/service-state.json`, `service.log`, `service-api-token`, `opencodex-service-launcher.vbs`, `opencodex-service-task.xml`, `opencodex-service.cmd`, `winsw`, `tray-state.json`, `tray-heartbeat.json`, `opencodex-tray.ps1`, `opencodex-tray-*.ico`, `update-job.json` | opencodex operators | Installed-service, Windows tray, and self-update artifacts and bookkeeping. The update record carries its worker PID so a dead worker recovers instead of blocking later runs. | -| `~/.opencodex/responses-state.json`, `usage-debug.jsonl`, `crash.log`, `artifacts/` | opencodex diagnostics and artifacts | Bounded caches, diagnostics, and generated image/video artifacts served locally. | +| `~/.opencodex/responses-state.json`, `responses-state-spill/`, `usage-debug.jsonl`, `crash.log`, `artifacts/` | opencodex diagnostics and artifacts | Bounded caches, diagnostics, and generated image/video artifacts served locally. The spill directory holds continuation state demoted out of the in-memory cap and is bounded in aggregate, not only per file. | | `~/.opencodex/codex-shim.json`, `*.lock`, `kimi-device-id`, `mimo-client-id`, `.star-prompted` | opencodex bookkeeping | Shim restore obligations, cross-process locks, per-install client identifiers, one-shot UI flags. | | `~/.opencodex/.opencodex-owner.json`, `.opencodex-uninstall.json` | opencodex | Ownership marker and the manifest that bounds what uninstall may remove. Both live in the OpenCodex state root, not in `$CODEX_HOME`. | | `$CODEX_HOME/config.toml` | Codex, edited by opencodex | Active provider and provider table. | diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index 8411884b5c..ddfd1d67e0 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -157,6 +157,34 @@ Hidden` inside an already-running PowerShell script, nor to .NET/VBS process-win - 다른 대안 대신 이 방식을 선택한 이유: Names and environment paths are caller-controlled, required secret writes must not silently skip ACLs, and elevation has a larger authority boundary that should remain FFI-only. - 장점, 단점 및 영향: Default Windows ARM64 installations can start and harden secrets; non-default Windows roots continue to fail closed until Bun exposes a trustworthy native system-directory API without FFI. +The durable response-spill directory `~/.opencodex/responses-state-spill/` is bounded in +aggregate, not only per file. Continuation state demoted out of the in-memory cap +(`MAX_STORED_RESPONSE_BYTES`) is written there, and eviction past +`MAX_SPILLED_RESPONSE_BYTES` removes oldest-first through the same deletion point that serves +TTL and count eviction, so an evicted entry unlinks its file. One function owns that ceiling and +three callers drive it: mutation pruning, the lazy load that follows a restart, and the periodic +sweep. The periodic caller is not redundant — the mutation path runs only when traffic arrives, so a +process that comes up over budget from a snapshot written under a larger ceiling would otherwise +stay over it while idle. + +The ceiling bounds what the store can account for, which is every entry in the map plus the +superseded generations queued for unlink, and deliberately not the directory as a whole. Spill files +orphaned by a crash are absent from the map, so this accounting can neither see nor price them; they +remain with the `recoverOrphanedResponseSpills` grace sweep described below, which is the only +mechanism that reclaims them. A host that crashes repeatedly can therefore hold spill bytes above +this ceiling for up to `RESPONSE_SPILL_ORPHAN_GRACE_MS` past each crash. Without that aggregate bound the +directory was limited only per file (256 MiB) and per entry (1000) — a 250 GiB product — which +left `RESPONSE_TTL_MS` as the only effective limit and made disk use a function of client +request rate rather than of anything the process controls. + +[Decision Log] +- 목적과 의도: Bound the durable spill directory in aggregate so demoted continuation state cannot consume the host disk. +- 기존 구현 및 제약 조건: The resident map has an unconditional byte cap and demotes past it, but the disk it demotes onto had only a per-file ceiling and the shared 1000-entry count cap. Retention itself worked — the hour-long TTL did evict — so the gap was a missing budget, not a leak. +- 검토한 주요 대안: Lower the per-file ceiling; shorten the TTL; sweep the directory on a timer; add a configurable budget key; carry a running byte counter. +- 선택한 방식: A constant aggregate ceiling checked at the end of the existing prune, evicting oldest-first, with the total recomputed per prune rather than carried as a counter. +- 다른 대안 대신 이 방식을 선택한 이유: Per-file or TTL changes alter retention semantics other bounds depend on; a timer adds a second owner for eviction; a config key would surface a knob the sibling bounds (count, TTL, per-file) do not have; and a running counter could silently disable the cap if any of the several insertion paths missed an increment, where a walk over at most 1000 entries cannot drift. +- 장점, 단점 및 영향: Disk use stops tracking client request rate. Ordinary traffic is unaffected because the count cap binds at a comparable point for median-sized payloads; a workload of unusually large continuations loses its oldest spills earlier than the TTL would, surfacing as the existing `previous_response_not_found` continuation miss. + Response-state loading performs a bounded recovery pass for interrupted snapshot writes. It only matches regular files named `responses-state.json.ocx...tmp`, waits at least 15 minutes, and skips the current or any live PID. Eligible files are truncated before unlinking so a diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts index 444f3d6e77..f031ce8f8b 100644 --- a/tests/bridge.test.ts +++ b/tests/bridge.test.ts @@ -1075,7 +1075,7 @@ describe("Responses bridge web_search_call native item", () => { }); }); - test("a batched (plural) search emits action.search.queries without a singular query", () => { + test("a batched (plural) search carries both query and queries for Console Go (#3071)", () => { const json = buildResponseJSON([ { type: "web_search_call_begin", id: "ws_3" }, { type: "web_search_call_end", id: "ws_3", queries: ["rust async", "tokio runtime"] }, @@ -1085,9 +1085,9 @@ describe("Responses bridge web_search_call native item", () => { const output = json.output as Record[]; const action = (output[0] as Record).action as Record; - // Native renders " ..." only when `query` is absent and queries.len() > 1. - expect(action).toEqual({ type: "search", queries: ["rust async", "tokio runtime"] }); - expect(action.query).toBeUndefined(); + // Console Go's upstream validator requires singular `query` on the search action, + // and DeepSeek native Responses requires `queries` — so a batch carries both now. + expect(action).toEqual({ type: "search", query: "rust async", queries: ["rust async", "tokio runtime"] }); }); test("a single-query search also carries queries so strict parsers accept the replay (#930)", () => { diff --git a/tests/catalog-vision-sidecar-modalities.test.ts b/tests/catalog-vision-sidecar-modalities.test.ts index f5ff51431d..8e71876dff 100644 --- a/tests/catalog-vision-sidecar-modalities.test.ts +++ b/tests/catalog-vision-sidecar-modalities.test.ts @@ -48,6 +48,50 @@ describe("vision-sidecar catalog modalities", () => { expect(hinted.inputModalities).toEqual(["text", "image"]); }); + test("modelInputModalities-declared text-only models advertise image without a noVisionModels entry", () => { + // Regression: isModelTextOnly (the RUNTIME sidecar gate) treats a text-only + // modelInputModalities declaration exactly like a noVisionModels entry, but the catalog hint + // pass only checked noVisionModels — so sidecar-covered models stayed advertised text-only + // and the Codex app blocked image paste before the sidecar could run. + const prov: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://api.example/v1", + modelInputModalities: { "deepseek-chat": ["text"] }, + }; + const hinted = applyProviderConfigHints("azu-lab2", prov, { id: "deepseek-chat", provider: "azu-lab2" }); + expect(hinted.inputModalities).toEqual(["text", "image"]); + }); + + test("modelInputModalities declaring image stays untouched (no duplication)", () => { + const prov: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://api.example/v1", + modelInputModalities: { "glm-5.3": ["text", "image"] }, + }; + const hinted = applyProviderConfigHints("vdi", prov, { id: "glm-5.3", provider: "vdi" }); + expect(hinted.inputModalities).toEqual(["text", "image"]); + }); + + test("audio-only modelInputModalities do not advertise image", () => { + const prov: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://api.example/v1", + modelInputModalities: { "audio-model": ["audio"] }, + }; + const hinted = applyProviderConfigHints("audio-provider", prov, { id: "audio-model", provider: "audio-provider" }); + expect(hinted.inputModalities).toEqual(["audio"]); + }); + + test("discovery-derived text-only rows are NOT advertised image (the runtime would not convert them)", () => { + // Only the two config sources the runtime predicate reads (noVisionModels, + // modelInputModalities) may widen the catalog; a listing that merely reports + // ["text"] without either must stay text-only. + const hinted = applyProviderConfigHints("opencode-go", base, { + id: "listing-text-model", provider: "opencode-go", inputModalities: ["text"], + }); + expect(hinted.inputModalities).toEqual(["text"]); + }); + test("MiMo token-plan sends only the Pro model through the sidecar (#1927)", () => { const canonical: OcxProviderConfig = { adapter: "openai-chat", @@ -173,6 +217,69 @@ describe("vision-sidecar custom-model override (#349/#344)", () => { globalThis.fetch = originalFetch; } }); + + test("a custom row whose modelId is declared text-only via modelInputModalities still advertises image", async () => { + // Same isModelTextOnly parity as the hint pass, applied to the custom-model override path: + // the registry/config text-only declaration covers the row at request time, so the catalog + // must let images through to the sidecar here too. + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { throw new Error("fetch should not be called"); }) as typeof fetch; + try { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "text-sidecar-provider", + providers: { + "text-sidecar-provider": { + baseUrl: "https://text-sidecar.example/v1", + adapter: "openai-chat", + authMode: "key", + liveModels: false, + models: ["baseline-model"], + modelInputModalities: { "glm-5.2": ["text"] }, + }, + }, + customModels: [ + { id: "cm-4", provider: "text-sidecar-provider", modelId: "glm-5.2", displayName: "GLM 5.2", addedAt: "2026-01-01T00:00:00.000Z" }, + ], + }); + const custom = models.find(m => m.provider === "text-sidecar-provider" && m.id === "glm-5.2"); + expect(custom).toBeDefined(); + expect(custom?.inputModalities).toEqual(["text", "image"]); + } finally { + globalThis.fetch = originalFetch; + clearModelCache("text-sidecar-provider"); + } + }); + + test("a custom row whose modelId is declared audio-only does not advertise image", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { throw new Error("fetch should not be called"); }) as typeof fetch; + try { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "audio-sidecar-provider", + providers: { + "audio-sidecar-provider": { + baseUrl: "https://audio-sidecar.example/v1", + adapter: "openai-chat", + authMode: "key", + liveModels: false, + models: ["baseline-model"], + modelInputModalities: { "audio-model": ["audio"] }, + }, + }, + customModels: [ + { id: "cm-audio", provider: "audio-sidecar-provider", modelId: "audio-model", displayName: "Audio Model", addedAt: "2026-01-01T00:00:00.000Z" }, + ], + }); + const custom = models.find(m => m.provider === "audio-sidecar-provider" && m.id === "audio-model"); + expect(custom).toBeDefined(); + expect(custom?.inputModalities?.includes("image") ?? false).toBe(false); + } finally { + globalThis.fetch = originalFetch; + clearModelCache("audio-sidecar-provider"); + } + }); }); describe("vision-capable provider models feed combo modalities", () => { @@ -258,6 +365,31 @@ describe("vision-capable provider models feed combo modalities", () => { const hinted = applyProviderConfigHints("xai", prov, { provider: "xai", id: "grok-4.5" }); expect(hinted.inputModalities).toEqual(["text", "image"]); }); + + test("a combo of modelInputModalities-declared text-only members advertises image through the hints", () => { + // End-to-end shape of the /planners bug: every member is sidecar-covered via + // modelInputModalities (not noVisionModels). The hinted members all carry image, so the + // derived combo keeps image input instead of collapsing to text-only. + const memberFor = (provider: string, id: string): CatalogModel => applyProviderConfigHints(provider, { + adapter: "openai-chat", + baseUrl: `https://${provider}.example/v1`, + modelInputModalities: { [id]: ["text"] }, + }, { id, provider, contextWindow: 200_000 }); + const memberA = memberFor("azu-lab2", "deepseek-chat"); + const memberB = memberFor("vdi", "glm-5.2"); + const derived = deriveComboCatalogModel( + "planners", + { + targets: [ + { provider: "azu-lab2", model: "deepseek-chat" }, + { provider: "vdi", model: "glm-5.2" }, + ], + defaultEffort: "high", + } as never, + [memberA, memberB], + ); + expect(derived?.inputModalities).toEqual(["text", "image"]); + }); }); describe("Cursor native vs sidecar vision registry", () => { diff --git a/tests/cli-dispatch.test.ts b/tests/cli-dispatch.test.ts index 012bbe7784..a4ac1084ed 100644 --- a/tests/cli-dispatch.test.ts +++ b/tests/cli-dispatch.test.ts @@ -123,6 +123,75 @@ describe("dispatchCommand exit codes", () => { }); }); +/** + * `ocx health` probed once. A proxy that has only just bound can miss a single + * probe while its event loop is still settling startup work, so health run + * seconds after a service restart reported "Proxy not healthy" and exited 1 for + * a proxy that was in fact serving. The stop paths already retry this exact race + * under SERVICE_STOP_LIVENESS (#764); health did not. + */ +describe("health retries a just-started proxy", () => { + test("passes a retry budget to findLiveProxy", async () => { + const seen: (number | undefined)[] = []; + const deps = { + ...fakeDeps, + args: ["health"], + findLiveProxy: async (io?: { attempts?: number }) => { + seen.push(io?.attempts); + return { pid: 4242, port: 10100 } as never; + }, + } as unknown as CliDispatchDeps; + + expect(await dispatchCommand({ kind: "command", command: "health", args: deps.args }, deps)).toBe(0); + // More than one: a single attempt is the defect. The exact number is the + // stop path's, and is asserted rather than inferred so a silent drop back to + // one probe fails here. + expect(seen).toEqual([3]); + }); + + test("still reports an absent proxy as unhealthy", async () => { + const deps = { + ...fakeDeps, + args: ["health"], + findLiveProxy: async () => null, + } as unknown as CliDispatchDeps; + + expect(await dispatchCommand({ kind: "command", command: "health", args: deps.args }, deps)).toBe(1); + }); +}); + +/** + * `handleStart` skipped the configured-port probe whenever the pid file and the + * runtime-port record were both absent. That absence proves nothing: a + * fallback-port sibling overwrites both records when it starts and removes them + * when it stops, so `start` shadowed a healthy configured-port proxy with an + * ephemeral-port copy and re-pointed client config at the copy. + * + * Source-level because the executable path binds real ports and spawns a real + * child; this pins the one option that decides the behavior, next to the + * `handleEnsure` call site that already had it right. + */ +describe("start probes the configured port before shadowing it (source-level)", () => { + const cliSource = readFileSync(join(import.meta.dir, "../src/cli/index.ts"), "utf8"); + + test("every findProxyOwnerBeforeJournalRecovery call site asks for the probe", () => { + const calls = cliSource.match(/findProxyOwnerBeforeJournalRecovery\s*\(([^)]*)\)/g) ?? []; + // The declaration plus both call sites (handleStart, handleEnsure). + expect(calls.length).toBeGreaterThanOrEqual(2); + const invocations = calls.filter(call => !call.includes("options:")); + expect(invocations.length).toBe(2); + for (const call of invocations) { + expect(call).toContain("probeConfiguredPort: true"); + } + }); + + test("the probe option still gates on an explicit true", () => { + // A truthy-but-not-true default would silently probe for callers that pass + // nothing, which is a different behavior than the one asserted above. + expect(cliSource).toContain("options.probeConfiguredPort === true"); + }); +}); + describe("logout parses argv before touching the credential store", () => { /** * `ocx logout --json` used to lowercase `--json`, pass it to removeCredential as a provider diff --git a/tests/cli-headless-parity.test.ts b/tests/cli-headless-parity.test.ts index 3900af2d7a..dcd05d295b 100644 --- a/tests/cli-headless-parity.test.ts +++ b/tests/cli-headless-parity.test.ts @@ -554,6 +554,41 @@ describe("headless GUI parity CLI", () => { ]); }); + test("enable can waive a conflict, and only when the flag is typed", async () => { + /* + * The parity this closes: the dashboard could resolve a conflict and the CLI + * could not, which strands the user who has no browser -- an SSH session, or + * an agent driving the proxy. That dead end is the reason the overwrite path + * exists, so leaving it GUI-only reproduces it for half the users. + */ + const runtime = fakeRuntime(); + expect(await handleClientIntegrationCommand(["enable", "--client", "hermes", "--json"], runtime.deps)).toBe(0); + expect(await handleClientIntegrationCommand( + ["enable", "--client", "hermes", "--overwrite-conflict", "--json"], + runtime.deps, + )).toBe(0); + expect(runtime.requests.map(row => row.body)).toEqual([ + // Absent rather than false: an older proxy sees the request it always saw. + { enabled: true }, + { enabled: true, overwriteConflict: true }, + ]); + }); + + test("a conflict waiver cannot ride along with disable", async () => { + /* + * Forcing a DISABLE over a conflict deletes a block we do not own, which is + * the one thing the refusal exists to prevent. The route answers 400; failing + * locally names the offending flag instead of surfacing a generic request + * failure, and sends nothing. + */ + const runtime = fakeRuntime(); + expect(await handleClientIntegrationCommand( + ["disable", "--client", "hermes", "--overwrite-conflict", "--json"], + runtime.deps, + )).not.toBe(0); + expect(runtime.requests).toEqual([]); + }); + test("a client integration command without its required target fails instead of guessing", async () => { const runtime = fakeRuntime(); // No `--client`: picking one for the user would write a config they never named. diff --git a/tests/cli-ready.test.ts b/tests/cli-ready.test.ts index 577491e0ae..56a9875b56 100644 --- a/tests/cli-ready.test.ts +++ b/tests/cli-ready.test.ts @@ -861,8 +861,10 @@ describe("handleStart OCX_SERVICE exit guard (source-level)", () => { test("service.ts teardown kills surviving wrapper processes on stop", () => { const serviceSource = readFileSync(join(import.meta.dir, "../src/service.ts"), "utf8"); expect(serviceSource).toMatch(/killWindowsServiceWrapperProcesses/); - const callSite = serviceSource.match(/stopServiceIfInstalled[\s\S]{0,1200}?killWindowsServiceWrapperProcesses\(\)/); - expect(callSite, "wrapper kill must run during stopServiceIfInstalled").not.toBeNull(); + // The boolean `stopServiceIfInstalled` is gone — it collapsed a live manager into the + // same false as "not installed" (#3008). The stop itself is the detailed function. + const callSite = serviceSource.match(/stopServiceIfInstalledDetailed[\s\S]{0,1600}?killWindowsServiceWrapperProcesses\(\)/); + expect(callSite, "wrapper kill must run during stopServiceIfInstalledDetailed").not.toBeNull(); }); test("wrapper kill matches the canonical paths of THIS installation, not bare filenames", () => { diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index f174acbcde..05223ca1e5 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -3,7 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { applyNativeVisibility, augmentRoutedModelsWithMetadata, augmentRoutedModelsWithRegistryOpenAiApiRows, buildCatalogEntries, buildComboCatalogOmission, catalogModelSlug, clampCatalogModelsToCodexSupport, clampEntryToCodexSupportedEfforts, clampedDefaultEffort, CODEX_ACCOUNT_BOUND_CATALOG_KIND, CODEX_NATIVE_ALIAS_CATALOG_KIND, comboCatalogOmissionReason, deriveComboCatalogModel, exactComboCatalogSlugs, filterCatalogVisibleModels, filterSupportedNativeSlugs, gatherRoutedModels as gatherRoutedModelsDirect, isDatedVariantId, isMediaGenerationModelId, loadBundledCodexCatalog, materializeBundledCodexCatalog, mergeCatalogEntriesForSync, NATIVE_DAYBREAK_BLUE_MODEL, NATIVE_OPENAI_MODELS, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiCapabilitySourceSlug, nativeOpenAiContextWindow, nativeReasoningEfforts, normalizeRoutedCatalogEntry, resetCatalogRuntimeStateForTests, resetOpenAiApiCatalogWarningStateForTests, resolveComboCatalogMember, shouldExposeRoutedModel, upstreamNativeEntry } from "../src/codex/catalog"; -import { applyProviderConfigHints } from "../src/codex/catalog/provider-fetch"; +import { applyProviderConfigHints, mergeConfiguredModelsIntoLiveCatalog } from "../src/codex/catalog/provider-fetch"; import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, @@ -1061,7 +1061,9 @@ describe("combo catalog capability intersection", () => { liveModels: false, models: ["m2"], modelContextWindows: { m2: 128_000 }, - modelInputModalities: { m2: ["text"] }, + // No declaration: text-only by catalog default, NOT sidecar-covered. A declared + // text-only member would widen to image (isModelTextOnly parity) and the pair + // would no longer be disjoint. }, }, combos: { @@ -1202,7 +1204,9 @@ describe("combo catalog capability intersection", () => { contextWindow: 128_000, contextCapped: false, maxInputTokens: 100_000, - inputModalities: ["text"], + // The text-only modelInputModalities declaration is sidecar-covered at runtime + // (isModelTextOnly), so the combo advertises image input like its member does. + inputModalities: ["text", "image"], reasoningEfforts: ["high"], }); expect(rows.find(row => row.provider === "combo" && row.id === "nova-sol")) @@ -1515,7 +1519,7 @@ describe("combo catalog capability intersection", () => { liveModels: false, models: ["m1"], modelContextWindows: { m1: 128_000 }, - // Disjoint modalities with b → empty intersection (incompatible_modalities). + // Image-only member. An image-only declaration is not sidecar-covered. modelInputModalities: { m1: ["image"] }, }, b: { @@ -1524,7 +1528,10 @@ describe("combo catalog capability intersection", () => { liveModels: false, models: ["m2"], modelContextWindows: { m2: 128_000 }, - modelInputModalities: { m2: ["audio"] }, + // No modality declaration: the member is text-only by catalog default and the + // sidecar does not cover it, so text and image stay genuinely disjoint + // (incompatible_modalities). A DECLARED text-only member would be widened to + // image (isModelTextOnly parity) and no longer be disjoint. }, }, combos: { @@ -3651,7 +3658,7 @@ describe("Codex catalog routed normalization", () => { } }); - test("isDatedVariantId matches only -YYYYMMDD", () => { + test("isDatedVariantId matches only -", () => { expect(isDatedVariantId("claude-haiku-4-5-20251001", "claude-haiku-4-5")).toBe(true); expect(isDatedVariantId("claude-haiku-4-5-2025", "claude-haiku-4-5")).toBe(false); expect(isDatedVariantId("claude-haiku-4-5-latest", "claude-haiku-4-5")).toBe(false); @@ -3659,6 +3666,103 @@ describe("Codex catalog routed normalization", () => { expect(isDatedVariantId("claude-haiku-4-5-20251001", "claude-haiku-4")).toBe(false); }); + // Real ids from a multi-provider install. A `\d{8}`-only rule matched none of them, so + // every one of these aliases dropped out of the authoritative live catalog (#3024). + test.each([ + ["YYYYMMDD", "claude-haiku-4-5-20251001", "claude-haiku-4-5"], + ["YYYYMMDD leap day", "acme-model-20240229", "acme-model"], + ["YYMMDD", "solar-pro4-260806", "solar-pro4"], + ["YYMMDD", "syn-pro-251021", "syn-pro"], + ["YYMMDD leap day", "acme-model-240229", "acme-model"], + ["MMDD", "deepseek/deepseek-v4-pro-0813", "deepseek/deepseek-v4-pro"], + ["MMDD", "moonshotai/kimi-k2-0905", "moonshotai/kimi-k2"], + ["MMDD", "openai/gpt-3.5-turbo-0613", "openai/gpt-3.5-turbo"], + ["MMDD leap day", "acme-model-0229", "acme-model"], + ["YYMM", "mistralai/mistral-large-2407", "mistralai/mistral-large"], + ["YYMM", "qwen/qwen3-235b-a22b-2507", "qwen/qwen3-235b-a22b"], + ])("folds a %s dated variant: %s", (_format, liveId, configuredId) => { + expect(isDatedVariantId(liveId, configuredId)).toBe(true); + }); + + test.each([ + ["a version number", "mistralai/mistral-medium-3-5", "mistralai/mistral-medium-3"], + ["a context size", "openai/gpt-3.5-turbo-16k", "openai/gpt-3.5-turbo"], + ["a variant name", "qwen/qwen3-coder-30b-a3b-instruct", "qwen/qwen3-coder"], + ["a batch lane of a dated id", "deepseek/deepseek-v4-pro-0813:batch", "deepseek/deepseek-v4-pro"], + ["a bare year", "acme-model-2025", "acme-model"], + ["an impossible YYMM", "acme-model-1301", "acme-model"], + ["a non-leap YYYYMMDD", "acme-model-20250229", "acme-model"], + ["a non-leap YYMMDD", "acme-model-250229", "acme-model"], + ["an impossible YYYYMMDD month-end", "acme-model-20240431", "acme-model"], + ["an impossible YYMMDD month-end", "acme-model-240431", "acme-model"], + ["an impossible MMDD month-end", "acme-model-0431", "acme-model"], + // Hyphenated ISO is ambiguous against ordinary name segments; out of scope for now. + ["a hyphenated ISO date", "openai/gpt-4o-2024-08-06", "openai/gpt-4o"], + ["a hyphenated MM-DD", "google/gemini-2.5-pro-preview-05-06", "google/gemini-2.5-pro-preview"], + ])("does not fold %s: %s", (_label, liveId, configuredId) => { + expect(isDatedVariantId(liveId, configuredId)).toBe(false); + }); + + test.each(["2048", "4096", "8192"])("a %s context suffix is not a date", suffix => { + expect(isDatedVariantId(`acme-model-${suffix}`, "acme-model")).toBe(false); + }); + + // Known, accepted cost of allowing MMDD: October 24th is a real date, so a `-1024` + // context suffix is indistinguishable from one. No month/day tightening can exclude it. + test("a -1024 suffix reads as MMDD and is accepted", () => { + expect(isDatedVariantId("acme-model-1024", "acme-model")).toBe(true); + }); + + // The fold stays one-directional: a configured id the provider no longer lists must not + // be retained on the strength of a format match alone (#1690 is the explicit opt-in for + // that). `deepseek-v4-pro-0813` configured against a live `deepseek-v4-pro` stays dropped. + test("does not fold configured=dated against live=base", () => { + expect(isDatedVariantId("deepseek-v4-pro", "deepseek-v4-pro-0813")).toBe(false); + }); + + // The predicate test above is necessary and not sufficient: it passes on any + // implementation, including one whose MERGE LOOP calls the predicate a second time with + // the arguments swapped. These three drive `mergeConfiguredModelsIntoLiveCatalog` itself, + // so they fail if the loop ever becomes bidirectional. Carried from #3041, where the + // reverse fold was proposed and then withdrawn — the guard outlives the proposal. + test("the merge loop does not infer a configured dated id from a live base id", () => { + const { models, droppedConfiguredIds } = mergeConfiguredModelsIntoLiveCatalog({ + name: "deepseek", + provider: {}, + models: [{ id: "deepseek-v4-pro" } as never], + configured: [{ id: "deepseek-v4-pro-0813" } as never], + }); + expect(droppedConfiguredIds).toEqual(["deepseek-v4-pro-0813"]); + expect(models.map(m => m.id)).not.toContain("deepseek-v4-pro-0813"); + }); + + test("the merge loop folds a live MMDD dated row onto its configured base", () => { + const { models, droppedConfiguredIds } = mergeConfiguredModelsIntoLiveCatalog({ + name: "deepseek", + provider: {}, + models: [{ id: "deepseek-v4-pro-0813" } as never], + configured: [{ id: "deepseek-v4-pro" } as never], + }); + expect(droppedConfiguredIds).toEqual([]); + expect(models.map(m => m.id)).toContain("deepseek-v4-pro"); + }); + + // Retention of a dated id is a decision someone made, not an inference from a name. + // Note what this set actually is: production fills `retainConfiguredModelIds` from combo + // targets, not from `providers.*.models`, so this pins the combo-target path (OCX-111). + // The operator-facing opt-in is #1690's `retainModels`, which does not exist yet. + test("a dated id named in retainConfiguredModelIds survives the drop", () => { + const { models, droppedConfiguredIds } = mergeConfiguredModelsIntoLiveCatalog({ + name: "deepseek", + provider: {}, + models: [{ id: "deepseek-v4-pro" } as never], + configured: [{ id: "deepseek-v4-pro-0813" } as never], + retainConfiguredModelIds: new Set(["deepseek-v4-pro-0813"]), + }); + expect(droppedConfiguredIds).toEqual([]); + expect(models.map(m => m.id)).toContain("deepseek-v4-pro-0813"); + }); + test("disabled providers are excluded from routed model gathering", async () => { const models = await gatherRoutedModels({ port: 10100, @@ -5335,7 +5439,9 @@ describe("Codex catalog routed normalization", () => { expect(models.find(m => m.id === "wide-model")).toMatchObject({ contextWindow: 100_000, maxInputTokens: 100_000, - inputModalities: ["text"], + // Declared text-only modalities are sidecar-covered at runtime (isModelTextOnly), + // so the catalog advertises image on top of the configured base. + inputModalities: ["text", "image"], }); expect(models.find(m => m.id === "small-model")?.contextWindow).toBe(64_000); }); diff --git a/tests/codex-history-job.test.ts b/tests/codex-history-job.test.ts index f6262a71c5..e424b068fa 100644 --- a/tests/codex-history-job.test.ts +++ b/tests/codex-history-job.test.ts @@ -132,6 +132,13 @@ test("the failure wording names the real reason instead of always blaming the Co expect(describeHistoryJobFailure(partialIntegrity, "restore")).toContain("partial restore"); expect(describeHistoryJobFailure(partialIntegrity, "restore")).toContain("manifest was retained"); + // An ambiguous reroute is not a retry: two histories produced the same row and no durable + // fact separates them, so "run doctor" points the operator the wrong way. + const ambiguous = { ...integrity, historyIntegrityCode: "history_apply_ambiguous_reroute" } as const; + expect(describeHistoryJobFailure(ambiguous, "apply")).toContain("cannot prove whether an earlier relabel was undone"); + expect(describeHistoryJobFailure(ambiguous, "apply")).toContain("Resolve it manually"); + expect(describeHistoryJobFailure(ambiguous, "apply")).not.toContain("'ocx doctor'"); + const partialPermission = { ...permission, rows: 1, files: 1 } as const; expect(describeHistoryJobFailure(partialPermission, "apply")).toContain("changed but did not converge"); expect(describeHistoryJobFailure(partialPermission, "apply")).toContain("manifest was retained"); diff --git a/tests/codex-history-provider.test.ts b/tests/codex-history-provider.test.ts index b0c69ff1d8..1fd083e138 100644 --- a/tests/codex-history-provider.test.ts +++ b/tests/codex-history-provider.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { Database } from "bun:sqlite"; import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; -import { classifyRecoverableHistoryError, countPendingOpencodexHistory, historyBackupPathFor, isRecoverableHistoryError, migrateHistoryToOpenai, restoreLegacyOpenaiHistory, setAfterNoopPendingCountForTests, setAfterStrictHistoryRolloutAppendForTests, setBeforeHistoryApplyTransactionForTests, setBeforeHistoryBackupConsumeForTests, setBeforeStrictHistoryRolloutAppendForTests, setHistoryDbBusyTimeoutForTests, snapshotCodexHistoryNoop, syncCodexHistoryProvider, withHistoryRetry } from "../src/codex/history-provider"; +import { classifyRecoverableHistoryError, countPendingOpencodexHistory, historyBackupPathFor, isRecoverableHistoryError, migrateHistoryToOpenai, restoreLegacyOpenaiHistory, restoredUserEventFor, setAfterNoopPendingCountForTests, setAfterStrictHistoryRolloutAppendForTests, setBeforeHistoryApplyTransactionForTests, setBeforeHistoryBackupConsumeForTests, setBeforeStrictHistoryRolloutAppendForTests, setHistoryDbBusyTimeoutForTests, snapshotCodexHistoryNoop, syncCodexHistoryProvider, withHistoryRetry } from "../src/codex/history-provider"; import { INVALID_HISTORY_BACKUP_FIXTURES, validHistoryBackupFixture } from "./helpers/codex-history-manifest-fixtures"; // Windows CI: a transient file lock can consume the full production 5s busy timeout, tripping @@ -136,6 +136,261 @@ describe("Codex history provider sync", () => { expect(existsSync(fixture.backupPath)).toBe(false); }); + test("a surviving manifest is re-snapshotted by the next routing attempt (#3026)", () => { + // A manifest that outlives its restore is the real hazard: its recorded snapshot + // describes the PREVIOUS attempt. Carrying it into a new route makes the new routed row + // match the expected post-image, and restore then erases activity that arrived in + // between. Forcing the consume to fail is what actually reaches that path - an ordinary + // restore deletes the manifest and the reopen code never runs. + const fixture = makeFixture(); + const db = new Database(fixture.dbPath); + db.run("UPDATE threads SET first_user_message = NULL, has_user_event = 0 WHERE id = 'thread-1'"); + db.close(); + + expect(syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath)) + .toEqual({ rows: 1, files: 1 }); + + setBeforeHistoryBackupConsumeForTests(() => { + throw new Error("manifest consume interrupted"); + }); + try { + // Reported rather than thrown: the restore itself succeeded, only the consume failed. + expect(syncCodexHistoryProvider("openai", fixture.dbPath, fixture.backupPath)) + .toMatchObject({ failed: true }); + } finally { + setBeforeHistoryBackupConsumeForTests(undefined); + } + expect(existsSync(fixture.backupPath)).toBe(true); + // The restore landed and only finalization failed, so the surviving manifest records + // that its relabel is undone - the proof a later attempt needs to refresh its baseline. + expect(JSON.parse(readFileSync(fixture.backupPath, "utf8")).entries["thread-1"].relabel).toBe("none"); + + // The user types while the manifest is still on disk, then a second route runs. + const active = new Database(fixture.dbPath); + active.run("UPDATE threads SET first_user_message = 'hello', has_user_event = 1 WHERE id = 'thread-1'"); + active.close(); + syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath); + + const after = JSON.parse(readFileSync(fixture.backupPath, "utf8")) as { + version: number; + entries: Record; + }; + expect(after.version).toBe(2); + // Re-snapshotted for THIS attempt: the message is non-empty now, so the expected + // post-image is a 1 that OpenCodex itself wrote. + expect(after.entries["thread-1"]?.hadFirstUserMessage).toBe(true); + + // And the user's activity survives the restore rather than being read as OpenCodex's. + syncCodexHistoryProvider("openai", fixture.dbPath, fixture.backupPath); + const restored = new Database(fixture.dbPath, { readonly: true }); + expect(restored.query("SELECT model_provider, has_user_event FROM threads WHERE id = 'thread-1'").get()) + .toEqual({ model_provider: "openai", has_user_event: 1 }); + restored.close(); + }); + + test("refuses to reroute when the surviving manifest cannot prove its relabel was undone", () => { + // If the "none" proof could not be written - a read-only directory during the rewrite, + // say - the entry still reads "committed" while the row has drifted. Keeping the + // recorded baseline would erase the user's event; refreshing it would preserve one + // OpenCodex authored. Undecidable, so refuse rather than pick. + // + // Undecidable requires that the previous route COULD have written the differing event: + // it sets 1 only when the message was non-empty at its own snapshot. So this fixture + // keeps the message, unlike the expected-0 case where an observed 1 is provably the + // user's. + const fixture = makeFixture(); + + expect(syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath)) + .toEqual({ rows: 1, files: 1 }); + + // Restore lands, finalization fails, and the proof write fails too: hand-write the + // manifest back to "committed" to model that outcome exactly. + setBeforeHistoryBackupConsumeForTests(() => { throw new Error("manifest consume interrupted"); }); + try { + expect(syncCodexHistoryProvider("openai", fixture.dbPath, fixture.backupPath)) + .toMatchObject({ failed: true }); + } finally { + setBeforeHistoryBackupConsumeForTests(undefined); + } + const stranded = JSON.parse(readFileSync(fixture.backupPath, "utf8")) as { + entries: Record; + }; + stranded.entries["thread-1"]!.relabel = "committed"; + writeFileSync(fixture.backupPath, JSON.stringify(stranded)); + + // The user types, then a reroute is attempted against the unproven manifest. + const active = new Database(fixture.dbPath); + active.run("UPDATE threads SET first_user_message = 'hello', has_user_event = 1 WHERE id = 'thread-1'"); + active.close(); + + // Reported as an integrity refusal with nothing applied, which is how this layer + // surfaces a state it will not guess at. + expect(syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath)) + .toMatchObject({ rows: 0, files: 0, failed: true, failureReason: "integrity" }); + // Nothing was rewritten: the row and its manifest are exactly as they were. + const untouched = new Database(fixture.dbPath, { readonly: true }); + expect(untouched.query("SELECT model_provider, has_user_event FROM threads WHERE id = 'thread-1'").get()) + .toEqual({ model_provider: "openai", has_user_event: 1 }); + untouched.close(); + }); + + test("restores an event OpenCodex authored even after legacy recovery returns the tuple", () => { + // The history the audit rounds kept circling: route writes opencodex/vscode/1, legacy + // recovery pulls it back to openai/vscode/1, and the row now wears its ORIGINAL tuple + // carrying an event OpenCodex wrote. The classifier has to read the committed marker + // plus the route's expected event rather than the tuple, or restore keeps a 1 the user + // never generated. A pure classifier input cannot express this - it needs the real + // route and the real recovery. + const fixture = makeFixture({ includeLegacy: true }); + + // The fixture's rollout omits `source`, which makes restore refuse during rollout + // preflight before the classifier is ever consulted - a test that passes on that + // refusal would stay green with a broken classifier. Write the matching source so the + // real path runs. + appendFileSync(fixture.rollout, JSON.stringify({ + type: "session_meta", + payload: { id: "thread-1", model_provider: "openai", source: "vscode" }, + }) + "\n"); + + expect(syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath)) + .toEqual({ rows: 1, files: 1 }); + const routed = new Database(fixture.dbPath, { readonly: true }); + expect(routed.query("SELECT model_provider, has_user_event FROM threads WHERE id = 'thread-1'").get()) + .toEqual({ model_provider: "opencodex", has_user_event: 1 }); + routed.close(); + + // Legacy recovery returns provider to openai and leaves the event flag at 1. + restoreLegacyOpenaiHistory(fixture.dbPath); + const recovered = new Database(fixture.dbPath, { readonly: true }); + expect(recovered.query("SELECT model_provider, has_user_event FROM threads WHERE id = 'thread-1'").get()) + .toEqual({ model_provider: "openai", has_user_event: 1 }); + recovered.close(); + + // Restore must put the event back to the recorded original: the route expected a 1, so + // that 1 is OpenCodex's, not the user's. + const result = syncCodexHistoryProvider("openai", fixture.dbPath, fixture.backupPath); + const restored = new Database(fixture.dbPath, { readonly: true }); + const row = restored.query<{ model_provider: string; source: string; has_user_event: number }, []>( + "SELECT model_provider, source, has_user_event FROM threads WHERE id = 'thread-1'", + ).get()!; + restored.close(); + + // The classifier reads the committed marker plus the route's expected event rather than + // the tuple, so the 1 is returned to 0 and the manifest is consumed. + // `files: 0` because the appended metadata already names openai/vscode; the database + // row is what this history put wrong. + expect(result).toEqual({ rows: 1, files: 0 }); + expect(row).toEqual({ model_provider: "openai", source: "vscode", has_user_event: 0 }); + expect(existsSync(fixture.backupPath)).toBe(false); + }); + + describe("restore classifier state matrix", () => { + // has_user_event has two writers, so the verdict is decided by which tuple the row + // wears plus the entry's recorded provenance. This table is the whole contract; the + // end-to-end tests above prove two of its rows against real databases. + const entry = (over: Partial[1]> = {}) => ({ + id: "t", rolloutPath: "/tmp/r.jsonl", + modelProvider: "openai", source: "vscode", hasUserEvent: 0 as 0 | 1, + ...over, + }); + const row = (provider: string, source: string, event: 0 | 1, message: string | null = "hi") => ({ + id: "t", rollout_path: "/tmp/r.jsonl", + model_provider: provider, source, has_user_event: event, first_user_message: message, + }); + + const cases: Array<[string, ReturnType, ReturnType, 0 | 1 | null]> = [ + // A - exactly the recorded original. + ["A: untouched original restores its own value", row("openai", "vscode", 0), entry(), 0], + // B - the expected post-image; OpenCodex wrote it, so the manifest is authoritative. + ["B: routed post-image restores the recorded 0", row("opencodex", "vscode", 1), entry({ hadFirstUserMessage: true }), 0], + ["B: routed post-image of a null-message row", row("opencodex", "vscode", 0, null), entry({ hadFirstUserMessage: false }), 0], + ["B: exec legacy bridge is still recognized", row("openai", "cli", 1), entry({ modelProvider: "opencodex", source: "exec", hasUserEvent: 1 }), 1], + // C - original tuple with 0 to 1 drift, decided by provenance. + ["C: relabel none is user activity", row("openai", "vscode", 1), entry({ relabel: "none", hadFirstUserMessage: false }), 1], + ["C: committed whose route expected 1 is OpenCodex's own", row("openai", "vscode", 1), entry({ relabel: "committed", hadFirstUserMessage: true }), 0], + ["C: committed whose route expected 0 cannot be OpenCodex's", row("openai", "vscode", 1), entry({ relabel: "committed", hadFirstUserMessage: false }), 1], + ["C: pending with an expected-0 route is decidable", row("openai", "vscode", 1), entry({ relabel: "pending", hadFirstUserMessage: false }), 1], + ["C: pending with an expected-1 route is undecidable", row("openai", "vscode", 1), entry({ relabel: "pending", hadFirstUserMessage: true }), null], + ["C: legacy entry with drift refuses, as dev does", row("openai", "vscode", 1), entry({ hadFirstUserMessage: true }), null], + // Reverse drift is foreign under every provenance: nothing in this system clears the + // flag, so a baseline that moved down is a decision this manifest does not own. + ["reverse drift refuses even with a none marker", row("openai", "vscode", 0), entry({ hasUserEvent: 1, relabel: "none" }), null], + ["C: exec-origin cannot be reached by legacy return", row("opencodex", "exec", 1), entry({ modelProvider: "opencodex", source: "exec", relabel: "pending", hadFirstUserMessage: true }), 1], + // D - routed tuple with drift; no provenance needed. + ["D: drift on the routed tuple is the user's", row("opencodex", "vscode", 1), entry({ relabel: "pending", hadFirstUserMessage: false }), 1], + // An exec-origin row at opencodex/cli/1 is B, not D: routeExec always writes 1, so + // that IS the expected post-image and the recorded value is authoritative. + ["B: exec-origin post-image is opencodex/cli/1", row("opencodex", "cli", 1), entry({ modelProvider: "opencodex", source: "exec", hadFirstUserMessage: false }), 0], + // Neither shape - a foreign decision this manifest does not own. + ["reverse drift never restores", row("openai", "vscode", 0), entry({ hasUserEvent: 1, relabel: "committed" }), null], + ["a different provider is foreign", row("anthropic", "vscode", 0), entry(), null], + ["a different source is foreign", row("openai", "cli", 0), entry(), null], + ]; + + for (const [name, observed, recorded, expected] of cases) { + test(name, () => { + expect(restoredUserEventFor(observed, recorded)).toBe(expected); + }); + } + }); + + test("preserves a first user message that arrived after routing (#3026)", () => { + // Routing derives the post-image has_user_event from the message AT SNAPSHOT TIME. A + // restore that recomputes it from the message as it is NOW reads the user's first + // message as OpenCodex's own write and erases the activity. + const fixture = makeFixture(); + const db = new Database(fixture.dbPath); + db.run("UPDATE threads SET first_user_message = NULL, has_user_event = 0 WHERE id = 'thread-1'"); + db.close(); + + expect(syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath)) + .toEqual({ rows: 1, files: 1 }); + + // The user types for the first time while the row is routed. + const active = new Database(fixture.dbPath); + active.run("UPDATE threads SET first_user_message = 'hello', has_user_event = 1 WHERE id = 'thread-1'"); + active.close(); + + expect(syncCodexHistoryProvider("openai", fixture.dbPath, fixture.backupPath)) + .toEqual({ rows: 1, files: 1 }); + const restored = new Database(fixture.dbPath, { readonly: true }); + // Provenance restored, activity kept: OpenCodex owns provider and source, the user owns + // this flag, and the routing write for this entry produced a 0. + expect(restored.query("SELECT model_provider, source, has_user_event FROM threads WHERE id = 'thread-1'").get()) + .toEqual({ model_provider: "openai", source: "vscode", has_user_event: 1 }); + restored.close(); + expect(existsSync(fixture.backupPath)).toBe(false); + }); + + test("restores a legacy manifest that predates the snapshot fields", () => { + // Every manifest already on disk lacks hadFirstUserMessage and relabel. Refusing them + // would brick exactly the population this fix exists to repair, so an entry without the + // fields must restore on the pre-existing behaviour. + const fixture = makeFixture(); + expect(syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath)) + .toEqual({ rows: 1, files: 1 }); + + // Rewrite the manifest in the v1 shape, dropping both new fields. + const manifest = JSON.parse(readFileSync(fixture.backupPath, "utf8")) as { + version: number; + entries: Record>; + }; + manifest.version = 1; + for (const entry of Object.values(manifest.entries)) { + delete entry.hadFirstUserMessage; + delete entry.relabel; + } + writeFileSync(fixture.backupPath, JSON.stringify(manifest)); + + expect(syncCodexHistoryProvider("openai", fixture.dbPath, fixture.backupPath)) + .toEqual({ rows: 1, files: 1 }); + const restored = new Database(fixture.dbPath, { readonly: true }); + expect(restored.query("SELECT model_provider, source, has_user_event FROM threads WHERE id = 'thread-1'").get()) + .toEqual({ model_provider: "openai", source: "vscode", has_user_event: 0 }); + restored.close(); + expect(existsSync(fixture.backupPath)).toBe(false); + }); + test("does not route a new database row that was not captured in the manifest snapshot", () => { const fixture = makeFixture(); const lateRollout = join(fixture.rollout, "..", "late-rollout.jsonl"); @@ -189,21 +444,27 @@ describe("Codex history provider sync", () => { expect(JSON.parse(before.split("\n")[0])).toEqual(JSON.parse(after.split("\n")[0])); }); - test("does not append when the latest session_meta belongs to a different thread id", () => { + test("appends this thread's own session_meta when the latest one belongs to a different thread id", () => { const { dbPath, backupPath, rollout } = makeFixture(); // Simulate a forked rollout whose trailing session_meta embeds a *different* thread's id. - appendFileSync(rollout, JSON.stringify({ + const foreignLine = JSON.stringify({ type: "session_meta", timestamp: "2026-01-02T00:00:00.000Z", payload: { id: "some-other-forked-thread", model_provider: "openai", cwd: "/tmp" }, - }) + "\n"); + }); + appendFileSync(rollout, foreignLine + "\n"); const before = readFileSync(rollout, "utf8"); const result = syncCodexHistoryProvider("opencodex", dbPath, backupPath); - // DB row still flips, but the rollout is left untouched (no misleading append for a foreign id). - expect(result.files).toBe(0); - expect(readFileSync(rollout, "utf8")).toBe(before); + // The append describes THIS thread — never a clone of the foreign record, which the app + // would discard. Routing the row without the file left the pair unrestorable (#3026). + expect(result.files).toBe(1); + const after = readFileSync(rollout, "utf8"); + expect(after.startsWith(before)).toBe(true); + expect(latestSessionMetaPayload(rollout)).toMatchObject({ id: "thread-1", model_provider: "opencodex" }); + // The foreign thread's record is still there, exactly once, exactly as written. + expect(after.split("\n").filter(Boolean).filter(line => line === foreignLine)).toHaveLength(1); const db = new Database(dbPath); expect(db.query("SELECT model_provider FROM threads WHERE id = 'thread-1'").get()).toEqual({ model_provider: "opencodex" }); db.close(); @@ -630,6 +891,69 @@ describe("Codex history provider sync", () => { expect(latestSessionMetaPayload(fixture.rollout).model_provider).toBe("custom"); }); + test("consumes the manifest when a foreign-id session_meta lands after restore readback", () => { + const fixture = makeFixture(); + syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath); + // The same last-moment race as the same-id case above, except the arriving record belongs + // to another thread. The app discards it, so it is not a newer decision to protect. + setBeforeHistoryBackupConsumeForTests(() => { + appendFileSync(fixture.rollout, JSON.stringify({ + type: "session_meta", + timestamp: "2026-03-04T00:00:00.000Z", + payload: { id: "parent-thread", model_provider: "custom", source: "exec" }, + }) + "\n"); + }); + + expect(syncCodexHistoryProvider("openai", fixture.dbPath, fixture.backupPath)) + .toEqual({ rows: 1, files: 1 }); + expect(existsSync(fixture.backupPath)).toBe(false); + }); + + test("restores a forked rollout that trails its parent thread's session_meta", () => { + const fixture = makeFixture(); + expect(syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath)) + .toEqual({ rows: 1, files: 1 }); + + // A forked/branched session appends the SOURCE thread's session_meta after its own. + // codex-rs `apply_session_meta_from_item` ignores a record whose payload id is not the + // canonical thread id, so this line is ordinary rollout content, not an integrity fault. + appendFileSync(fixture.rollout, JSON.stringify({ + type: "session_meta", + timestamp: "2026-03-03T00:00:00.000Z", + payload: { id: "parent-thread", model_provider: "openai", source: "cli" }, + }) + "\n"); + + expect(syncCodexHistoryProvider("openai", fixture.dbPath, fixture.backupPath)) + .toEqual({ rows: 1, files: 1 }); + const db = new Database(fixture.dbPath, { readonly: true }); + expect(db.query("SELECT model_provider, source FROM threads WHERE id = 'thread-1'").get()) + .toEqual({ model_provider: "openai", source: "vscode" }); + db.close(); + expect(latestSessionMetaPayload(fixture.rollout)).toMatchObject({ + id: "thread-1", + model_provider: "openai", + source: "vscode", + }); + expect(existsSync(fixture.backupPath)).toBe(false); + }); + + test("leaves a foreign trailing session_meta untouched while restoring its own", () => { + const fixture = makeFixture(); + syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath); + const parentLine = JSON.stringify({ + type: "session_meta", + timestamp: "2026-03-03T00:00:00.000Z", + payload: { id: "parent-thread", model_provider: "opencodex", source: "exec" }, + }); + appendFileSync(fixture.rollout, parentLine + "\n"); + + expect(syncCodexHistoryProvider("openai", fixture.dbPath, fixture.backupPath)) + .toEqual({ rows: 1, files: 1 }); + // The parent's record still says what it said: restore repairs this thread only. + const lines = readFileSync(fixture.rollout, "utf8").split("\n").filter(Boolean); + expect(lines.filter(line => line === parentLine)).toHaveLength(1); + }); + test("reports applied permission progress when manifest finalization is denied", () => { const fixture = makeFixture(); syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath); @@ -1031,7 +1355,15 @@ describe("Design B migration helpers", () => { // Work cannot converge without its bound database; the manifest remains retry evidence. const result = migrateHistoryToOpenai(missingDb, backupPath); - expect(result).toEqual({ rows: 0, files: 0, failed: true, failureReason: "integrity" }); + expect(result).toEqual({ + rows: 0, + files: 0, + failed: true, + failureReason: "integrity", + // The specific condition travels with the result: an operator can tell a missing + // database from a manifest that needs manual resolution. + integrityCode: "history_state_database_missing", + }); expect(existsSync(backupPath)).toBe(true); }); diff --git a/tests/codex-routing.test.ts b/tests/codex-routing.test.ts index 1ec9f8cfda..75a3cebe64 100644 --- a/tests/codex-routing.test.ts +++ b/tests/codex-routing.test.ts @@ -47,6 +47,7 @@ import { updateAccountQuota, } from "../src/codex/auth-api"; import { CODEX_UNKNOWN_USAGE_SCORE, isCodexQuotaExhausted } from "../src/codex/quota"; +import { setCodexAccountPriority } from "../src/codex/account-priority"; import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account"; import { routeModel } from "../src/router"; import { consumeForInspection } from "../src/server/relay"; @@ -138,6 +139,98 @@ describe("codex routing", () => { expect(computeCodexUsageScore({ weeklyPercent: 40, shortPercent: 0 })).toBe(40); }); + test("a full burst window scores terminal while it is still in force (#3029)", () => { + // A FULL short window is not an optimistic guess about an unobserved long window - it + // is a direct observation that the account cannot serve a request right now. Leaving it + // unknown keeps the account selectable and suppresses auto-switch, which is exactly the + // pool wedge #3029 reports. + const now = 1_700_000_000_000; + expect(computeCodexUsageScore({ shortPercent: 100, shortResetAt: now + 60_000 }, undefined, now)).toBe(100); + + // Freshness is the other half. getAccountQuota performs no expiry check, partial + // updates carry the old short tuple forward, and disk hydration accepts a persisted + // reading for hours - so a reset window must go back to unknown, or #3029 is simply + // inverted into a recovered account that stays excluded. + expect(computeCodexUsageScore({ shortPercent: 100, shortResetAt: now - 60_000 }, undefined, now)) + .toBe(CODEX_UNKNOWN_USAGE_SCORE); + // No resetAt at all cannot be aged, so it stays unknown: a wrongly-selected account + // fails one request, a wrongly-excluded one is invisible until someone reads the pool. + expect(computeCodexUsageScore({ shortPercent: 100 }, undefined, now)).toBe(CODEX_UNKNOWN_USAGE_SCORE); + // Still narrow: a non-terminal short-only reading is unchanged. + expect(computeCodexUsageScore({ shortPercent: 99, shortResetAt: now + 60_000 }, undefined, now)) + .toBe(CODEX_UNKNOWN_USAGE_SCORE); + }); + + test("a terminal burst window is read in either unit (#3029)", () => { + // normalizeResetAt does not scale, and the GUI disambiguates by magnitude at read time, + // so both seconds and milliseconds reach storage. A comparison written against one + // assumption is off by 1000x against the other - and in the seconds-read-as-ms + // direction every terminal reading looks like it reset in 1970, which is a fix that + // passes its own test and does nothing. + const now = 1_700_000_000_000; + expect(computeCodexUsageScore({ shortPercent: 100, shortResetAt: now + 60_000 }, undefined, now)).toBe(100); + expect(computeCodexUsageScore({ shortPercent: 100, shortResetAt: (now + 60_000) / 1000 }, undefined, now)).toBe(100); + }); + + test("a live full burst window moves selection off the account (#3029)", () => { + // The scorer assertions above prove the value; this proves the pool acts on it. A + // clock far from wall time is the point: a fixture whose now matches Date.now() cannot + // tell a threaded clock from one that was dropped somewhere in the helper chain. + const now = 1_700_000_000_000; + const config = makeConfig({ activeCodexAccountId: "a" }); + + // A is full for the next hour, recorded in SECONDS. B has ordinary headroom. + setAccountQuotaFromParsed("a", { shortPercent: 100, shortResetAt: (now + 3_600_000) / 1000 }); + updateAccountQuota("b", 10); + expect(resolveCodexAccountForThread("thread-terminal-new", config, now)).toBe("b"); + + // Same pool, but a thread already BOUND to A. Bind it while A is cool, so the rebind + // below is a real transition rather than a first selection that happened to pick B. + clearAccountQuota("a"); + clearAccountQuota("b"); + updateAccountQuota("a", 10); + updateAccountQuota("b", 20); + const bound = makeConfig({ activeCodexAccountId: "a" }); + expect(resolveCodexAccountForThread("thread-terminal-bound", bound, now)).toBe("a"); + // A's burst window fills, in MILLISECONDS this time so both units run through the real + // selection path. The bound thread must move rather than keep an account that cannot + // serve it. + clearAccountQuota("a"); + setAccountQuotaFromParsed("a", { shortPercent: 100, shortResetAt: now + 3_600_000 }); + expect(resolveCodexAccountForThread("thread-terminal-bound", bound, now)).toBe("b"); + + // And once the window resets, A stays selected. B carries KNOWN headroom here on + // purpose: with both accounts unknown, A would be kept by default and the assertion + // would hold even against a freshness-blind scorer. Against one, expired-A scores 100 + // and the request moves to B. + clearAccountQuota("a"); + clearAccountQuota("b"); + setAccountQuotaFromParsed("a", { shortPercent: 100, shortResetAt: now - 60_000 }); + updateAccountQuota("b", 20); + const recovered = makeConfig({ activeCodexAccountId: "a" }); + expect(resolveCodexAccountForThread("thread-terminal-recovered", recovered, now)).toBe("a"); + }); + + test("the priority tier reads the request clock, not wall time (#3029)", () => { + // selectPriorityTier consults hasCodexQuotaHeadroom only when the pool carries + // DIFFERENT priorities, so a clock dropped in that lambda is invisible to an ordinary + // pool. Higher numbers run earlier, so A (2) outranks B (1). + // + // The clock is historical, well before wall time. A's window is full for an hour after + // THAT instant, so it is live against the request clock and long expired against + // Date.now(). With the correct clock the tier sees A drained and descends to B; reading + // wall time makes A look unknown, the tier keeps it, and fill-first hands back A. + const now = 1_700_000_000_000; + const config = makeConfig({ activeCodexAccountId: "a", accountPoolStrategy: "fill-first" }); + setCodexAccountPriority(config, "a", 2); + setCodexAccountPriority(config, "b", 1); + + setAccountQuotaFromParsed("a", { shortPercent: 100, shortResetAt: now + 3_600_000 }); + updateAccountQuota("b", 20); + + expect(resolveCodexAccountForThread("thread-priority-terminal", config, now)).toBe("b"); + }); + test("exact-account failures record health without rotating the active Pool account", () => { const transient = makeConfig({ upstreamFailoverThreshold: 1, activeCodexAccountId: "a" }); const transientThread = "fixed-transient-thread"; diff --git a/tests/cursor-hardening.test.ts b/tests/cursor-hardening.test.ts index b740fb8617..519f3ea7c4 100644 --- a/tests/cursor-hardening.test.ts +++ b/tests/cursor-hardening.test.ts @@ -424,6 +424,25 @@ describe("Cursor discovery bounded retry", () => { expect(result).toEqual({ ok: true, models: ["gpt-5.5-high"] }); }); + test("retries an HTTP/2 stream that ends before response headers", async () => { + const body = toBinary(GetUsableModelsResponseSchema, create(GetUsableModelsResponseSchema, { + models: [create(ModelDetailsSchema, { modelId: "gpt-5.5-high" })], + })); + let requests = 0; + const result = await withDiscoveryServer(stream => { + requests += 1; + if (requests === 1) { + stream.close(http2.constants.NGHTTP2_NO_ERROR); + return; + } + stream.respond({ ":status": 200, "content-type": "application/proto" }); + stream.end(body); + }, baseUrl => fetchCursorUsableModels({ apiKey: "test-token", baseUrl })); + + expect(requests).toBe(2); + expect(result).toEqual({ ok: true, models: ["gpt-5.5-high"] }); + }); + test("does not retry deterministic auth failures", async () => { let requests = 0; const result = await withDiscoveryServer(stream => { diff --git a/tests/grok-lifecycle.test.ts b/tests/grok-lifecycle.test.ts index f245e917b5..b33c19d96c 100644 --- a/tests/grok-lifecycle.test.ts +++ b/tests/grok-lifecycle.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { isServiceOwnershipError, ServiceOwnershipError } from "../src/service"; +import { classifyWindowsServiceStop, installedServiceRespawnRisk, isServiceOwnershipError, ServiceOwnershipError } from "../src/service"; const CLI_SOURCE = readFileSync(join(import.meta.dir, "..", "src", "cli", "index.ts"), "utf8"); const ENSURE_SOURCE = readFileSync(join(import.meta.dir, "..", "src", "cli", "ensure-desired-integrations.ts"), "utf8"); @@ -95,20 +95,25 @@ describe("Grok fence lifecycle wiring", () => { expect(stopFn).toContain("isServiceOwnershipError(err)"); expect(stopFn).toContain("ownershipBlocked = true"); - expect(stopFn).toContain("if (!ownershipBlocked)"); + // Ownership is now one of two reasons to skip the restore; the other is an inherited + // obligation whose proxy could not be confirmed down (#3008). + expect(stopFn).toContain("const restoreBlocked = ownershipBlocked ||"); + expect(stopFn).toContain("if (!restoreBlocked) {"); expect(stopFn).toContain("await restoreSharedClientStateAfterStop()"); expect(restoreFn).toContain("restoreNativeCodexAsync()"); expect(restoreFn).not.toContain("revertSystemEnv()"); expect(restoreFn).toContain("stripGrokConfig()"); - expect(stopFn.indexOf("revertSystemEnv()")).toBeLessThan(stopFn.indexOf("if (!ownershipBlocked)")); + expect(stopFn.indexOf("revertSystemEnv()")).toBeLessThan(stopFn.indexOf("if (!restoreBlocked) {")); }); test("a refused Grok strip makes ocx stop fail instead of reporting success", () => { const restoreFn = sliceFn(CLI_SOURCE, "async function restoreSharedClientStateAfterStop(", "async function handleStop("); const stopFn = sliceFn(CLI_SOURCE, "async function handleStop(", "async function handleUninstall("); - expect(restoreFn).toContain("else if (!grok.ok) { restored = false;"); + // A Grok strip failure is "other", never history-only: it points Grok at a dead proxy, + // so an update must abort rather than proceed (#3008). + expect(restoreFn).toContain("else if (!grok.ok) { other = true;"); expect(restoreFn).toContain("Grok config restore failed"); - expect(stopFn).toContain("if (!await restoreSharedClientStateAfterStop()) stopFailed = true"); + expect(stopFn).toContain("if (restore.other) stopFailed = true"); }); test("a refused proxy stop reports WHY, not just that it failed", () => { @@ -148,13 +153,161 @@ describe("Grok fence lifecycle wiring", () => { expect(restartHelper).toContain("requestBoundSystemRestart(previous, deadlineAt)"); }); + test("a stopped scheduler is verified across the respawn window before stop succeeds", () => { + const stopFn = sliceFn(CLI_SOURCE, "async function handleStop(", "async function handleUninstall("); + // killWindowsSchedulerWrappers is best-effort and the `:loop` wrapper respawns after + // ~5s, so "stopped" alone is not a proven-down proxy. An update that trusts it can + // start replacing files during the dead interval (#3008). + expect(stopFn).toContain("proxyStillLiveAfterStop({ canRespawn: true })"); + // A survivor is an ordinary failure AND blocks shared teardown: restoring client + // config while the proxy runs leaves both pointing at each other. + expect(stopFn).toContain("stopFailed = true;"); + expect(stopFn).toContain("ownershipBlocked = true;"); + }); + + test("only Task Scheduler earns the respawn wait", () => { + const stopFn = sliceFn(CLI_SOURCE, "async function handleStop(", "async function handleUninstall("); + const serviceSource = readFileSync(join(import.meta.dir, "..", "src", "service.ts"), "utf8"); + // schtasks /end leaves the `cmd :loop` wrapper alive to respawn its child (#764). + // launchd, systemd and WinSW are down when they report stopped, so charging them a + // seven-second poll on every ocx stop would be a regression in ordinary use. + expect(serviceSource).toContain('"absent" | "stopped" | "stopped-respawnable" | "failed"'); + expect(serviceSource).toContain('schedulerStopped ? "stopped-respawnable" : "stopped"'); + expect(stopFn).toContain("if (schedulerCanRespawn && !ownershipBlocked)"); + // The wait is gated on the scheduler flag, not on "a service stopped". + expect(stopFn).not.toContain("if (stoppedService && !ownershipBlocked)"); + }); + + test("ocx stop defers shared teardown so a respawn survivor keeps its config", () => { + const stopFn = sliceFn(CLI_SOURCE, "async function handleStop(", "async function handleUninstall("); + const apiSource = readFileSync(join(import.meta.dir, "..", "src", "server", "management-api.ts"), "utf8"); + const controlSource = readFileSync(join(import.meta.dir, "..", "src", "lib", "process-control.ts"), "utf8"); + // POST /api/stop normally restores native Codex and strips the Grok fence itself. If + // ocx stop let it, a scheduler wrapper that respawns seconds later would already have + // lost its client config, and the parent ownershipBlocked guard could only prevent a + // second redundant teardown (#3008). + expect(stopFn).toContain("deferSharedTeardownNonce: teardownNonce"); + expect(controlSource).toContain("deferSharedTeardown"); + expect(apiSource).toContain("performStopTeardown(url, { ownsReceipt: deferralMatchesReceipt })"); + // The deferral is an obligation, so it is claimed on disk BEFORE it is requested and + // released only after THIS process has restored the shared config itself. A bare + // query flag could not survive the parent dying mid-stop. + const claimAt = stopFn.indexOf("claimTeardown(exact ?? configuredEndpoint()"); + expect(claimAt).toBeGreaterThan(-1); + expect(claimAt).toBeLessThan(stopFn.indexOf("deferSharedTeardownNonce: teardownNonce")); + // One resolved stop target feeds BOTH the receipt and the request, so the endpoint + // recorded is the endpoint contacted — recovery probes exactly that one. + // The endpoint is resolved ONCE: reading the runtime record twice let the receipt name + // the configured guess while the request went to one that appeared in between. + expect(stopFn).toContain("const exact = discovered ?? endpointOf(readRuntimePort(pid));"); + // Every stop claims a receipt, including the one that resolves no endpoint at all — + // that path goes straight to the kill ladder with no child teardown, so a warning + // instead of a receipt is exactly the parent-crash window this exists to close. + expect(stopFn).toContain('claimTeardown(exact ?? configuredEndpoint(), exact ? "exact" : "guessed");'); + // A guessed endpoint records an obligation but must not direct the stop request. + expect(stopFn).toContain("runtimeEndpoint: exact ?? undefined"); + // Nor may it authorize a later recovery: "the configured port refuses" is not proof + // that a proxy on an explicit --port is down. + expect(stopFn).toContain('if (read.receipt.endpointSource === "guessed")'); + const guessedBranch = stopFn.slice(stopFn.indexOf('if (read.receipt.endpointSource === "guessed")'), stopFn.indexOf("if (await abandonedTeardownIsSafeToFinish(")); + expect(guessedBranch).toContain("inheritedBlocks = true;"); + expect(guessedBranch).toContain("stopFailed = true;"); + expect(controlSource).toContain("io.runtimeEndpoint ?? readRuntime(pid)"); + // Inherited obligations are snapshotted BEFORE this run claims anything, so its own + // receipt is never mistaken for one it inherited. + expect(stopFn).toContain("isPendingTeardownAbandoned(read, isProcessAlive)"); + expect(stopFn.indexOf("listPendingTeardowns()")).toBeLessThan(claimAt); + expect(stopFn).toContain("clearPendingTeardown(nonce)"); + expect(stopFn.indexOf("await restoreSharedClientStateAfterStop()")) + .toBeLessThan(stopFn.indexOf("clearPendingTeardown(nonce)")); + // A receipt that survives its discharge would re-trigger recovery forever. + expect(stopFn).toContain("if (!clearPendingTeardown(nonce)) {"); + }); + + test("an unconfirmed inherited obligation blocks the restore, it does not merely warn", () => { + const stopFn = sliceFn(CLI_SOURCE, "async function handleStop(", "async function handleUninstall("); + // Finishing SOMEBODY ELSE's obligation needs a definitive "dead", not findLiveProxy's + // null, which also covers a timeout and a listener that withholds /healthz. The first + // attempt at this only logged a warning and then restored anyway, which is not a gate. + expect(stopFn).toContain("await abandonedTeardownIsSafeToFinish(read.receipt.endpoint)"); + expect(stopFn).toContain("const restoreBlocked = ownershipBlocked || inheritedBlocks"); + expect(stopFn).toContain("if (!restoreBlocked) {"); + // The restore is reached only through that gate — no other call site may bypass it. + const restoreCalls = stopFn.split("await restoreSharedClientStateAfterStop()").length - 1; + expect(restoreCalls).toBe(1); + expect(stopFn.indexOf("const restoreBlocked")).toBeLessThan(stopFn.indexOf("await restoreSharedClientStateAfterStop()")); + // An obligation that cannot be discharged fails the stop and is preserved. + const gateBlock = stopFn.slice(stopFn.indexOf("const recoveredNonces"), stopFn.indexOf("const restoreBlocked")); + expect(gateBlock).toContain("inheritedBlocks = true;"); + expect(gateBlock).toContain("stopFailed = true;"); + expect(gateBlock).not.toContain("clearPendingTeardown"); + // An unreadable obligation names no endpoint, so it can never probe dead. It fails the + // stop rather than being waved through, and is set aside only AFTER the outcome is + // known — moving it earlier would erase it from every future scan while the restore it + // stood for had not run. + expect(gateBlock).not.toContain("quarantinePendingTeardown"); + // Setting aside is not discharging, and the message must not claim otherwise: the + // renamed file still blocks an update until an operator removes it. + const quarantineBlock = stopFn.slice(stopFn.indexOf("if (unreadable.length > 0"), stopFn.indexOf("// Set the code rather than exiting inline")); + expect(quarantineBlock).toContain("It still blocks 'ocx update'"); + expect(quarantineBlock).toContain("has NOT restored on its behalf"); + expect(quarantineBlock).not.toContain("no longer blocks an update"); + expect(stopFn.indexOf("await restoreSharedClientStateAfterStop()")) + .toBeLessThan(stopFn.indexOf("quarantinePendingTeardown(read.nonce)")); + // Inherited receipts are evaluated whether or not this run claimed one of its own, and + // every discharged nonce is released together — otherwise a stop that finds a live + // proxy clears only its own and older obligations accumulate forever. + expect(stopFn).toContain("if (inheritedTeardowns.length > 0 && !ownershipBlocked)"); + expect(stopFn).toContain("teardownNonce ? [teardownNonce, ...recoveredNonces] : recoveredNonces"); + // The orphan path hands over the endpoint the probe already found; its runtime record + // is typically what went missing in the first place. + expect(stopFn).toContain('stopWithDeferral(live.pid, { hostname: live.hostname ?? "127.0.0.1", port: live.port })'); + // A live proxy with no killable pid is not "no proxy found": purging state and + // restoring over it is the same failure arrived at from the other direction. + expect(stopFn).toContain("} else if (live) {"); + const noPidBranch = stopFn.slice(stopFn.indexOf("} else if (live) {"), stopFn.indexOf('} else if (!stoppedService) {')); + expect(noPidBranch).toContain("stopFailed = true;"); + expect(noPidBranch).toContain("ownershipBlocked = true;"); + const gateFn = sliceFn(CLI_SOURCE, "const abandonedTeardownIsSafeToFinish", "let stopFailed = false;"); + expect(gateFn).toContain('probeProxyLiveness(endpoint.port, endpoint.hostname) === "dead"'); + expect(gateFn).toContain("return false;"); + }); + + test("an outstanding teardown receipt makes both updaters run the stop", () => { + // After a parent crashed mid-deferral the service, pid and runtime records can all be + // absent while shared client config still points at a proxy that is gone. Installing + // over that skips the recovery the receipt exists to trigger (#3008). + const updateSource = readFileSync(join(import.meta.dir, "..", "src", "update", "index.ts"), "utf8"); + expect(updateSource).toContain("readPid() || readRuntimePort() || pendingTeardownOutstanding()"); + const launcherSource = readFileSync(join(import.meta.dir, "..", "bin", "ocx.mjs"), "utf8"); + // The launcher runs under plain Node, so it shares the naming rule as ESM rather than + // spelling it out — which is how it ended up watching the retired singleton filename + // after receipts moved to one file per claim, silently seeing none of them. + expect(launcherSource).toContain("hasPendingTeardownIn(readdirSync, configDir())"); + expect(launcherSource).not.toContain('"pending-teardown.json"'); + expect(launcherSource).toContain("serviceWasInstalled || hasRuntimeState || hasPendingTeardown"); + // Checked AFTER the stop too: a quarantined receipt lets the stop succeed, so a + // pre-stop check alone let the retry install over a teardown that never ran. + expect(launcherSource).toContain("teardownOutstanding: hasPendingTeardownIn(readdirSync, configDir())"); + const updateSource2 = readFileSync(join(import.meta.dir, "..", "src", "update", "index.ts"), "utf8"); + expect(updateSource2).toContain("teardownOutstanding: pendingTeardownOutstanding()"); + const decisionSource = readFileSync(join(import.meta.dir, "..", "src", "update", "stop-decision.mjs"), "utf8"); + expect(decisionSource).toContain('if (teardownOutstanding) return { proceed: false, reason: "teardown-outstanding" };'); + const receiptSource = readFileSync(join(import.meta.dir, "..", "src", "config", "pending-teardown.ts"), "utf8"); + expect(receiptSource).toContain('from "./pending-teardown-names.mjs"'); + expect(receiptSource).toContain("isPendingTeardownFileName(name)"); + }); + test("handleStop treats an incomplete native Codex restore as a stop failure", () => { const restoreFn = sliceFn(CLI_SOURCE, "async function restoreSharedClientStateAfterStop(", "async function handleStop("); const stopFn = sliceFn(CLI_SOURCE, "async function handleStop(", "async function handleUninstall("); expect(restoreFn).toContain("if (result.success) console.log"); - expect(restoreFn).toContain("restored = false"); + // Config or catalog failure is a real teardown failure - a client reads those. Only a + // history-only failure is separable, and it still surfaces (#3008). + expect(restoreFn).toContain('artifacts.config.state === "failed" || artifacts.catalog.state === "failed"'); + expect(restoreFn).toContain("else other = true"); expect(restoreFn).toContain("console.error(`⚠️ ${result.message}`)"); - expect(stopFn).toContain("if (!await restoreSharedClientStateAfterStop()) stopFailed = true"); + expect(stopFn).toContain("if (restore.other) stopFailed = true"); }); test("the daemon's exit cleanup keeps the OCX_SERVICE exclusion and adds the ownership check", () => { @@ -217,15 +370,124 @@ describe("POST /api/stop teardown", () => { }); test("strips the Grok fence on an accepted stop", () => { + // The teardown moved to src/server/stop-teardown.ts so a test can call it: the route + // schedules process.exit 200ms after answering, which made the inline version + // unreachable. tests/stop-deferred-teardown.test.ts proves the behaviour; this proves + // the route still delegates to it rather than growing a second copy. + const handler = sliceFn(MANAGEMENT_SOURCE, '"/api/stop"', "/api/codex-auth/"); + expect(handler).toContain("performStopTeardown(url, { ownsReceipt: deferralMatchesReceipt })"); + const teardownSource = readFileSync(join(import.meta.dir, "..", "src", "server", "stop-teardown.ts"), "utf8"); + expect(teardownSource).toContain('await import("../grok/inject")'); + expect(teardownSource).toContain("stripGrokConfig()"); + }); + + test("an unreadable scheduler state gets the same diagnosis from the CLI and the API", () => { + const serviceSource = readFileSync(join(import.meta.dir, "..", "src", "service.ts"), "utf8"); + // A manager that refused to stop and a query that could not answer are different + // problems: reporting the second as "did not stop" sends the operator looking for the + // wrong thing, and `ocx stop` was the command the API told them to run (#3008). + expect(serviceSource).toContain('"absent" | "stopped" | "stopped-respawnable" | "failed" | "state-unknown"'); + // Behavioural, because a source-text assertion cannot tell whether an unreadable probe + // is still being folded into the generic failure. + expect(classifyWindowsServiceStop({ stopped: false, failed: false, schedulerStopped: false, stateUnknown: true })) + .toBe("state-unknown"); + // A readable failure outranks it — something actually refused to stop. + expect(classifyWindowsServiceStop({ stopped: false, failed: true, schedulerStopped: false, stateUnknown: true })) + .toBe("failed"); + // And an unreadable state outranks success: a scheduler we cannot see may respawn. + expect(classifyWindowsServiceStop({ stopped: true, failed: false, schedulerStopped: true, stateUnknown: true })) + .toBe("state-unknown"); + expect(classifyWindowsServiceStop({ stopped: true, failed: false, schedulerStopped: true, stateUnknown: false })) + .toBe("stopped-respawnable"); + expect(classifyWindowsServiceStop({ stopped: true, failed: false, schedulerStopped: false, stateUnknown: false })) + .toBe("stopped"); + expect(classifyWindowsServiceStop({ stopped: false, failed: false, schedulerStopped: false, stateUnknown: false })) + .toBe("absent"); + const stopFn = sliceFn(CLI_SOURCE, "async function handleStop(", "async function handleUninstall("); + expect(stopFn).toContain('if (serviceStop === "state-unknown")'); + const unknownBranch = stopFn.slice(stopFn.indexOf('if (serviceStop === "state-unknown")'), stopFn.indexOf('if (serviceStop === "state-unknown")') + 700); + expect(unknownBranch).toContain("stopFailed = true;"); + expect(unknownBranch).toContain("ocx service status"); + expect(unknownBranch).not.toContain("did not stop"); const handler = sliceFn(MANAGEMENT_SOURCE, '"/api/stop"', "/api/codex-auth/"); - expect(handler).toContain('await import("../grok/inject")'); - expect(handler).toContain("stripGrokConfig()"); + expect(handler).toContain('if (serviceStop === "state-unknown")'); + // The route answers the post-stop case with the same code as the pre-check. + expect((handler.match(/service_state_unknown/g) ?? []).length).toBeGreaterThanOrEqual(2); }); test("maps a failed shutdown drain to a nonzero process exit", () => { const handler = sliceFn(MANAGEMENT_SOURCE, '"/api/stop"', "/api/codex-auth/"); expect(handler).toContain("shutdownSucceeded = await drainAndShutdown"); - expect(handler).toContain("process.exit(shutdownSucceeded ? 0 : 1)"); + expect(handler).toContain("process.exit(shutdownSucceeded && teardown.success ? 0 : 1)"); + }); + + test("the route consumes the detailed service outcome instead of the boolean", () => { + const handler = sliceFn(MANAGEMENT_SOURCE, '"/api/stop"', "/api/codex-auth/"); + // stopServiceIfInstalled collapses "failed" into the same false as "not installed", so + // this route used to tear down shared config while a manager that refused to stop was + // still there to respawn the proxy (#3008). + expect(handler).toContain("stopServiceIfInstalledDetailed()"); + expect(handler).not.toContain("stopServiceIfInstalled();"); + expect(handler).toContain('if (serviceStop === "failed")'); + expect(handler.indexOf('if (serviceStop === "failed")')).toBeLessThan(handler.indexOf("await performStopTeardown")); + }); + + test("a respawnable backend is refused BEFORE the manager is touched", () => { + const handler = sliceFn(MANAGEMENT_SOURCE, '"/api/stop"', "/api/codex-auth/"); + // Stopping the Task Scheduler task and then returning 409 left the proxy running with + // its manager stopped — worse than either outcome, and the dashboard's Stop button + // sends a bare request on every backend. + expect(handler).toContain('const respawnRisk = holdsReceipt ? "none" : installedServiceRespawnRisk();'); + expect(handler).toContain('code: "respawnable_service"'); + expect(handler.indexOf("installedServiceRespawnRisk()")).toBeLessThan(handler.indexOf("stopServiceIfInstalledDetailed()")); + // The refusal must say nothing was changed, because nothing was. + expect(handler).toContain("Nothing was changed."); + // An unreadable scheduler state is its own answer: sending that operator to `ocx stop` + // would be a loop, because it maps the same unknown probe to a stop failure. + expect(handler).toContain('code: "service_state_unknown"'); + const unknownBranch = handler.slice(handler.indexOf('code: "service_state_unknown"'), handler.indexOf('code: "service_state_unknown"') + 500); + expect(unknownBranch).toContain("ocx service status"); + expect(unknownBranch).not.toContain("run `ocx stop`"); + }); + + test("only a proven absence is safe to stop inline", () => { + // Behavioural, not source-shaped: the previous assertion matched an unrelated + // `return true` in the catch and therefore passed while "unknown" was let through. + expect(installedServiceRespawnRisk(() => ({ status: "present" }) as never, "win32")).toBe("respawnable"); + // "unknown" is an ordinary return value from the probe, not a throw. Treating it as + // absence let the route kill scheduler wrappers before refusing. + // It is also kept distinct from "respawnable", because the remedy differs: `ocx stop` + // maps the same unknown to a stop failure, so telling that operator to run it loops. + expect(installedServiceRespawnRisk(() => ({ status: "unknown" }) as never, "win32")).toBe("unknown"); + expect(installedServiceRespawnRisk(() => { throw new Error("schtasks unavailable"); }, "win32")).toBe("unknown"); + // A proven absence is the only case that proceeds. + expect(installedServiceRespawnRisk(() => ({ status: "absent" }) as never, "win32")).toBe("none"); + // Every other platform is down when it says so; no wrapper can respawn. + expect(installedServiceRespawnRisk(() => ({ status: "present" }) as never, "darwin")).toBe("none"); + expect(installedServiceRespawnRisk(() => ({ status: "present" }) as never, "linux")).toBe("none"); + }); + + test("the daemon's exit status reflects the shared teardown, not just the drain", () => { + const handler = sliceFn(MANAGEMENT_SOURCE, '"/api/stop"', "/api/codex-auth/"); + // A drained proxy whose restore failed did not finish the job; exiting 0 told a + // supervisor the stop was clean while client config still pointed at this process. + expect(handler).toContain("process.exit(shutdownSucceeded && teardown.success ? 0 : 1)"); + }); + + test("direct service stop and uninstall fail when a shared teardown half fails", () => { + const serviceSource = readFileSync(join(import.meta.dir, "..", "src", "service.ts"), "utf8"); + // These paths logged the failure and exited 0, so a script could not tell a complete + // teardown from one that left Grok aimed at a stopped proxy. + const stopCase = serviceSource.slice( + serviceSource.indexOf("service stopped + native Codex restored"), + serviceSource.indexOf('case "status": {'), + ); + expect(stopCase).toContain("if (!restore.success) process.exitCode = 1;"); + expect((stopCase.match(/process\.exitCode = 1;/g) ?? []).length).toBeGreaterThanOrEqual(2); + const uninstallStart = serviceSource.indexOf("`⚠️ native Codex restore FAILED:"); + expect(uninstallStart).toBeGreaterThan(-1); + const uninstallCase = serviceSource.slice(uninstallStart, uninstallStart + 700); + expect((uninstallCase.match(/process\.exitCode = 1;/g) ?? []).length).toBeGreaterThanOrEqual(2); }); test("a 409 does not escalate to a forced kill", () => { diff --git a/tests/integrations-journal.test.ts b/tests/integrations-journal.test.ts index ac38e1daf5..b98628d62c 100644 --- a/tests/integrations-journal.test.ts +++ b/tests/integrations-journal.test.ts @@ -338,3 +338,54 @@ describe("store isolation", () => { expect(store.findOperation("with-prior")?.priorRecord).toEqual(priorRecord); }); }); + +/** + * `OperationKind` is declared three times and imported zero times across the + * boundaries that carry it: the store writes it, the management route re-declares + * it in its envelope, and the GUI adapter re-declares it again. Neither of the + * latter two imports from `src/integrations/journal`, so the compiler has nothing + * to compare and a kind added in one place is a silent gap in the others -- the + * rollback list renders a raw i18n key and no build fails. + * + * Only the GUI's `JOURNAL_KIND_KEY` is compiler-checked, and only against the + * GUI's own copy of the union. This reads all three declarations as text, which + * is unpleasant and is also the only thing that can see across three trees that + * do not share a module graph. + */ +describe("the operation-kind union agrees across the three trees that redeclare it", () => { + const UNION = /"apply"\s*\|\s*"disable"\s*\|\s*"refresh"\s*\|\s*"restore"[^;]*/; + + function kindsIn(relPath: string, anchor: RegExp): string[] { + const source = readFileSync(join(import.meta.dir, "..", relPath), "utf8"); + const declaration = source.match(anchor); + if (!declaration) throw new Error(`no operation-kind union found in ${relPath}`); + const union = declaration[0].match(UNION); + if (!union) throw new Error(`the union in ${relPath} no longer starts with the four original kinds`); + return [...union[0].matchAll(/"([a-z]+)"/g)].map(match => match[1]!).sort(); + } + + test("the store, the management envelope and the GUI adapter list the same kinds", () => { + const store = kindsIn("src/integrations/journal.ts", /export type OperationKind =[^;]*/); + const route = kindsIn("src/server/management/integration-routes.ts", /kind: "apply"[^;]*/); + const gui = kindsIn("gui/src/pages/integrations/integration-api.ts", /kind: "apply"[^;]*/); + + // Named explicitly so a kind silently dropped from ALL THREE still fails. + expect(store).toEqual(["apply", "disable", "overwrite", "refresh", "restore"]); + expect(route).toEqual(store); + expect(gui).toEqual(store); + }); + + test("every kind the store can persist has rollback-list copy in the GUI", () => { + /* + * The failure this catches is not a type error anywhere: `JOURNAL_KIND_KEY` + * is exhaustive over the GUI's own union, so a kind missing from BOTH the + * GUI union and the map type-checks clean and renders the raw key. + */ + const map = readFileSync(join(import.meta.dir, "..", "gui/src/pages/integrations/overview-clients.ts"), "utf8"); + const block = map.match(/JOURNAL_KIND_KEY[^}]*}/); + if (!block) throw new Error("JOURNAL_KIND_KEY is gone from overview-clients.ts"); + for (const kind of kindsIn("src/integrations/journal.ts", /export type OperationKind =[^;]*/)) { + expect(block[0]).toContain(`${kind}: "integrations.kind.${kind}"`); + } + }); +}); diff --git a/tests/integrations-writer.test.ts b/tests/integrations-writer.test.ts index 17aca22612..486a29dece 100644 --- a/tests/integrations-writer.test.ts +++ b/tests/integrations-writer.test.ts @@ -12,6 +12,7 @@ import { exportContextOf, readIntegrationState } from "../src/integrations/state import { applyIntegration, disableIntegration, + overwriteIntegration, restoreIntegration, type IntegrationWriteInput, } from "../src/integrations/writer"; @@ -1157,3 +1158,167 @@ describe("nothing leaks", () => { expect(doc).toEqual({ providers: {} }); }); }); + +describe("overwriting a conflict on purpose", () => { + /* + * Conflict was a dead end. The writer refused unconditionally, the GUI locked + * the switch, and the only way forward was editing the file by hand -- the + * thing a dashboard exists to avoid. These prove the escape hatch is real AND + * that it stays narrow: it waives the conflict refusal and nothing else. + */ + + test("replaces a block we did not write, and the original is restorable", () => { + const configPath = installHermes(); + // A block occupying our exact paths that we never wrote: unowned-key. + writeFileSync(configPath, "providers:\n opencodex:\n api: http://someone-else.invalid\n note: hand written\n"); + const before = readFileSync(configPath, "utf8"); + + // The default still refuses, which is what makes the opt-in meaningful. + const refused = applyIntegration(input()); + expect(refused.ok).toBe(false); + if (!refused.ok) expect(refused.reason).toBe("conflict"); + expect(readFileSync(configPath, "utf8")).toBe(before); + + const forced = overwriteIntegration(input()); + expect(forced.ok).toBe(true); + if (!forced.ok) return; + expect(forced.changed).toBe(true); + expect(forced.state).toBe("current"); + + const after = readFileSync(configPath, "utf8"); + expect(after).not.toContain("someone-else.invalid"); + expect(after).not.toContain("hand written"); + expect(readIntegrationState(input())).toMatchObject({ state: "current" }); + + // Journaled as its own kind: "applied" would be a lie about an operation that + // replaced somebody else's block, and this list is where a user looks after a + // mistake. + const operations = store.listOperations("hermes"); + expect(operations).toHaveLength(1); + expect(operations[0]!.kind).toBe("overwrite"); + + // And it is undoable, byte for byte. + const undone = restoreIntegration({ ...input(), opId: operations[0]!.opId }); + expect(undone.ok).toBe(true); + expect(readFileSync(configPath, "utf8")).toBe(before); + }); + + test("discards an edit inside our own block without stranding what the old record owned", () => { + const configPath = installHermes(); + expect(applyIntegration(input()).ok).toBe(true); + writeFileSync(configPath, readFileSync(configPath, "utf8").replace( + "api_mode: chat_completions", + "api_mode: user_edited", + )); + expect(readIntegrationState(input())).toMatchObject({ state: "conflict", reason: "foreign-edit" }); + + expect(overwriteIntegration(input()).ok).toBe(true); + + const after = readFileSync(configPath, "utf8"); + expect(after).not.toContain("api_mode: user_edited"); + expect(after).toContain("api_mode: chat_completions"); + /* + * A foreign-edit force drops what the PREVIOUS record owned before merging, + * the same way a stale refresh does. Without that, a path the old record + * covered and the new one does not would be stranded, unremovable by any + * later disable -- so disable has to return the file to a clean absence. + */ + expect(disableIntegration(input()).ok).toBe(true); + expect(readFileSync(configPath, "utf8")).not.toContain("opencodex"); + }); + + test("leaves the user's own containers standing after a forced apply is disabled", () => { + const configPath = installHermes(); + // The user already owns `providers`, and something they wrote sits in our slot. + writeFileSync(configPath, "providers:\n mine:\n api: http://user.invalid\n opencodex:\n api: http://squatter.invalid\n"); + + expect(overwriteIntegration(input()).ok).toBe(true); + expect(disableIntegration(input()).ok).toBe(true); + + const doc = Bun.YAML.parse(readFileSync(configPath, "utf8")) as Record; + // We did not create `providers`, so pruning it would be a second act of + // destruction after the one the user actually authorized. + expect(doc.providers).toBeDefined(); + expect((doc.providers as Record).mine).toEqual({ api: "http://user.invalid" }); + expect((doc.providers as Record).opencodex).toBeUndefined(); + }); + + test("still refuses an unsafe document, where a snapshot is not a licence", () => { + const configPath = installHermes(); + /* + * A non-object where we would have to write a section. The merge would + * replace a value it cannot reason about, so forcing it is data loss with a + * receipt -- the force path must not reach it. + */ + writeFileSync(configPath, "providers: not-a-mapping\n"); + const before = readFileSync(configPath, "utf8"); + + const result = overwriteIntegration(input()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("unsafe"); + expect(readFileSync(configPath, "utf8")).toBe(before); + }); + + test("behaves exactly like apply when there is no conflict to overwrite", () => { + installHermes(); + /* + * Not a way to skip any other check: on a clean file this is an ordinary + * apply, journaled as one, and on an already-current file it is the same + * no-op apply performs. + */ + const first = overwriteIntegration(input()); + expect(first.ok).toBe(true); + if (first.ok) expect(first.changed).toBe(true); + expect(store.listOperations("hermes")[0]!.kind).toBe("apply"); + + const second = overwriteIntegration(input()); + expect(second.ok).toBe(true); + if (second.ok) expect(second.changed).toBe(false); + expect(store.listOperations("hermes")).toHaveLength(1); + }); + + test("refuses a client that is not installed", () => { + // installHermes() deliberately not called: a missing client is not a conflict. + const result = overwriteIntegration(input()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("not_installed"); + }); + + test("drops a path only the OLD record owned, so nothing is left unremovable", () => { + /* + * The stranding case the sibling test above cannot see. When every path the + * old record owned is also a path the new contribution writes, dropping the + * old fragments first changes nothing observable -- the merge overwrites them + * anyway. The drop only matters when the layouts DISAGREE, which is what an + * upgrade leaves behind: a path we owned under the previous shape and no + * longer write. Without the drop, the replacement record never covers it, so + * no later disable can ever remove it. + */ + const configPath = installOpencode(); + const request = input({ clientId: "opencode" }); + expect(applyIntegration(request).ok).toBe(true); + + const document = JSON.parse(readFileSync(configPath, "utf8")) as { + providers: Record>; + }; + document.providers["opencodex-legacy"] = { api: "http://legacy.invalid" }; + // An edit inside a fragment we DO own is what makes this a foreign-edit + // conflict rather than ordinary drift. + document.providers.opencodex!.options = { baseURL: "http://user-edited.invalid" }; + writeFileSync(configPath, `${JSON.stringify(document, null, 2)}\n`); + + const record = { ...store.readRecords().opencode! }; + record.fragmentPaths = [...record.fragmentPaths, ["providers", "opencodex-legacy"]]; + store.putRecord(record); + expect(readIntegrationState(request)).toMatchObject({ state: "conflict", reason: "foreign-edit" }); + + expect(overwriteIntegration(request).ok).toBe(true); + + const after = JSON.parse(readFileSync(configPath, "utf8")) as { + providers: Record; + }; + expect(after.providers["opencodex-legacy"]).toBeUndefined(); + expect(after.providers.opencodex).toBeDefined(); + expect(store.readRecords().opencode!.fragmentPaths).not.toContainEqual(["providers", "opencodex-legacy"]); + }); +}); diff --git a/tests/management-integration-routes.test.ts b/tests/management-integration-routes.test.ts index 095a5d146a..e8f1d37327 100644 --- a/tests/management-integration-routes.test.ts +++ b/tests/management-integration-routes.test.ts @@ -162,6 +162,15 @@ function put(clientId: string, enabled: boolean): Promise { }); } +/** A toggle that also waives the conflict refusal. */ +function putOverwrite(clientId: string, body: Record): Promise { + return api(`/api/client-integrations/${clientId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + function restore(body: unknown): Promise { return api("/api/client-integrations/restore", { method: "POST", @@ -568,6 +577,68 @@ describe("refusals", () => { expect(store.listOperations()).toHaveLength(journalBefore); }); + test("the same conflict is resolvable when the caller waives it by name", async () => { + const configPath = installHermes(); + expect((await put("hermes", true)).status).toBe(200); + const edited = readFileSync(configPath, "utf8").replace( + "api_mode: chat_completions", + "api_mode: user_edited", + ); + writeFileSync(configPath, edited); + + // The plain enable is still refused: that is what makes the flag the only door. + expect((await put("hermes", true)).status).toBe(409); + expect(readFileSync(configPath, "utf8")).toBe(edited); + + const forced = await putOverwrite("hermes", { enabled: true, overwriteConflict: true }); + expect(forced.status).toBe(200); + expect(await forced.json()).toMatchObject({ ok: true, clientId: "hermes", state: "current" }); + expect(readFileSync(configPath, "utf8")).not.toContain("api_mode: user_edited"); + + // Journaled under its own kind, so the rollback list does not call this an apply. + const operations = store.listOperations("hermes"); + expect(operations[0]!.kind).toBe("overwrite"); + }); + + test("an explicit false waiver is exactly a plain apply, and still refuses", async () => { + const configPath = installHermes(); + expect((await put("hermes", true)).status).toBe(200); + const edited = readFileSync(configPath, "utf8").replace( + "api_mode: chat_completions", + "api_mode: user_edited", + ); + writeFileSync(configPath, edited); + + const response = await putOverwrite("hermes", { enabled: true, overwriteConflict: false }); + expect(response.status).toBe(409); + expect(readFileSync(configPath, "utf8")).toBe(edited); + }); + + test("a non-boolean waiver is rejected, and disable can never carry one", async () => { + installHermes(); + const nonBoolean = await putOverwrite("hermes", { enabled: true, overwriteConflict: "yes" }); + expect(nonBoolean.status).toBe(400); + expect(await nonBoolean.json()).toEqual({ + error: "overwriteConflict must be a boolean", + code: "invalid_overwrite_conflict", + }); + + /* + * Rejected rather than ignored. Forcing a DISABLE over a conflict is the + * deletion of unowned work this whole subsystem exists to prevent, so a + * caller sending the combination has misunderstood the field; answering 200 + * would confirm an intent we refused. + */ + const forcedDisable = await putOverwrite("hermes", { enabled: false, overwriteConflict: true }); + expect(forcedDisable.status).toBe(400); + expect(await forcedDisable.json()).toEqual({ + error: "overwriteConflict applies only to enabling an integration", + code: "invalid_overwrite_conflict", + }); + + expect(store.listOperations()).toHaveLength(0); + }); + test("a write failure in a non-failure state keeps its own envelope", async () => { /* * The defect this activates: routing on `state` instead of `reason`. diff --git a/tests/oauth-refresh.test.ts b/tests/oauth-refresh.test.ts index 0975e8285c..3ba3e57ce8 100644 --- a/tests/oauth-refresh.test.ts +++ b/tests/oauth-refresh.test.ts @@ -7,7 +7,19 @@ import { getValidAccessToken, getValidAccessTokenForAccount, OAuthLoginRequiredE import { RefreshIntentIOError, nousRefreshIntentBlocksReplay } from "../src/oauth/nous"; import * as nousModule from "../src/oauth/nous"; import { AnthropicTokenError } from "../src/oauth/anthropic"; -import { credentialGeneration, getAccountCredential, getAccountSet, getAuthRefreshIntentPath, getCredential, markAccountNeedsReauth, readOAuthRefreshIntent, saveCredential, writeOAuthRefreshIntent } from "../src/oauth/store"; +import { + OAuthRefreshIntentIOError, + credentialGeneration, + getAccountCredential, + getAccountSet, + getAuthRefreshIntentPath, + getCredential, + markAccountNeedsReauth, + markOAuthRefreshIntentCleanupPending, + readOAuthRefreshIntent, + saveCredential, + writeOAuthRefreshIntent, +} from "../src/oauth/store"; import * as storeModule from "../src/oauth/store"; import * as configModule from "../src/config"; @@ -517,6 +529,423 @@ describe("oauth refresh hardening", () => { expect(getAccountSet("anthropic")!.accounts[0]!.needsReauth).toBe(true); }); + /** + * A definitive non-terminal HTTP failure is retryable: the endpoint answered with a failure, + * so the durable intent must not turn that promised retry into OAuthLoginRequiredError. + * A timeout or unreadable response is different — the provider may already have rotated the + * token, so that post-dispatch intent remains as the replay guard. + */ + test("a definitive transient Anthropic HTTP failure leaves the account refreshable", async () => { + await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" }); + const id = getAccountSet("anthropic")!.activeAccountId; + const transient = new AnthropicTokenError("server", 503, undefined); + await expect(refreshAnthropicAccountWithLock("anthropic", id, { + ...OAUTH_PROVIDERS.anthropic!, + refresh: async () => { throw transient; }, + }, getAccountCredential("anthropic", id)!)).rejects.toBe(transient); + expect(readOAuthRefreshIntent("anthropic", id)).toBeUndefined(); + + // Upstream recovers: the retry the caller was promised must actually succeed. + await expect(refreshAnthropicAccountWithLock("anthropic", id, { + ...OAUTH_PROVIDERS.anthropic!, + refresh: async () => ({ access: "fresh", refresh: "rt-fresh", expires: Date.now() + 3_600_000 }), + }, getAccountCredential("anthropic", id)!)).resolves.toBe("fresh"); + expect(getAccountSet("anthropic")!.accounts.find(account => account.id === id)!.needsReauth).toBeUndefined(); + }); + + test("an Anthropic refresh with an uncertain post-dispatch outcome preserves its intent", async () => { + await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" }); + const id = getAccountSet("anthropic")!.activeAccountId; + const credential = getAccountCredential("anthropic", id)!; + const generation = credentialGeneration(credential); + const timeout = new AnthropicTokenError("timeout", undefined, undefined); + let refreshCalls = 0; + const def = { + ...OAUTH_PROVIDERS.anthropic!, + refresh: async () => { + refreshCalls += 1; + throw timeout; + }, + }; + + await expect(refreshAnthropicAccountWithLock("anthropic", id, def, credential)).rejects.toBe(timeout); + + const pending = readOAuthRefreshIntent("anthropic", id); + expect(pending).toMatchObject({ generation }); + expect(pending?.cleanupPending).toBeUndefined(); + expect(getAccountSet("anthropic")!.accounts.find(account => account.id === id)!.needsReauth).toBeUndefined(); + await expect(refreshAnthropicAccountWithLock("anthropic", id, def, credential)) + .rejects.toBeInstanceOf(OAuthLoginRequiredError); + expect(refreshCalls).toBe(1); + expect(readOAuthRefreshIntent("anthropic", id)).toEqual(pending); + }); + + test("a pre-dispatch Anthropic abort clears its unconsumed refresh intent", async () => { + await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" }); + const id = getAccountSet("anthropic")!.activeAccountId; + const credential = getAccountCredential("anthropic", id)!; + const aborted = new Error("aborted before dispatch"); + const controller = new AbortController(); + controller.abort(aborted); + let calls = 0; + + await expect(refreshAnthropicAccountWithLock("anthropic", id, { + ...OAUTH_PROVIDERS.anthropic!, + refresh: async () => { calls += 1; throw new Error("must not run"); }, + }, credential, { signal: controller.signal })).rejects.toBe(aborted); + + expect(calls).toBe(0); + expect(readOAuthRefreshIntent("anthropic", id)).toBeUndefined(); + }); + + test("a failed pre-dispatch cleanup remains safely retryable", async () => { + await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" }); + const id = getAccountSet("anthropic")!.activeAccountId; + const credential = getAccountCredential("anthropic", id)!; + const controller = new AbortController(); + const aborted = new Error("aborted before dispatch"); + controller.abort(aborted); + const realClear = storeModule.clearOAuthRefreshIntentIfMatch; + let clearCalls = 0; + const clearSpy = spyOn(storeModule, "clearOAuthRefreshIntentIfMatch").mockImplementation((...args) => { + clearCalls += 1; + if (clearCalls === 1) throw new Error("intent unlink failed"); + return realClear(...args); + }); + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + try { + await expect(refreshAnthropicAccountWithLock("anthropic", id, { + ...OAUTH_PROVIDERS.anthropic!, + refresh: async () => { throw new Error("must not dispatch"); }, + }, credential, { signal: controller.signal })).rejects.toBe(aborted); + expect(readOAuthRefreshIntent("anthropic", id)?.cleanupPending).toBe("pre-dispatch"); + + let retryCalls = 0; + await expect(refreshAnthropicAccountWithLock("anthropic", id, { + ...OAUTH_PROVIDERS.anthropic!, + refresh: async () => { + retryCalls += 1; + return { access: "fresh", refresh: "rt-fresh", expires: Date.now() + 3_600_000 }; + }, + }, credential)).resolves.toBe("fresh"); + expect(retryCalls).toBe(1); + expect(readOAuthRefreshIntent("anthropic", id)).toBeUndefined(); + } finally { + warnSpy.mockRestore(); + clearSpy.mockRestore(); + } + }); + + test("a cleanup-marker persistence failure surfaces a typed error with the provider outcome", async () => { + await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" }); + const id = getAccountSet("anthropic")!.activeAccountId; + const credential = getAccountCredential("anthropic", id)!; + const transient = new AnthropicTokenError("server", 503, undefined); + const markerFailure = new configModule.ConfigMutationLockError( + "Could not acquire config mutation transaction", + { cause: Object.assign(new Error("intent marker write failed"), { code: "EACCES" }) }, + ); + let markerCalls = 0; + const markerSpy = spyOn(storeModule, "markOAuthRefreshIntentCleanupPending").mockImplementation(() => { + markerCalls += 1; + throw markerFailure; + }); + try { + let rejection: unknown; + try { + await refreshAnthropicAccountWithLock("anthropic", id, { + ...OAUTH_PROVIDERS.anthropic!, + refresh: async () => { throw transient; }, + }, credential); + } catch (error) { + rejection = error; + } + expect(rejection).toBeInstanceOf(OAuthRefreshIntentIOError); + expect(rejection).toMatchObject({ + operation: "mark-cleanup-pending", + code: "OAUTH_REFRESH_INTENT_IO", + cause: markerFailure, + refreshError: transient, + }); + expect(markerCalls).toBe(1); + const pending = readOAuthRefreshIntent("anthropic", id); + expect(pending?.cleanupPending).toBeUndefined(); + expect(getAccountSet("anthropic")!.accounts.find(account => account.id === id)!.needsReauth).toBeUndefined(); + } finally { + markerSpy.mockRestore(); + } + }); + + test("transient cleanup-marker lock contention settles a retryable rejection without reauth", async () => { + await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" }); + const id = getAccountSet("anthropic")!.activeAccountId; + const credential = getAccountCredential("anthropic", id)!; + const transient = new AnthropicTokenError("server", 503, undefined); + const realMark = storeModule.markOAuthRefreshIntentCleanupPending; + let markerCalls = 0; + const markerSpy = spyOn(storeModule, "markOAuthRefreshIntentCleanupPending").mockImplementation((...args) => { + markerCalls += 1; + if (markerCalls <= 2) { + throw new configModule.ConfigMutationLockError( + "Config mutation already in progress", + { cause: Object.assign(new Error("database is locked"), { code: "SQLITE_BUSY" }) }, + ); + } + return realMark(...args); + }); + try { + let providerCalls = 0; + await expect(refreshAnthropicAccountWithLock("anthropic", id, { + ...OAUTH_PROVIDERS.anthropic!, + refresh: async () => { + providerCalls += 1; + throw transient; + }, + }, credential)).rejects.toBe(transient); + + expect(providerCalls).toBe(1); + expect(markerCalls).toBe(3); + expect(readOAuthRefreshIntent("anthropic", id)).toBeUndefined(); + expect(getAccountSet("anthropic")!.accounts.find(account => account.id === id)!.needsReauth).toBeUndefined(); + + await expect(refreshAnthropicAccountWithLock("anthropic", id, { + ...OAUTH_PROVIDERS.anthropic!, + refresh: async () => ({ + access: "fresh", + refresh: "rt-fresh", + expires: Date.now() + 3_600_000, + }), + }, getAccountCredential("anthropic", id)!)).resolves.toBe("fresh"); + expect(getAccountCredential("anthropic", id)?.access).toBe("fresh"); + expect(readOAuthRefreshIntent("anthropic", id)).toBeUndefined(); + } finally { + markerSpy.mockRestore(); + } + }); + + test("persistent cleanup-marker lock contention stays bounded and preserves the replay guard", async () => { + await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" }); + const id = getAccountSet("anthropic")!.activeAccountId; + const credential = getAccountCredential("anthropic", id)!; + const generation = credentialGeneration(credential); + const transient = new AnthropicTokenError("server", 503, undefined); + let markerCalls = 0; + let lastBusy: configModule.ConfigMutationLockError | undefined; + const markerSpy = spyOn(storeModule, "markOAuthRefreshIntentCleanupPending").mockImplementation(() => { + markerCalls += 1; + lastBusy = new configModule.ConfigMutationLockError( + "Config mutation already in progress", + { cause: Object.assign(new Error("database table is locked"), { code: "SQLITE_LOCKED" }) }, + ); + throw lastBusy; + }); + try { + let providerCalls = 0; + let rejection: unknown; + try { + await refreshAnthropicAccountWithLock("anthropic", id, { + ...OAUTH_PROVIDERS.anthropic!, + refresh: async () => { + providerCalls += 1; + throw transient; + }, + }, credential); + } catch (error) { + rejection = error; + } + + expect(providerCalls).toBe(1); + expect(markerCalls).toBe(4); + expect(rejection).toBeInstanceOf(OAuthRefreshIntentIOError); + expect(rejection).toMatchObject({ + operation: "mark-cleanup-pending", + code: "OAUTH_REFRESH_INTENT_IO", + cause: lastBusy, + refreshError: transient, + }); + expect(getAccountCredential("anthropic", id)).toEqual(credential); + expect(readOAuthRefreshIntent("anthropic", id)).toMatchObject({ generation }); + expect(readOAuthRefreshIntent("anthropic", id)?.cleanupPending).toBeUndefined(); + expect(getAccountSet("anthropic")!.accounts.find(account => account.id === id)!.needsReauth).toBeUndefined(); + } finally { + markerSpy.mockRestore(); + } + }); + + test("a failed definitive-rejection cleanup is retried before the next Anthropic request", async () => { + await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" }); + const id = getAccountSet("anthropic")!.activeAccountId; + const credential = getAccountCredential("anthropic", id)!; + const generation = credentialGeneration(credential); + const transient = new AnthropicTokenError("server", 503, undefined); + const clearFailure = new Error("intent unlink failed"); + const realClear = storeModule.clearOAuthRefreshIntentIfMatch; + const events: string[] = []; + let clearCalls = 0; + const clearSpy = spyOn(storeModule, "clearOAuthRefreshIntentIfMatch").mockImplementation((...args) => { + clearCalls += 1; + if (clearCalls === 1) { + events.push("clear-fail"); + throw clearFailure; + } + events.push(args[2].cleanupPending ? "clear-recovery" : "clear-success"); + return realClear(...args); + }); + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + try { + await expect(refreshAnthropicAccountWithLock("anthropic", id, { + ...OAUTH_PROVIDERS.anthropic!, + refresh: async () => { + events.push("provider-503"); + throw transient; + }, + }, credential)).rejects.toBe(transient); + + expect(readOAuthRefreshIntent("anthropic", id)).toMatchObject({ + generation, + cleanupPending: "definitive-rejection", + }); + expect(getAccountSet("anthropic")!.accounts.find(account => account.id === id)!.needsReauth).toBeUndefined(); + + let retryCalls = 0; + await expect(refreshAnthropicAccountWithLock("anthropic", id, { + ...OAUTH_PROVIDERS.anthropic!, + refresh: async () => { + events.push("provider-retry"); + retryCalls += 1; + return { access: "fresh", refresh: "rt-fresh", expires: Date.now() + 3_600_000 }; + }, + }, getAccountCredential("anthropic", id)!)).resolves.toBe("fresh"); + expect(retryCalls).toBe(1); + expect(clearCalls).toBe(3); + expect(events).toEqual([ + "provider-503", + "clear-fail", + "clear-recovery", + "provider-retry", + "clear-success", + ]); + expect(readOAuthRefreshIntent("anthropic", id)).toBeUndefined(); + } finally { + warnSpy.mockRestore(); + clearSpy.mockRestore(); + } + }); + + test("a persistently blocked cleanup fails operationally without replay or reauth", async () => { + await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" }); + const id = getAccountSet("anthropic")!.activeAccountId; + const credential = getAccountCredential("anthropic", id)!; + const transient = new AnthropicTokenError("server", 503, undefined); + const cleanupFailure = new Error("intent unlink failed"); + const clearSpy = spyOn(storeModule, "clearOAuthRefreshIntentIfMatch").mockImplementation(() => { + throw cleanupFailure; + }); + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + try { + await expect(refreshAnthropicAccountWithLock("anthropic", id, { + ...OAUTH_PROVIDERS.anthropic!, + refresh: async () => { throw transient; }, + }, credential)).rejects.toBe(transient); + expect(readOAuthRefreshIntent("anthropic", id)?.cleanupPending).toBe("definitive-rejection"); + + let retryCalls = 0; + let rejection: unknown; + try { + await refreshAnthropicAccountWithLock("anthropic", id, { + ...OAUTH_PROVIDERS.anthropic!, + refresh: async () => { + retryCalls += 1; + return { access: "must-not-run", refresh: "must-not-run", expires: Date.now() + 3_600_000 }; + }, + }, credential); + } catch (error) { + rejection = error; + } + + expect(retryCalls).toBe(0); + expect(rejection).toBeInstanceOf(OAuthRefreshIntentIOError); + expect(rejection).toMatchObject({ + operation: "resume-cleanup", + code: "OAUTH_REFRESH_INTENT_IO", + cause: cleanupFailure, + }); + expect(getAccountSet("anthropic")!.accounts.find(account => account.id === id)!.needsReauth).toBeUndefined(); + expect(readOAuthRefreshIntent("anthropic", id)?.cleanupPending).toBe("definitive-rejection"); + } finally { + warnSpy.mockRestore(); + clearSpy.mockRestore(); + } + }); + + test("Anthropic persistence failure after provider success preserves the replay guard", async () => { + await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" }); + const id = getAccountSet("anthropic")!.activeAccountId; + const credential = getAccountCredential("anthropic", id)!; + const generation = credentialGeneration(credential); + const persistenceFailure = new Error("credential persistence failed"); + let refreshCalls = 0; + const def = { + ...OAUTH_PROVIDERS.anthropic!, + refresh: async () => { + refreshCalls += 1; + return { + access: "fresh", + refresh: "rt-fresh", + expires: Date.now() + 3_600_000, + }; + }, + }; + const mergeSpy = spyOn(storeModule, "mergeAccountCredential").mockImplementation(async () => { + throw persistenceFailure; + }); + let pendingAfterFailure: ReturnType; + try { + await expect(refreshAnthropicAccountWithLock("anthropic", id, def, credential)) + .rejects.toBe(persistenceFailure); + + const pending = readOAuthRefreshIntent("anthropic", id); + pendingAfterFailure = pending; + expect(pending).toMatchObject({ generation }); + expect(pending?.cleanupPending).toBeUndefined(); + expect(getAccountCredential("anthropic", id)).toEqual(credential); + expect(getAccountSet("anthropic")!.accounts.find(account => account.id === id)!.needsReauth).toBeUndefined(); + } finally { + mergeSpy.mockRestore(); + } + await expect(refreshAnthropicAccountWithLock("anthropic", id, def, credential)) + .rejects.toBeInstanceOf(OAuthLoginRequiredError); + expect(refreshCalls).toBe(1); + expect(readOAuthRefreshIntent("anthropic", id)).toEqual(pendingAfterFailure); + }); + + test("post-persist intent cleanup failure does not turn Anthropic refresh success into failure", async () => { + await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" }); + const id = getAccountSet("anthropic")!.activeAccountId; + const credential = getAccountCredential("anthropic", id)!; + const oldGeneration = credentialGeneration(credential); + const clearSpy = spyOn(storeModule, "clearOAuthRefreshIntentIfMatch").mockImplementation(() => { + throw new Error("intent unlink failed"); + }); + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + try { + await expect(refreshAnthropicAccountWithLock("anthropic", id, { + ...OAUTH_PROVIDERS.anthropic!, + refresh: async () => ({ + access: "fresh", + refresh: "rt-fresh", + expires: Date.now() + 3_600_000, + }), + }, credential)).resolves.toBe("fresh"); + + expect(getAccountCredential("anthropic", id)?.access).toBe("fresh"); + expect(readOAuthRefreshIntent("anthropic", id)).toMatchObject({ generation: oldGeneration }); + } finally { + warnSpy.mockRestore(); + clearSpy.mockRestore(); + } + }); + test("Anthropic post-dispatch stale flight replacement stays retryable without replay or reauth", async () => { await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" }); const id = getAccountSet("anthropic")!.activeAccountId; @@ -638,6 +1067,135 @@ describe("oauth refresh hardening", () => { expect(readOAuthRefreshIntent("anthropic", id)).toEqual(legacy); expect(readOAuthRefreshIntent("anthropic", id)?.uncertain).toBeUndefined(); + let refreshCalls = 0; + await expect(refreshAnthropicAccountWithLock("anthropic", id, { + ...OAUTH_PROVIDERS.anthropic!, + refresh: async () => { + refreshCalls += 1; + return { access: "must-not-run", refresh: "must-not-run", expires: Date.now() + 3_600_000 }; + }, + }, getAccountCredential("anthropic", id)!)).rejects.toBeInstanceOf(OAuthLoginRequiredError); + expect(refreshCalls).toBe(0); + expect(readOAuthRefreshIntent("anthropic", id)).toEqual(legacy); + }); + + test("cleanup-pending CAS never rewrites a foreign same-generation attempt", async () => { + await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" }); + const id = getAccountSet("anthropic")!.activeAccountId; + const generation = credentialGeneration(getAccountCredential("anthropic", id)!); + const intent = writeOAuthRefreshIntent("anthropic", id, generation, Date.now()); + + for (const foreign of [ + { ...intent, generation: "foreign-generation" }, + { ...intent, attemptId: "foreign-attempt" }, + { ...intent, flightId: "foreign-flight" }, + { ...intent, createdAt: intent.createdAt + 1 }, + ]) { + expect(markOAuthRefreshIntentCleanupPending( + "anthropic", + id, + foreign, + "definitive-rejection", + )).toBeUndefined(); + expect(storeModule.clearOAuthRefreshIntentIfMatch("anthropic", id, foreign)).toBe(false); + expect(readOAuthRefreshIntent("anthropic", id)).toEqual(intent); + } + }); + + test("a replaced obsolete intent blocks Anthropic redispatch", async () => { + await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" }); + const id = getAccountSet("anthropic")!.activeAccountId; + const credential = getAccountCredential("anthropic", id)!; + const obsolete = writeOAuthRefreshIntent("anthropic", id, "0".repeat(64)); + const replacement = { ...obsolete, attemptId: "foreign-attempt" }; + writeFileSync(getAuthRefreshIntentPath("anthropic", id), `${JSON.stringify(replacement)}\n`); + const clearSpy = spyOn(storeModule, "clearOAuthRefreshIntentIfMatch").mockReturnValue(false); + let refreshCalls = 0; + try { + await expect(refreshAnthropicAccountWithLock("anthropic", id, { + ...OAUTH_PROVIDERS.anthropic!, + refresh: async () => { + refreshCalls += 1; + return { access: "must-not-run", refresh: "must-not-run", expires: Date.now() + 3_600_000 }; + }, + }, credential)).rejects.toBeInstanceOf(OAuthTokenRefreshStaleError); + expect(refreshCalls).toBe(0); + expect(readOAuthRefreshIntent("anthropic", id)).toEqual(replacement); + } finally { + clearSpy.mockRestore(); + } + }); + + test("cleanup-pending marker commit preserves an attempt replaced during comparison", async () => { + await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" }); + const id = getAccountSet("anthropic")!.activeAccountId; + const path = getAuthRefreshIntentPath("anthropic", id); + const generation = credentialGeneration(getAccountCredential("anthropic", id)!); + const original = writeOAuthRefreshIntent("anthropic", id, generation, Date.now(), "original-flight"); + const replacement = { + ...original, + attemptId: "replacement-attempt", + flightId: "replacement-flight", + }; + + expect(markOAuthRefreshIntentCleanupPending( + "anthropic", + id, + original, + "definitive-rejection", + { beforeMarkCommit: () => writeFileSync(path, `${JSON.stringify(replacement)}\n`) }, + )).toBeUndefined(); + expect(readOAuthRefreshIntent("anthropic", id)).toEqual(replacement); + }); + + test("exact cleanup preserves an attempt replaced before unlink", async () => { + await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" }); + const id = getAccountSet("anthropic")!.activeAccountId; + const path = getAuthRefreshIntentPath("anthropic", id); + const generation = credentialGeneration(getAccountCredential("anthropic", id)!); + const original = writeOAuthRefreshIntent("anthropic", id, generation, Date.now(), "original-flight"); + const replacement = { + ...original, + attemptId: "replacement-attempt", + flightId: "replacement-flight", + }; + + expect(storeModule.clearOAuthRefreshIntentIfMatch( + "anthropic", + id, + original, + { beforeClearRecheck: () => writeFileSync(path, `${JSON.stringify(replacement)}\n`) }, + )).toBe(false); + expect(readOAuthRefreshIntent("anthropic", id)).toEqual(replacement); + }); + + test("an invalid cleanup-pending marker fails closed as uncertain", async () => { + await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" }); + const id = getAccountSet("anthropic")!.activeAccountId; + const intent = writeOAuthRefreshIntent( + "anthropic", + id, + credentialGeneration(getAccountCredential("anthropic", id)!), + ); + const { attemptId: _attemptId, ...withoutAttemptId } = intent; + for (const invalid of [ + { ...intent, cleanupPending: "unsafe-retry" }, + { ...withoutAttemptId, cleanupPending: "definitive-rejection" }, + { ...intent, cleanupPending: "definitive-rejection", uncertain: true }, + { ...intent, cleanupPending: "definitive-rejection", staleOwner: true }, + ]) { + writeFileSync(getAuthRefreshIntentPath("anthropic", id), `${JSON.stringify(invalid)}\n`); + expect(readOAuthRefreshIntent("anthropic", id)?.uncertain).toBe(true); + } + let refreshCalls = 0; + await expect(refreshAnthropicAccountWithLock("anthropic", id, { + ...OAUTH_PROVIDERS.anthropic!, + refresh: async () => { + refreshCalls += 1; + return { access: "must-not-run", refresh: "must-not-run", expires: Date.now() + 3_600_000 }; + }, + }, getAccountCredential("anthropic", id)!)).rejects.toBeInstanceOf(OAuthLoginRequiredError); + expect(refreshCalls).toBe(0); }); test("Anthropic never replays an outstanding oauth-source generation across re-entry", async () => { @@ -745,6 +1303,47 @@ describe("oauth refresh hardening", () => { expect(getCredential("anthropic")?.refresh).toBe("rt-new"); }); + /** + * The disk credential is committed before the observed intent is cleaned up. Cleanup is a + * secondary durability concern, so an unlink failure must not throw over the committed + * credential and turn a successful adoption into a caller-visible refresh error. + */ + test("a cleanup failure after adopting a disk credential does not mask the committed token", async () => { + await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, source: "local-cli" }); + const id = getAccountSet("anthropic")!.activeAccountId; + const stored = getAccountCredential("anthropic", id)!; + const pending = writeOAuthRefreshIntent("anthropic", id, credentialGeneration(stored)); + expect(markOAuthRefreshIntentCleanupPending( + "anthropic", + id, + pending, + "definitive-rejection", + )).toMatchObject({ cleanupPending: "definitive-rejection" }); + + seedClaudeCredentials("disk", "rt-new", Date.now() + 3600_000); + const mock = mockRefreshFetch([new Response("unexpected", { status: 500 })]); + + // The intent carries an attemptId, so cleanup runs through the exact-match clear. + // Fail it the way a locked or read-only file would. + let cleanupAttempts = 0; + const cleanupSpy = spyOn(storeModule, "clearOAuthRefreshIntentIfMatch").mockImplementation(() => { + cleanupAttempts += 1; + throw new OAuthRefreshIntentIOError("clear-cleanup-pending", new Error("forced cleanup failure (EROFS)")); + }); + try { + await expect(getValidAccessToken("anthropic")).resolves.toBe("disk"); + } finally { + cleanupSpy.mockRestore(); + } + + // The adoption committed and no network refresh was attempted; only the guard survives. + expect(cleanupAttempts).toBeGreaterThan(0); + expect(mock.count()).toBe(0); + expect(getCredential("anthropic")?.refresh).toBe("rt-new"); + expect(getAccountSet("anthropic")!.accounts[0]!.needsReauth).toBeUndefined(); + expect(readOAuthRefreshIntent("anthropic", id)?.cleanupPending).toBe("definitive-rejection"); + }); + test("marked Anthropic local-cli account lazily recovers only from a newer disk generation", async () => { await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, source: "local-cli" }); const id = getAccountSet("anthropic")!.activeAccountId; diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index 6687671023..a1972dc1fb 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -122,6 +122,80 @@ test("noncanonical pool-required providers use only their configured static cred expect(request.headers.session_id).toBeUndefined(); }); +test("noncanonical Responses destinations strip Codex-private item metadata", () => { + const rawBody = { + model: "openai/gpt-5.6-sol", + input: [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "ping" }], + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + }, + { + type: "function_call", + name: "lookup", + arguments: "{}", + call_id: "call-1", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + }, + ], + }; + + for (const configuredProvider of [ + { + adapter: "openai-responses", + baseUrl: "https://gateway.example/v1", + authMode: "key" as const, + apiKey: "test-key", + }, + { + adapter: "openai-responses", + baseUrl: "https://gateway.example/v1", + authMode: "forward" as const, + }, + ]) { + const request = createResponsesPassthroughAdapter(configuredProvider).buildRequest({ + modelId: rawBody.model, + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: rawBody, + }, { headers: new Headers() }); + const body = JSON.parse(request.body) as { input: Record[] }; + + expect(body.input.every(item => !("internal_chat_message_metadata_passthrough" in item))) + .toBe(true); + } + + expect(rawBody.input.every(item => "internal_chat_message_metadata_passthrough" in item)) + .toBe(true); +}); + +test("canonical ChatGPT forward preserves Codex-private item metadata", () => { + const request = createResponsesPassthroughAdapter(provider).buildRequest({ + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "gpt-5.6-sol", + input: [{ + type: "message", + role: "user", + content: [{ type: "input_text", text: "ping" }], + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + }], + }, + }, { headers: new Headers({ authorization: "Bearer token" }) }); + const body = JSON.parse(request.body) as { + input: { internal_chat_message_metadata_passthrough?: unknown }[]; + }; + + expect(body.input[0].internal_chat_message_metadata_passthrough) + .toEqual({ turn_id: "turn-1" }); +}); + test("passthrough serialized-body observation releases after the request settles", () => { const budget = createTranslatorBudget(); const request = createResponsesPassthroughAdapter(provider).buildRequest({ @@ -1295,11 +1369,11 @@ describe("OpenAI Responses passthrough sanitization", () => { expect(input[0]).not.toHaveProperty("id"); }); - test("backfills queries on a replayed single-query web_search_call (#930)", () => { + test("backfills web_search_call actions in either missing direction (#930, #3071)", () => { // The bridge fix only helps items created after it. A conversation that already - // recorded {type:"search", query:"..."} replays that stored item every turn, and - // DeepSeek's parser rejects the whole request over it — so upgrading alone would - // leave those threads permanently broken. + // recorded a legacy web_search_call replays that stored item every turn. DeepSeek's + // parser rejects an action without `queries` (#930) and Console Go rejects one + // without `query` (#3071) — so upgrading alone would leave those threads broken. const adapter = createResponsesPassthroughAdapter(provider); const request = adapter.buildRequest({ modelId: "provider-model", @@ -1319,13 +1393,66 @@ describe("OpenAI Responses passthrough sanitization", () => { // Repaired: singular query gains the array the strict parser requires. expect(input[0].action).toEqual({ type: "search", query: "legacy", queries: ["legacy"] }); - // Untouched: a batch already satisfies the parser, and adding `query` would collapse - // the native plural rendering. - expect(input[1].action).toEqual({ type: "search", queries: ["a", "b"] }); + // Repaired: multi-query batch gains the singular `query` Console Go requires. + expect(input[1].action).toEqual({ type: "search", query: "a", queries: ["a", "b"] }); // Untouched: not a search action. expect(input[2].action).toEqual({ type: "open_page", url: "https://example.test" }); }); + test("does not forge a singular query from a malformed or empty queries array (#3071)", () => { + // Input items use a loose schema, so a stored `queries` need not be an array of + // strings. Copying a non-string first member would satisfy the presence check and + // still fail the Console Go validator this repair exists to satisfy — a repair that + // reports success and produces an invalid shape is worse than no repair. + const adapter = createResponsesPassthroughAdapter(provider); + const request = adapter.buildRequest({ + modelId: "provider-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "provider-model", + input: [ + { type: "web_search_call", id: "ws_num", status: "completed", action: { type: "search", queries: [42] } }, + { type: "web_search_call", id: "ws_obj", status: "completed", action: { type: "search", queries: [{ q: "x" }] } }, + { type: "web_search_call", id: "ws_empty", status: "completed", action: { type: "search", queries: [] } }, + ], + }, + }, meta); + const input = (JSON.parse(request.body) as { input: Array<{ action: Record }> }).input; + + // Left alone: a non-string first member is not a query. + expect(input[0].action).toEqual({ type: "search", queries: [42] }); + expect(input[1].action).toEqual({ type: "search", queries: [{ q: "x" }] }); + // Canonicalized: an empty array satisfies neither validator, so it becomes the same + // empty-search shape the bridge emits rather than being passed through. + expect(input[2].action).toEqual({ type: "search", query: "", queries: [""] }); + }); + + test("repairs a partly-malformed or already-queried empty action (#3071)", () => { + const adapter = createResponsesPassthroughAdapter(provider); + const request = adapter.buildRequest({ + modelId: "provider-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "provider-model", + input: [ + { type: "web_search_call", id: "ws_mixed", status: "completed", action: { type: "search", queries: ["a", 42] } }, + { type: "web_search_call", id: "ws_qempty", status: "completed", action: { type: "search", query: "legacy", queries: [] } }, + ], + }, + }, meta); + const input = (JSON.parse(request.body) as { input: Array<{ action: Record }> }).input; + + // Left alone: deriving `query: "a"` would satisfy Console Go and leave DeepSeek to + // reject the same replay over the non-string second member. + expect(input[0].action).toEqual({ type: "search", queries: ["a", 42] }); + // Canonicalized without discarding the query the item already carried. + expect(input[1].action).toEqual({ type: "search", query: "legacy", queries: ["legacy"] }); + }); + test("strips invalid type-specific ids from serialized input items", () => { const adapter = createResponsesPassthroughAdapter(provider); const encryptedContent = "opaque-openai-encrypted-content"; diff --git a/tests/quota-401-recovery-runtime.test.ts b/tests/quota-401-recovery-runtime.test.ts new file mode 100644 index 0000000000..82cd83b251 --- /dev/null +++ b/tests/quota-401-recovery-runtime.test.ts @@ -0,0 +1,386 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** + * Runtime behaviour of the WHAM 401 recovery (#3019). + * + * tests/quota-401-recovery.test.ts exercises the budget store in isolation, and every case + * there stays green if the recovery is never wired into the quota path at all. These drive + * the real primitive and the real store together: a refresh that actually happens, a + * settlement that survives caller cancellation, and provenance that comes from the flight + * rather than from the adoption site. + */ + +const REJECTED = "rejected-bearer"; +const ROTATED = "rotated-bearer"; +let home: string; +let previousHome: string | undefined; +let originalFetch: typeof globalThis.fetch; + +beforeEach(async () => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-quota-401-")); + process.env.OPENCODEX_HOME = home; + originalFetch = globalThis.fetch; + const { resetQuotaRecoveryForTests } = await import("../src/codex/quota-401-recovery"); + resetQuotaRecoveryForTests(); +}); + +afterEach(async () => { + globalThis.fetch = originalFetch; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + rmSync(home, { recursive: true, force: true }); + const { resetQuotaRecoveryForTests } = await import("../src/codex/quota-401-recovery"); + resetQuotaRecoveryForTests(); +}); + +async function seedAccount(id: string): Promise { + const { readCodexAccountRecord, saveCodexAccountCredential } = await import("../src/codex/account-store"); + saveCodexAccountCredential(id, { + accessToken: REJECTED, + refreshToken: "grant", + expiresAt: Date.now() + 3600_000, + chatgptAccountId: "acc", + }); + return readCodexAccountRecord(id)!.generation; +} + +/** Register the account in config too, so the account-list API actually returns a row. */ +async function seedListedAccount(id: string): Promise { + const generation = await seedAccount(id); + const { loadConfig, saveConfig } = await import("../src/config"); + const config = loadConfig(); + const accounts = [...(config.codexAccounts ?? []).filter(a => a.id !== id), { id, label: id }]; + saveConfig({ ...config, codexAccounts: accounts }); + return generation; +} + +test("a refresh settles the budget even when the caller cancels first", async () => { + const { forceRefreshCodexPoolToken } = await import("../src/codex/account-store"); + const { claimQuotaRecovery, quotaRecoveryRecordForTests, settleQuotaRecovery, releaseQuotaRecovery } = + await import("../src/codex/quota-401-recovery"); + const generation = await seedAccount("cancelled-owner"); + + let released = 0; + globalThis.fetch = (async () => { + await new Promise(resolve => setTimeout(resolve, 30)); + return Response.json({ access_token: ROTATED, refresh_token: "grant2", expires_in: 3600 }); + }) as typeof fetch; + + const claim = claimQuotaRecovery("cancelled-owner", generation); + if (!claim.granted) throw new Error("expected a claim"); + const controller = new AbortController(); + let callerRejected: unknown; + const settled = new Promise(resolve => { + void forceRefreshCodexPoolToken("cancelled-owner", { + rejectedGeneration: generation, + rejectedAccessToken: REJECTED, + signal: controller.signal, + onSettled: outcome => { + if (outcome.kind === "resolved") settleQuotaRecovery("cancelled-owner", claim.claimId, outcome); + else { released += 1; releaseQuotaRecovery("cancelled-owner", claim.claimId, 60_000); } + resolve(); + }, + }).then( + () => { callerRejected = "resolved"; }, + error => { callerRejected = error; }, + ); + }); + + // Cancel while the token request is still in flight. The shared refresh keeps running and + // commits; settling from the cancelled await would report "failed", release the budget, + // and let the freshly refreshed lineage claim again moments later. + const abortReason = new Error("caller went away"); + controller.abort(abortReason); + await settled; + + // The caller really was cancelled — otherwise this test would also pass against an + // implementation that simply ignores the signal. + expect(callerRejected).toBe(abortReason); + expect(released).toBe(0); + expect(quotaRecoveryRecordForTests("cancelled-owner")).toEqual({ state: "spent", lineage: generation + 1 }); +}); + +test("exactly one of two callers on a shared flight performed the CAS", async () => { + const { forceRefreshCodexPoolToken } = await import("../src/codex/account-store"); + const generation = await seedAccount("adopting-joiner"); + + globalThis.fetch = (async () => { + await new Promise(resolve => setTimeout(resolve, 20)); + return Response.json({ access_token: ROTATED, refresh_token: "grant2", expires_in: 3600 }); + }) as typeof fetch; + + const outcomes: string[] = []; + await Promise.allSettled([ + forceRefreshCodexPoolToken("adopting-joiner", { + rejectedGeneration: generation, + rejectedAccessToken: REJECTED, + onSettled: o => { if (o.kind === "resolved") outcomes.push(o.provenance); }, + }), + forceRefreshCodexPoolToken("adopting-joiner", { + rejectedGeneration: generation, + rejectedAccessToken: REJECTED, + onSettled: o => { if (o.kind === "resolved") outcomes.push(o.provenance); }, + }), + ]); + + // Only one caller can have moved the credential. Accepting "any of the three enum + // values" would pass against the bug where a joiner copies the flight's own + // `self-refresh` and reports a CAS it never performed. + expect(outcomes).toHaveLength(2); + expect(outcomes.filter(p => p === "self-refresh")).toHaveLength(1); + // Specifically joined-lineage: an "external-replacement" joiner is also non-self, and + // would wrongly leave this lineage's budget unspent. + expect(outcomes.filter(p => p === "joined-lineage")).toHaveLength(1); +}); + +test("a revoked grant routes to terminal settlement, a slow one does not", async () => { + const { forceRefreshCodexPoolToken } = await import("../src/codex/account-store"); + const { claimQuotaRecovery, quotaRecoveryRecordForTests } = await import("../src/codex/quota-401-recovery"); + + // Drive the real primitive to a real refresh failure and route it exactly the way + // recoverPoolQuotaFrom401 does, so a wiring that never calls terminal settlement fails. + const { TokenRefreshError } = await import("../src/codex/account-store"); + const { releaseQuotaRecovery, settleQuotaRecoveryTerminal, quotaRecoveryTerminalFor } = + await import("../src/codex/quota-401-recovery"); + const route = (accountId: string, claimId: string, error: unknown) => { + if (error instanceof TokenRefreshError && /invalid_grant|revoked|expired|invalid_refresh_token/i.test(String(error.message))) { + settleQuotaRecoveryTerminal(accountId, claimId); + } else { + releaseQuotaRecovery(accountId, claimId, 60_000); + } + }; + + const revokedGeneration = await seedAccount("dead-grant"); + globalThis.fetch = (async () => new Response("{\"error\":\"invalid_grant\"}", { status: 400 })) as typeof fetch; + const revokedClaim = claimQuotaRecovery("dead-grant", revokedGeneration); + if (!revokedClaim.granted) throw new Error("expected a claim"); + await forceRefreshCodexPoolToken("dead-grant", { + rejectedGeneration: revokedGeneration, + rejectedAccessToken: REJECTED, + onSettled: o => { if (o.kind === "failed") route("dead-grant", revokedClaim.claimId, o.error); }, + }).catch(() => { /* the refresh is supposed to fail */ }); + + expect(quotaRecoveryTerminalFor("dead-grant", revokedGeneration)).toBe(true); + expect(quotaRecoveryRecordForTests("dead-grant")).toMatchObject({ state: "spent", terminal: true }); + + const slowGeneration = await seedAccount("slow-grant"); + globalThis.fetch = (async () => { throw new Error("socket hang up"); }) as typeof fetch; + const slowClaim = claimQuotaRecovery("slow-grant", slowGeneration); + if (!slowClaim.granted) throw new Error("expected a claim"); + await forceRefreshCodexPoolToken("slow-grant", { + rejectedGeneration: slowGeneration, + rejectedAccessToken: REJECTED, + onSettled: o => { if (o.kind === "failed") route("slow-grant", slowClaim.claimId, o.error); }, + }).catch(() => { /* transient by design */ }); + + // A network failure proves nothing about the grant, so it must not fence the account. + expect(quotaRecoveryTerminalFor("slow-grant", slowGeneration)).toBe(false); + expect(quotaRecoveryRecordForTests("slow-grant")).toMatchObject({ state: "backoff" }); +}); + +test("a revoked grant stays needs-reauth across polls, through the real quota path", async () => { + const { listCodexAuthAccounts } = await import("../src/codex/auth-api"); + const { loadConfig } = await import("../src/config"); + await seedListedAccount("dead-grant"); + + // Bare WHAM 401 -> token endpoint says invalid_grant -> the account list re-polls. + // Routing this through listCodexAuthAccounts is the point: a local reimplementation of + // the settlement callback stays green with the production wiring deleted. + let tokenCalls = 0; + globalThis.fetch = (async (input: string | URL | Request) => { + const url = String(input); + if (url.includes("/wham/usage")) return new Response("{}", { status: 401 }); + tokenCalls += 1; + return new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }); + }) as typeof fetch; + + const config = loadConfig(); + const first = await listCodexAuthAccounts(config, true); + expect(first.find(row => row.id === "dead-grant")?.needsReauth).toBe(true); + + // The evidence has to be DURABLE, not just present in the response that discovered it. + // The recovery record alone cannot carry it: by the time the next poll runs, the claim is + // spent and a spent budget reports transient. Assert the account-level mark directly, so + // dropping markAccountNeedsReauth fails here rather than being masked by a cached quota. + const { isAccountNeedsReauth } = await import("../src/codex/auth-api"); + expect(isAccountNeedsReauth("dead-grant")).toBe(true); + + const second = await listCodexAuthAccounts(config, true); + expect(second.find(row => row.id === "dead-grant")?.needsReauth).toBe(true); + // And the dead grant is not retried on every poll. + expect(tokenCalls).toBe(1); +}); + +test("a transient refresh failure does not quarantine the account", async () => { + const { listCodexAuthAccounts } = await import("../src/codex/auth-api"); + const { loadConfig } = await import("../src/config"); + const { quotaRecoveryRecordForTests } = await import("../src/codex/quota-401-recovery"); + await seedListedAccount("slow-grant"); + + globalThis.fetch = (async (input: string | URL | Request) => { + if (String(input).includes("/wham/usage")) return new Response("{}", { status: 401 }); + throw new Error("socket hang up"); + }) as typeof fetch; + + const rows = await listCodexAuthAccounts(loadConfig(), true); + // A network failure proves nothing about the grant. + expect(rows.find(row => row.id === "slow-grant")?.needsReauth).toBe(false); + // Nor may it leave a durable quarantine behind. + const { isAccountNeedsReauth } = await import("../src/codex/auth-api"); + expect(isAccountNeedsReauth("slow-grant")).toBe(false); + expect(quotaRecoveryRecordForTests("slow-grant")).toMatchObject({ state: "backoff" }); +}); + +test("an already-aborted caller starts no refresh at all", async () => { + const { forceRefreshCodexPoolToken } = await import("../src/codex/account-store"); + const { claimQuotaRecovery, quotaRecoveryRecordForTests, releaseQuotaRecovery, settleQuotaRecovery } = + await import("../src/codex/quota-401-recovery"); + const generation = await seedAccount("pre-aborted"); + + let fetches = 0; + globalThis.fetch = (async () => { + fetches += 1; + return Response.json({ access_token: ROTATED, refresh_token: "grant2", expires_in: 3600 }); + }) as typeof fetch; + + const claim = claimQuotaRecovery("pre-aborted", generation); + if (!claim.granted) throw new Error("expected a claim"); + const reason = new Error("caller already gone"); + const controller = new AbortController(); + controller.abort(reason); + + let failures = 0; + let rejected: unknown; + await forceRefreshCodexPoolToken("pre-aborted", { + rejectedGeneration: generation, + rejectedAccessToken: REJECTED, + signal: controller.signal, + onSettled: outcome => { + if (outcome.kind === "failed") { failures += 1; releaseQuotaRecovery("pre-aborted", claim.claimId, 60_000); } + else settleQuotaRecovery("pre-aborted", claim.claimId, outcome); + }, + }).catch(error => { rejected = error; }); + + // Settlement runs on an uncancelled completion, which bypasses resolveCodexToken's own + // pre-abort guard — so without an explicit check here a caller that is already gone + // would rotate a credential nobody is waiting for. + expect(fetches).toBe(0); + expect(rejected).toBe(reason); + expect(failures).toBe(1); + // And the claim is not left held: it was released, not abandoned to its lease. + expect(quotaRecoveryRecordForTests("pre-aborted")).toMatchObject({ state: "backoff" }); +}); + +test("structured terminal evidence on the FIRST 401 needs no refresh and is generation-scoped", async () => { + const { listCodexAuthAccounts, isAccountNeedsReauth } = await import("../src/codex/auth-api"); + const { loadConfig } = await import("../src/config"); + const generation = await seedListedAccount("structured-first"); + + let tokenCalls = 0; + globalThis.fetch = (async (input: string | URL | Request) => { + if (String(input).includes("/wham/usage")) { + return new Response(JSON.stringify({ detail: { code: "invalid_refresh_token" } }), { status: 401 }); + } + tokenCalls += 1; + return Response.json({ access_token: ROTATED, refresh_token: "grant2", expires_in: 3600 }); + }) as typeof fetch; + + const rows = await listCodexAuthAccounts(loadConfig(), true); + expect(rows.find(row => row.id === "structured-first")?.needsReauth).toBe(true); + // A body that says the grant is dead needs no refresh to prove it. + expect(tokenCalls).toBe(0); + expect(isAccountNeedsReauth("structured-first")).toBe(true); + + // The evidence is about THAT credential. A replacement must not inherit the quarantine. + const { saveCodexAccountCredential } = await import("../src/codex/account-store"); + saveCodexAccountCredential("structured-first", { + accessToken: "fresh-login", + refreshToken: "fresh-grant", + expiresAt: Date.now() + 3600_000, + chatgptAccountId: "acc", + }); + expect(isAccountNeedsReauth("structured-first")).toBe(false); + void generation; +}); + +test("structured terminal evidence on the REPLAY is scoped to the refreshed credential", async () => { + const { listCodexAuthAccounts, isAccountNeedsReauth } = await import("../src/codex/auth-api"); + const { loadConfig } = await import("../src/config"); + await seedListedAccount("structured-replay"); + + let whamCalls = 0; + globalThis.fetch = (async (input: string | URL | Request) => { + if (String(input).includes("/wham/usage")) { + whamCalls += 1; + // Bare first, structured-terminal on the replay: only the second answer proves death. + return whamCalls === 1 + ? new Response("{}", { status: 401 }) + : new Response(JSON.stringify({ detail: { code: "invalid_refresh_token" } }), { status: 401 }); + } + return Response.json({ access_token: ROTATED, refresh_token: "grant2", expires_in: 3600 }); + }) as typeof fetch; + + const rows = await listCodexAuthAccounts(loadConfig(), true); + expect(whamCalls).toBe(2); + expect(rows.find(row => row.id === "structured-replay")?.needsReauth).toBe(true); + expect(isAccountNeedsReauth("structured-replay")).toBe(true); + + const { saveCodexAccountCredential } = await import("../src/codex/account-store"); + saveCodexAccountCredential("structured-replay", { + accessToken: "fresh-login", + refreshToken: "fresh-grant", + expiresAt: Date.now() + 3600_000, + chatgptAccountId: "acc", + }); + expect(isAccountNeedsReauth("structured-replay")).toBe(false); +}); + +test("no bearer reaches the console, the debug buffer, or a serialized account row", async () => { + const { forceRefreshCodexPoolToken } = await import("../src/codex/account-store"); + const { getDebugLogEntries } = await import("../src/lib/debug-log-buffer"); + // Registered, not just credentialed: an unlisted account produces no row at all, and the + // serializer assertion below would then be checking an empty list. + const generation = await seedListedAccount("quiet-refresh"); + globalThis.fetch = (async () => + Response.json({ access_token: ROTATED, refresh_token: "grant2", expires_in: 3600 })) as typeof fetch; + + const before = getDebugLogEntries({ limit: 500 }).length; + const captured: string[] = []; + const originals = { log: console.log, warn: console.warn, error: console.error, debug: console.debug }; + const capture = (...args: unknown[]) => { captured.push(args.map(String).join(" ")); }; + console.log = capture; console.warn = capture; console.error = capture; console.debug = capture; + let result: Awaited>; + try { + result = await forceRefreshCodexPoolToken("quiet-refresh", { + rejectedGeneration: generation, + rejectedAccessToken: REJECTED, + }); + } finally { + console.log = originals.log; console.warn = originals.warn; + console.error = originals.error; console.debug = originals.debug; + } + // The refresh really happened — otherwise this asserts silence about nothing. + expect(result.accessToken).toBe(ROTATED); + expect(result.rotated).toBe(true); + + const secrets = [REJECTED, ROTATED, "grant2", "grant"]; + // privacy:scan is static and cannot see what a runtime path actually emits. + const transcript = captured.join("\n"); + for (const secret of secrets) expect(transcript).not.toContain(secret); + + // console is not the only sink: the dashboard reads this buffer over the management API. + const debugText = JSON.stringify(getDebugLogEntries({ after: before, limit: 500 })); + for (const secret of secrets) expect(debugText).not.toContain(secret); + + // And the REAL serializer, not a hand-built stand-in: a leak has to be caught in what + // the management API actually returns. + const { listCodexAuthAccounts } = await import("../src/codex/auth-api"); + const { loadConfig } = await import("../src/config"); + const rows = JSON.stringify(await listCodexAuthAccounts(loadConfig(), false)); + expect(rows).toContain("quiet-refresh"); + for (const secret of secrets) expect(rows).not.toContain(secret); +}); diff --git a/tests/quota-401-recovery.test.ts b/tests/quota-401-recovery.test.ts new file mode 100644 index 0000000000..bf49cb46b6 --- /dev/null +++ b/tests/quota-401-recovery.test.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + claimQuotaRecovery, + quotaRecoveryRecordForTests, + releaseQuotaRecovery, + resetQuotaRecoveryForTests, + settleQuotaRecovery, + sweepExpiredQuotaRecovery, +} from "../src/codex/quota-401-recovery"; +import { + CODEX_REFRESH_FLIGHT_CEILING_MS, + QUOTA_RECOVERY_LEASE_MS, + WHAM_REQUEST_TIMEOUT_MS, +} from "../src/codex/quota-recovery-timing"; + +/** + * The budget that keeps a WHAM 401 recovery from becoming a retry loop (#3019). + * + * These call the store directly. The rule they encode — one refresh per credential lineage, + * and a stale completion may never spend somebody else's claim — is not observable from the + * quota path's source text, which is where earlier attempts at this went wrong. + */ + +const ACCOUNT = "acct-1"; + +beforeEach(() => resetQuotaRecoveryForTests()); +afterEach(() => resetQuotaRecoveryForTests()); + +describe("claim identity", () => { + test("a claim is granted once per lineage and refused after it is spent", () => { + const first = claimQuotaRecovery(ACCOUNT, 7); + expect(first.granted).toBe(true); + if (!first.granted) return; + settleQuotaRecovery(ACCOUNT, first.claimId, { provenance: "self-refresh", generation: 8 }); + + // The refresh moved 7 -> 8, so 8 is what is fenced: a successful token response can + // rotate only the refresh grant, so the rejected generation is already stale. + expect(quotaRecoveryRecordForTests(ACCOUNT)).toEqual({ state: "spent", lineage: 8 }); + expect(claimQuotaRecovery(ACCOUNT, 8)).toEqual({ granted: false, reason: "spent" }); + }); + + test("a live claim blocks every other lineage, not just its own", () => { + const held = claimQuotaRecovery(ACCOUNT, 7); + expect(held.granted).toBe(true); + // The refresh this claim fences commits 7 -> 8 BEFORE it can settle. Granting an 8 + // claim in that window would leave the late settlement landing on nothing, and 8 + // unspent (#3019). + expect(claimQuotaRecovery(ACCOUNT, 8)).toEqual({ granted: false, reason: "in-flight" }); + expect(claimQuotaRecovery(ACCOUNT, 7)).toEqual({ granted: false, reason: "in-flight" }); + }); + + test("a stale settlement cannot spend a later claimant's budget", () => { + const first = claimQuotaRecovery(ACCOUNT, 7); + expect(first.granted).toBe(true); + if (!first.granted) return; + releaseQuotaRecovery(ACCOUNT, first.claimId, 1_000); + + const second = claimQuotaRecovery(ACCOUNT, 7, Date.now() + 2_000); + expect(second.granted).toBe(true); + if (!second.granted) return; + expect(second.claimId).not.toBe(first.claimId); + + // The first caller finally completes. Lineage alone could not tell it from the second + // claimant, and it would have spent a budget it no longer owns. + settleQuotaRecovery(ACCOUNT, first.claimId, { provenance: "self-refresh", generation: 8 }); + expect(quotaRecoveryRecordForTests(ACCOUNT)).toMatchObject({ state: "claimed", claimId: second.claimId }); + }); + + test("settling or releasing without a claim is a no-op", () => { + settleQuotaRecovery(ACCOUNT, "never-issued", { provenance: "self-refresh", generation: 8 }); + expect(quotaRecoveryRecordForTests(ACCOUNT)).toBeUndefined(); + releaseQuotaRecovery(ACCOUNT, "never-issued", 1_000); + expect(quotaRecoveryRecordForTests(ACCOUNT)).toBeUndefined(); + }); +}); + +describe("settlement by outcome", () => { + test("a joined lineage spends the returned generation, like a self-refresh", () => { + const claim = claimQuotaRecovery(ACCOUNT, 7); + if (!claim.granted) throw new Error("expected a claim"); + // Joining an in-flight refresh of the same grant IS this lineage's one attempt. + settleQuotaRecovery(ACCOUNT, claim.claimId, { provenance: "joined-lineage", generation: 8 }); + expect(quotaRecoveryRecordForTests(ACCOUNT)).toEqual({ state: "spent", lineage: 8 }); + }); + + test("an external replacement spends the OLD lineage and leaves the new one free", () => { + const claim = claimQuotaRecovery(ACCOUNT, 7); + if (!claim.granted) throw new Error("expected a claim"); + settleQuotaRecovery(ACCOUNT, claim.claimId, { provenance: "external-replacement", generation: 9 }); + + // 7 used its attempt. 9 is somebody else's fresh grant and has had none — fencing it + // would deny the new credential the recovery this exists to grant. + expect(quotaRecoveryRecordForTests(ACCOUNT)).toEqual({ state: "spent", lineage: 7 }); + expect(claimQuotaRecovery(ACCOUNT, 9).granted).toBe(true); + }); + + test("external replacement with an unrotated token still frees the returned lineage", () => { + const claim = claimQuotaRecovery(ACCOUNT, 7); + if (!claim.granted) throw new Error("expected a claim"); + // rotated:false means the caller does not replay; it does not change who owes what. + settleQuotaRecovery(ACCOUNT, claim.claimId, { provenance: "external-replacement", generation: 9 }); + expect(quotaRecoveryRecordForTests(ACCOUNT)).toEqual({ state: "spent", lineage: 7 }); + expect(claimQuotaRecovery(ACCOUNT, 9).granted).toBe(true); + }); +}); + +describe("failure handling", () => { + test("a transient failure backs off instead of restoring eligibility", () => { + const claim = claimQuotaRecovery(ACCOUNT, 7); + if (!claim.granted) throw new Error("expected a claim"); + const now = Date.now(); + releaseQuotaRecovery(ACCOUNT, claim.claimId, 60_000, now); + + // A failed quota request does not refresh the quota timestamp, so immediate polls would + // otherwise each issue another token refresh — the loop, from the other side. + expect(claimQuotaRecovery(ACCOUNT, 7, now + 1)).toEqual({ granted: false, reason: "backoff" }); + expect(claimQuotaRecovery(ACCOUNT, 7, now + 60_001).granted).toBe(true); + }); + + test("a new lineage is not held by the old lineage's backoff", () => { + const claim = claimQuotaRecovery(ACCOUNT, 7); + if (!claim.granted) throw new Error("expected a claim"); + const now = Date.now(); + releaseQuotaRecovery(ACCOUNT, claim.claimId, 60_000, now); + // A replacement credential has had no attempt and no failure of its own. + expect(claimQuotaRecovery(ACCOUNT, 8, now + 1).granted).toBe(true); + }); +}); + +describe("lease and sweep", () => { + test("an abandoned lease is reclaimable but never becomes spent", () => { + const claim = claimQuotaRecovery(ACCOUNT, 7); + if (!claim.granted) throw new Error("expected a claim"); + const expired = Date.now() + QUOTA_RECOVERY_LEASE_MS + 1; + + // The caller died between claim and settle. The refresh may never have happened, so + // promoting this to spent would deny a real attempt. + expect(claimQuotaRecovery(ACCOUNT, 7, expired).granted).toBe(true); + expect(quotaRecoveryRecordForTests(ACCOUNT)?.state).toBe("claimed"); + }); + + test("the sweep drops leases and backoff but never a spent fence", () => { + const claim = claimQuotaRecovery(ACCOUNT, 7); + if (!claim.granted) throw new Error("expected a claim"); + settleQuotaRecovery(ACCOUNT, claim.claimId, { provenance: "self-refresh", generation: 8 }); + // Expiring a spent record would hand the same lineage another refresh. + expect(sweepExpiredQuotaRecovery(Date.now() + 10 * QUOTA_RECOVERY_LEASE_MS)).toBe(0); + expect(quotaRecoveryRecordForTests(ACCOUNT)).toEqual({ state: "spent", lineage: 8 }); + + resetQuotaRecoveryForTests(); + const stale = claimQuotaRecovery("acct-2", 3); + if (!stale.granted) throw new Error("expected a claim"); + expect(sweepExpiredQuotaRecovery(Date.now() + QUOTA_RECOVERY_LEASE_MS + 1)).toBe(1); + expect(quotaRecoveryRecordForTests("acct-2")).toBeUndefined(); + }); + + test("one record per account, so lineage churn cannot accumulate", () => { + // Each cycle: a lineage takes a 401, refreshes once, and something external then moves + // the credential on — a login, another lane's refresh. The record is REPLACED, not + // appended, so 25 rounds leave exactly one row. + let lineage = 1; + for (let round = 0; round < 25; round += 1) { + const claim = claimQuotaRecovery(ACCOUNT, lineage); + if (!claim.granted) throw new Error(`expected a claim for lineage ${lineage}`); + const refreshedTo = lineage + 1; + settleQuotaRecovery(ACCOUNT, claim.claimId, { provenance: "self-refresh", generation: refreshedTo }); + expect(quotaRecoveryRecordForTests(ACCOUNT)).toEqual({ state: "spent", lineage: refreshedTo }); + // That lineage has spent its one attempt, and stays refused however long this runs. + expect(claimQuotaRecovery(ACCOUNT, refreshedTo)).toEqual({ granted: false, reason: "spent" }); + lineage = refreshedTo + 1; + } + }); +}); + +describe("the lease is derived from what it covers", () => { + test("it outlasts the longest admitted flight plus both quota legs", () => { + // Derived from the same constants the refresh and the WHAM request use, not restated: + // a restated number is how a lease drifts shorter than the flight it fences, and a + // lease that expires mid-refresh admits a second claim for a lineage already refreshing. + expect(QUOTA_RECOVERY_LEASE_MS).toBeGreaterThan(CODEX_REFRESH_FLIGHT_CEILING_MS + WHAM_REQUEST_TIMEOUT_MS); + expect(QUOTA_RECOVERY_LEASE_MS).toBe(CODEX_REFRESH_FLIGHT_CEILING_MS + WHAM_REQUEST_TIMEOUT_MS * 2); + }); +}); diff --git a/tests/responses-state.test.ts b/tests/responses-state.test.ts index 3422d8447b..8f22fd444d 100644 --- a/tests/responses-state.test.ts +++ b/tests/responses-state.test.ts @@ -38,6 +38,7 @@ import { recoverStaleResponseStateTemps, rememberResponseState, sweepAbandonedResponseStateTemps, + sweepExpiredResponseStates, responseAdmissionCountersForTests, responseStateMetrics, responseStatePersistPendingForTests, @@ -45,6 +46,9 @@ import { runPendingResponseStatePersistForTests, setResponseSpillAsyncAclAttemptBudgetForTests, setResponseStateByteCapForTests, + setSpilledResponseByteCapForTests, + getSpilledResponseBytesForTests, + getAccountedResponseSpillBytesForTests, setResponseStatePersistAttemptHookForTests, setResponseSpillShutdownBudgetForTests, getStoredResponseBytesForTests, @@ -131,6 +135,17 @@ function spillTempNames(home: string): string[] { return existsSync(dir) ? readdirSync(dir).filter(name => name.endsWith(".tmp")) : []; } +/** Real bytes the spill directory occupies, temps included. */ +function bytesOnDisk(home: string): number { + const dir = responseSpillDirectory(home); + if (!existsSync(dir)) return 0; + let total = 0; + for (const name of readdirSync(dir)) { + try { total += statSync(join(dir, name)).size; } catch { /* raced with an unlink */ } + } + return total; +} + interface ShutdownBudgetChildResult { settled: boolean; reported: boolean; @@ -282,6 +297,7 @@ describe("Responses previous_response_id state", () => { setResponseSpillShutdownBudgetForTests(null); setResponseSpillAsyncAclAttemptBudgetForTests(null); setResponseStateByteCapForTests(null); + setSpilledResponseByteCapForTests(null); clearResponseStateForTests(); rmSync(home, { recursive: true, force: true }); if (priorHome === undefined) delete process.env["OPENCODEX_HOME"]; @@ -812,6 +828,23 @@ describe("Responses previous_response_id state", () => { }) as { input: unknown[] }).input).toHaveLength(3); }); + test("evicts oldest spills once the durable set exceeds the disk cap", () => { + setResponseStateByteCapForTests(1_024); + setSpilledResponseByteCapForTests(20_000); + for (let i = 0; i < 6; i += 1) rememberLarge(`resp_spill_budget_${i}`, "x".repeat(8_000)); + // Without a disk cap all six stay on disk: the RAM cap only moves bytes out of + // memory, it never bounds where they land. + expect(getSpilledResponseBytesForTests()).toBeLessThanOrEqual(20_000); + expect(spillFileNames(home).length).toBeLessThan(6); + // The oldest entry is the one that must be gone, not merely "some" entry. + expect(spillFileNames(home).some(name => name.startsWith("resp_spill_budget_0."))).toBe(false); + // Eviction is oldest-first and must not clear the set it was asked to bound. + expect(spillFileNames(home).length).toBeGreaterThanOrEqual(1); + expect((expandPreviousResponseInput({ + previous_response_id: "resp_spill_budget_5", input: "next", + }) as { input: unknown[] }).input).toHaveLength(3); + }); + test("does not swap a resident row to a stub before fsync and no-replace publication succeed", () => { const events: string[] = []; setSpillIoForTest({ record: event => events.push(event) }); @@ -857,6 +890,126 @@ describe("Responses previous_response_id state", () => { expect(responseStateMetrics()).toMatchObject({ residentCount: 0, spillStubCount: 1, spillWrites: 1, spillWriteFailures: 0 }); }); + test("holds the disk cap while a copy-fallback publication has temp and destination on disk", async () => { + // The cap is a promise about the volume, and a file being created by + // writeResponseSpillDurablyAsync is on the volume. Accounting that walks only + // installed spills reports a satisfied budget while the directory grows — the + // incident behind this cap put 6.8 GiB on disk in 44 minutes. + // + // The peak is TWO envelopes, not one: when hard-linking fails, publication copies + // with COPYFILE_EXCL and then hardens the destination, so the temp and the copy exist + // together. This drives that exact path and measures real bytes on disk. + setPlatformForTests("win32"); + setResponseStateByteCapForTests(1_024); + + let gateDestinationHarden = false; + let release!: () => void; + let entered!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { entered = resolve; }); + let announced = false; + setAsyncIcaclsRunnerForTests(async args => { + if (gateDestinationHarden && args.some(arg => arg.endsWith(".spill.json"))) { + if (!announced) { + announced = true; + entered(); + } + await gate; + } + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + + // One resident spill already on disk, so the cap has real prior occupancy. + rememberLarge("resp_cap_existing", "e".repeat(8_000)); + await flushPendingResponseSpillsForTests(); + const existingBytes = getSpilledResponseBytesForTests(); + expect(existingBytes).toBeGreaterThan(0); + expect(spillFileNames(home)).toHaveLength(1); + + // Room for the two-envelope publication and nothing more. The seeded spill does not + // fit alongside it, so correct admission must reclaim it before publishing; without + // the check, seeded + temp + destination sit on disk together and blow the cap. + // Generous enough that this publication is admitted: the point of THIS test is that + // the accounting sees the in-flight bytes. The cap-refusal behaviour is proven + // separately below, where admission is the only thing standing between the request + // and an over-budget directory. + const spillCap = existingBytes * 4; + setSpilledResponseByteCapForTests(spillCap); + + // Force the exclusive-copy fallback, then gate the destination hardening that follows + // it, so the observation below happens with BOTH files present. + setSpillIoForTest({ + link: () => { throw Object.assign(new Error("EXDEV"), { code: "EXDEV" }); }, + }); + gateDestinationHarden = true; + + rememberLarge("resp_cap_inflight", "x".repeat(8_000)); + await started; + try { + // Real disk: the temp and the copied destination coexist during hardening. + const onDisk = spillFileNames(home).length + spillTempNames(home).length; + expect(onDisk).toBeGreaterThanOrEqual(3); + // The walk over installed spills still reports only the settled file, so accounting + // built on it alone would price a three-envelope directory as one. + expect(getSpilledResponseBytesForTests()).toBe(existingBytes); + // Reservation prices the in-flight publication at its peak, so the accounted total + // covers what is actually on the volume. + expect(getAccountedResponseSpillBytesForTests()) + .toBeGreaterThanOrEqual(existingBytes * 3); + // And the bytes ACTUALLY on disk stay inside the configured cap. This is the + // assertion the admission check has to earn: without it, the seeded spill plus the + // temp plus the destination copy exceed a cap sized for two envelopes. + expect(bytesOnDisk(home)).toBeLessThanOrEqual(spillCap); + } finally { + release(); + setSpillIoForTest(null); + } + await flushPendingResponseSpillsForTests(); + // Settled: the reservation is released exactly once and accounting collapses to the + // real files. A leaked reservation would be monotonic — it would ratchet the usable + // cap toward zero until nothing could spill at all. + expect(getAccountedResponseSpillBytesForTests()).toBe(getSpilledResponseBytesForTests()); + expect(spillTempNames(home)).toHaveLength(0); + // And the newest continuation is still replayable: the cap must not have turned the + // fail-closed path into the ordinary one. + expect(JSON.stringify(expandPreviousResponseInput({ previous_response_id: "resp_cap_inflight", input: "next" }))) + .toContain("xxxxxxxx"); + }); + + test("refuses a publication whose peak footprint does not fit the disk cap", async () => { + // Enforcement, not accounting: a publication that cannot fit must be refused BEFORE + // any file is created. Deleting the overflow afterwards is not equivalent — on + // Windows the file outlives the decision by however long ACL hardening takes, which + // is the window the measured 6.8 GiB accumulated in. + setPlatformForTests("win32"); + setResponseStateByteCapForTests(1_024); + setAsyncIcaclsRunnerForTests(async () => ({ success: true, exitCode: 0, timedOut: false, stdout: "" })); + + rememberLarge("resp_cap_seed", "s".repeat(8_000)); + await flushPendingResponseSpillsForTests(); + const seededBytes = getSpilledResponseBytesForTests(); + expect(spillFileNames(home)).toHaveLength(1); + + // Room for the seeded spill and nothing else. The next publication needs two + // envelopes at peak, and the seeded file is live rather than reclaimable-on-sight, + // so admission has to refuse. + setSpilledResponseByteCapForTests(Math.floor(seededBytes * 1.5)); + + rememberLarge("resp_cap_refused", "r".repeat(8_000)); + await flushPendingResponseSpillsForTests(); + + // No second file, and no temp left behind: the refusal happened before publication. + expect(spillFileNames(home)).toHaveLength(1); + expect(spillTempNames(home)).toHaveLength(0); + expect(bytesOnDisk(home)).toBeLessThanOrEqual(Math.floor(seededBytes * 1.5)); + // Fail-closed, and it says so: the refused continuation is a spill failure, not a + // silent drop, so replay reports it and the client resends. + expect(responseStateMetrics()).toMatchObject({ spillWriteFailures: 1 }); + // The seeded continuation is untouched — a refusal must not cost an unrelated replay. + expect(JSON.stringify(expandPreviousResponseInput({ previous_response_id: "resp_cap_seed", input: "next" }))) + .toContain("ssssssss"); + }); + test("Windows spill retries one transient ACL timeout without installing a tombstone", async () => { setPlatformForTests("win32"); process.env.OPENCODEX_ACL_TIMEOUT_MS = "1000"; @@ -1120,6 +1273,57 @@ describe("Responses previous_response_id state", () => { } }); + test("shutdown fallback prices the job-owned superseded generation before publishing", async () => { + // Supersession releases the job, so its superseded generation leaves the accounting + // walk while the FILE stays on the volume. Without pricing it, the fallback decides + // against a total short by a whole envelope, and a cap sitting between + // (debt + footprint) and (old + debt + footprint) admits a publication that puts the + // directory over budget. + setPlatformForTests("win32"); + setResponseSpillShutdownBudgetForTests({ totalMs: 120, fallbackReserveMs: 80 }); + let release!: () => void; + let entered!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { entered = resolve; }); + let announced = false; + setAsyncIcaclsRunnerForTests(async () => { + if (!announced) { + announced = true; + entered(); + } + await gate; + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + setIcaclsRunnerForTests(() => ({ success: true, exitCode: 0, timedOut: false, stdout: "" })); + setResponseStateByteCapForTests(1_024); + + // First generation settles to a real file, then a same-id replacement makes the job + // the owner of that superseded generation. + rememberLarge("resp_shutdown_superseded", "o".repeat(8_000)); + await flushPendingResponseSpillsForTests(); + const oldBytes = getSpilledResponseBytesForTests(); + expect(oldBytes).toBeGreaterThan(0); + + rememberLarge("resp_shutdown_superseded", "n".repeat(8_000)); + await started; + + try { + // Cap allows the new publication on its own, but not alongside the superseded + // generation the job still owns. + setSpilledResponseByteCapForTests(Math.floor(oldBytes * 2.4)); + // The refusal surfaces as a shutdown failure, which is the honest signal: the + // operator learns a continuation was dropped rather than the volume being + // silently overfilled. + await expect(flushResponseState()).rejects.toThrow(/shutdown fallback incomplete/); + // Fail-closed rather than over-budget: the drain refused to add a third envelope. + expect(bytesOnDisk(home)).toBeLessThanOrEqual(Math.floor(oldBytes * 2.4)); + expect(spillTempNames(home)).toHaveLength(0); + expect(pendingResponseSpillMetricsForTests()).toEqual({ count: 0, bytes: 0 }); + } finally { + release(); + } + }); + test("shutdown fallback spends only its reserved ACL budget", async () => { setPlatformForTests("win32"); const totalMs = 500; @@ -1535,6 +1739,147 @@ describe("Responses previous_response_id state", () => { expect(spillFileNames(home)).toHaveLength(1); }); + test("evicts by createdAt, not by insertion order", () => { + const realNow = Date.now; + try { + setResponseStateByteCapForTests(1_024); + // Insert the NEWER entry first so insertion order and createdAt order + // disagree. Map order alone would evict the newer one; `states` is not a + // reliable age index — writeBoundedSnapshot even serializes it reversed. + const base = realNow(); + Date.now = () => base; + const beforeNewer = new Set(spillFileNames(home)); + rememberLarge("resp_order_newer", "x".repeat(8_000)); + const newerFile = spillFileNames(home).find(name => !beforeNewer.has(name))!; + + Date.now = () => base - 5 * 60_000; + const beforeOlder = new Set(spillFileNames(home)); + rememberLarge("resp_order_older", "x".repeat(8_000)); + const olderFile = spillFileNames(home).find(name => !beforeOlder.has(name))!; + Date.now = () => base; + + // Each spill payload is ~16.2 KB, so 20_000 leaves room for exactly one. + setSpilledResponseByteCapForTests(20_000); + rememberLarge("resp_order_trigger", "y"); + + const dir = responseSpillDirectory(home); + expect(existsSync(join(dir, olderFile))).toBe(false); + expect(existsSync(join(dir, newerFile))).toBe(true); + } finally { + Date.now = realNow; + } + }); + + test("breaks createdAt ties on the response id, not on insertion order", () => { + const realNow = Date.now; + try { + setResponseStateByteCapForTests(1_024); + const base = realNow(); + Date.now = () => base; + // Same createdAt, inserted in reverse id order: a stable sort alone would + // keep insertion order and evict "_b" first. + const before = new Set(spillFileNames(home)); + rememberLarge("resp_tie_b", "x".repeat(8_000)); + const bFile = spillFileNames(home).find(name => !before.has(name))!; + const beforeA = new Set(spillFileNames(home)); + rememberLarge("resp_tie_a", "x".repeat(8_000)); + const aFile = spillFileNames(home).find(name => !beforeA.has(name))!; + + setSpilledResponseByteCapForTests(20_000); + rememberLarge("resp_tie_trigger", "y"); + + const dir = responseSpillDirectory(home); + expect(existsSync(join(dir, aFile))).toBe(false); + expect(existsSync(join(dir, bFile))).toBe(true); + } finally { + Date.now = realNow; + } + }); + + test("survives a single spill payload larger than the whole disk budget", () => { + setResponseStateByteCapForTests(1_024); + // Below one payload (~16.2 KB), so every spill is written and then evicted on + // the same tick and the running total goes negative. The loop must still + // terminate and leave the store usable. + setSpilledResponseByteCapForTests(1_000); + rememberLarge("resp_over_budget_a", "x".repeat(8_000)); + rememberLarge("resp_over_budget_b", "x".repeat(8_000)); + expect(spillFileNames(home)).toHaveLength(0); + expect(getSpilledResponseBytesForTests()).toBe(0); + // Raising the cap restores ordinary retention: the store is not wedged. + setSpilledResponseByteCapForTests(1_000_000); + rememberLarge("resp_over_budget_c", "x".repeat(8_000)); + expect(spillFileNames(home)).toHaveLength(1); + }); + + test("counts deferred spill generations against the disk cap and drains them first", () => { + setResponseStateByteCapForTests(1_024); + // Replacing a spilled id queues the superseded file in pendingSpillUnlinks + // instead of unlinking it, so the directory holds generations that `states` + // alone cannot see. Payloads are ~16.2 KB, so 60_000 fits about three. + setSpilledResponseByteCapForTests(60_000); + for (let i = 0; i < 8; i += 1) rememberLarge("resp_deferred_gen", "x".repeat(8_000)); + // Counting only the live entry would report ~16 KB here and evict nothing while + // eight files sat on disk. + expect(getSpilledResponseBytesForTests()).toBeLessThanOrEqual(60_000); + const onDisk = spillFileNames(home); + expect(onDisk.length).toBeLessThanOrEqual(4); + // The live continuation survives: deferred generations are released first. + expect((expandPreviousResponseInput({ + previous_response_id: "resp_deferred_gen", input: "next", + }) as { input: unknown[] }).input).toHaveLength(3); + }); + + test("reclaims an over-budget snapshot without a new continuation mutation", async () => { + const realNow = Date.now; + try { + setResponseStateByteCapForTests(4_000); + const base = realNow(); + const files: string[] = []; + for (let i = 0; i < 4; i += 1) { + Date.now = () => base - (10 - i) * 60_000; + const before = new Set(spillFileNames(home)); + rememberResponseState( + { model: "test/model", input: "z".repeat(8_000), store: false }, + fixedResponse(`resp_budget_restart_${i}`, [{ type: "message", role: "assistant", content: "stored" }]), + undefined, + { force: true, clientThreadId: "task-budget" }, + ); + files.push(spillFileNames(home).find(name => !before.has(name))!); + } + Date.now = () => base; + await flushResponseState(); + clearResponseStateMemoryForTests(); + + // Come back up under a ceiling the snapshot was not written for. Nothing has + // mutated the store yet, which is the case the mutation-path prune misses. + setResponseStateByteCapForTests(4_000); + // Four spills total ~32.9 KB, so this ceiling keeps the two newest. + setSpilledResponseByteCapForTests(20_000); + expect(spillFileNames(home).length).toBe(4); + + // A read, not a write: this drives the lazy load and its prune. + const expanded = expandPreviousResponseInput( + { previous_response_id: "resp_budget_restart_3", input: "next" }, + "task-budget", + ); + + expect(getSpilledResponseBytesForTests()).toBeLessThanOrEqual(20_000); + expect(existsSync(join(responseSpillDirectory(home), files[0]!))).toBe(false); + // The newest entry survives the reclaim and still replays. + expect(existsSync(join(responseSpillDirectory(home), files[3]!))).toBe(true); + expect((expanded as { input: unknown[] }).input).toHaveLength(3); + + // The periodic sweep owns the same ceiling: lower it again and let the tick, + // not a request, do the work. + setSpilledResponseByteCapForTests(10_000); + sweepExpiredResponseStates(); + expect(getSpilledResponseBytesForTests()).toBeLessThanOrEqual(10_000); + } finally { + Date.now = realNow; + } + }); + test("TTL and count eviction delete dedicated spill files and release stub bytes", () => { const realNow = Date.now; setResponseStateByteCapForTests(1_024); diff --git a/tests/restore-completes-shared-teardown.test.ts b/tests/restore-completes-shared-teardown.test.ts new file mode 100644 index 0000000000..4efd282e28 --- /dev/null +++ b/tests/restore-completes-shared-teardown.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { dispatchCommand, type CliDispatchDeps } from "../src/cli/dispatch"; + +/** + * `ocx restore` must finish the WHOLE shared teardown, including when Codex is already + * off (#3008). + * + * The deferred-teardown recovery path prints "run 'ocx restore', then delete the receipt". + * If restore returns success on the Codex no-op path before touching the Grok fence, an + * operator following those instructions signs off an incomplete teardown and deletes the + * obligation that would have caught it — leaving Grok pointed at a proxy that is gone. + */ + +const BEGIN = "# >>> opencodex managed block — do not edit (removed by `ocx stop`) >>>"; +const END = "# <<< opencodex managed block <<<"; +const depsFor = (args: string[]) => ({ args } as unknown as CliDispatchDeps); + +let grokHome: string; +let opencodexHome: string; +let codexHome: string; +let previous: Record = {}; + +beforeEach(() => { + previous = { + GROK_HOME: process.env.GROK_HOME, + OPENCODEX_HOME: process.env.OPENCODEX_HOME, + CODEX_HOME: process.env.CODEX_HOME, + }; + grokHome = mkdtempSync(join(tmpdir(), "ocx-restore-grok-")); + opencodexHome = mkdtempSync(join(tmpdir(), "ocx-restore-home-")); + codexHome = mkdtempSync(join(tmpdir(), "ocx-restore-codex-")); + process.env.GROK_HOME = grokHome; + process.env.OPENCODEX_HOME = opencodexHome; + process.env.CODEX_HOME = codexHome; +}); + +afterEach(() => { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + for (const dir of [grokHome, opencodexHome, codexHome]) rmSync(dir, { recursive: true, force: true }); +}); + +async function seedOffConfig(): Promise { + // Codex already OFF in this home, so the desired-state write reports "unchanged" and the + // residue classifier reports clean — the no-op path under test. Written through the real + // saver so the file satisfies the same schema the CLI validates. + const { loadConfig, saveConfig } = await import("../src/config"); + const config = loadConfig(); + saveConfig({ ...config, clientIntegrations: { ...(config.clientIntegrations ?? {}), codex: false } }); +} + +async function seedOnConfig(): Promise { + // Codex ON, so the desired-state write is a real change and restore takes its ordinary + // forward path rather than the already-clean branch. + const { loadConfig, saveConfig } = await import("../src/config"); + const config = loadConfig(); + const integrations = { ...(config.clientIntegrations ?? {}) }; + delete integrations.codex; + saveConfig({ ...config, clientIntegrations: integrations }); +} + +function writeManagedGrokFence(): string { + mkdirSync(grokHome, { recursive: true }); + const configPath = join(grokHome, "config.toml"); + writeFileSync(configPath, [ + "# user content above", + BEGIN, + 'base_url = "http://127.0.0.1:10100/v1"', + END, + "", + ].join("\n")); + return configPath; +} + +test("restore strips the Grok fence even when Codex is already off and native", async () => { + await seedOffConfig(); + const configPath = writeManagedGrokFence(); + expect(readFileSync(configPath, "utf8")).toContain(BEGIN); + + // Codex is untouched in this home, so the desired-state write is "unchanged" and the + // residue classifier reports clean — the exact no-op path that used to return 0 before + // stripGrokConfig() ever ran. + const code = await dispatchCommand({ kind: "run", command: "restore", args: ["restore"] }, depsFor(["restore"])); + + const after = readFileSync(configPath, "utf8"); + expect(after).not.toContain(BEGIN); + expect(after).not.toContain(END); + expect(after).toContain("# user content above"); + expect(code).toBe(0); +}); + +test("the JSON envelope on that path reports the Grok cleanup too", async () => { + await seedOffConfig(); + writeManagedGrokFence(); + const lines: string[] = []; + const originalLog = console.log; + console.log = (...args: unknown[]) => { lines.push(args.map(String).join(" ")); }; + try { + await dispatchCommand({ kind: "run", command: "restore", args: ["restore", "--json"] }, depsFor(["restore", "--json"])); + } finally { + console.log = originalLog; + } + const envelope = JSON.parse(lines.at(-1)!); + // A machine caller must not read "already OFF and native" as "nothing was left to do". + expect(envelope.success).toBe(true); + expect(String(envelope.message)).toContain("already OFF and native"); + expect(String(envelope.message)).toMatch(/Grok|managed block/i); +}); + +test("the ordinary forward-restore path strips the fence before emitting JSON", async () => { + await seedOnConfig(); + // NOT the already-clean branch: Codex is ON here, so restore runs its real machinery + // and used to return the JSON envelope before stripGrokConfig() was ever called. + const configPath = writeManagedGrokFence(); + const lines: string[] = []; + const originalLog = console.log; + console.log = (...args: unknown[]) => { lines.push(args.map(String).join(" ")); }; + try { + await dispatchCommand({ kind: "run", command: "restore", args: ["restore", "--json"] }, depsFor(["restore", "--json"])); + } finally { + console.log = originalLog; + } + // The fence itself, not the wording: a message can claim a cleanup that never happened. + const after = readFileSync(configPath, "utf8"); + expect(after).not.toContain(BEGIN); + expect(after).toContain("# user content above"); + const envelope = JSON.parse(lines.at(-1)!); + expect(envelope).toHaveProperty("artifacts"); + expect(String(envelope.message)).toMatch(/Grok|managed block/i); +}); + +test("eject --json is the same runner and gets the same teardown", async () => { + await seedOnConfig(); + const configPath = writeManagedGrokFence(); + const lines: string[] = []; + const originalLog = console.log; + console.log = (...args: unknown[]) => { lines.push(args.map(String).join(" ")); }; + try { + await dispatchCommand({ kind: "run", command: "eject", args: ["eject", "--json"] }, depsFor(["eject", "--json"])); + } finally { + console.log = originalLog; + } + expect(readFileSync(configPath, "utf8")).not.toContain(BEGIN); +}); + +test("a Grok cleanup failure is not reported as a successful restore", async () => { + await seedOffConfig(); + // A directory where config.toml belongs: the strip cannot succeed, and the envelope + // must not say the teardown is done. + mkdirSync(join(grokHome, "config.toml"), { recursive: true }); + const lines: string[] = []; + const originalLog = console.log; + const originalError = console.error; + console.log = (...args: unknown[]) => { lines.push(args.map(String).join(" ")); }; + console.error = (...args: unknown[]) => { lines.push(args.map(String).join(" ")); }; + let code: number; + try { + code = await dispatchCommand({ kind: "run", command: "restore", args: ["restore", "--json"] }, depsFor(["restore", "--json"])); + } finally { + console.log = originalLog; + console.error = originalError; + } + expect(code).toBe(1); +}); + +test("with no Grok home at all the no-op path still succeeds quietly", async () => { + await seedOffConfig(); + rmSync(grokHome, { recursive: true, force: true }); + const code = await dispatchCommand({ kind: "run", command: "restore", args: ["restore"] }, depsFor(["restore"])); + expect(code).toBe(0); + expect(existsSync(grokHome)).toBe(false); +}); diff --git a/tests/state-store-sweeper.test.ts b/tests/state-store-sweeper.test.ts index ea2d473b27..3bcfb882b7 100644 --- a/tests/state-store-sweeper.test.ts +++ b/tests/state-store-sweeper.test.ts @@ -101,6 +101,10 @@ describe("state-store sweeper", () => { "combo-target-cooldowns", "anthropic-routing-health", "xai-refresh-verdicts", + // #3019: the WHAM 401 recovery budget. Registered here deliberately — the inventory + // is hand-maintained so a new store cannot be added without someone deciding it has + // an owner and a sweep policy. + "codex-quota-401-recovery", "responses-continuation", "antigravity-replay", "config-warning-memos", diff --git a/tests/stop-deferred-teardown.test.ts b/tests/stop-deferred-teardown.test.ts new file mode 100644 index 0000000000..69f978c83d --- /dev/null +++ b/tests/stop-deferred-teardown.test.ts @@ -0,0 +1,457 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { stopProxyGracefully } from "../src/lib/process-control"; +import { performStopTeardown } from "../src/server/stop-teardown"; +import type { CodexNativeRestoreResult } from "../src/codex/inject"; + +/** + * Behavioural cover for the deferred shared teardown (#3008). + * + * The wiring assertions in tests/grok-lifecycle.test.ts read source text, which cannot + * tell a working deferral from a plausible-looking one. These tests call the real + * functions: the graceful-stop client that builds the URL, the teardown decision the + * route delegates to, and the on-disk receipts that decide whether a deferral is an owned + * obligation or an unbacked request. + */ + +const ENDPOINT = { hostname: "127.0.0.1", port: 10100 }; +const FOREIGN_NONCE = "ffffffffffffffffffffffffffffffff"; +let home: string; +let previousHome: string | undefined; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-deferred-teardown-")); + process.env.OPENCODEX_HOME = home; +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + rmSync(home, { recursive: true, force: true }); +}); + +function restoreResult(success: boolean): CodexNativeRestoreResult { + return { + success, + message: success ? "native Codex restored" : "config restore failed", + artifacts: { + config: { state: success ? "restored" : "failed" }, + catalog: { state: "restored" }, + history: { state: "restored" }, + }, + } as unknown as CodexNativeRestoreResult; +} + +describe("stopProxyGracefully deferral flag", () => { + test("the default stop asks for no deferral", async () => { + const urls: string[] = []; + await stopProxyGracefully(11, { + readRuntime: () => ({ port: 10100 }), + fetchFn: (async (url: string | URL | Request) => { + urls.push(String(url)); + return new Response(JSON.stringify({ success: true }), { status: 200 }); + }) as typeof fetch, + waitExit: () => true, + env: {}, + }); + expect(urls).toEqual(["http://127.0.0.1:10100/api/stop"]); + }); + + test("a claimed nonce is carried in the query the route reads", async () => { + const urls: string[] = []; + await stopProxyGracefully(11, { + readRuntime: () => ({ port: 10100 }), + fetchFn: (async (url: string | URL | Request) => { + urls.push(String(url)); + return new Response(JSON.stringify({ success: true }), { status: 200 }); + }) as typeof fetch, + waitExit: () => true, + env: {}, + deferSharedTeardownNonce: FOREIGN_NONCE, + }); + expect(urls).toEqual([`http://127.0.0.1:10100/api/stop?deferSharedTeardown=1&teardownNonce=${FOREIGN_NONCE}`]); + }); + + test("the caller's endpoint snapshot is used instead of re-reading the runtime file", async () => { + const urls: string[] = []; + // The receipt records the endpoint the stop contacted. If this call re-read the + // runtime record it could contact a different one, and recovery would then probe an + // endpoint that was never stopped. + await stopProxyGracefully(11, { + readRuntime: () => ({ port: 19999, hostname: "127.0.0.1" }), + runtimeEndpoint: { hostname: "127.0.0.1", port: 10100 }, + fetchFn: (async (url: string | URL | Request) => { + urls.push(String(url)); + return new Response(JSON.stringify({ success: true }), { status: 200 }); + }) as typeof fetch, + waitExit: () => true, + env: {}, + }); + expect(urls).toEqual(["http://127.0.0.1:10100/api/stop"]); + }); +}); + +describe("performStopTeardown", () => { + test("an ordinary stop restores native Codex and strips the Grok fence", async () => { + let restored = 0; + let stripped = 0; + const body = await performStopTeardown(new URL("http://127.0.0.1:10100/api/stop"), { + ownsReceipt: () => false, + restoreNativeCodex: async () => { restored += 1; return restoreResult(true); }, + stripGrok: () => { stripped += 1; return { ok: true, changed: true, message: "Grok config restored" }; }, + }); + expect(restored).toBe(1); + expect(stripped).toBe(1); + expect(body.sharedTeardown).toBe("performed"); + expect(body.message).toContain("native Codex restored"); + }); + + test("a receipt-backed deferral touches neither config and says so", async () => { + let restored = 0; + let stripped = 0; + const body = await performStopTeardown(new URL(`http://127.0.0.1:10100/api/stop?deferSharedTeardown=1&teardownNonce=${FOREIGN_NONCE}`), { + ownsReceipt: nonce => nonce === FOREIGN_NONCE, + restoreNativeCodex: async () => { restored += 1; return restoreResult(true); }, + stripGrok: () => { stripped += 1; return { ok: true, changed: true, message: "Grok config restored" }; }, + }); + expect(restored).toBe(0); + expect(stripped).toBe(0); + expect(body.sharedTeardown).toBe("deferred"); + expect(body.message).toContain("deferred to the stopping client"); + // The old response claimed a restore that never happened; an operator reading it + // would believe native Codex was back while the deferral was still outstanding. + expect(body.message).not.toContain("native Codex restored"); + }); + + test("the real ownership check accepts only a nonce with a readable receipt on disk", async () => { + const mod = await import("../src/config/pending-teardown"); + const claimed = mod.claimPendingTeardown(ENDPOINT, "exact", 1234); + let restored = 0; + const deferred = await performStopTeardown(new URL(`http://127.0.0.1:10100/api/stop?deferSharedTeardown=1&teardownNonce=${claimed.nonce}`), { + restoreNativeCodex: async () => { restored += 1; return restoreResult(true); }, + stripGrok: () => ({ ok: true, changed: true, message: "Grok config restored" }), + }); + expect(deferred.sharedTeardown).toBe("deferred"); + expect(restored).toBe(0); + + // Another caller riding on the existence of that obligation gets nothing: it does not + // own the nonce, so it cannot hand its teardown to anyone. + const ridden = await performStopTeardown(new URL(`http://127.0.0.1:10100/api/stop?deferSharedTeardown=1&teardownNonce=${FOREIGN_NONCE}`), { + restoreNativeCodex: async () => { restored += 1; return restoreResult(true); }, + stripGrok: () => ({ ok: true, changed: true, message: "Grok config restored" }), + }); + expect(ridden.sharedTeardown).toBe("performed"); + expect(restored).toBe(1); + }); + + test("the query alone does not buy a deferral without a receipt", async () => { + let restored = 0; + const body = await performStopTeardown(new URL("http://127.0.0.1:10100/api/stop?deferSharedTeardown=1"), { + ownsReceipt: () => false, + restoreNativeCodex: async () => { restored += 1; return restoreResult(true); }, + stripGrok: () => ({ ok: true, changed: true, message: "Grok config restored" }), + }); + // An authenticated caller that sets the flag and exits must not be able to leave + // client config pointed at a proxy that is going away. + expect(restored).toBe(1); + expect(body.sharedTeardown).toBe("performed"); + }); + + test("an unreadable receipt does not authorize a deferral", async () => { + const mod = await import("../src/config/pending-teardown"); + const claimed = mod.claimPendingTeardown(ENDPOINT, "exact", 1234); + writeFileSync(mod.pendingTeardownPathFor(claimed.nonce), "{not json"); + let restored = 0; + const body = await performStopTeardown(new URL(`http://127.0.0.1:10100/api/stop?deferSharedTeardown=1&teardownNonce=${claimed.nonce}`), { + restoreNativeCodex: async () => { restored += 1; return restoreResult(true); }, + stripGrok: () => ({ ok: true, changed: true, message: "Grok config restored" }), + }); + expect(restored).toBe(1); + expect(body.sharedTeardown).toBe("performed"); + }); + + test("a failed restore still reports failure and the remediation", async () => { + const body = await performStopTeardown(new URL("http://127.0.0.1:10100/api/stop"), { + ownsReceipt: () => false, + restoreNativeCodex: async () => restoreResult(false), + stripGrok: () => ({ ok: false, changed: false, message: "grok home is read-only" }), + }); + expect(body.success).toBe(false); + expect(body.message).toContain("ocx restore"); + expect(body.message).toContain("Grok config cleanup failed"); + }); + + test("a Grok-only failure is not reported as a successful teardown", async () => { + // The native restore succeeding said nothing about the fence. Deciding success from + // the native half alone let a caller read success: true while Grok still pointed at a + // proxy that was exiting — the previous test masked it by failing both halves. + const body = await performStopTeardown(new URL("http://127.0.0.1:10100/api/stop"), { + ownsReceipt: () => false, + restoreNativeCodex: async () => restoreResult(true), + stripGrok: () => ({ ok: false, changed: false, message: "grok home is read-only" }), + }); + expect(body.success).toBe(false); + expect(body.sharedTeardown).toBe("performed"); + expect(body.message).toContain("Grok fence was not removed"); + expect(body.message).toContain("ocx restore"); + }); + + test("both halves succeeding is the only success", async () => { + const body = await performStopTeardown(new URL("http://127.0.0.1:10100/api/stop"), { + ownsReceipt: () => false, + restoreNativeCodex: async () => restoreResult(true), + stripGrok: () => ({ ok: true, changed: true, message: "Grok config restored" }), + }); + expect(body.success).toBe(true); + expect(body.message).not.toContain("Grok config cleanup failed"); + }); +}); + +describe("receipt naming is shared by both update lanes", () => { + test("the launcher's scan and the TypeScript listing agree on what is outstanding", async () => { + const mod = await import("../src/config/pending-teardown"); + const names = await import("../src/config/pending-teardown-names.mjs"); + const claimed = mod.claimPendingTeardown(ENDPOINT, "exact", 1234); + + // bin/ocx.mjs runs under plain Node and cannot import the TypeScript module, so the + // naming rule lives in one shared .mjs. Spelling it twice is exactly how the npm lane + // ended up watching a filename that no longer existed. + expect(names.hasPendingTeardownIn(readdirSync, home)).toBe(true); + expect(mod.pendingTeardownOutstanding()).toBe(true); + + // The retired singleton name is not a receipt. + expect(names.isPendingTeardownFileName("pending-teardown.json")).toBe(false); + expect(names.isPendingTeardownFileName(`pending-teardown-${claimed.nonce}.json`)).toBe(true); + // A quarantined receipt is no longer READ by the recovery loop... + const quarantinedName = `pending-teardown-${claimed.nonce}.unreadable.json`; + expect(names.isPendingTeardownFileName(quarantinedName)).toBe(false); + // ...but it is still an obligation, so it still blocks an update. + expect(names.isQuarantinedTeardownFileName(quarantinedName)).toBe(true); + expect(names.isAnyTeardownObligationFileName(quarantinedName)).toBe(true); + + mod.quarantinePendingTeardown(claimed.nonce); + expect(mod.listPendingTeardowns()).toHaveLength(0); + // Both lanes still refuse to install over a teardown that never ran. + expect(names.hasPendingTeardownIn(readdirSync, home)).toBe(true); + expect(mod.pendingTeardownOutstanding()).toBe(true); + expect(mod.listQuarantinedTeardowns()).toHaveLength(1); + + // Only a human removing the file ends the enforcement. + rmSync(mod.listQuarantinedTeardowns()[0]!); + expect(names.hasPendingTeardownIn(readdirSync, home)).toBe(false); + expect(mod.pendingTeardownOutstanding()).toBe(false); + }); + + test("a scan that fails is not an empty scan", async () => { + const names = await import("../src/config/pending-teardown-names.mjs"); + // Only a missing home is honestly empty. Any other failure may be hiding an + // obligation, and reporting "none" would let an update install over a teardown that + // never ran — absence of proof is not proof of absence. + const enoent = Object.assign(new Error("no such directory"), { code: "ENOENT" }); + expect(names.hasPendingTeardownIn(() => { throw enoent; }, home)).toBe(false); + const denied = Object.assign(new Error("permission denied"), { code: "EACCES" }); + expect(names.hasPendingTeardownIn(() => { throw denied; }, home)).toBe(true); + expect(names.hasPendingTeardownIn(() => { throw new Error("no code at all"); }, home)).toBe(true); + }); + + test("a home that cannot be scanned is its own state, not a fabricated receipt", async () => { + const mod = await import("../src/config/pending-teardown"); + const previous = process.env.OPENCODEX_HOME; + // A file where the home should be: readdir fails with ENOTDIR, which is not absence. + const notADir = join(home, "not-a-directory"); + writeFileSync(notADir, ""); + process.env.OPENCODEX_HOME = notADir; + try { + const listed = mod.listPendingTeardowns(); + // handleStop must see something blocking rather than an empty set it would restore over. + expect(listed).toHaveLength(1); + // Not "invalid": that carries a nonce, and a synthesized one would be handed to the + // quarantine and clear paths, which could rename or delete a real receipt. + expect(listed[0]!.state).toBe("unscannable"); + expect(listed[0]).not.toHaveProperty("nonce"); + expect(mod.isPendingTeardownAbandoned(listed[0]!, () => false, 1)).toBe(true); + expect(mod.pendingTeardownOutstanding()).toBe(true); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previous; + } + }); +}); + +describe("endpoint provenance", () => { + test("a guessed endpoint is recorded as such and is not exact evidence", async () => { + const mod = await import("../src/config/pending-teardown"); + const guessed = mod.claimPendingTeardown({ hostname: "127.0.0.1", port: 10100 }, "guessed", 1234); + const read = mod.readPendingTeardown(guessed.nonce); + expect(read.state === "valid" && read.receipt.endpointSource).toBe("guessed"); + + // A proxy started with an explicit --port can be respawned there while the configured + // address refuses, so a dead probe of THIS address proves nothing. handleStop reads + // the provenance and fails closed rather than restoring on it. + const exact = mod.claimPendingTeardown({ hostname: "127.0.0.1", port: 19999 }, "exact", 1234); + expect(mod.readPendingTeardown(exact.nonce)).toMatchObject({ state: "valid" }); + }); + + test("a receipt without provenance is invalid, so an old-format file cannot be trusted", async () => { + const mod = await import("../src/config/pending-teardown"); + const claimed = mod.claimPendingTeardown(ENDPOINT, "exact", 1234); + writeFileSync( + mod.pendingTeardownPathFor(claimed.nonce), + JSON.stringify({ ownerPid: 1234, nonce: claimed.nonce, createdAt: "t", endpoint: ENDPOINT }), + ); + expect(mod.readPendingTeardown(claimed.nonce).state).toBe("invalid"); + writeFileSync( + mod.pendingTeardownPathFor(claimed.nonce), + JSON.stringify({ ownerPid: 1234, nonce: claimed.nonce, createdAt: "t", endpoint: ENDPOINT, endpointSource: "maybe" }), + ); + expect(mod.readPendingTeardown(claimed.nonce).state).toBe("invalid"); + }); +}); + +describe("post-stop update decision", () => { + test("an outstanding obligation aborts the install even when the stop succeeded", async () => { + const { decidePostStopUpdate } = await import("../src/update/stop-decision.mjs"); + // A quarantined receipt lets the stop itself succeed — there is nothing left to stop — + // so checking only BEFORE the stop let the retry sail through and install over a + // teardown that never ran. + expect(decidePostStopUpdate({ status: 0, hasRuntimeState: false, liveness: "dead", teardownOutstanding: true })) + .toEqual({ proceed: false, reason: "teardown-outstanding" }); + expect(decidePostStopUpdate({ status: 0, hasRuntimeState: false, liveness: "dead", teardownOutstanding: false })) + .toEqual({ proceed: true, reason: "ok" }); + // Omitting the field keeps the previous behaviour for any caller that has not adopted it. + expect(decidePostStopUpdate({ status: 0, hasRuntimeState: false, liveness: "dead" })) + .toEqual({ proceed: true, reason: "ok" }); + // A real stop failure still wins: it is the stronger signal. + expect(decidePostStopUpdate({ status: 1, hasRuntimeState: false, liveness: "dead", teardownOutstanding: true })) + .toEqual({ proceed: false, reason: "stop-failed" }); + }); +}); + +describe("pending teardown receipts", () => { + test("a claim is durable and carries the endpoint it was stopping", async () => { + const mod = await import("../src/config/pending-teardown"); + const claimed = mod.claimPendingTeardown(ENDPOINT, "exact", 1234); + expect(claimed.nonce).toMatch(/^[0-9a-f]{32}$/); + expect(existsSync(mod.pendingTeardownPathFor(claimed.nonce))).toBe(true); + const read = mod.readPendingTeardown(claimed.nonce); + expect(read.state).toBe("valid"); + expect(read.state === "valid" && read.receipt.endpoint).toEqual(ENDPOINT); + expect(mod.pendingTeardownOutstanding()).toBe(true); + }); + + test("a clear names one obligation, so a concurrent claim cannot be deleted by it", async () => { + const mod = await import("../src/config/pending-teardown"); + // Review round 8 reproduced the delete-the-wrong-receipt bug; round 10 pointed out + // that a read-compare-unlink against ONE shared path is still racy, because the file + // can be replaced between the compare and the unlink. The nonce is the filename now, + // so the replacement is a DIFFERENT file and the delete cannot reach it — no ordering + // of the two operations matters. + const abandoned = mod.claimPendingTeardown(ENDPOINT, "exact", 1111); + const concurrent = mod.claimPendingTeardown(ENDPOINT, "exact", 2222); + expect(mod.listPendingTeardowns()).toHaveLength(2); + + expect(mod.clearPendingTeardown(abandoned.nonce)).toBe(true); + const survivors = mod.listPendingTeardowns(); + expect(survivors).toHaveLength(1); + expect(survivors[0]!.state === "valid" && survivors[0]!.receipt.nonce).toBe(concurrent.nonce); + }); + + test("clearing reports whether the obligation is actually gone", async () => { + const mod = await import("../src/config/pending-teardown"); + const claimed = mod.claimPendingTeardown(ENDPOINT, "exact", 1234); + expect(mod.clearPendingTeardown(claimed.nonce)).toBe(true); + // Already gone is still "gone" — an idempotent discharge is not a failure. + expect(mod.clearPendingTeardown(claimed.nonce)).toBe(true); + // A receipt that cannot be removed must be reported, or recovery repeats forever. + const stuck = mod.claimPendingTeardown(ENDPOINT, "exact", 1234); + rmSync(mod.pendingTeardownPathFor(stuck.nonce)); + mkdirSync(mod.pendingTeardownPathFor(stuck.nonce), { recursive: true }); + mkdirSync(join(mod.pendingTeardownPathFor(stuck.nonce), "child"), { recursive: true }); + expect(mod.clearPendingTeardown(stuck.nonce)).toBe(false); + rmSync(mod.pendingTeardownPathFor(stuck.nonce), { recursive: true, force: true }); + }); + + test("an unreadable receipt is invalid, outstanding, and quarantinable", async () => { + const mod = await import("../src/config/pending-teardown"); + const claimed = mod.claimPendingTeardown(ENDPOINT, "exact", 1234); + writeFileSync(mod.pendingTeardownPathFor(claimed.nonce), "{not json"); + const read = mod.readPendingTeardown(claimed.nonce); + expect(read.state).toBe("invalid"); + expect(mod.pendingTeardownOutstanding()).toBe(true); + // It names no endpoint, so nothing can prove its proxy down. Quarantine stops the + // recovery loop from re-reading garbage on every stop, but the obligation REMAINS + // outstanding: filing it away to unblock an update would let the next install land + // over a teardown that never ran. + const moved = mod.quarantinePendingTeardown(claimed.nonce); + expect(moved).toBeTruthy(); + expect(existsSync(moved!)).toBe(true); + expect(mod.listPendingTeardowns()).toHaveLength(0); + expect(mod.pendingTeardownOutstanding()).toBe(true); + expect(readdirSync(home).some(n => n.endsWith(".unreadable.json"))).toBe(true); + }); + + test("a directory where a receipt belongs is invalid, not missing", async () => { + const mod = await import("../src/config/pending-teardown"); + const claimed = mod.claimPendingTeardown(ENDPOINT, "exact", 1234); + rmSync(mod.pendingTeardownPathFor(claimed.nonce)); + mkdirSync(mod.pendingTeardownPathFor(claimed.nonce), { recursive: true }); + // Reading that as absence hides an obligation that may still be outstanding. + expect(mod.readPendingTeardown(claimed.nonce).state).toBe("invalid"); + expect(mod.pendingTeardownOutstanding()).toBe(true); + rmSync(mod.pendingTeardownPathFor(claimed.nonce), { recursive: true, force: true }); + }); + + test("a receipt whose body disagrees with its filename is invalid", async () => { + const mod = await import("../src/config/pending-teardown"); + const claimed = mod.claimPendingTeardown(ENDPOINT, "exact", 1234); + writeFileSync( + mod.pendingTeardownPathFor(claimed.nonce), + JSON.stringify({ ownerPid: 1234, nonce: FOREIGN_NONCE, createdAt: "t", endpoint: ENDPOINT, endpointSource: "exact" }), + ); + // Otherwise an edited body could claim an identity the file name does not carry. + expect(mod.readPendingTeardown(claimed.nonce).state).toBe("invalid"); + expect(mod.deferralMatchesReceipt(claimed.nonce)).toBe(false); + }); + + test("a receipt without a usable endpoint is invalid, because recovery could not locate it", async () => { + const mod = await import("../src/config/pending-teardown"); + const claimed = mod.claimPendingTeardown(ENDPOINT, "exact", 1234); + const path = mod.pendingTeardownPathFor(claimed.nonce); + const base = { ownerPid: 7, nonce: claimed.nonce, createdAt: "t", endpointSource: "exact" }; + writeFileSync(path, JSON.stringify(base)); + expect(mod.readPendingTeardown(claimed.nonce).state).toBe("invalid"); + writeFileSync(path, JSON.stringify({ ...base, endpoint: { hostname: "", port: 10100 } })); + expect(mod.readPendingTeardown(claimed.nonce).state).toBe("invalid"); + writeFileSync(path, JSON.stringify({ ...base, endpoint: { hostname: "127.0.0.1", port: 0 } })); + expect(mod.readPendingTeardown(claimed.nonce).state).toBe("invalid"); + }); + + test("only an abandoned receipt is recoverable", async () => { + const mod = await import("../src/config/pending-teardown"); + const claimed = mod.claimPendingTeardown(ENDPOINT, "exact", 4242); + const live = mod.readPendingTeardown(claimed.nonce); + + // A stop that is still running owns its own obligation; finishing it from here would + // restore client config while that stop is still deciding whether a proxy survived. + expect(mod.isPendingTeardownAbandoned(live, () => true, 1)).toBe(false); + // This process's own receipt is not "abandoned" either. + expect(mod.isPendingTeardownAbandoned(live, () => false, 4242)).toBe(false); + // A dead owner left the obligation behind: recover it. + expect(mod.isPendingTeardownAbandoned(live, () => false, 1)).toBe(true); + expect(mod.isPendingTeardownAbandoned({ state: "missing" }, () => false, 1)).toBe(false); + }); + + test("deferralMatchesReceipt needs a well-formed nonce that names a readable receipt", async () => { + const mod = await import("../src/config/pending-teardown"); + const claimed = mod.claimPendingTeardown(ENDPOINT, "exact", 7); + expect(mod.deferralMatchesReceipt(claimed.nonce)).toBe(true); + expect(mod.deferralMatchesReceipt(FOREIGN_NONCE)).toBe(false); + expect(mod.deferralMatchesReceipt(null)).toBe(false); + // A path-shaped "nonce" must not be able to reach outside the receipt namespace. + expect(mod.deferralMatchesReceipt("../config")).toBe(false); + expect(mod.deferralMatchesReceipt("")).toBe(false); + }); +}); diff --git a/tests/subagent-model-fallback.test.ts b/tests/subagent-model-fallback.test.ts index f37a07ef4a..75b2359ad5 100644 --- a/tests/subagent-model-fallback.test.ts +++ b/tests/subagent-model-fallback.test.ts @@ -21,7 +21,7 @@ import { } from "../src/codex/subagent-model-fallback"; import { saveCodexAccountCredential } from "../src/codex/account-store"; import { clearAccountNeedsReauth, markAccountNeedsReauth } from "../src/codex/account-runtime-state"; -import { clearAccountQuota, updateAccountQuota } from "../src/codex/quota"; +import { clearAccountQuota, setAccountQuotaFromParsed, updateAccountQuota } from "../src/codex/quota"; import { canAcquireCodexQuotaProbeLease, canAcquireCodexQuotaScopeProbeLease, @@ -734,6 +734,36 @@ describe("subagent model fallback chain", () => { expect(isSubagentModelUnavailable("gpt-5.6-sol", config, "pool-a")).toBe(false); }); + test("a full burst window makes the native model exhausted only while it holds (#3029)", () => { + // Subagent fallback reads the same usage score as pool selection, so a stale terminal + // reading pushes subagents off a native model whose five-hour window has already reset. + // The clock is passed explicitly and deliberately far from wall time: a fixture whose + // clock matches Date.now() cannot tell a threaded clock from a substituted one. + const now = 1_700_000_000_000; + const config = cfg({ + activeCodexAccountId: "pool-a", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "pool", + }, + }, + subagentModelFallback: ["kimi/k3"], + }); + + resetSubagentModelFallbackStateForTests(); + clearAccountQuota("pool-a"); + setAccountQuotaFromParsed("pool-a", { shortPercent: 100, shortResetAt: now + 60_000 }); + expect(isNativeModelQuotaExhausted("gpt-5.6-sol", config, "pool-a", now)).toBe(true); + + resetSubagentModelFallbackStateForTests(); + clearAccountQuota("pool-a"); + setAccountQuotaFromParsed("pool-a", { shortPercent: 100, shortResetAt: now - 60_000 }); + expect(isNativeModelQuotaExhausted("gpt-5.6-sol", config, "pool-a", now)).toBe(false); + }); + test("openai-direct/gpt-5.5 is accepted as encrypted-task fallback when canonical", () => { resetSubagentModelFallbackStateForTests(); updateAccountQuota("pool-a", 95, undefined, 20); diff --git a/tests/uninstall.test.ts b/tests/uninstall.test.ts index 8988ed3431..a0d233a1f4 100644 --- a/tests/uninstall.test.ts +++ b/tests/uninstall.test.ts @@ -95,9 +95,165 @@ describe("full uninstall command", () => { expect(uninstallBody).toContain('runStep("proxy stopped"'); expect(uninstallBody).toContain('runStep("service removed"'); expect(uninstallBody).toContain("await stopProxy(pid);"); - expect(uninstallBody).toContain("uninstallServiceIfInstalled()"); + expect(uninstallBody).toContain("uninstallServiceDetailed()"); expect(uninstallBody.indexOf('runStep("service stopped"')).toBeLessThan(uninstallBody.indexOf('runStep("proxy stopped"')); expect(uninstallBody.indexOf('runStep("proxy stopped"')).toBeLessThan(uninstallBody.indexOf('runStep("service removed"')); - expect(uninstallBody.indexOf("await stopProxy(pid);")).toBeLessThan(uninstallBody.indexOf("uninstallServiceIfInstalled()")); + expect(uninstallBody.indexOf("await stopProxy(pid);")).toBeLessThan(uninstallBody.indexOf("uninstallServiceDetailed()")); }); }); +describe("uninstall gates shared teardown on a proven service stop", () => { + test("the authorization rule, exercised for every failure permutation", async () => { + const { sharedTeardownAuthorized } = await import("../src/cli/uninstall-plan"); + const base = { + serviceStop: "stopped" as const, + proxyProvenDown: true, + serviceRemoval: "removed" as const, + respawnWindowVerified: false, + }; + expect(sharedTeardownAuthorized(base)).toBe(true); + expect(sharedTeardownAuthorized({ ...base, serviceStop: "absent" })).toBe(true); + expect(sharedTeardownAuthorized({ ...base, serviceRemoval: "absent" })).toBe(true); + // Removing the registration does not prove an already-running wrapper died; killing it + // is best-effort (#764), so the restart window has to be polled first. + expect(sharedTeardownAuthorized({ ...base, serviceStop: "stopped-respawnable" })).toBe(false); + expect(sharedTeardownAuthorized({ ...base, serviceStop: "stopped-respawnable", respawnWindowVerified: true })).toBe(true); + // A manager that refused to stop, or one we could not read, may still be running. + expect(sharedTeardownAuthorized({ ...base, serviceStop: "failed" })).toBe(false); + expect(sharedTeardownAuthorized({ ...base, serviceStop: "state-unknown" })).toBe(false); + // The step itself threw: we know nothing. + expect(sharedTeardownAuthorized({ ...base, serviceStop: null })).toBe(false); + // A proxy that could not be PROVEN down — a live orphan with no pid, or an endpoint + // that would not answer — blocks it. A findLiveProxy miss is not proof. + expect(sharedTeardownAuthorized({ ...base, proxyProvenDown: false })).toBe(false); + // A removal that failed used to look like absence on darwin and linux. + expect(sharedTeardownAuthorized({ ...base, serviceRemoval: "failed" })).toBe(false); + expect(sharedTeardownAuthorized({ ...base, serviceRemoval: null })).toBe(false); + }); + + test("a removal failure is distinguishable from nothing being installed", async () => { + const { setUninstallServiceHooksForTests, uninstallServiceDetailed } = await import("../src/service"); + // Windows is the platform whose hooks are injectable; the darwin/linux catch arms that + // returned the same false as absence are now typed outcomes rather than a boolean. + setUninstallServiceHooksForTests({ + platform: "win32", + assertEnvironment: () => {}, + probeWindowsTask: () => ({ status: "absent" }) as never, + uninstallWindowsTask: () => {}, + nativeStatus: () => "nonexistent", + uninstallNative: () => {}, + removeInstallState: () => {}, + } as never); + expect(uninstallServiceDetailed()).toBe("absent"); + + const serviceSource = await readText("src/service.ts"); + // The darwin and linux arms return "failed", not the absence value. + expect(serviceSource).toContain('try { uninstallLaunchd(); removeServiceInstallState(); return "removed"; } catch { return "failed"; }'); + expect(serviceSource).toContain('try { unlinkSync(unitPath()); removeServiceInstallState(); return "removed"; } catch { return "failed"; }'); + }); + + test("a live orphan with no pid file blocks the teardown", async () => { + const cli = await readText("src/cli/index.ts"); + const at = cli.indexOf("async function handleUninstall("); + const fn = cli.slice(at, at + 9000); + // A missing pid file is not proof that nothing is serving — the same discovery + // `ocx stop` performs. Without it, uninstall restored shared config under a live proxy. + expect(fn).toContain("const live = await findLiveProxy();"); + expect(fn).toContain("observed.proxyProvenDown = await proxyEndpointProvenDown();"); + expect(fn).toContain("no process id could be resolved for it"); + // The orphan-with-no-pid branch THROWS, so `proxyProvenDown` stays false and the + // authorization rule refuses the shared teardown. + const orphanBranch = fn.slice(fn.indexOf("const live = await findLiveProxy();"), fn.indexOf("const live = await findLiveProxy();") + 600); + expect(orphanBranch).toContain("throw new Error("); + // A findLiveProxy miss is not proof either: it goes through the tri-state probe first. + expect(orphanBranch).toContain("could not be confirmed down either"); + }); + + async function uninstallFn(): Promise { + const cli = await readText("src/cli/index.ts"); + const at = cli.indexOf("async function handleUninstall("); + expect(at).toBeGreaterThan(-1); + return cli.slice(at, at + 9000); + } + + test("the detailed outcome is consumed, not the boolean collapse", async () => { + const fn = await uninstallFn(); + // stopServiceIfInstalled returns false for "not installed", "refused to stop" and + // "state could not be read" alike, so this step reported "not installed" for a manager + // that might still be running (#3008). + expect(fn).toContain("stopServiceIfInstalledDetailed()"); + expect(fn).not.toContain("stopServiceIfInstalled()"); + expect(fn).toContain('if (outcome === "absent") return false;'); + expect(fn).toContain('if (outcome === "failed")'); + expect(fn).toContain('if (outcome === "state-unknown")'); + }); + + test("shared teardown runs only when nothing that could still serve is unaccounted for", async () => { + const fn = await uninstallFn(); + // The rule itself is exercised by calling it above; this pins the wiring. + expect(fn).toContain("if (sharedTeardownAuthorized(observed)) {"); + // Every step that could leave something serving records what it observed, and the + // fields start pessimistic so a step that throws cannot look like a success. + expect(fn).toContain("serviceStop: null,"); + expect(fn).toContain("proxyProvenDown: false,"); + expect(fn).toContain("serviceRemoval: null,"); + expect(fn).toContain("respawnWindowVerified: false,"); + expect(fn).toContain("observed.serviceStop = outcome;"); + expect(fn).toContain("observed.serviceRemoval = outcome;"); + expect(fn).toContain('if (observed.serviceStop === "stopped-respawnable")'); + expect(fn).toContain("observed.respawnWindowVerified = true;"); + const gateAt = fn.indexOf("if (sharedTeardownAuthorized(observed)) {"); + expect(gateAt).toBeLessThan(fn.indexOf("native Codex restored", gateAt)); + // The skip is a failure, not a silent pass: the command must exit nonzero and say what + // to run once the blocker is resolved. + expect(fn).toContain('failures.push("native Codex restored", "Grok Build config restored");'); + expect(fn).toContain("Skipping shared teardown"); + // Naming only `ocx restore` was wrong: it restores client routing but leaves the + // service removal and local cleanup this command had not reached. + expect(fn).toContain("rerun 'ocx uninstall'"); + expect(fn).toContain("interim step"); + }); +}); + test("proof covers every distinct endpoint, not just the preferred one", async () => { + const { endpointsToProve, everyEndpointProvenDown } = await import("../src/cli/uninstall-plan"); + + // A stale runtime record pointing at a closed port, and the live proxy on the + // configured one. Probing only the runtime candidate reports "dead" for a port nobody + // is using and authorizes the teardown (#3008). + const endpoints = endpointsToProve({ port: 10999, hostname: "127.0.0.1" }, { port: 10100, hostname: "127.0.0.1" }); + expect(endpoints).toEqual([ + { hostname: "127.0.0.1", port: 10999 }, + { hostname: "127.0.0.1", port: 10100 }, + ]); + const closedRuntimeLiveConfig = (e: { port: number }) => (e.port === 10999 ? "dead" as const : "live" as const); + expect(everyEndpointProvenDown(endpoints, closedRuntimeLiveConfig)).toBe(false); + // A silent listener is not absence either. + expect(everyEndpointProvenDown(endpoints, e => (e.port === 10999 ? "dead" : "unknown"))).toBe(false); + // Both definitively dead is the only proof. + expect(everyEndpointProvenDown(endpoints, () => "dead")).toBe(true); + + // Identical candidates collapse to one; a missing runtime record leaves the config one. + expect(endpointsToProve({ port: 10100, hostname: "127.0.0.1" }, { port: 10100 })).toHaveLength(1); + expect(endpointsToProve(null, { port: 10100 })).toEqual([{ hostname: "127.0.0.1", port: 10100 }]); + // No configured port still yields the default, so the set is never empty in practice. + expect(endpointsToProve(null, {})).toEqual([{ hostname: "127.0.0.1", port: 10100 }]); + // An empty set is not proof of anything. + expect(everyEndpointProvenDown([], () => "dead")).toBe(false); + // A nonsense runtime port is skipped rather than probed. + expect(endpointsToProve({ port: 0 }, { port: 10100 })).toEqual([{ hostname: "127.0.0.1", port: 10100 }]); + }); + + test("the respawn window is verified by evidence, not by a silent poll", async () => { + const cli = await readText("src/cli/index.ts"); + const at = cli.indexOf("async function handleUninstall("); + const fn = cli.slice(at, at + 9000); + // proxyStillLiveAfterStop returns null on a timeout as well as on a genuinely dead + // endpoint, so a respawned-but-unresponsive proxy looked verified-down. + const windowStep = fn.slice(fn.indexOf('runStep("respawn window verified"'), fn.indexOf('runStep("respawn window verified"') + 900); + expect(windowStep).toContain("if (!await proxyEndpointProvenDown())"); + expect(windowStep).toContain("could not be confirmed down either"); + expect(windowStep.indexOf("if (!await proxyEndpointProvenDown())")) + .toBeLessThan(windowStep.indexOf("observed.respawnWindowVerified = true;")); + // And the proof itself asks every candidate. + expect(fn).toContain("endpointsToProve(readRuntimePort(), loadConfig())"); + expect(fn).toContain("everyEndpointProvenDown(endpoints, e => probeProxyLiveness(e.port, e.hostname))"); + }); diff --git a/tests/update-stop-classification.test.ts b/tests/update-stop-classification.test.ts new file mode 100644 index 0000000000..d9d4c73349 --- /dev/null +++ b/tests/update-stop-classification.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, test } from "bun:test"; +import { spawn } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../src/update/stop-contract.mjs"; +import { probeProxyLiveness } from "../src/update/proxy-liveness-probe.mjs"; +import { decidePostStopUpdate } from "../src/update/stop-decision.mjs"; + +const repoRoot = join(import.meta.dir, ".."); +const read = (rel: string): string => readFileSync(join(repoRoot, rel), "utf8"); + +/** + * #3008: `ocx update` aborted after a stop that had already succeeded. + * + * `handleStop` sets a failure code AFTER history restoration — that is, after the proxy + * and service are already down — so a failed Codex-history cleanup was indistinguishable + * from a proxy that refused to die. The update aborted with the service stopped, no + * listener, and the old package still installed. + * + * The distinguishing signal has to survive `spawnSync`, so it is an exit code rather than + * a type. These assertions pin the contract at both ends of that process boundary, and the + * decision table each end implements. + */ +describe("stop failure classification (#3008)", () => { + test("the history-only code is outside every code this CLI already uses", () => { + // Picking an occupied code would make a history-only stop indistinguishable from + // whatever else emits it, and `bin/ocx.mjs` mirrors the child's status faithfully + // enough to propagate the confusion. + expect(STOP_HISTORY_INCOMPLETE_EXIT_CODE).toBe(79); + // sysexits.h occupies 64-78; 128+signal starts at 129. + expect(STOP_HISTORY_INCOMPLETE_EXIT_CODE).toBeGreaterThan(78); + expect(STOP_HISTORY_INCOMPLETE_EXIT_CODE).toBeLessThan(128); + + const cliCodes = [...read("src/cli/index.ts").matchAll(/process\.exit(?:Code)?\s*(?:=|\()\s*(\d+)/g)] + .map(match => Number(match[1])); + const dispatchCodes = [...read("src/cli/dispatch.ts").matchAll(/return (\d+);/g)] + .map(match => Number(match[1])); + expect(cliCodes).not.toContain(STOP_HISTORY_INCOMPLETE_EXIT_CODE); + expect(dispatchCodes).not.toContain(STOP_HISTORY_INCOMPLETE_EXIT_CODE); + }); + + test("the shared contract is plain ESM so the Node launcher can import it", () => { + // A .ts module would be unusable from bin/ocx.mjs, and inlining the number in two + // places is how the two ends drift. + const contract = read("src/update/stop-contract.mjs"); + expect(contract).toContain("export const STOP_HISTORY_INCOMPLETE_EXIT_CODE"); + expect(read("bin/ocx.mjs")).toContain("stop-contract.mjs"); + expect(read("src/update/index.ts")).toContain("stop-contract.mjs"); + }); + + test("the liveness probe sees a surviving proxy, and fails open when nothing is there", async () => { + // Behavioural, not textual: absent PID and runtime files are weak evidence, so the + // updaters ask the endpoint. The listener runs in a SEPARATE process because the probe + // uses spawnSync - an in-process server could never answer while the parent's event + // loop is blocked, which is also why the probe speaks node:http rather than fetch. + const listener = spawn(process.execPath, ["-e", [ + "const http = require('node:http');", + "const server = http.createServer((req, res) => {", + " if (req.url !== '/healthz') { res.writeHead(404); res.end(); return; }", + " res.writeHead(200, { 'content-type': 'application/json' });", + " res.end(JSON.stringify({ service: 'opencodex', pid: process.pid, version: 'test' }));", + "});", + "server.listen(0, '127.0.0.1', () => process.stdout.write(String(server.address().port)));", + ].join("\n")], { stdio: ["ignore", "pipe", "ignore"] }); + + const port = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("listener did not report a port")), 10_000); + listener.stdout.once("data", chunk => { clearTimeout(timer); resolve(Number(String(chunk))); }); + listener.once("error", error => { clearTimeout(timer); reject(error); }); + }); + + try { + expect(probeProxyLiveness(port)).toBe("live"); + } finally { + listener.kill(); + await new Promise(resolve => listener.once("exit", () => resolve())); + } + + // A refused connection is the one error that proves nothing is listening. + expect(probeProxyLiveness(port)).toBe("dead"); + // Nothing to ask is not ambiguity. + expect(probeProxyLiveness(0)).toBe("dead"); + expect(probeProxyLiveness(Number.NaN)).toBe("dead"); + }); + + test("identity decides live, and an unexpected status is unknown", async () => { + // Mirrors isOpencodexHealthz: a foreign server exposing /healthz is not our proxy, a + // pre-identity build of ours is, and any status other than 200 says the endpoint is + // answering without telling us what it is - which is not evidence of absence. + const cases: Array<[string, string, "live" | "dead" | "unknown"]> = [ + ["canonical", "{ service: 'opencodex', pid: 1 }", "live"], + ["legacy pre-identity", "{ status: 'ok', version: '2.0.0', uptime: 12 }", "live"], + ["foreign", "{ service: 'other', status: 'ok' }", "dead"], + ["foreign lookalike", "{ status: 'ok' }", "dead"], + ]; + for (const [name, body, expected] of cases) { + const listener = spawn(process.execPath, ["-e", [ + "const http = require('node:http');", + "const server = http.createServer((req, res) => {", + " res.writeHead(200, { 'content-type': 'application/json' });", + ` res.end(JSON.stringify(${body}));`, + "});", + "server.listen(0, '127.0.0.1', () => process.stdout.write(String(server.address().port)));", + ].join("\n")], { stdio: ["ignore", "pipe", "ignore"] }); + const port = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`${name} listener did not report a port`)), 10_000); + listener.stdout.once("data", chunk => { clearTimeout(timer); resolve(Number(String(chunk))); }); + listener.once("error", error => { clearTimeout(timer); reject(error); }); + }); + try { + expect(probeProxyLiveness(port)).toBe(expected); + } finally { + listener.kill(); + await new Promise(resolve => listener.once("exit", () => resolve())); + } + } + }); + + test("a non-200 from our own endpoint is unknown, never dead", async () => { + const listener = spawn(process.execPath, ["-e", [ + "const http = require('node:http');", + "const server = http.createServer((req, res) => {", + " res.writeHead(500, { 'content-type': 'application/json' });", + " res.end(JSON.stringify({ service: 'opencodex' }));", + "});", + "server.listen(0, '127.0.0.1', () => process.stdout.write(String(server.address().port)));", + ].join("\n")], { stdio: ["ignore", "pipe", "ignore"] }); + const port = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("listener did not report a port")), 10_000); + listener.stdout.once("data", chunk => { clearTimeout(timer); resolve(Number(String(chunk))); }); + listener.once("error", error => { clearTimeout(timer); reject(error); }); + }); + try { + expect(probeProxyLiveness(port)).toBe("unknown"); + } finally { + listener.kill(); + await new Promise(resolve => listener.once("exit", () => resolve())); + } + }); + + test("the shared probe normalizes wildcard and bracketed IPv6 hosts", async () => { + // Normalization lives in the probe, not at each call site: doing it per-lane fixed + // the npm launcher and left the TypeScript updater passing a bracketed literal + // straight to node:http, which answers nothing - read as "unknown", which aborts a + // healthy update and leaves the service down. That is the original failure shape. + const listener = spawn(process.execPath, ["-e", [ + "const http = require('node:http');", + "const server = http.createServer((req, res) => {", + " res.writeHead(200, { 'content-type': 'application/json' });", + " res.end(JSON.stringify({ service: 'opencodex', pid: process.pid }));", + "});", + "server.listen(0, () => process.stdout.write(String(server.address().port)));", + ].join("\n")], { stdio: ["ignore", "pipe", "ignore"] }); + const port = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("listener did not report a port")), 10_000); + listener.stdout.once("data", chunk => { clearTimeout(timer); resolve(Number(String(chunk))); }); + listener.once("error", error => { clearTimeout(timer); reject(error); }); + }); + + try { + // A wildcard bind cannot be dialled as a wildcard; it answers on loopback. + expect(probeProxyLiveness(port, "0.0.0.0")).toBe("live"); + expect(probeProxyLiveness(port, "*")).toBe("live"); + // A URL-spelled literal is unwrapped rather than handed to the socket layer. + expect(probeProxyLiveness(port, "[127.0.0.1]")).toBe("live"); + // Empty falls back to loopback rather than dialling "". + expect(probeProxyLiveness(port, "")).toBe("live"); + } finally { + listener.kill(); + await new Promise(resolve => listener.once("exit", () => resolve())); + } + }); + + test("an unreachable or silent endpoint is unknown, not dead", async () => { + // Fail-open was the wrong default: a listener that accepts connections but withholds + // /healthz, or a probe that times out, is exactly the state where replacing package + // files is most dangerous. "We could not tell" is not evidence the proxy is gone. + const listener = spawn(process.execPath, ["-e", [ + "const net = require('node:net');", + // Accepts the connection and never answers, so the request times out. + "const server = net.createServer(() => {});", + "server.listen(0, '127.0.0.1', () => process.stdout.write(String(server.address().port)));", + ].join("\n")], { stdio: ["ignore", "pipe", "ignore"] }); + + const port = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("listener did not report a port")), 10_000); + listener.stdout.once("data", chunk => { clearTimeout(timer); resolve(Number(String(chunk))); }); + listener.once("error", error => { clearTimeout(timer); reject(error); }); + }); + + try { + expect(probeProxyLiveness(port, "127.0.0.1", 400)).toBe("unknown"); + } finally { + listener.kill(); + await new Promise(resolve => listener.once("exit", () => resolve())); + } + }); + + test("the shared decision covers the whole post-stop matrix", () => { + // This is THE predicate both updaters call, not a copy of it: src/update/index.ts and + // bin/ocx.mjs each import decidePostStopUpdate. Testing a local reimplementation would + // stay green while either lane drifted, which is how #3008 shipped fixed on one side. + const dead = { hasRuntimeState: false, liveness: "dead" } as const; + + // Proceed: a clean stop, or the history-only code with everything else quiet. + expect(decidePostStopUpdate({ status: 0, ...dead })).toEqual({ proceed: true, reason: "ok" }); + expect(decidePostStopUpdate({ status: STOP_HISTORY_INCOMPLETE_EXIT_CODE, ...dead })) + .toEqual({ proceed: true, reason: "history-only" }); + + // Abort: a stop that did not finish. A signal kill carries no evidence that it did. + for (const status of [1, 2, 4, 64, 130, null]) { + expect(decidePostStopUpdate({ status, ...dead })) + .toEqual({ proceed: false, reason: "stop-failed" }); + } + + // Abort: records survived the stop, even on a clean exit. + expect(decidePostStopUpdate({ status: 0, hasRuntimeState: true, liveness: "dead" })) + .toEqual({ proceed: false, reason: "runtime-state" }); + + // Abort: something still answers as our proxy, or the probe could not tell. Absence of + // proof is not proof of absence when the cost is a server running mixed modules. + expect(decidePostStopUpdate({ status: 0, hasRuntimeState: false, liveness: "live" })) + .toEqual({ proceed: false, reason: "proxy-live" }); + expect(decidePostStopUpdate({ status: 0, hasRuntimeState: false, liveness: "unknown" })) + .toEqual({ proceed: false, reason: "proxy-unknown" }); + // The history-only code does not buy past a live or unclear proxy either. + expect(decidePostStopUpdate({ status: STOP_HISTORY_INCOMPLETE_EXIT_CODE, hasRuntimeState: false, liveness: "unknown" })) + .toEqual({ proceed: false, reason: "proxy-unknown" }); + }); + + test("both updater lanes call the shared decision", () => { + // The reported path is a dashboard npm update through the plain-Node launcher. Fixing + // only the Bun updater would leave that lane broken while every focused test went + // green, which is exactly how #3008 reached a release. + for (const lane of ["src/update/index.ts", "bin/ocx.mjs"]) { + const source = read(lane); + expect(source).toContain("decidePostStopUpdate({"); + // And neither lane keeps a private copy of the rule it was supposed to delegate. + expect(source).not.toMatch(/status !== 0 && !historyOnlyStop/); + } + }); + + test("handleStop emits the code only for a history-only failure and still returns", () => { + const cli = read("src/cli/index.ts"); + // Ordinary failure wins: it is the stronger signal. + expect(cli).toMatch(/if \(stopFailed\) process\.exitCode = 1;\s*\n\s*else if \(historyOnlyFailure\) process\.exitCode = STOP_HISTORY_INCOMPLETE_EXIT_CODE;/); + // The code is set rather than exited inline so the dispatcher still receives the + // return value and decides what happens next. + expect(cli).toMatch(/process\.exitCode = STOP_HISTORY_INCOMPLETE_EXIT_CODE;\s*\n\s*return !stopFailed;/); + // Config and catalog failures are real teardown failures: a client reads those. + expect(cli).toMatch(/artifacts\.config\.state === "failed" \|\| artifacts\.catalog\.state === "failed"/); + }); +}); diff --git a/tests/update-stop-first.test.ts b/tests/update-stop-first.test.ts index 0f7fd7ff55..d20eafb5c7 100644 --- a/tests/update-stop-first.test.ts +++ b/tests/update-stop-first.test.ts @@ -94,7 +94,7 @@ describe("update stops the running proxy before replacing files", () => { expect(stopAt).toBeGreaterThan(-1); expect(updateAt).toBeGreaterThan(-1); expect(stopAt).toBeLessThan(updateAt); - expect(updateSource).toContain("if (serviceWasInstalled || readPid() || readRuntimePort())"); + expect(updateSource).toContain("if (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding())"); }); test("integrity pre-flight runs BEFORE the stop so anomalous metadata never unloads the proxy", () => { @@ -306,10 +306,12 @@ esac // may claim a DB lock or that every routed thread is hidden. expect(updateSource).toContain("export function historyRestoreIncomplete("); expect(updateSource).toContain('name.startsWith("codex-history-backup-") && name.endsWith(".json")'); - expect(updateSource).toContain("if (historyRestoreIncomplete())"); + // The warning now also fires on the dedicated stop code, so the manifest check is one + // of two triggers rather than the whole condition (#3008). + expect(updateSource).toContain("if (historyOnlyStop || historyRestoreIncomplete())"); expect(launcherSource).toContain("function historyRestoreIncomplete()"); expect(launcherSource).toContain('name.startsWith("codex-history-backup-") && name.endsWith(".json")'); - expect(launcherSource).toContain("if (historyRestoreIncomplete())"); + expect(launcherSource).toContain("if (historyOnlyStop || historyRestoreIncomplete())"); const warnAt = launcherSource.indexOf("Codex resume-history metadata restore is incomplete"); const installAt = launcherSource.indexOf("transactionalNpmUpdate({"); expect(warnAt).toBeGreaterThan(-1); @@ -320,9 +322,16 @@ esac }); test("the stop gate covers service-managed and orphaned proxies whose pid file is stale/missing", () => { - expect(updateSource).toContain("if (serviceWasInstalled || readPid() || readRuntimePort())"); - expect(launcherSource).toContain("if (serviceWasInstalled || hasRuntimeState)"); - expect(launcherSource).toContain("stopRes.status !== 0 || stillHasRuntimeState"); + // A pending-teardown receipt is a fourth reason to stop: after a parent crashed + // mid-deferral the service, pid and runtime records can all be absent while shared + // client config still points at a proxy that is gone (#3008). + expect(updateSource).toContain("if (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding())"); + expect(launcherSource).toContain("if (serviceWasInstalled || hasRuntimeState || hasPendingTeardown)"); + // The rule now lives in the shared post-stop decision both lanes import (#3008): a + // history-only stop proceeds, every other nonzero status and any surviving runtime + // state aborts. Pinned by tests/update-stop-classification.test.ts. + expect(launcherSource).toContain("decidePostStopUpdate({"); + expect(launcherSource).toContain("hasRuntimeState: stillHasRuntimeState"); }); test("GUI worker update children use pipe stdio so background updates do not open consoles", () => { diff --git a/tests/v2-agent-message-failfast.test.ts b/tests/v2-agent-message-failfast.test.ts index 5501c3362b..9e218dec11 100644 --- a/tests/v2-agent-message-failfast.test.ts +++ b/tests/v2-agent-message-failfast.test.ts @@ -32,6 +32,10 @@ const ROUTING_ENVELOPE = [ "", ].join("\n"); +// The same envelope a delegated agent uses to REPLY, as opposed to being spawned. +// #3021 saw one of these reach the parent conversation as raw `gAAAA...` text. +const MESSAGE_ROUTING_ENVELOPE = ROUTING_ENVELOPE.replace("NEW_TASK", "MESSAGE"); + afterEach(() => { globalThis.fetch = originalFetch; }); @@ -135,6 +139,46 @@ describe("V2 routed agent-message ciphertext guard", () => { ]))).toBe(true); }); + /** + * #3021: a delegated subagent's MESSAGE reply reached the parent conversation as + * raw `gAAAA...` ciphertext after an `adapter_eof`. + * + * The detector decides "unreadable" by stripping the routing envelope and asking + * whether any plaintext survives, so an envelope shape it does not recognise counts + * as surviving text. The envelope pattern matched only NEW_TASK, so a MESSAGE whose + * entire body was one Fernet token measured as READABLE and was forwarded verbatim. + * + * This is the detection half only. Recovery stays NEW_TASK-only on purpose: + * decrypting a MESSAGE on the parent's behalf would build a plaintext oracle out of + * a payload the parent's session may not be entitled to read. + */ + test("blocks a MESSAGE reply envelope followed only by a Fernet payload", () => { + expect(hasUnreadableEncryptedAgentTask(agentMessage([ + { type: "input_text", text: MESSAGE_ROUTING_ENVELOPE }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ]))).toBe(true); + }); + + test("blocks a MESSAGE envelope carried inside the encrypted slot itself", () => { + // The shape the report describes: header and ciphertext arrive as one + // encrypted_content string rather than as separate parts. + expect(hasUnreadableEncryptedAgentTask(agentMessage([ + { + type: "encrypted_content", + encrypted_content: `${MESSAGE_ROUTING_ENVELOPE}${FERNET_TASK}`, + }, + ]))).toBe(true); + }); + + test("a MESSAGE reply that carries real text stays readable", () => { + // The control. Widening the envelope must not turn every agent reply into a + // blocked one -- only the ones with nothing left after the header comes off. + expect(hasUnreadableEncryptedAgentTask(agentMessage([ + { type: "input_text", text: `${MESSAGE_ROUTING_ENVELOPE}the worker finished the migration` }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ]))).toBe(false); + }); + test("blocks a control preamble mixed into the Fernet slot before sanitization", async () => { const input = agentMessage([ { type: "input_text", text: ROUTING_ENVELOPE }, diff --git a/tests/vision-eligibility.test.ts b/tests/vision-eligibility.test.ts index 6469b6cb0a..e28d406978 100644 --- a/tests/vision-eligibility.test.ts +++ b/tests/vision-eligibility.test.ts @@ -4,6 +4,7 @@ import type { OcxConfig } from "../src/types"; import { BASELINE_VISION_MODELS, isVisionEligibleModel, + isVisionSidecarConsumer, modelAcceptsImageInput, visionBackendForCandidate, visionEligibleModelOptions, @@ -54,6 +55,24 @@ describe("vision eligibility core", () => { expect(modelAcceptsImageInput(config, candidate)).toBe(false); }); + test("2b. configured text-only rows are consumers, but audio-only rows are not", () => { + const config = configWithProviders({ + test: { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + modelInputModalities: { + "text-only": ["text"], + "audio-only": ["audio"], + "text-and-image": ["text", "image"], + }, + }, + }); + + expect(isVisionSidecarConsumer(config, "test", "text-only")).toBe(true); + expect(isVisionSidecarConsumer(config, "test", "audio-only")).toBe(false); + expect(isVisionSidecarConsumer(config, "test", "text-and-image")).toBe(false); + }); + test("3a. silent catalog rows fall back to generated metadata (live /api/models shape)", () => { // anthropic / claude-opus-4-6 carries ["text","image"] in the generated table, but live // /api/models rows omit inputModalities entirely. diff --git a/tests/vision-text-only-predicate.test.ts b/tests/vision-text-only-predicate.test.ts index 02577902b6..05574bf9bb 100644 --- a/tests/vision-text-only-predicate.test.ts +++ b/tests/vision-text-only-predicate.test.ts @@ -19,6 +19,10 @@ describe("isModelTextOnly (#1024)", () => { expect(isModelTextOnly(provider({ modelInputModalities: { "vision-model": ["text", "image"] } }), "vision-model")).toBe(false); }); + test("returns false for audio-only modelInputModalities", () => { + expect(isModelTextOnly(provider({ modelInputModalities: { "audio-model": ["audio"] } }), "audio-model")).toBe(false); + }); + test("returns false for unknown models (no evidence)", () => { expect(isModelTextOnly(provider(), "unknown-model")).toBe(false); });