diff --git a/devlog/_plan/260803_codex_desktop_toggle/000_plan.md b/devlog/_plan/260803_codex_desktop_toggle/000_plan.md index 33b319e0e..8a2814875 100644 --- a/devlog/_plan/260803_codex_desktop_toggle/000_plan.md +++ b/devlog/_plan/260803_codex_desktop_toggle/000_plan.md @@ -1,81 +1,130 @@ -# Codex and Claude Desktop toggles +# Client integration switches: Codex, Claude Desktop, and the memory they need -Split out of `260803_integrations_toggle_all` after its fourth audit -(`../260803_integrations_toggle_all/007_audit_synthesis_r4.md`). That unit keeps -Claude Code and Grok, whose toggles need no durable operation state. These two -need one, and it is the shared dependency four audit rounds kept circling. +Split out of `260803_integrations_toggle_all` after its fourth audit. That unit +shipped Claude Code and Grok. This one was scoped around a durable +operation-state engine — and a research cycle against the real code says that +engine solves the wrong problem. -## Why these two are together and separate +> **Re-planned 260803 after four research passes.** The rollback engine +> (`010_operation_state.md`, never written) is **dropped**. Evidence: +> `001`-`004`. The replacement is smaller, and it also fixes a defect in the +> toggle we shipped hours ago. -| | Codex | Claude Desktop | -|---|---|---| -| Artifacts | `config.toml`, `opencodex.config.toml`, model catalog, resume history (SQLite) | `.json`, `.bak`, `_meta.json`, four fields in our config | -| Owner | us, mostly | **Claude Desktop** — we edit another app's registry | -| Prior state recoverable? | from its own journal, when hashes still match | only from `_meta.json` bytes; `appliedId` was never recorded | -| Removal code exists? | yes, `restoreNativeCodex` | **no** | +## The requirement in the owner's words -Neither can be undone by re-running its enable path, which is what makes Claude -Code and Grok cheap. Codex's enable is not the inverse of its disable — the -journal fallback means a disable may strip fragments rather than restore bytes, -and a later enable writes today's catalog, not yesterday's arrangement. Desktop -cannot re-derive which profile the user had selected at all. +> "스위치를 꺼도 프록시는 살아있어야 돼. 코덱스 말고 다른 것만 켜고 싶을 수도 있잖아." -So both need an operation record that outlives the request, and that record is -the first phase. +Turning a client off must leave the proxy running and serving every other +client. That is two obligations: the mutation must not stop the proxy, and the +OFF must survive a restart. + +## What the research changed + +| Prior claim | What the code says | +|---|---| +| Codex needs a durable operation-state engine | `ocx restore` already restores native Codex **without stopping the proxy** (`src/cli/help.ts:18`), and `ocx restore back` is the enable direction. Both exist. (`001`) | +| Desktop removal is impossible to do safely | Anthropic documents that a selected config without a valid `inferenceProvider` launches **standard mode**. We aim at that instead of guessing. (`002`) | +| The missing piece is crash recovery | The missing piece is **desired state**. Only Claude Code has one; Grok's shipped toggle is silently re-enabled by the next `ocx start`. (`003`) | + +The fourth research doc (`004`) is an unrelated live defect found while looking: +one out-of-enum modality value makes a client reject its entire config. It joins +this unit as an independent work-phase (LOOP-UNIT-CHAIN-01). ## Read first -From the parent unit, all still authoritative: +In this unit, all four written this cycle: + +- `001_native_restore_thesis.md` — Codex restore, its asymmetries, the history lock +- `002_desktop_standard_mode.md` — the official standard-mode contract, and why `default` is not the restore verb +- `003_durable_desired_state.md` — desired vs observed, the Grok regression, the schema +- `004_export_modality_poisoning.md` — the gjc/Pi enum defect -- `001_removal_path_inventory.md` — what each client's disable actually costs -- `003`, `004`, `005`, `007` — the four audit syntheses; `007` is why this unit - exists -- `002_consequence_dialog_ux.md` — dialog direction; the Codex and Desktop copy - lives there +From the parent unit, still authoritative: `001_removal_path_inventory.md` +(what each disable costs) and `002_consequence_dialog_ux.md` (dialog direction). +Its `007_audit_synthesis_r4.md` is the reason this unit exists, but its central +conclusion is now **superseded** by `001`-`003` here. ## Phases -| Phase | Doc | Deliverable | -|---|---|---| -| WP1 | `010_operation_state.md` | The durable operation record: a versioned discriminated journal entry, prepare/commit, restart reconciliation, a field-scoped config writer | -| WP2 | `020_codex_toggle.md` | Codex disable/enable on top of it | -| WP3 | `030_desktop_toggle.md` | Desktop removal + rollback, `appliedProfileId` schema work | - -WP2 and WP3 are **parallel siblings**, not a sequence: Desktop does not depend on -Codex (audit r4 #11). Both depend on WP1 and nothing else. - -## What WP1 must deliver - -Named by audit round 4, findings #1, #2, #5, #3: - -1. **A versioned discriminated journal entry.** Today's `JournalEntry` - (`src/integrations/journal.ts:34-51`) is file-shaped: one `configPath`, one - `SnapshotRef`, one `resultFingerprint`. A routing description and three - library members do not fit it. Needs `file-v1 | native-state-v1 | - desktop-hybrid-v1` with validated serialized state, per-member fingerprints, - and retention behavior. -2. **Prepare/commit with restart reconciliation.** A crash between the mutation - and the append leaves no undo state at all. Idempotent re-apply does not fix - this — it helps once a state exists, and none was recorded. The prepared - record must be durable before the mutation and resolved after. -3. **A field-scoped config writer.** `saveConfigPreservingClaudeCode` persists - the whole live object and its own docstring says a `providers` hand edit is - clobbered (`src/config.ts:2132-2135`); worse, when disk and caller both - changed `claudeCode`, the caller's stale subtree wins, so one toggle field can - clobber a concurrent Desktop-profile edit. Desktop's four fields need a write - that reloads from disk and touches only named paths. - -## Carried-forward findings - -Everything in `005` §Carried forward plus round 4's, in particular: Codex's -pre-state must capture the effective injection mode and history policy, not just -a routing kind (r4 #4); `injectCodexConfig` takes a concrete `catalogPath`, so -"selector" needs a resolver (r4 #4); Desktop's ordering is dangling-pointer-safe -but not transactionally crash-safe and must say so (r4 #6); auto-apply must be -suppressed while an operation is prepared (r4 #6). - -## Status - -Not started. `020` and `030` carry their pre-split content and are **stale** -against the operation-state design WP1 has not written yet; their next P -re-verifies them against it before any build. +**Re-sliced after audit round 2** (`006_audit_synthesis_r2.md`). The first map +sliced along a schema — one ten-key `clientIntegrations` map — which forced every +phase to touch every client's write path. Two audit rounds widened rather than +narrowed. This map slices along **ownership boundaries**: one client family per +phase, each independently auditable. + +| Phase | Doc | Deliverable | Audit state | +|---|---|---|---| +| WP2 | `010_modality_boundary.md` | The client-dialect modality filter | **clean through two rounds** | +| WP3 | `020_api_keys_row.md` | API keys out of the card grid into their own row | never blocking | +| WP4 | `030_desired_state.md` → re-scoped | Desired state for **Codex only**, plus its gates, single-flight, ownership preflight and CLI semantics | re-scope pending | +| WP5 | `040_codex_toggle.md` | The Codex switch on WP4's flag | rewrite pending | +| WP6 | *(new doc)* | Grok's desired state, reusing the shape WP4 proved | not started | +| WP7 | `050_desktop_toggle.md` | The Desktop switch via documented standard mode | deferred behind WP5 | + +The two pending docs were renamed so the decade order matches the phase order: +reading the unit lexicographically now gives the build order, which is the whole +point of the numbering convention. + +WP1 was the research cycle plus this roadmap, twice audited. + +WP2 and WP3 depend on nothing here and on each other not at all — they ship +first, alone, because each changes one thing at one boundary. That property is +the only thing that survived both audit rounds intact. + +Three deliberate exclusions, each an accepted audit finding rather than a +convenience: + +- **Claude Code's ingress gates are not touched at all.** Round 1 #1 established + that `claudeCode.enabled` is the documented kill switch for `/v1/messages`; + the honest conclusion is not to route it through a new helper in this unit. +- **The six file clients get no desired-state flag here.** Round 2 #4 (existing + explicit OFF choices are not migrated) and #5 (a mutating GET) both belong to a + phase that does not exist yet, and inventing it under audit pressure is what + produced round 2's new findings. +- **Desktop is behind Codex, not beside it.** Round 2 #3 found no coherent rule + when a foreign profile is selected. The goal explicitly permits an evidenced + deferral; this is one. + +## Scope boundary + +IN: `src/clients/config-export.ts`, `src/types.ts`, `src/config.ts`, +`src/codex/sync.ts`, `src/grok/sync.ts`, `src/cli/index.ts`, +`src/cli/opencode.ts`, `src/claude/desktop-3p.ts`, +`src/server/management/native-integration-routes.ts`, +`src/server/management/agent-settings-routes.ts`, `src/server/management-api.ts`, +`gui/src/pages/integrations/*`, `gui/src/styles-integrations.css`, +`gui/src/i18n/*`, `tests/`, `gui/tests/`. + +OUT: releases, publishing, deploys, tags; starring the repository; rewriting the +six-client file machinery in `src/integrations/`; `docs-site` restructuring; +recording the previous `appliedId` (deferred, `002` §Residual). + +## Criteria + +- C1 — gjc loads our emitted config with no schema error, proven from the real + file; Pi's identical exposure is closed in the same change. +- C2 — Codex stays disabled across a proxy restart, an `ocx ensure`, and a + `POST /api/sync`. +- C3 — an upgrading user with no `clientIntegrations` key sees no behavior change. +- C4 — disabling Codex never stops the proxy and never closes `/v1/responses`. +- C5 — Codex toggles both directions from the overview with the proxy running. +- C6 — a Codex disable blocked by the held history DB is an explained refusal + naming the cause, never a raw 500 and never a false green. +- C7 — startup convergence never touches state owned by a service running from a + different `OPENCODEX_HOME` (round 2 #2, blocking). +- C8 — API keys render as a row above the grid, observed rendered. +- C9 — typecheck, full test, gui lint, gui test, privacy scan all green. +- C10 — Desktop ships with a proven restore path OR is deferred with recorded + evidence. Both outcomes close this criterion. + +## Risk register + +| Risk | Mitigation | +|---|---| +| A gate silently unplugs a working client on upgrade | Absent key means ON, everywhere, with a test for the absent-config case (`003`) | +| Gating a safety path | Explicit do-not-gate list: journal repair, ownership checks, owned teardown, shared transports (`003`) | +| Desktop pointed at a missing file | Never delete `appliedId`, never leave it dangling, never pick an entry by the name `Default` — this machine's `Default` is already dangling (`002`) | +| The modality fix erases valid internal metadata | Filter at the client-dialect boundary only; management and CLI keep carrying `audio` verbatim (`004`) | +| A green suite hides the real failure | 91 tests pass today beside a config gjc refuses to load. Every criterion names a live artifact, not a unit test | +| A repair introduces a worse defect than the one it fixes | Round 2 produced exactly this. One client family per phase, re-audited before the next starts — never a cross-client rewrite under audit pressure | +| Convergence tears down a foreign home's state | Every native remover runs `assertNativeTeardownOwned` before removal, and reconciliation runs only after service-ownership resolution (round 2 #2) | diff --git a/devlog/_plan/260803_codex_desktop_toggle/001_native_restore_thesis.md b/devlog/_plan/260803_codex_desktop_toggle/001_native_restore_thesis.md new file mode 100644 index 000000000..edba568ba --- /dev/null +++ b/devlog/_plan/260803_codex_desktop_toggle/001_native_restore_thesis.md @@ -0,0 +1,144 @@ +# The native-restore thesis, tested against the code + +Research doc. No diffs here — the implementation designs live in the decade +docs. This one records what the code actually does, because the previous +unit's plan was built on a claim the code does not support. + +## The claim under test + +`000_plan.md` asserts that Codex and Claude Desktop both need a durable +operation-state engine — a versioned discriminated journal entry, prepare/commit +with restart reconciliation, and a field-scoped config writer — before either +can get a toggle. The owner's counter-thesis: the proxy keeps RUNNING while a +client is switched back to its native path, so each client only needs to be +returned to a path that already exists, not replayed from a recorded snapshot. + +For Codex the counter-thesis is correct, and the evidence is not subtle. + +## Codex: the toggle pair already ships as a CLI + +`ocx restore` is documented in `src/cli/help.ts:18-20` as: + +> Restore native Codex config without stopping the proxy; `restore back` +> re-points codex at the running proxy. + +That is the toggle, both directions, with the proxy up. `src/cli/index.ts:770` +calls `restoreNativeCodex()` with no lifecycle operation anywhere near it, and +`src/cli/index.ts:756` implements the enable direction as `syncModelsToCodex(live.port)` +against a proxy it first proves is live via `findLiveProxy()` (`:751`). + +Stronger still: `POST /api/stop` (`src/server/management-api.ts:181`) calls +`restoreNativeCodex()` FIRST and only then schedules the drain and exit. The +restore therefore already executes while the listener is serving. Whether the +proxy later stops is irrelevant to the restore itself. + +The service-stop path does verify the listener is gone before restoring +(`src/service.ts:2571`), but that check enforces the requested outcome "service +stopped" so success is not claimed while a supervisor respawns the process. It +is not a precondition inside `restoreNativeCodex()`. + +**Conclusion:** no durable operation-state engine is required to make Codex +switchable. The disable path exists, the enable path exists, and both are +proxy-agnostic. + +## What restore actually touches, and where it is not symmetric + +Four state groups, when no external `model_provider` owns the config: + +| Group | Restore behavior | Evidence | +|---|---|---| +| `config.toml` + `opencodex.config.toml` | Byte-exact from the journal when the injected hash still matches; otherwise strip owned fragments | `src/codex/journal.ts:109`, `src/codex/inject.ts:770` | +| Injection journal | Deleted on a complete restore, retained on a partial one | `src/codex/journal.ts:133` | +| Model catalog | Pristine backup + post-sync native additions, or drop slash-qualified routed rows keeping native ones | `src/codex/catalog/sync.ts:572-590` | +| Resume history | May update `state_5.sqlite`, patch/append rollout JSONL, consume the backup manifest | `src/codex/history-provider.ts:413,656,691` | + +When an external provider such as `custom` owns `model_provider`, restore removes +only the stale journal and deliberately leaves everything else alone +(`src/codex/inject.ts:765`). That is a pre-existing courtesy to a user who moved +off us by hand, and the toggle must preserve it rather than "fixing" it. + +Three asymmetries matter for the toggle's honesty: + +1. **Enable is `syncModelsToCodex()`, not `injectCodexConfig()`.** Injection + selects and writes a catalog path but does not build the routed rows; + `src/codex/sync.ts:83-110` refreshes the catalog and then injects, and that + is what `ocx restore back` uses. A toggle wired to bare injection would + re-point Codex at a catalog that no longer lists the routed models. +2. **A post-injection root model selection is destroyed, not restored.** If the + user edited config after we injected, the hash no longer matches, the + fallback strip runs, and a root `model = "provider/slug"` line is removed + (`src/codex/inject.ts:315,700`). Re-enabling has no record of that selection. + The dialog copy must not promise to put it back. +3. **Resume history is reversible but not byte-identical.** Restore patches line + one when safe and appends a `session_meta` + (`src/codex/history-provider.ts:81,444`); re-enabling appends another + provider change rather than deleting that history. + +## The history lock, and why the current return shape is not enough + +The write path mirrors Codex's five-second SQLite busy timeout and retries twice +with a 500 ms delay (`src/codex/history-provider.ts:25,526`). A Codex app or IDE +holding the WAL writer lock is exactly what makes it fail. + +At the low-level boundary the failure is structured: + +```ts +return withHistoryRetry(...) ?? { rows: 0, files: 0, failed: true }; +``` + +Recoverable busy/lock/permission failures return `failed: true`; corruption and +programming errors throw (`src/codex/history-provider.ts:511,536,577`). + +But `restoreNativeCodex()` discards that structure. `history.failed` only edits +the message string, and `success` stays `cfg.success` +(`src/codex/inject.ts:787,794`). A config restore that succeeded while the +history stayed locked returns roughly: + +``` +{ success: true, message: "... history could NOT be restored ..." } +``` + +So a GUI that trusts `success` reports a clean disable while routed threads stay +tagged `opencodex` and remain invisible in the native app. And `failed: true` +itself conflates lock contention with `EPERM`/`EACCES`. + +**Design consequence for `020`:** the toggle route must not consume +`restoreNativeCodex()`'s boolean. It needs the structured per-artifact outcome — +config, catalog, history — with the history failure carrying a classified reason, +so the card can render "disabled, but your routed threads are still hidden +because the Codex app is holding the history database" instead of a green check +or a raw 500. Parsing the message string is not acceptable. + +## The real hazard is not the socket + +The sibling explorer's central finding, and the one that reshapes this unit: +**there is no persisted per-client desired state.** After a disable, the next +proxy startup, `ocx ensure`, `ocx sync`, a provider/model mutation, or +`POST /api/sync` can silently re-inject Codex (`src/cli/index.ts:318,365`, +`src/server/management/config-routes.ts:261`). + +That is the actual engineering work this unit needs — and it is a much smaller +thing than the operation-state engine `000_plan.md` specified. A durable +desired-state flag per client, defaulting ON so no existing setup dies on +upgrade, consulted by every automatic apply path. It is a switch's memory, not a +rollback engine. + +`030` and the durable-state design pick this up; `010` is retired as specified +because the crash-recovery machinery it described is not what the evidence asks +for. + +## Observable consequences of a Codex disable + +What the consequence dialog must actually name: + +- plain `codex` returns to its native provider path (`src/codex/inject.ts:688`) +- routed `provider/model` catalog rows disappear; native rows survive + (`src/codex/catalog/sync.ts:578,590`) +- the generated `opencodex` profile is removed or replaced, and managed native + subagent defaults are restored or stripped (`src/codex/inject.ts:703`) +- previously routed threads are retagged to their native provider — or stay + hidden if the history DB was locked (`src/codex/history-provider.ts:677`) +- the proxy keeps serving Claude, Grok, the exported file clients, and direct API + callers, because restore contains no lifecycle operation at all + +That last line is the whole point of the feature. diff --git a/devlog/_plan/260803_codex_desktop_toggle/002_desktop_standard_mode.md b/devlog/_plan/260803_codex_desktop_toggle/002_desktop_standard_mode.md new file mode 100644 index 000000000..77976443d --- /dev/null +++ b/devlog/_plan/260803_codex_desktop_toggle/002_desktop_standard_mode.md @@ -0,0 +1,115 @@ +# Claude Desktop: standard mode is documented, so disable is buildable + +Research doc. This one overturns the load-bearing conclusion of +`../260803_integrations_toggle_all/001_removal_path_inventory.md` §Claude Desktop. + +## What the previous conclusion got right, and where it overreached + +Right, and re-confirmed here: + +- `apply` sets `_meta.json`'s `appliedId` to our id **unconditionally** and never + records the previous value (`src/claude/desktop-3p.ts:345,358`). Exact + "put back whatever was selected before" is impossible from our bookkeeping. +- `.json.bak` holds the previous profile, and that profile carries + `inferenceGatewayApiKey` — so the backup can contain a credential and nothing + cleans it up (`src/claude/desktop-3p.ts:371`). +- `/status.applied` is derived from the saved fingerprint, not from actual + selection (`src/server/management/agent-settings-routes.ts:797`), so a disable + that forgets to clear the markers keeps reporting `applied: true`. +- `desktopAutoApply` is enabled by ABSENCE (`src/types.ts:458-459`); its guard + suppresses only an explicit `false`, and the subagent-model update route can + re-create a removed profile (`agent-settings-routes.ts:130,518`). + +The overreach was treating "cannot restore the exact prior selection" as +"cannot safely disable". **Those are different requirements**, and only the +first one is blocked. + +## The official semantics that make disable safe + +Primary Anthropic documentation, opened and read (not inferred from our devlog). +Dates are deliberately omitted: the sitemap `lastmod` values originally recorded +here are not visible on the pages themselves, and an independent reviewer could +not confirm them. The semantic claims below WERE independently re-verified by +that reviewer, twice. + +- [Configuration reference](https://claude.com/docs/third-party/claude-desktop/configuration) +- [In-app configuration](https://claude.com/docs/third-party/claude-desktop/in-app-configuration) +- [Claude API provider](https://claude.com/docs/third-party/claude-desktop/claude-api) +- [Gateway provider](https://claude.com/docs/third-party/claude-desktop/gateway) + +The load-bearing sentence: third-party mode activates **only** when +`inferenceProvider` and that provider's required credentials are valid; +otherwise Desktop launches in **standard mode**. Configuration is read once at +launch. + +That is a documented contract we can aim at deliberately. We do not need to +guess what Desktop does with a missing or dangling `appliedId` — we can point it +at a configuration that is *present, readable, and deliberately without an +`inferenceProvider`*, which the docs say yields standard mode. + +## What stayed UNPROVEN, and why it no longer blocks us + +| Question | Status | +|---|---| +| Is there a documented "return to standard" UI button? | UNPROVEN | +| What happens when `appliedId` is absent? | UNPROVEN | +| What happens when `appliedId` dangles? | UNPROVEN | +| Is a `Default` entry guaranteed? | UNPROVEN | + +The design simply avoids all four. It never deletes `appliedId`, never leaves it +dangling, and never selects an entry merely because it is named `Default`. + +This machine proves why that last guard matters: the real `_meta.json` has a +`Default` entry **whose `.json` does not exist**. A disable that "just picks +Default" would have pointed Desktop at a missing file. The one observation the +earlier RCA generalized from was the exception, not the rule. + +The installed Desktop bundle (v1.18286.0) does appear to seed a `Default`/`{}` +profile when metadata does not yet exist, and its current reader does fall back +when the selected file is unreadable — but that is a bundle observation, not a +contract, and it does not upgrade any row above. + +## `default` is not the restore verb + +The hypothesis worth testing was that `ocx claude desktop default` already +performs an official restore. It does not. + +`default ` sets the default model **inside one opencodex +model family** (opus/fable/sonnet/haiku) via `setDesktopFamilyDefault`, saves +`claudeCode.desktopProfile`, and returns without touching Desktop's config +library or `appliedId` at all (`src/cli/claude-desktop.ts:148`, +`src/claude/desktop-profile.ts:219`). `move` likewise only edits our own routing +profile (`src/cli/claude-desktop.ts:138`). + +Neither is a disable mechanism. The name collides with the concept; the behavior +does not. + +## The disable sequence + +Ordered so Desktop is never pointed at something missing: + +1. Persist `desktopAutoApply: false` **first**, so no concurrent auto-apply + re-creates what we are about to remove. +2. Write a new opencodex-owned, credential-free configuration — no + `inferenceProvider` — under a fresh UUID, and set `appliedId` to it. +3. Only then remove the old `opencodex` entry, its `.json`, and its `.bak` + (the credential-bearing file). +4. Clear `appliedFingerprint` and `appliedAt` so `/status` stops claiming + `applied: true`. +5. Preserve `desktopProfile.assignments`/`defaults` so re-enabling does not throw + away the user's model organization. This machine currently has 33 assignments. + +Two explicit non-choices: do not route through `inferenceProvider: "anthropic"`, +because that is direct Claude API billing rather than the user's normal +subscription mode; and do not reuse the `default` subcommand. + +Because configuration is read once at launch, the disable does not take effect +until Desktop restarts. The consequence dialog must say that plainly rather than +implying an instant switch. + +## Residual, deliberately deferred + +Recording the previous `appliedId` at apply time is still worth doing, but it now +buys only the stronger feature: "restore exactly whichever *other* third-party +provider was active before opencodex." Returning the user to standard Claude does +not need it. That keeps it out of this unit's critical path. diff --git a/devlog/_plan/260803_codex_desktop_toggle/003_durable_desired_state.md b/devlog/_plan/260803_codex_desktop_toggle/003_durable_desired_state.md new file mode 100644 index 000000000..34f8811ae --- /dev/null +++ b/devlog/_plan/260803_codex_desktop_toggle/003_durable_desired_state.md @@ -0,0 +1,133 @@ +# Desired state vs observed state — the thing this unit actually needs + +Research doc. It replaces the durable-operation-state design `000_plan.md` WP1 +asked for. That design solved crash recovery; the evidence says the real defect +is that a switch has no memory. + +## The requirement, stated exactly + +"Turning a switch off must leave the proxy running and serving every other +client. I might want everything except Codex on." + +That has two halves, and only the first was ever examined: + +1. **The mutation must not stop the proxy.** Already true for Codex + (`001_native_restore_thesis.md`) and for the two shipped toggles. +2. **The OFF must survive.** Not true today for anything except Claude Code. + +## The distinction everything turns on + +| | Desired state | Observed state | +|---|---|---| +| Means | what the user asked for | what is currently on disk | +| Survives a restart | yes | only accidentally | +| Can gate an automatic path | yes | no — it IS the thing the path rewrites | + +Almost every artifact we have is observed, not desired: + +| Artifact | Which | Client | +|---|---|---| +| `config.claudeCode.enabled` | **desired** | Claude Code | +| `claudeCode.desktopAutoApply` | desired *policy*, but only "auto-rewrite a saved profile" — not "Desktop is enabled" | Claude Desktop | +| `desktopProfile.appliedFingerprint` / `appliedAt` | observed | Claude Desktop | +| Six-client ownership records | observed provenance — and **disable deletes the record** (`src/integrations/writer.ts:373-384`), so it structurally cannot carry an OFF | the six file clients | +| Grok fence presence | observed disk artifact (`src/grok/inspect.ts:19-44`) | Grok | +| Codex journal | observed recovery artifact (`src/codex/journal.ts:10-18`) | Codex | +| `codexAutoStart` | desired *lifecycle* policy; false makes `ocx ensure` skip the proxy entirely (`src/cli/index.ts:358-364`) | not a client flag at all | + +The ownership-record row is the sharpest proof. A record that is deleted by the +very operation whose intent we want to remember can never be that memory. + +## The shipped Grok toggle is already broken this way + +Not a hypothetical. `PUT` OFF strips the fence and returns, persisting nothing +(`src/server/management/native-integration-routes.ts:228-256`). Then: + +- every `ocx start` rewrites it (`src/cli/index.ts:334-341`) +- both branches of `ocx ensure` rewrite it (`src/cli/index.ts:372-379`, `:398-404`) +- service start, login start, dashboard restart and tray restart all funnel into + startup (`src/service.ts:318-340`, `src/server/management/system-restart.ts:90-170`) +- `POST /api/grok/apply` regenerates it directly (`agent-settings-routes.ts:639-652`) + +`syncGrokConfig` has no enabled check at all (`src/grok/sync.ts:29-65`). So a +user who turns Grok off gets it back on the next restart, silently. That is a +regression against the switch we shipped hours ago, and it lands in this unit +because the fix is the same schema. + +Claude Code is the counter-example that proves the shape works: `enabled` is +persisted and every automatic consumer honors it — system env +(`src/server/system-env.ts:251-256`), the launcher (`src/cli/claude.ts:236-242`), +agent sync (`src/claude/agents-inject.ts:247-254`), inbound +(`src/server/claude-messages.ts:65-69`), model discovery +(`src/server/index.ts:493-502`). + +## Codex's automatic re-apply paths + +All ungated today: + +| Path | Trigger | +|---|---| +| `ocx start` | every proxy start (`src/cli/index.ts:318-341`) | +| `ocx ensure` (both branches) | tray, restart, many commands (`src/cli/index.ts:365-411`) | +| `POST /api/sync` | dashboard sync (`config-routes.ts:261-268`) | +| `ocx sync`, `ocx restore back` | explicit, but should still respect OFF | +| `ocx models custom add/remove` | any custom-model edit with a live proxy (`src/cli/models.ts:102-206`) | +| provider/model/combo mutations | via `refreshCodexCatalogBestEffort` (`management-api.ts:105-112`) — catalog only, not injection | + +The last row matters for scoping: provider and model mutations rewrite catalog +artifacts but do NOT call `injectCodexConfig`, so they need their own gate rather +than riding on the sync gate. + +`ocx opencode` deserves its own line: it injects `provider.opencodex` inline via +`OPENCODE_CONFIG_CONTENT`, and that inline layer outranks disk config +(`src/cli/opencode.ts:461-477,531-572`). The six file clients look safe from +auto-reapply only because no automatic writer calls them — an accident, not a +guarantee — and this path already bypasses it. + +## The schema + +In `OcxConfig`, not the integrations store — the store holds observed state by +construction: + +```ts +type ClientIntegrationId = + | "codex" | "claude-code" | "claude-desktop" | "grok" + | "opencode" | "pi" | "hermes" | "openclaw" | "kimi" | "gajae"; + +clientIntegrations?: Partial>; +``` + +Effective state is `config.clientIntegrations?.[client] !== false`. A missing +map, a missing key, and an explicit `true` all mean ON, so **no existing setup +changes behavior on upgrade**. That defaulting is not a convenience; it is the +only acceptable migration for a feature that can silently unplug someone's +working client. + +Compatibility rules: + +- `claude-code` absent → fall back to `config.claudeCode?.enabled !== false`, and + mirror both during the transition, so an existing Claude OFF never migrates + itself back to ON. +- Never infer Desktop OFF from `desktopAutoApply: false`. Different intent. +- **Desired intent persists independently of mutation success.** If a disable + hits an ownership refusal or drift, keep desired `false` and report + "desired OFF, observed conflict". Otherwise the next automatic path quietly + undoes what the user asked for. + +## What must NOT be gated + +A gate in the wrong place turns a safety mechanism off: + +- Codex crash-journal reconciliation (`src/codex/journal.ts:148-162`) — it repairs + our own stale state. +- Ownership, drift and compare-before-write checks (`src/integrations/writer.ts:171-223`). +- Owned teardown on stop/uninstall (`src/service.ts:2587-2594`). Stopping the + service must never rewrite desired ON into OFF. +- Grok's non-loopback credential-safety cleanup (`src/grok/inject.ts:359-380`). +- **Shared transports.** Codex OFF must not disable `/v1/responses`, which other + clients use; Claude Code and Desktop flags must not shut down their shared + `/v1/messages`. Disabling an integration means "stop writing into that client's + config", never "stop serving". + +That last rule is the user's requirement restated as an invariant, and it is the +one an implementer is most likely to violate while feeling productive. diff --git a/devlog/_plan/260803_codex_desktop_toggle/004_export_modality_poisoning.md b/devlog/_plan/260803_codex_desktop_toggle/004_export_modality_poisoning.md new file mode 100644 index 000000000..5c23869fa --- /dev/null +++ b/devlog/_plan/260803_codex_desktop_toggle/004_export_modality_poisoning.md @@ -0,0 +1,122 @@ +# One rejected modality value poisons a whole client config + +Research doc. A live bug, its blast radius across the other five exporters, and +where the fix belongs. + +## The observed failure + +Gajae Code refuses to load its entire config: + +``` +Failed to load config file models, Schema error: +/providers/opencodex/models/30/input/2: Invalid option: expected one of "text"|"image" +``` + +Model index 30 (0-based) is `zenmux/meta-muse-spark-1.1`, whose `input` we wrote +as `[text, image, audio]`. Index 2 of that array is `audio`. + +The user-visible consequence is out of all proportion to the cause: gjc falls +back to its built-in Anthropic list, so every routed model disappears at once. +One value in one model takes down the whole file. + +## Why we emit a value the client rejects + +Our internal modality vocabulary is `text | image | audio` +(`src/server/management/model-routes.ts:13`, `src/cli/models.ts`), and the two +affected exporters copy it through verbatim: + +- `buildGajaeClientConfig` — `src/clients/config-export.ts:765` +- `buildPiClientConfig` — `src/clients/config-export.ts:659` + +Both clients accept only `text | image`. Gajae's installed schema pins it at +`@gajae-code/coding-agent/src/config/models-config-schema.ts:119`, and Pi's +upstream source does the same in `packages/coding-agent/src/core/model-config.ts:156-169`, +with whole-file rejection at `:267-274` — Pi returns an EMPTY model config on a +schema failure rather than dropping the offending entry. + +**So Pi carries the identical bug.** It has not been observed only because this +machine's `~/.pi/agent/models.json` is currently empty. This is a latent live +defect, not a hypothetical. + +A stale comment at `config-export.ts:649` still calls Pi's schema UNVERIFIED. +It is verified now, and it says `text|image`. + +## This exact bug already happened once + +`tests/catalog-input-modality-enum.test.ts:5-12` records the precedent in its own +words: zenmux advertised `video`, we wrote it through verbatim, and the Codex app +reported `unknown variant 'video'` **while showing zero apps** — because the +catalog is referenced from config, so the rejection cascaded into plugins, apps +and MCP servers. + +`ensureStrictCatalogFields` (`src/codex/catalog/parsing.ts`) was the fix for the +Codex path: filter to the accepted enum, and fall back to `["text"]` rather than +an empty list, because a modality-less entry would leave the client unable to +tell the model takes prompts at all. + +The lesson did not generalize to the client exporters. Same class, same shape, +different destination. + +## The other four exporters + +| Client | Emits modalities? | Residual risk | +|---|---|---| +| OpenCode | no | ids/names/context unvalidated against client semantics | +| Hermes | no | selector strings unvalidated | +| OpenClaw | no | ids/names/context unvalidated | +| Kimi | no | alias/model characters unchecked; non-finite contexts already omitted | + +No modality exposure outside Pi and Gajae. The residual rows are marked INFERRED +by the audit — only Gajae's and Pi's schemas were directly verified — so they are +recorded as follow-up, not folded into this unit. + +Two things that are NOT at risk, checked rather than assumed: syntax injection +via quotes/newlines is prevented by the serializers +(`src/integrations/serialize.ts:52,167,224`), and numeric handling already omits +zero/negative/NaN/Infinity and floors fractions (`config-export.ts:420,431`). +There is no upper sanity cap on context, which is a real but separate gap. + +## Where the fix goes + +At the **client-dialect boundary**, as one helper beside `authoritativeContextWindow`: + +```ts +inputModalitiesForClient(client, inputModalities) +``` + +Accepted vocabulary `text|image` for both current callers; preserve order, +dedupe, and fall back to `["text"]` when filtering empties the list — matching +the Codex precedent exactly. + +Called from the two emission sites only: `config-export.ts:659` (Pi) and `:765` +(Gajae). + +Two rejected alternatives, each for a concrete reason: + +- **Not in `ExportModel` construction.** The management and CLI boundaries carry + catalog modalities verbatim on purpose (`src/server/management/model-rows.ts:91`, + `src/cli/export-command.ts:82`). Stripping `audio` globally would destroy valid + internal metadata before the destination is known. +- **Not inside `normalizeExportModels`.** Its documented and tested contract is + first-wins dedupe plus deterministic sorting (`config-export.ts:508`, + `tests/client-config-export.test.ts:261`). Overloading it hides the behavior + from the place a reader would look for it. + +## What the existing tests do and do not pin + +Pinned: Pi's `["text"]` default, `[text,image]` preservation, numeric +omission/clamping, and a byte-exact golden +(`tests/client-config-export.test.ts:184,191,201,343`). Gajae's tests pin allowed +**field names**, not value enums +(`tests/client-config-export-new-clients.test.ts:251`). + +Nothing exercises `audio` or any rejected modality, which is precisely why 91 +green tests coexist with a config the client refuses to load. The regression test +must assert the emitted VALUE vocabulary for both clients, and the fix must not +disturb the byte-exact golden beyond the intended modality change. + +## Verification bar + +A unit test alone does not close this. The criterion is the actually emitted +file: re-apply the gajae integration and confirm gjc loads it with no schema +error. The bug was found in a real file, and it gets closed in one. diff --git a/devlog/_plan/260803_codex_desktop_toggle/005_audit_synthesis.md b/devlog/_plan/260803_codex_desktop_toggle/005_audit_synthesis.md new file mode 100644 index 000000000..cbb0dbd44 --- /dev/null +++ b/devlog/_plan/260803_codex_desktop_toggle/005_audit_synthesis.md @@ -0,0 +1,129 @@ +# Audit round 1 — synthesis + +Verdict: **FAIL**, 7 High plus 3 Medium and 1 Low. Independent reviewer, auditing +the roadmap against the real tree rather than against its own claims. + +What survived matters as much as what failed, so it goes first. + +## Confirmed by the reviewer + +- The **overturned conclusion is justified.** The full operation-state engine is + not inherently required for "OFF means converge to native/standard mode". The + predecessor unit's central finding really was too strong. +- The **official Anthropic citation is genuine.** The reviewer opened + the configuration reference itself and confirmed both the standard-mode + sentence and read-once-at-launch. +- Pi's `text|image` restriction is real, re-confirmed from current upstream docs. +- Absent `clientIntegrations` keys really do default ON. +- The whole-catalog modality assertion really would have caught the gjc bug. +- Numbered lexicographic split and diff-level coverage are satisfied. + +So the direction holds and WP2 is clean. Everything below is about the machinery +the roadmap put around it. + +## The one defect seen five ways + +Findings #2, #3, #4, #6 and #7 are one thing: **I designed a flag and called it a +state machine.** + +A durable boolean answers "what does the user want". It does not answer: + +- who writes it safely when two processes write at once (#2) +- who writes it at all for six of the ten clients (#3) +- what the CLI prints when it skips because of it (#4) +- what stops an in-flight writer that read it before the flip (#6) +- what converges observed state when we crash between persist and mutate (#7) + +I dropped the operation-state engine because its *rollback* half was +disproportionate. That was right. But the engine also carried a *coordination* +half — single-flight, ordering, restart reconciliation — and I dropped that too, +without noticing it was load-bearing for a different reason. + +#7 is the sharpest version. I wrote in `003` that a boolean "survives a restart", +which is true of the boolean and false of the system: persist OFF, crash before +the remover runs, and restart merely *skips future writes*. Desired OFF, observed +ON, forever, with nothing that ever reconciles them. The reviewer is right that +this is not established by anything I wrote. + +**Disposition: accept all five.** The replacement is not the old engine. It is +three specific mechanisms, and they belong in WP3 where the flag lives: + +1. **Field-scoped persistence.** `mutatePersistedConfig` already exists + (`src/config.ts:1854`) — I specified `saveConfigPreservingClaudeCode` without + checking whether the better primitive was already in the tree. It was. This + also removes the "mutate live object before persisting" bug the reviewer found + in the shipped route. +2. **Per-client single-flight around every irreversible write**, with desired + state re-read immediately before the write, not only at entry. +3. **Startup reconciliation**: desired OFF is a *converge* instruction, so + startup re-runs the idempotent remover rather than only skipping. + +That is meaningfully smaller than a versioned discriminated journal with +prepare/commit, and it is what the evidence actually demands. + +## The finding I most need to accept, and why + +**#1 — removing the Claude transport gates.** I had flagged this myself as the +decision most likely to matter, and the reviewer's judgment is that it is wrong. + +My reasoning was the invariant "disabling an integration means stop writing that +client's config, never stop serving." That invariant is correct **for +installation state** and I over-applied it to **ingress admission**. +`claudeCode.enabled` is documented and implemented as the kill switch for +`/v1/messages` (`src/server/claude-messages.ts:65-69`) and empties Anthropic +discovery (`src/server/index.ts:496`). An upgrading user whose switch reads OFF +would silently get an ingress that starts accepting traffic again. That is a +shipped-behavior regression dressed as a principle. + +**Accept.** Keep both gates, drive them through +`clientIntegrationEnabled(config, "claude-code")`, and add the compatibility test +proving a legacy `enabled: false` config still gets 403 and an empty model list +after migration. The invariant survives in its correct form: Codex OFF must not +close `/v1/responses`, which no client-specific gate ever guarded. + +## #5 — the phases do not compose + +WP5 and WP6 were dispatched in parallel and each wrote a diff against the tree as +it is today, not against the tree as the other leaves it. WP6 even re-types the +client union as `"claude" | "claude-desktop" | "grok"` — dropping the Codex entry +WP5 adds. + +That is my dispatch error, not the writers'. Two authors editing the same route +and the same union need one contract they both consume. + +**Accept.** WP3 owns the complete shared contract: the four-client union, the +status/success response schema including `desiredEnabled`, the refusal envelope, +and the status helpers. WP5 and WP6 are rewritten against it, and they are +**sequential, not parallel**, wherever their diffs touch the same file. + +## #8 and #9 — Desktop status honesty + +Both accepted, and #9 is the more embarrassing: a status read that hardcodes +`installed: true` and creates the config library when metadata is missing would +manufacture Claude Desktop directories on a machine that never had Desktop. A +read must not write. Observed state gets derived from `_meta.json`'s selected id +and the selected profile's actual contents, not from our own fingerprint. + +## #10, #11 + +#10 accepted: the test plans cover the happy paths and none of the five failure +modes above. Each new mechanism lands with the test that would catch its absence. + +#11 accepted with a correction to my own habit: several line citations drifted by +a few lines, and I asserted a `lastmod` date the reviewer could not see on the +page. The semantic claim was verified; the metadata was not. Cite what the page +says, not what the sitemap claimed about it. + +## Nothing rebutted + +Eleven findings, eleven accepted. That is not deference — I checked #1's gates, +#2's `mutatePersistedConfig`, and #3's toggle route in the tree before accepting. + +## Next + +A is a loop, and a FAIL round never exits it (AUDIT-LOOP-01). The amendments +above go into `020`, `040` and `050`, WP3 absorbs the shared contract and the +three coordination mechanisms, then the SAME reviewer re-audits. + +WP2 (`010_modality_boundary.md`) is untouched by every finding and stays as +written. diff --git a/devlog/_plan/260803_codex_desktop_toggle/006_audit_synthesis_r2.md b/devlog/_plan/260803_codex_desktop_toggle/006_audit_synthesis_r2.md new file mode 100644 index 000000000..f918b7a54 --- /dev/null +++ b/devlog/_plan/260803_codex_desktop_toggle/006_audit_synthesis_r2.md @@ -0,0 +1,109 @@ +# Audit round 2 — synthesis, and the replan it forces + +Verdict: **FAIL**. Five of eleven closed, six still open, and **six NEW findings** +including three High. + +## The number that decides this round + +| Round | Closed | Still open | New | +|---|---|---|---| +| 1 | — | — | 11 | +| 2 | 5 | 6 | 6 | + +A converging audit closes more than it opens. This one did not. And the new +findings are not nitpicks — they are the same *class* as the old ones, found one +layer deeper: + +- round 1 said "persistence is unsafe" → round 2 says the fix drops the + `authModeMigratedAt` sentinel, so a client toggle silently pins the user's + Claude auth mode (`src/claude/auth-mode-migration.ts:16-31`) +- round 1 said "in-flight writers can re-enable" → round 2 says teardown callers + in `src/service.ts:2587-2592` and `management-api.ts:181-186` still bypass the + flight entirely +- round 1 said "startup must converge" → round 2 says the convergence I added can + **tear down another installed service's Codex/Grok state** from a different + `OPENCODEX_HOME`, because the registry calls the removers directly without + `assertNativeTeardownOwned` + +That last one is the tell. My fix for a finding *created a worse defect than the +finding*. And #5 (GUI union ownership) is still open after I explicitly assigned +the shared contract to WP3 — because I put the server contract there and left the +GUI parser unowned. + +## Root cause (LOOP-REPAIR-01 → root-cause mode) + +Two failed repair rounds on the same failure means stop patching and diagnose. + +The diagnosis: **I coupled ten clients into one schema change.** `clientIntegrations` +as a ten-key map forced every phase to touch every client's write path, so each +round of fixes widened the blast radius instead of narrowing it. Codex, Grok, +Desktop, Claude Code and six file clients each have different ownership rules, +different teardown callers, and different migration histories. One map made them +one problem. + +The evidence that this is the cause rather than bad luck: **WP2 passed both +rounds untouched.** It is the only phase that changes one thing at one boundary. + +## The replan + +Return to P (LOOP-REPAIR-01 escalation) with a decoupled map. Not a smaller +objective — the same four deliverables, sliced so each is independently +auditable. + +1. **WP2 modality fix ships first, alone.** Clean through two adversarial rounds. + It fixes a live user-facing failure and depends on nothing here. +2. **WP4 API-keys row ships second, alone.** Never audited as blocking; pure GUI; + no coupling to the schema. +3. **Desired state is re-scoped to ONE client: Codex.** A `codex` flag, its + gates, its single-flight, its ownership preflight, its CLI semantics. Not a + ten-key map. Grok's regression gets its own later phase reusing whatever + shape survives audit. +4. **Claude Code's gates are left exactly as they are.** Round 1 #1 proved I had + no business touching that ingress; the amended plan keeps them, and now the + honest move is to not route them through a new helper at all in this unit. +5. **Desktop moves behind Codex** and is re-audited on its own once the + one-client shape is proven. The goal explicitly permits an evidenced deferral, + and round 2 #3 (no coherent rule when a foreign profile is selected) plus #6 + (marker cleanup failure ignored) say it is not ready. + +## What carries forward regardless + +Verified across both rounds and not in dispute: the native-restore thesis +(`001`), the official standard-mode contract (`002`, re-opened by the reviewer in +round 2), the Grok regression (`003`), and the modality defect (`004`). The +research holds. It was the *phase map* that was wrong, which is exactly what +PHASE-SPLIT-01 exists to catch and what I got wrong by slicing along a schema +instead of along ownership boundaries. + +## Carried-forward findings for the re-scoped phases + +Round 3 found my first version of this table incomplete — findings were missing, +two were assigned to one phase when three inherit them, and two pointed at a +"file-client phase" the roadmap does not contain. A ledger that quietly drops an +obligation is worse than no ledger, because the next author reads absence as +closure. Complete version, every finding from both rounds: + +| Finding | Status | Inherits | +|---|---|---| +| r1 #1 Claude ingress gates | **scope-eliminated** — this unit no longer touches them | — | +| r1 #2 unsafe persistence | open | WP4 (Codex), then reused by WP6/WP7 | +| r1 #3 file clients have no writer | **deferred** — no file-client flag in this unit; tracked as `FOLLOWUP-FILECLIENT-01` | a future unit | +| r1 #4 false-green CLI | open | WP4 | +| r1 #5 GUI contract ownership | open | WP4 defines it; **WP5 and WP7 both extend it** | +| r1 #6 in-flight writers | open | WP4 for Codex, WP6 for Grok, WP7 for Desktop — each must prove its OWN callers | +| r1 #7 restart reconciliation | open | same three, per client | +| r1 #8 Desktop status from stale bookkeeping | open | WP7 | +| r1 #9 Desktop status can create files | closed in r2 | WP7 keeps the test | +| r1 #10 test adequacy | open | **every** phase — each lands the test that would catch its own absence | +| r1 #11 stale citations | fixed | `001`, `002`, and `010` in this cycle | +| r2 #1 auth-mode sentinel dropped | open | WP4 | +| r2 #2 reconciliation can tear down a foreign home | open — **blocking** | WP4, and every native remover after it | +| r2 #3 Desktop foreign-selected profile has no rule | open | WP7 — the reason Desktop is deferred | +| r2 #4 existing-disabled clients not migrated | **deferred** with r1 #3 | `FOLLOWUP-FILECLIENT-01` | +| r2 #5 mutating GET | open | WP4 — status GETs stay inspection-only | +| r2 #6 Desktop marker cleanup failure ignored | open | WP7 | + +`FOLLOWUP-FILECLIENT-01` is a named placeholder, not a phase: the six file +clients get no desired-state flag in this unit, so r1 #3 and r2 #4 are recorded +as owed work rather than silently dropped. Inventing that phase under audit +pressure is what produced round 2's new findings. diff --git a/devlog/_plan/260803_codex_desktop_toggle/007_audit_synthesis_wp4.md b/devlog/_plan/260803_codex_desktop_toggle/007_audit_synthesis_wp4.md new file mode 100644 index 000000000..105bf1207 --- /dev/null +++ b/devlog/_plan/260803_codex_desktop_toggle/007_audit_synthesis_wp4.md @@ -0,0 +1,103 @@ +# WP4 audit — synthesis + +Verdict: **FAIL**, 4 High blocking plus 3 Medium. Fresh reviewer, auditing the +re-scoped Codex-only doc. + +## What the re-scope bought + +The reviewer confirmed, by independent grep and trace, the things the previous +two rounds kept failing on: + +- **It really is Codex-only.** No surviving non-Codex gate, writer, or type + change; the Claude/Grok/Desktop/file-client mentions are exclusions or the + auth-sentinel proof. +- **The flag shape is extension-safe** and absent-key-means-ON holds against the + real load/save path. +- **The auth-sentinel claim holds**, and holds even when a second key is added + under `clientIntegrations`. + +So the diagnosis in `006` was right: slicing by ownership fixed the divergence. +Three rounds of findings about coupling are gone in one pass. + +What replaced them is narrower and, unlike the previous rounds, all of it is +about **Codex itself**. + +## The one defect behind #1 and #2 + +I designed a check where I needed a lock. + +`#1`: my "re-read intent immediately before the write" closes a window, it does +not eliminate one. An apply reads ON, another process commits OFF through the +*separate* config lock, and the apply proceeds into `atomicWriteFile`. Every +check/write pair has that gap, including the several writes hidden inside one +history callback. My own test would have passed while the bug was live, because +it flips OFF *before* the check rather than between check and write. + +`#2`: the ownership preflight fails open by design +(`ownership-preflight.ts:21`), and `readServiceInstallState` returns `null` for +corrupt, unreadable, and missing-mirror states alike (`service.ts:165`). That is +defensible for an interactive route where a human reads a refusal. It is wrong +for **unattended startup convergence**, where fail-open means "remove a foreign +home's Codex state and tell nobody". Worse, my ordering was wrong twice over: +startup runs `reconcileJournal` before my preflight, and my own flight database +would be created under `$CODEX_HOME` before the preflight runs — so the +"byte-exact refusal" claim is already false by the time the check happens. + +**Accept both.** The replacement: + +- one per-`CODEX_HOME` linearization lock covering **both** desired-state commits + and native commit sections, with model gathering left outside it +- a tri-state ownership answer — `owned | foreign | unknown` — where automatic + convergence fails **closed** on `foreign` and `unknown` +- ownership resolved before journal repair and before any lock artifact exists, + with the lock stored outside `CODEX_HOME`, keyed by canonical-path hash + +## #3 — the artifact I did not know about + +`restoreNativeCodex` restores config, profile, catalog and history. It never +touches `models_cache.json`, and apply writes routed data there +(`catalog/sync.ts:600`). So a converged OFF can report success while native +Codex still advertises routed models from the integration we just disabled. + +This is the same shape as the WP2 bug: a state that only shows up in the real +artifact, invisible to a test that asserts the artifacts it already knew about. +Cache restoration joins the remover and the observed-state inspection. + +## #4 — the command I gated without deciding what it means + +`ocx restore back` is the documented reverse switch (`cli/help.ts:18`). I gated +`syncModelsToCodex` beneath it and never said what the command does when Codex is +durably OFF — so it either cannot perform its documented job, or prints "now +routes through opencodex" after writing nothing. That is the false-green class +again, one phase after I wrote a doc section about it. + +Resolution, adopting the reviewer's split: + +| Path | Behavior while OFF | +|---|---| +| `POST /api/sync` | 409; an automatic-ish surface does not override intent | +| `ocx sync` | refuse, naming the switch | +| `ocx restore back` | **explicit enable**: persist ON atomically, then apply | +| `ocx init` | may establish ON — it writes a fresh config and asks separately | + +And a skipped sync returns `ok: false` with a `skippedReason`, not a bare `ok` +that every caller reads as success. + +## Medium findings + +- **#5** `mutatePersistedConfig` **throws** `ConfigMutationLockError` on lock + contention; `unavailable` means rebase exhaustion. I documented three outcomes + and missed the thrown branch, so a route following my instructions leaks a 500. +- **#6** two processes sharing `OPENCODEX_HOME` with different `CODEX_HOME`s can + both write the legacy `catalog-backup.json`. The hashed backup usually masks + it, which is exactly why it needs a test rather than a comment. +- **#7** SOT-SYNC: `docs-site` lifecycle pages still promise unconditional + sync/restore-back. A user-visible command semantics change has to land there. + +All accepted. + +## Next + +A is a loop; a FAIL never exits it. Amendments go into `030`, then the SAME +reviewer re-audits. Nothing about the phase map changes — this round produced no +evidence of divergence, only of Codex being genuinely concurrent. diff --git a/devlog/_plan/260803_codex_desktop_toggle/008_audit_synthesis_wp4_r2.md b/devlog/_plan/260803_codex_desktop_toggle/008_audit_synthesis_wp4_r2.md new file mode 100644 index 000000000..498fe2378 --- /dev/null +++ b/devlog/_plan/260803_codex_desktop_toggle/008_audit_synthesis_wp4_r2.md @@ -0,0 +1,106 @@ +# WP4 audit round 2 — synthesis, and a scope decision + +Verdict: **FAIL**. Three closed (#4, #6, #7), four still open, **five new High**. + +## The count that matters + +| Round | Closed | Still open | New | +|---|---|---|---| +| 1 | — | — | 7 (4 blocking) | +| 2 | 3 | 4 | 6 (5 blocking) | + +Same signature as the earlier divergence: fixes opening more than they close. +But the diagnosis is different this time, and it is not the phase map. + +## What round 2 actually discovered + +My lock is not implementable over the code it has to wrap. + +- **The refresh dependency has no seam.** `refreshCodexCatalog?: () => Promise` + (`src/server/management/context.ts:11`) gathers AND writes inside one opaque + async call. My design says "gather outside the lock, commit inside it". You + cannot do that to a callback that does both and returns nothing. Calling it + outside leaves writes unprotected; calling it inside breaks my own + no-await-under-lock rule (new #1). +- **The history write is not bounded.** Two SQLite busy waits at 5 s plus a + synchronous `Bun.sleepSync(500)` (`history-provider.ts:31,537`), then + row-dependent rollout writes. My "bounded synchronous commit" can hold the + lock for ~10.5 s **on the server's event loop**, so a dashboard-initiated OFF + stops serving every other client — the precise thing this feature exists to + prevent (new #3). +- **I deleted a guard I did not recognize.** Startup suppresses journal repair + when `currentExternalCodexModelProvider()` is set. I replaced that with + service-home ownership and called it "unconditional". Those are different + authorities, and the result can write our journal baseline over a config + another provider owns (new #2). +- **Persisted ON can read as OFF.** `/api/sync` gates on the server's + long-lived `config` object before reaching my fresh read, so a CLI enable in + another process leaves the running server refusing (new #4). +- **`unchanged` is not `converged`.** I let a no-op config commit skip native + convergence, which false-greens OFF-with-residue and ON-with-missing-artifacts + (new #5). + +Every one is accepted. None is a nitpick; #3 alone would ship a switch that +freezes the proxy. + +## The decision this forces + +Rounds 1 and 2 of WP4 are not the coupling failure `006` diagnosed — the +reviewer re-confirmed the re-scope holds. They are something narrower and more +honest: **Codex's write path was never designed to be interrupted**, and making +it safely interruptible is its own unit of work. + +The evidence, plainly: to satisfy this design I would have to introduce a +gather/commit seam into the management refresh contract, move history mutation +off the server event loop or give it a fail-fast mode, define an async lock +acquisition protocol with deadlines and fairness, and specify a per-user lock +namespace with symlink hardening. That is a concurrency substrate. It is not +"add a boolean and check it". + +I am not going to keep patching a design whose foundation the audit keeps +finding underneath it. Two rounds, eleven accepted findings, five of them +discovered only after my own fixes created them. + +## What that means for the goal + +The objective has four deliverables. Two shipped and are proven: + +| | Status | +|---|---| +| gjc/Pi modality poisoning | **DONE** — proven against the client's own schema | +| API keys row | **DONE** — rendered, observed, isolated | +| Codex toggle | **BLOCKED on a prerequisite this unit uncovered** | +| Desktop toggle | deferred behind Codex, by design | + +The Codex switch is not blocked by anything external, and it is not +unachievable. It is blocked by a real finding: the safe-interruption substrate +has to exist first, and its scope is a unit, not a work-phase inside this one. + +Recording it as `NEEDS_HUMAN` rather than continuing is the honest call under +LOOP-CONTINUE-01. The rule forbids shrinking the objective to escape a loop; it +does not require pretending a newly-discovered prerequisite is small. The +owner should decide whether to fund the substrate now or ship the two proven +deliverables and revisit. + +## Carried forward, in full + +Every open and new finding, for whoever picks this up: + +| Finding | Status | Belongs to | +|---|---|---| +| r1 #1 lock linearization | open — lock is right, wrapping is not implementable | substrate unit | +| r1 #2 ownership tri-state | open — external-provider guard regression (r2 #2) | substrate unit | +| r1 #3 `models_cache.json` | open — invalidation conflated with restoration (r2 #6) | substrate unit | +| r1 #4 caller semantics | **closed** | — | +| r1 #5 lock-error branch | open — `conflict` wrongly called non-retryable | substrate unit | +| r1 #6 legacy catalog backup | **closed** | — | +| r1 #7 docs-site sync | **closed** | — | +| r2 #1 refresh contract has no seam | open — **blocking**, needs a contract change | substrate unit | +| r2 #3 history is unbounded on the event loop | open — **blocking** | substrate unit | +| r2 #4 stale in-memory config admission | open — **blocking** | substrate unit | +| r2 #5 `unchanged` skips convergence | open — **blocking** | substrate unit | +| r2 #7 lock namespace hardening | open | substrate unit | + +WP6 (Grok) and WP7 (Desktop) both depend on the same substrate, so neither is +startable ahead of it. That dependency is why the loop stops here rather than +chaining to the next work-phase. diff --git a/devlog/_plan/260803_codex_desktop_toggle/010_modality_boundary.md b/devlog/_plan/260803_codex_desktop_toggle/010_modality_boundary.md new file mode 100644 index 000000000..eaf6d2367 --- /dev/null +++ b/devlog/_plan/260803_codex_desktop_toggle/010_modality_boundary.md @@ -0,0 +1,203 @@ +# WP2 — the client-dialect modality filter + +Research: `004_export_modality_poisoning.md`. Read it first; this doc is the diff. + +Independent of every other phase. It fixes a failure the user is hitting right +now, so it goes first despite not being a switch. + +## IN / OUT + +IN: `src/clients/config-export.ts` (MODIFY), +`tests/client-export-modality-enum.test.ts` (NEW). + +OUT: `src/server/management/model-rows.ts`, `src/cli/export-command.ts`, +`src/codex/catalog/*`, `normalizeExportModels`. All four deliberately keep +carrying `audio` — the internal vocabulary is correct, only two destinations are +narrower. + +## The helper + +MODIFY `src/clients/config-export.ts`, immediately after `outputBudgetFor` +(currently line 432) so the two value-normalizing helpers sit together: + +```ts +/** + * Modalities a given client's schema will actually accept. + * + * Our internal vocabulary is `text | image | audio` (model-routes.ts + * ALLOWED_INPUT_MODALITIES). Pi and Gajae both accept only `text | image`, and + * both reject the WHOLE config file over one out-of-enum value — Gajae reports + * `/providers/opencodex/models/N/input/2: Invalid option` and falls back to its + * built-in list, Pi returns an empty model config. So a single `audio` model + * takes every routed model down with it. + * + * This is the same defect the Codex catalog had with `video`, where the app + * showed zero apps (tests/catalog-input-modality-enum.test.ts). The fix is the + * same shape — filter to what the destination accepts — with one deliberate + * difference, below. + * + * UNKNOWN and INCOMPATIBLE are not the same input, and the Codex fix could + * conflate them safely only because its enum is wider. A model with no declared + * modalities is unknown, and `text` is the honest floor: every routed model + * takes prompts. A model that declares `["audio"]` and nothing else is + * INCOMPATIBLE with a text|image client, and rewriting it to `["text"]` would + * advertise a capability the model does not have. That input is reachable: + * `ocx models add --modalities audio` accepts it (src/cli/models.ts:139-146), + * `/api/custom-models` accepts it (model-routes.ts:13), and provider discovery + * can return an audio-only list (src/codex/catalog/provider-fetch.ts:341). + * + * So unknown falls back to text, and incompatible omits the model. Omitting one + * model costs the user that row in a picker; fabricating `text` costs them a + * model that fails at call time with no explanation. + * + * Deliberately NOT applied in ExportModel construction: the management and CLI + * boundaries carry catalog modalities verbatim on purpose, and stripping `audio` + * globally would destroy valid metadata before the destination is known. + */ +const CLIENT_INPUT_MODALITIES: Record<"pi" | "gajae", ReadonlySet> = { + pi: new Set(["text", "image"]), + gajae: new Set(["text", "image"]), +}; + +/** + * `null` means "this model cannot be represented for this client" — the caller + * drops the row. Deliberately not an empty array, which a caller could spread + * into a config without noticing. + */ +function inputModalitiesForClient( + client: "pi" | "gajae", + modalities: readonly string[] | undefined, +): string[] | null { + const declared = modalities ?? []; + // Nothing declared is unknown, not incompatible. + if (declared.length === 0) return ["text"]; + const accepted = CLIENT_INPUT_MODALITIES[client]; + const kept: string[] = []; + for (const value of declared) { + if (accepted.has(value) && !kept.includes(value)) kept.push(value); + } + return kept.length > 0 ? kept : null; +} +``` + +Order-preserving and deduping, so `[text, image, audio]` becomes `[text, image]`. +The existing byte-exact golden is unaffected: its fixture declares no modalities, +which still emits `["text"]` through the unknown branch. + +## Call site 1 — Pi + +`buildPiClientConfig`, currently line 653. The `map` becomes a `for` because the +helper now filters as well as transforms, and a `null` sentinel inside a `map` +would need a second pass: + +```diff +- const models: PiModelEntry[] = normalizeExportModels(ctx.models).map(model => { +- const entry: PiModelEntry = { +- id: model.namespaced, +- name: exportModelLabel(model), +- // Text is the one modality every routed model supports; anything richer must come +- // from the catalog rather than an assumption. +- input: model.inputModalities && model.inputModalities.length > 0 ? [...model.inputModalities] : ["text"], +- }; ++ const models: PiModelEntry[] = []; ++ for (const model of normalizeExportModels(ctx.models)) { ++ // Pi returns an EMPTY model config on a schema failure rather than dropping ++ // the offending entry, so one out-of-enum value costs every routed model. ++ const input = inputModalitiesForClient("pi", model.inputModalities); ++ // An audio-only model has no honest representation here; claiming `text` ++ // would fail at call time instead, so the row is dropped. ++ if (input === null) continue; ++ const entry: PiModelEntry = { id: model.namespaced, name: exportModelLabel(model), input }; + const context = authoritativeContextWindow(model.contextWindow); + if (context !== undefined) { + entry.contextWindow = context; + entry.maxTokens = outputBudgetFor(context); + } +- return entry; +- }); ++ models.push(entry); ++ } +``` + +Also MODIFY the stale docstring above `buildPiClientConfig` (line 649), which +still says Pi's schema is UNVERIFIED. It is verified now. Cite the *stable* doc +rather than a line range that has already drifted between audit rounds +(`packages/coding-agent/docs/models.md` states the accepted values), and name the +behavior rather than the line: Pi returns an empty model config when validation +fails. A line-pinned citation into a moving upstream file is a comment that rots. + +## Call site 2 — Gajae + +`buildGajaeClientConfig`, currently line 761, takes the identical shape: + +```diff +- const models: GajaeModelEntry[] = normalizeExportModels(ctx.models).map(model => { +- const entry: GajaeModelEntry = { +- id: model.namespaced, +- name: exportModelLabel(model), +- input: model.inputModalities && model.inputModalities.length > 0 +- ? [...model.inputModalities] +- : ["text"], +- }; ++ const models: GajaeModelEntry[] = []; ++ for (const model of normalizeExportModels(ctx.models)) { ++ const input = inputModalitiesForClient("gajae", model.inputModalities); ++ if (input === null) continue; ++ const entry: GajaeModelEntry = { id: model.namespaced, name: exportModelLabel(model), input }; + ``` + +with the same `return entry;` → `models.push(entry);` change at the loop tail. + +Gajae's enum is at `models-config-schema.ts:141` in the installed +`@gajae-code/coding-agent` — line 119, which `004` cited, only opens the model +schema object. Cite the installed version alongside the line. + +## Test — `tests/client-export-modality-enum.test.ts` (NEW) + +Named to sit beside `catalog-input-modality-enum.test.ts`, whose incident this +repeats. Cases: + +1. `audio` is dropped from a Gajae entry — the exact live failure, using + `zenmux/meta-muse-spark-1.1` with `[text, image, audio]`, asserting + `[text, image]`. +2. The same for Pi, so the latent half is pinned too. +3. **An audio-only model is OMITTED from both exports, not rewritten to + `["text"]`.** Assert its id is absent from `models` entirely. The fixture + builds it the way a user reaches it — `ocx models add --modalities audio` + accepts exactly this (`src/cli/models.ts:139-146`). +4. A model with NO declared modalities still emits `["text"]`. Unknown is not + incompatible, and this branch is what keeps the byte-exact golden stable. +5. `[text, image]` survives untouched in both. +6. Order and dedupe: `[image, text, image]` yields `[image, text]`. +7. A whole-catalog assertion over a catalog carrying BOTH a mixed + `[text, image, audio]` model and an audio-ONLY model: every emitted Pi and + Gajae `input` value is inside `text|image`, and no emitted model claims a + modality its catalog entry did not have. This is the case that would have + caught the live bug — every per-entry test passed while the file was broken. + +## Verification + +A unit test does not close this — 91 tests were green beside a config gjc +refuses to load. + +1. `bun run typecheck`, `bun run test` +2. Re-apply the gajae integration through the running proxy +3. **Parse** the emitted YAML and assert every + `providers.opencodex.models[*].input` value is in `text|image`. A `grep` for + `audio` is the wrong check: the string also appears in model ids, display + names, and other providers' preserved blocks, so it can fail while our output + is correct. Same structural check against Pi's JSON. +4. Launch gjc and confirm the model list loads with no schema error + +Step 4 is the criterion. Steps 1-3 are necessary and insufficient. + +## Accept criteria + +- C1 — gjc loads the emitted config with no schema error, observed in the real + file, and Pi's identical exposure is closed in the same change. +- No emitted Pi/Gajae `input` value outside the client's enum, asserted over a + whole catalog rather than one entry. +- No model is advertised to a client with a modality it does not have: an + incompatible model is omitted, never rewritten to `text`. +- The byte-exact export goldens still pass, changing only where `audio` was + previously emitted. diff --git a/devlog/_plan/260803_codex_desktop_toggle/011_wp2_evidence.md b/devlog/_plan/260803_codex_desktop_toggle/011_wp2_evidence.md new file mode 100644 index 000000000..4c1b6fd50 --- /dev/null +++ b/devlog/_plan/260803_codex_desktop_toggle/011_wp2_evidence.md @@ -0,0 +1,75 @@ +# WP2 — verification evidence + +The criterion in `010` was never "a unit test passes". 91 export tests were +green while the real file was broken. So the close-out is an A/B against the +client's own schema, on the real emitted bytes. + +## The decisive check + +`@gajae-code/coding-agent@0.7.11` ships its schema as source, so it can be +imported and run against our output directly rather than approximated. + +``` +ModelsConfigSchema.safeParse({... input: ["text","image","audio"] ...}) +→ FAIL {"path":["providers","opencodex","models",0,"input",2], + "message":"Invalid option: expected one of \"text\"|\"image\""} +``` + +That is the user's reported error, reproduced from the schema itself +(`models-config-schema.ts:141`, `input: z.array(z.enum(["text","image"]))`). +The bug and the oracle now agree, which is what makes the A/B meaningful. + +## A/B on the real emitted file + +Both runs: `bun run src/cli/index.ts export --client gajae --out `, output +parsed as YAML and handed to the real schema. + +| | Models | audio-bearing | Schema | +|---|---|---|---| +| `HEAD~1` (pre-fix) | 34 | `zenmux/meta-muse-spark-1.1` | **FAIL** at `models[31].input[2]` | +| `HEAD` (fixed) | 34 | none | **PASS** | + +Two details worth keeping: + +- **The model count is unchanged.** `meta-muse-spark-1.1` is still exported, now + as `[text, image]`. The fix removes a rejected value, not a model — the + omission branch only fires for a model with nothing acceptable left, and this + catalog has none. +- The pre-fix index is 31 here versus 30 in the user's report. The catalog moved + between then and now; the offending entry is the same one. + +## Structural check, not grep + +Audit round 3 rejected `grep -c audio` as the verification: the string also +occurs in model ids, display names and other providers' preserved blocks, so it +can fail while our output is correct. The check walks +`providers.opencodex.models[*].input` and asserts every value is in +`text|image`. Result: `out-of-enum input values: NONE` across 34 models. + +## Gates + +| Gate | Result | +|---|---| +| `bun x tsc --noEmit` | clean | +| `bun run test` | **7632 pass, 0 fail**, 7 skip, 507 files | +| `bun run privacy:scan` | passed | +| `tests/client-export-modality-enum.test.ts` | 7 pass | +| existing export/catalog suites | 91 pass | + +## The test earns its place + +Reverting `config-export.ts` alone makes **5 of 7** new cases fail, including the +whole-catalog assertion. A test that passes with and without the fix would have +been decoration. + +## Note on method + +A first attempt at this A/B reported "BEFORE FIX → PASS", which was wrong: the +`git stash push` used to revert the file hit a conflict and the fix was never +actually removed, so the "before" run measured the fixed code. Re-done by +extracting `HEAD~1:src/clients/config-export.ts` directly. The worktree was +restored afterwards and matches HEAD; the six pre-existing stashes are intact. + +Recording this because the failure mode — a verification that silently measures +the wrong build and reports green — is exactly the class of thing this unit +exists to stop. diff --git a/devlog/_plan/260803_codex_desktop_toggle/020_api_keys_row.md b/devlog/_plan/260803_codex_desktop_toggle/020_api_keys_row.md new file mode 100644 index 000000000..d8c3d0a76 --- /dev/null +++ b/devlog/_plan/260803_codex_desktop_toggle/020_api_keys_row.md @@ -0,0 +1,575 @@ +# WP4 — API keys out of the client-card grid + +> "이 api 키는 박스로 두지말고 위쪽에 한 라인으로 빼자" +> +> "don't leave this API key as a box/card - pull it out to the top as a single line/row." + +Independent of WP2/WP3. This is a layout and summary-semantics change; the +`/api/keys` read already returns the only fact this surface needs. + +## IN / OUT + +IN: `gui/src/pages/integrations/overview-clients.ts` (MODIFY), +`gui/src/pages/integrations/IntegrationsOverview.tsx` (MODIFY), +`gui/src/styles-integrations.css` (MODIFY), +`gui/src/i18n/en.ts` (MODIFY), `gui/src/i18n/ko.ts` (MODIFY), +`gui/src/i18n/ja.ts` (MODIFY), `gui/src/i18n/zh.ts` (MODIFY), +`gui/src/i18n/de.ts` (MODIFY), `gui/src/i18n/ru.ts` (MODIFY), +`gui/tests/integrations-overview-rows.test.ts` (MODIFY), +`gui/tests/overview-state-merge.test.ts` (MODIFY), and +`gui/tests/integrations-surfaces.test.tsx` (MODIFY), +`gui/tests/api-key-count-loader.test.ts` (NEW — the only new file; no +implementation file is NEW). + +IN also: `gui/src/pages/integrations/integration-api.ts` (MODIFY) — see +§Distinguishing a failed read. + +OUT: `gui/src/pages/integrations/IntegrationStateBadge.tsx` — **not reused at +all**, neither the component nor its `unknown/current/absent` vocabulary. Its +labels are “Applied”/“Not applied” (`:12`), which is the claim this phase +exists to stop making about a credential. `src/`, `tests/`, and `docs-site/` — +this neither changes the management contract nor setup behavior. + +## Why keys is not a card + +Every grid card represents a client that can be detected, applied, stale, or +unsafe against client-owned configuration. Most carry a switch and a config +path; the exceptions still describe routing or a profile that can drift. + +API keys have neither property. They cannot be installed as a client, toggled, +or drift from a config file. Their entire overview state is the count returned +by `loadApiKeyCount`: zero keys, N issued keys, or an unsettled read, plus a way +to open the keys tab. Painting that credential inventory as one peer in the +client grid is the asymmetry that caused both the orphaned card and the false +summary totals. The type boundary should now say the same thing as the layout. + +## Distinguishing a failed read from an in-flight one + +`loadApiKeyCount` returns `null` for an in-flight read, a network failure, a +non-ok response, and a malformed body alike, because `readOptional` catches +everything (`integration-api.ts:259-265`). `useDataSurface` then sees a +SUCCESSFUL result carrying `null` (`data-surface.ts:127`), and there is no +polling on this surface. + +So a “Checking…” string on that `null` would be permanent after a failure. The +audit is right that avoiding “No keys issued” is necessary but not sufficient: +indefinite progress copy is its own lie, and it is the more annoying one because +it never resolves. + +A returned `null` cannot express failure, because the resource layer treats any +successful return as data. So the loader **throws**, and the read phase comes +from the resource state that already exists. + +MODIFY `integration-api.ts`: + +```diff +-export async function loadApiKeyCount(apiBase: string, signal?: AbortSignal): Promise { +- const body = await readOptional<{ keys?: unknown }>(fetch(`${apiBase}/api/keys`, { signal })); +- if (!body || !Array.isArray(body.keys)) return null; +- return body.keys.length; +-} ++/** ++ * Throws on a failed or malformed read rather than returning null. ++ * ++ * `readOptional` is right for surfaces that treat "no answer" and "empty" the ++ * same. This one cannot: the overview must say "Checking..." while a read is in ++ * flight and "Key status unavailable" once it has settled badly, and a ++ * successfully-returned null collapses both into `ready-empty` ++ * (data-surface.ts:128) with no polling to ever correct it. Throwing is what ++ * produces `failed-cold` / `failed-with-stale`, which is the signal the row ++ * needs. Aborts never reach a state: client-resource.ts:230-244 discards an ++ * aborted generation before publishing either data or failure. ++ */ ++export async function loadApiKeyCount(apiBase: string, signal?: AbortSignal): Promise { ++ const response = await fetch(`${apiBase}/api/keys`, { signal }); ++ if (!response.ok) throw new Error(`/api/keys responded ${response.status}`); ++ const body = await readJsonIfOk<{ keys?: unknown }>(response); ++ if (!body || !Array.isArray(body.keys)) throw new Error("/api/keys returned an unexpected body"); ++ return body.keys.length; ++} +``` + +`loadApiKeyCount` has exactly one production consumer, `IntegrationsOverview`; +the other API-key surfaces fetch `/api/keys` directly, so nothing else changes. + +The component wiring for this lives in one place — §Render — so an implementer +never has to reconcile two partial diffs of the same object. + +`overview-clients.ts` exports the phase type and `OverviewSources` carries it +(`:82-93`): + +```diff ++/** How far the `/api/keys` read has got, since the count alone cannot say. */ ++export type ApiKeyReadPhase = "checking" | "unavailable" | "settled"; ++ + export interface OverviewSources { + /** File-client rows; an empty array means the list has not settled. */ + clients: readonly IntegrationStatus[]; + clientsSettled: boolean; + codex: CodexRoutingPayload | null; + keyCount: number | null; ++ /** ++ * Read phase for `keyCount`. Separate because a settled zero and a failed ++ * read are both `null`-adjacent facts that must not render the same way. ++ */ ++ keyPhase: ApiKeyReadPhase; + claude: ClaudeCodePayload | null; +``` + +## Row model — `overview-clients.ts` + +`OverviewClientId` stops admitting `keys`. `buildOverviewRows` still owns the +normalization of all overview sources, but its result separates the credential +row from client rows instead of returning one mixed array (`:24-59`, `:153-176`, +`:352-397`). + +```diff + export type OverviewClientId = + | "codex" +- | "keys" + | "claude" + | "claudeDesktop" + | "grok" + | FileIntegrationClientId; + ++export interface ApiKeysOverviewRow { ++ hash: "integrations/keys"; ++ labelKey: TKey; ++ /** ++ * Credential vocabulary, deliberately NOT the client `unknown|absent|current` ++ * triple. Those words carry "applied", which is the claim this row must never ++ * make. Keeping the client values here — even unrendered — would be an open ++ * invitation to reconnect IntegrationStateBadge and undo the whole phase. ++ */ ++ state: "checking" | "unavailable" | "none-issued" | "issued"; ++ detailKey: TKey | null; ++ detailVars: Record | null; ++} ++ ++export interface OverviewRows { ++ keysRow: ApiKeysOverviewRow; ++ rows: OverviewRow[]; ++} + + /** API keys are issued or not; there is no config file to drift. */ +-function keysRow(count: number | null): OverviewRow { +- const base = { +- id: "keys" as const, +- hash: "integrations/keys", +- labelKey: "integrations.tab.keys" as TKey, +- toggle: null, +- toggleBlocked: null, +- togglePath: null, +- status: null, +- detail: null, +- }; ++function keysRow(phase: ApiKeyReadPhase, count: number | null): ApiKeysOverviewRow { ++ const base = { ++ hash: "integrations/keys" as const, ++ labelKey: "integrations.tab.keys" as TKey, ++ }; ++ // Every branch names a detail key. The detail line is the ONLY state ++ // expression now that the badge is gone, so a null one renders a row with no ++ // state at all — which is how the first draft of this phase managed to ++ // declare a "Checking…" string and never show it. ++ if (phase === "checking") { ++ return { ...base, state: "checking", detailKey: "integrations.detail.keyChecking", detailVars: null }; ++ } ++ if (phase === "unavailable" || count === null) { ++ return { ...base, state: "unavailable", detailKey: "integrations.detail.keyUnavailable", detailVars: null }; ++ } ++ return { ++ ...base, ++ state: count > 0 ? "issued" : "none-issued", ++ detailKey: count > 0 ? "integrations.detail.keyCount" : "integrations.detail.keyNone", ++ detailVars: count > 0 ? { count: String(count) } : null, + }; + } + +-export function buildOverviewRows(sources: OverviewSources): OverviewRow[] { ++export function buildOverviewRows(sources: OverviewSources): OverviewRows { + const nativeClaude = sources.native?.find(status => status.clientId === "claude"); + const nativeGrok = sources.native?.find(status => status.clientId === "grok"); + const statusByClient = new Map(sources.clients.map(status => [status.clientId, status])); + const rows: OverviewRow[] = [ + codexRow(sources.codex), +- keysRow(sources.keyCount), + claudeRow(sources.claude, nativeClaude, sources.nativeSettled), + claudeDesktopRow(sources.claudeDesktop), + grokRow(sources.grok, nativeGrok, sources.nativeSettled), + ]; + // Existing file-client loop stays byte-for-byte unchanged. +- return rows; ++ return { keysRow: keysRow(sources.keyPhase, sources.keyCount), rows }; + } +``` + +Also change the file docstring's “Five more sources join the grid” to “Four +more client sources join the grid; API keys return separately,” change +`OverviewRow`'s “other five” detail comment to “other four,” and replace the +catalog-order comment at `:352-356` with the actual client order: Codex, Claude, +Claude Desktop, Grok, then the file clients. Comments must not keep claiming +that keys joins the grid after the type no longer permits it. + +`ApiKeysOverviewRow` deliberately has no `installed`, `applied`, `toggle`, or +`status`. Re-adding one of those fields would recreate the semantic leak this +phase removes. `gui/tests/overview-state-merge.test.ts` changes only its helper +to search `buildOverviewRows(...).rows`; native-state behavior is otherwise +untouched. + +## Render — `IntegrationsOverview.tsx` + +The row goes immediately **below the summary strip** and above onboarding, +notices, and the grid (`:433-498`). The summary is the page-level aggregate; +the keys row is one credential surface with one action. Putting keys above the +summary would promote one surface over the aggregate, while merging it into the +strip would make “Manage keys” look like a bulk-status control beside “Disable +all.” Below preserves aggregate → individual → client catalog hierarchy. + +```diff + import { + buildOverviewRows, + countOverviewRows, ++ type ApiKeyReadPhase, ++ type ApiKeysOverviewRow, + type OverviewRow, + } from "./overview-clients"; + ++/** ++ * Credentials are one explicit action, not a clickable client card. ++ * ++ * Do not stretch the title over this row. The card overlay exists because a ++ * card also contains a switch; this row has no nested-control problem to ++ * solve. A plain Manage keys button is the one tab stop, so its visible label, ++ * focus ring, Enter, and Space behavior all come from a native button. ++ */ ++function ApiKeysRow({ row }: { row: ApiKeysOverviewRow }) { ++ const t = useT(); ++ const detail = row.detailKey ? t(row.detailKey, row.detailVars ?? undefined) : null; ++ return ( ++
++
++

{t(row.labelKey)}

++ {detail &&

{detail}

} ++
++ {/* ++ NOT IntegrationStateBadge. It renders `current` as "Applied" and ++ `absent` as "Not applied" in all six locales (LABEL_KEYS in ++ IntegrationStateBadge.tsx:12), which is exactly the claim this phase ++ argues is false — the live zero-key card says `미적용` today. The ++ detail line above already carries the honest state, so a badge here ++ could only re-say it wrongly. ++ ++ `data-key-state` on the wrapper is how the four states stay testable ++ and stylable without a visible client-vocabulary badge. It is the one ++ consumer of `row.state`; without it the field would be dead weight and ++ the next author would reach for the badge again. ++ */} ++ ++
++ ); ++} + + const keysResource = useDataSurface( + `integration-keys:${apiBase}`, + [apiBase], + fetchKeyCount, +- { isEmpty: value => value === null, enabled: active }, ++ // The loader now throws instead of resolving null, so null is no longer a ++ // value it can produce. Leaving the old predicate would classify nothing ++ // and quietly outlive the contract it was written for. ++ { isEmpty: () => false, enabled: active }, + ); +... ++ /* ++ * The three phases the keys row distinguishes, read off the resource rather ++ * than guessed from a null — the same idiom as clientsSettled above. A ++ * failed read must never reach the count branch: `failed-with-stale` still ++ * carries the previous number, and rendering it as "N issued" would report a ++ * stale credential inventory as current. ++ */ ++ const keyPhase: ApiKeyReadPhase = ++ keysResource.state.kind === "cold" || keysResource.state.kind === "retrying-cold" ++ ? "checking" ++ : keysResource.state.kind === "failed-cold" || keysResource.state.kind === "failed-with-stale" ++ ? "unavailable" ++ : "settled"; +- const rows = buildOverviewRows({ ++ const { keysRow, rows } = buildOverviewRows({ + clients, + clientsSettled, + codex: codexResource.state.data ?? null, + keyCount: keysResource.state.data ?? null, ++ keyPhase, + claude: claudeResource.state.data ?? null, + claudeDesktop: claudeDesktopResource.state.data ?? null, + grok: grokResource.state.data ?? null, + native, + nativeSettled, + }); + const counts = countOverviewRows(rows); + + + ++ ++ +

{t("integrations.onboarding")}

+ + {/* +- The grid used to disappear entirely when no FILE client was installed, +- which now means hiding Codex, API keys, Claude and Grok because the ++ The grid used to disappear entirely when no FILE client was installed, ++ which means hiding Codex, Claude and Grok because the + user has not installed OpenCode. The "nothing detected" panel is about + the file clients specifically, so it sits BELOW the grid and says so + instead of replacing everything. + */} +``` + +The row itself has no click handler and no `tabIndex`. Its heading, count, and +state text remain readable in document order; only the native action button enters +the tab sequence, once, at the same visual position. This does not copy the +card's `::after` overlay, does not add a second `tabIndex={-1}` action, and +cannot swallow a future control through stacking order. + +## CSS — `styles-integrations.css` + +Insert after the summary rules (`:11-14`), before `.integration-cards`. This is +a full-width divider row, not `.integration-card` with `grid-column: 1 / -1`: +it has no raised background, surrounding border, radius, or card hover state. + +```diff + .integration-summary-label { font-size: var(--text-caption); color: var(--muted); } + .integration-summary .btn { margin-left: auto; } + ++/* API keys are credential inventory, not a client card. A bottom rule keeps ++ the full-width row in the page flow without making it an orphaned wide card. */ ++.integration-api-keys-row { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; min-height: 44px; padding: 0 0 14px; border-bottom: 1px solid var(--border); margin-bottom: 14px; } ++.integration-api-keys-copy { display: flex; align-items: baseline; flex: 1 1 220px; min-width: 0; flex-wrap: wrap; gap: 4px 10px; } ++.integration-api-keys-copy h4, ++.integration-api-keys-copy .integration-meta { margin: 0; } ++.integration-api-keys-row .btn { margin-inline-start: auto; flex: 0 0 auto; } +``` + +`flex-wrap` is intentional: at 320-390 px, long German/Russian action copy may +move below the state text instead of clipping. It remains one credential +row surface. At normal widths, `flex: 1 1 220px` keeps title/count together on +the left and the action on the right. The existing global `:focus-visible` +rule owns the button ring; the rendered keyboard check below proves it. + +## Counts + +Keys leave all four totals. `countOverviewRows(rows)` remains unchanged because +its input is now client rows only. + +- `detected`: a keys API endpoint exists on every dashboard; that is not a + detected client. Counting it makes the total increase even with zero keys. +- `applied`: issuing a credential is not applying opencodex to a client. Two + keys are inventory, not two applied integrations — and the old code counted + either number as exactly one anyway. +- `stale`: keys have no config file and cannot enter this state. +- `unknown`: a failed `/api/keys` read stays visible in the row's own state + text, but must not inflate the number of client integrations whose state is + unknown. + +This changes current totals by at most one. That is not a regression hidden by +the layout move; it corrects what the labels already claim to measure. The +“Disable all” button remains file-client-only (`IntegrationsOverview.tsx:303-360`) +and is unaffected. + +### The labels have to say what they now count + +Audit finding #2: “at most one” is still a number the user watches change with +no explanation. On the live zero-key machine, **Detected goes 5 → 4** the moment +this ships, and Applied drops by one once a key exists. The current labels are +bare “Detected” and “Applied”, which never disclose that the scope is clients. + +So the labels move with the scope. MODIFY all six locales: + +| Key | en | ko | +|---|---|---| +| `integrations.summary.detected` | `Clients detected` | `감지된 클라이언트` | +| `integrations.summary.applied` | `Configured clients` | `설정된 클라이언트` | + +ja `検出されたクライアント` / `設定済みクライアント`, zh `已检测客户端` / +`已配置客户端`, de `Clients erkannt` / `Konfigurierte Clients`, ru +`Клиентов найдено` / `Настроено клиентов`. + +“Configured” rather than “applied” across the board, on the reviewer's reading +of each locale: `Clients applied` is awkward in English because configuration is +what gets applied, `適用中` reads as in-progress, `Clients aktiv` changes the +metric outright since a `stale` row still counts as applied while not being +active, and `Клиентов применено` is not a collocation. Configuration is the +thing all six can name accurately. + +`integrations.summary.stale` and `lastChange` are unchanged: keys were never +counted in either. + +Existing test `gui/tests/integrations-overview-rows.test.ts:128` pins the old +`applied` at 6 and must be updated to the new expected value, not deleted — a +pinned number that changes is the reason to pin it. + +## i18n + +The existing title/count keys stay unchanged. Add one action key to every +locale; hardcoding “Manage keys” in JSX is forbidden by `gui/AGENTS.md`. + +| Key | File | Value | +|---|---|---| +| `integrations.action.manageKeys` | `gui/src/i18n/en.ts` | `Manage keys` | +| `integrations.action.manageKeys` | `gui/src/i18n/ko.ts` | `키 관리` | +| `integrations.action.manageKeys` | `gui/src/i18n/ja.ts` | `キーを管理` | +| `integrations.action.manageKeys` | `gui/src/i18n/zh.ts` | `管理密钥` | +| `integrations.action.manageKeys` | `gui/src/i18n/de.ts` | `Schlüssel verwalten` | +| `integrations.action.manageKeys` | `gui/src/i18n/ru.ts` | `Управлять ключами` | + +Reused in all six locales: `integrations.tab.keys`, +`integrations.detail.keyCount`, and `integrations.detail.keyNone`. + +**No state-badge label is reused.** The row does not render +`IntegrationStateBadge`, because its `current`/`absent` labels are “Applied” and +“Not applied” (`IntegrationStateBadge.tsx:12`) — the exact wording this phase +argues is wrong for a credential. The detail line IS the state. + +That leaves the two non-count states needing their own words, since neither can +borrow the badge “Unknown”. Add two keys to every locale: + +| Key | en | ko | +|---|---|---| +| `integrations.detail.keyChecking` | `Checking…` | `확인 중…` | +| `integrations.detail.keyUnavailable` | `Key status unavailable` | `키 상태를 확인할 수 없음` | + +ja `確認中…` / `キーの状態を取得できません`, zh `检查中…` / `无法获取密钥状态`, +de `Wird geprüft…` / `Schlüsselstatus nicht verfügbar`, ru `Проверка…` / +`Статус ключей недоступен`. + +Two strings rather than one because they are two different facts, per +§Distinguishing a failed read. Neither may render as “No keys issued”: that is a +claim about the account of the user that a failed read cannot support. + +## Test plan + +MODIFY `gui/tests/integrations-overview-rows.test.ts`: + +1. Destructure `{ keysRow, rows }` and assert all FOUR credential states: + `("checking", null)` → `checking` + `keyChecking`; + `("unavailable", null)` → `unavailable` + `keyUnavailable`; + `("settled", 0)` → `none-issued` + `keyNone`; + `("settled", 2)` → `issued` + `keyCount` with `{ count: "2" }`. + A `("settled", null)` fixture must also yield `unavailable`, never + `none-issued` — that is the branch that would otherwise tell a user they + have no keys because a read failed. +2. Client `unknown` count is 4, not 5, in every one of those cases. With Codex, + Claude, Desktop, Grok, and one file client applied, walking the key phase + through all four leaves `applied === 5` unchanged. +3. Update settled lengths from 5 to 4 and unsettled lengths from 11 to 10; + assert no member of `rows` has id `keys`. +4. Keep every existing Codex/Desktop/file-client mapping case against `rows`. +5. Update the pinned `applied` at `:128` to its new value and leave it pinned. + +NEW `gui/tests/api-key-count-loader.test.ts`, because the loader's contract +changed from "returns null on anything bad" to "throws on anything bad", and a +mounted "failure" case only exercises one of the five ways it can now throw: + +1. a valid `{ keys: [] }` resolves `0` — zero is data, not a failure; +2. a valid `{ keys: [a, b] }` resolves `2`; +3. a non-ok status rejects; +4. a malformed or empty body rejects; +5. `{ keys: "not-an-array" }` rejects; +6. a network rejection propagates rather than being swallowed. + +Case 5 is the one the old `readOptional` path silently turned into "no keys +issued", which is the exact false claim this phase exists to stop. Abort +behavior is not retested here — `client-resource` already owns it. + +MODIFY `gui/tests/overview-state-merge.test.ts`: its `row()` helper reads the +`.rows` member. No assertion changes. + +**Both fixture builders need the new required field.** `keyPhase` is not +optional on `OverviewSources`, and the `sources()` helpers in +`overview-state-merge.test.ts:17` and `integrations-overview-rows.test.ts:28` +both construct one today without it — so neither file compiles until each +defaults `keyPhase: "settled"`, with per-case overrides in the four-state +matrix above. Naming this explicitly because "no assertion changes" reads like +"no edits needed", and it is not. + +MODIFY `gui/tests/integrations-surfaces.test.tsx` with a mounted overview case. + +**First, the fixture needs work the audit found missing.** `failExtraSources` +fails Codex, keys, Claude, Desktop and Grok together +(`integrations-surfaces.test.tsx:56`), so it cannot show that an API-key failure +ALONE leaves the client totals alone — the assertion would pass or fail for +unrelated unknown rows. The mock also has no `/api/native-integrations` +response, leaving native state permanently unsettled in mounted tests, which +makes every count assertion mushy. + +So add an independent `keyResponse` / `failKeys` control and a settled native +response, then hold every other source constant while driving `/api/keys` +through its three outcomes. + +1. `[data-client="keys"]` exists, but + `.integration-cards [data-client="keys"]` is null. +2. DOM order is `.integration-summary` → keys row → `.integration-cards`. +3. Drive `/api/keys` through a DEFERRED response (still in flight, asserting + the "Checking…" copy before it settles), then `[]`, two keys, and a failure, + with everything else settled and constant. Assert the row copy each time AND assert the client + summary totals are **exact and identical** across all four. A relative + "text is present" check would not catch the leak this phase exists to close. +4. **Tabbability, not button-counting.** Query every tabbable descendant of the + row and assert the single result is the Manage keys button. "Exactly one + button with tabIndex 0" still passes if the row later gains `tabIndex={0}`, + an anchor, or another naturally focusable control — which is precisely the + regression the assertion is supposed to prevent. +5. Assert the control is a native ` + {/* + Below the aggregate, above the client catalog. The summary is the + page-level total; this is one credential surface with one action. + Putting it above the summary would promote one surface over the + aggregate, and merging it into the strip would make "Manage keys" read + as a bulk control beside "Disable all". + */} + +

{t("integrations.onboarding")}

{statesResource.state.kind === "failed-cold" && ( {t("integrations.error.load")} @@ -568,3 +595,31 @@ export default function IntegrationsOverview({ ); } +/** + * Credentials are one explicit action, not a clickable client card. + * + * No `IntegrationStateBadge`: it renders `current` as "Applied" and `absent` as + * "Not applied" in all six locales, which is the one thing a credential row + * must not claim — issuing a key does not apply an integration. The detail line + * IS the state, and `data-key-state` is what keeps the four states testable and + * stylable without borrowing client vocabulary. + * + * The card overlay is also deliberately absent. It exists because a card holds + * a switch as well as a title; this row has no nested-control problem to solve, + * so one plain button is the whole keyboard path. + */ +function ApiKeysRow({ row }: { row: ApiKeysOverviewRow }) { + const t = useT(); + const detail = row.detailKey ? t(row.detailKey, row.detailVars ?? undefined) : null; + return ( +
+
+

{t(row.labelKey)}

+ {detail &&

{detail}

} +
+ +
+ ); +} diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index 3fdbf592b..927f1417a 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -280,9 +280,26 @@ export async function loadCodexRoutingStatus(apiBase: string, signal?: AbortSign }; } -export async function loadApiKeyCount(apiBase: string, signal?: AbortSignal): Promise { - const body = await readOptional<{ keys?: unknown }>(fetch(`${apiBase}/api/keys`, { signal })); - if (!body || !Array.isArray(body.keys)) return null; +/** + * Throws on a failed or malformed read rather than returning null. + * + * `readOptional` is right for surfaces that treat "no answer" and "empty" the + * same. This one cannot: the overview says "Checking…" while a read is in + * flight and "Key status unavailable" once it has settled badly, and a + * successfully-returned null collapses both into `ready-empty` with no polling + * to ever correct it — so the row would claim the user has no keys because a + * request failed. Throwing is what produces `failed-cold` / `failed-with-stale`, + * which is the signal the row reads. Aborts never reach a state: an aborted + * generation is discarded before either data or failure is published. + */ +export async function loadApiKeyCount(apiBase: string, signal?: AbortSignal): Promise { + const response = await fetch(`${apiBase}/api/keys`, { signal }); + // These two strings are diagnostics for the failure path, never rendered: + // the row shows the localized `integrations.detail.keyUnavailable` instead. + // eslint-disable-next-line local-i18n/no-hardcoded-ui-strings -- rejection reason, not UI text + if (!response.ok) throw new Error(`/api/keys responded ${response.status}`); + const body = await readJsonIfOk<{ keys?: unknown }>(response); + if (!body || !Array.isArray(body.keys)) throw new Error("/api/keys returned an unexpected body"); return body.keys.length; } diff --git a/gui/src/pages/integrations/native-api.ts b/gui/src/pages/integrations/native-api.ts index a7dddf187..05fec0e8f 100644 --- a/gui/src/pages/integrations/native-api.ts +++ b/gui/src/pages/integrations/native-api.ts @@ -1,6 +1,14 @@ import { readJsonIfOk } from "../../fetch-json"; -export type NativeIntegrationClientId = "claude" | "grok"; +/** + * Kept in step with the server, which has accepted `codex` since the Codex + * toggle shipped (`src/server/management/native-integration-routes.ts`). + * + * The GUI type lagged behind the call site that already passed `"codex"`, and + * nothing caught it locally because GUI typecheck runs from its own tsconfig — + * `bun x tsc --noEmit` at the repository root does not read this file. CI did. + */ +export type NativeIntegrationClientId = "claude" | "grok" | "codex"; export type NativeIntegrationState = "absent" | "current" | "unsafe"; export type NativeRefusalReason = | "not_installed" @@ -48,7 +56,9 @@ export interface NativeErrorEnvelope { export type NativeErrorBody = NativeErrorEnvelope | NativeRefusalEnvelope; -const NATIVE_CLIENTS: ReadonlySet = new Set(["claude", "grok"]); +// Widening the type alone would leave this guard rejecting a `codex` response at +// runtime, so the set moves with it. +const NATIVE_CLIENTS: ReadonlySet = new Set(["claude", "grok", "codex"]); const NATIVE_REFUSAL_CODES: ReadonlySet = new Set([ "native_integration_refused", "native_integration_failed", diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts index e4eb0a004..6fe89d5ec 100644 --- a/gui/src/pages/integrations/overview-clients.ts +++ b/gui/src/pages/integrations/overview-clients.ts @@ -23,12 +23,39 @@ import type { NativeStatus } from "./native-api"; export type OverviewClientId = | "codex" - | "keys" | "claude" | "claudeDesktop" | "grok" | FileIntegrationClientId; +/** How far the `/api/keys` read has got, since the count alone cannot say. */ +export type ApiKeyReadPhase = "checking" | "unavailable" | "settled"; + +/** + * The credential row, deliberately NOT an `OverviewRow`. + * + * API keys cannot be installed, toggled, or drift from a config file, so they + * have no `installed`, `applied`, `toggle`, or `status`. Re-adding one would + * recreate the semantic leak this shape removes. + * + * `state` uses credential vocabulary rather than the client + * `unknown|absent|current` triple, because those words carry "applied" — + * `IntegrationStateBadge` renders them as "Applied" and "Not applied" in all + * six locales, which is exactly the claim a credential row must never make. + */ +export interface ApiKeysOverviewRow { + hash: "integrations/keys"; + labelKey: TKey; + state: "checking" | "unavailable" | "none-issued" | "issued"; + detailKey: TKey | null; + detailVars: Record | null; +} + +export interface OverviewRows { + keysRow: ApiKeysOverviewRow; + rows: OverviewRow[]; +} + export interface OverviewRow { id: OverviewClientId; /** Tab this card opens. Claude Desktop opens Claude's nested route. */ @@ -85,6 +112,11 @@ export interface OverviewSources { clientsSettled: boolean; codex: CodexRoutingPayload | null; keyCount: number | null; + /** + * Read phase for `keyCount`. Separate because a settled zero and a failed + * read are both null-adjacent facts that must not render the same way. + */ + keyPhase: ApiKeyReadPhase; claude: ClaudeCodePayload | null; claudeDesktop: ClaudeDesktopPayload | null; grok: GrokPayload | null; @@ -120,7 +152,7 @@ function codexRow(payload: CodexRoutingPayload | null): OverviewRow { id: "codex" as const, hash: "integrations/codex", labelKey: "integrations.tab.codex" as TKey, - toggle: null, + toggle: "codex" as const, toggleBlocked: null, togglePath: null, status: null, @@ -151,25 +183,26 @@ function codexRow(payload: CodexRoutingPayload | null): OverviewRow { } /** API keys are issued or not; there is no config file to drift. */ -function keysRow(count: number | null): OverviewRow { +function keysRow(phase: ApiKeyReadPhase, count: number | null): ApiKeysOverviewRow { const base = { - id: "keys" as const, - hash: "integrations/keys", + hash: "integrations/keys" as const, labelKey: "integrations.tab.keys" as TKey, - toggle: null, - toggleBlocked: null, - togglePath: null, - status: null, - detail: null, }; - if (count === null) { - return { ...base, state: "unknown", installed: false, applied: false, detailKey: null, detailVars: null }; + // Every branch names a detail key. The detail line is the ONLY state + // expression — there is no badge — so a null one would render a row with no + // state at all. + if (phase === "checking") { + return { ...base, state: "checking", detailKey: "integrations.detail.keyChecking", detailVars: null }; + } + // `count === null` is defensive: a failed read is already `unavailable`, and + // claiming "no keys issued" because a request failed is a statement about the + // account of the user that we cannot support. + if (phase === "unavailable" || count === null) { + return { ...base, state: "unavailable", detailKey: "integrations.detail.keyUnavailable", detailVars: null }; } return { ...base, - state: count > 0 ? "current" : "absent", - installed: true, - applied: count > 0, + state: count > 0 ? "issued" : "none-issued", detailKey: count > 0 ? "integrations.detail.keyCount" : "integrations.detail.keyNone", detailVars: count > 0 ? { count: String(count) } : null, }; @@ -354,14 +387,13 @@ function fileRow(status: IntegrationStatus): OverviewRow { * clients, then the file clients in their existing order. It matches the tab * strip above the grid, so the eye moves the same way in both. */ -export function buildOverviewRows(sources: OverviewSources): OverviewRow[] { +export function buildOverviewRows(sources: OverviewSources): OverviewRows { const nativeClaude = sources.native?.find(status => status.clientId === "claude"); const nativeGrok = sources.native?.find(status => status.clientId === "grok"); // One lookup table, not a find per client (react-doctor js-index-maps). const statusByClient = new Map(sources.clients.map(status => [status.clientId, status])); const rows: OverviewRow[] = [ codexRow(sources.codex), - keysRow(sources.keyCount), claudeRow(sources.claude, nativeClaude, sources.nativeSettled), claudeDesktopRow(sources.claudeDesktop), grokRow(sources.grok, nativeGrok, sources.nativeSettled), @@ -393,7 +425,7 @@ export function buildOverviewRows(sources: OverviewSources): OverviewRow[] { }); } } - return rows; + return { keysRow: keysRow(sources.keyPhase, sources.keyCount), rows }; } export interface OverviewCounts { diff --git a/gui/src/styles-integrations.css b/gui/src/styles-integrations.css index 6ef9bf1b9..50ecd3e2d 100644 --- a/gui/src/styles-integrations.css +++ b/gui/src/styles-integrations.css @@ -18,6 +18,15 @@ down while a one-line one did not. Reserving the detail line's height keeps the switches on one baseline across the row. */ .integration-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; list-style: none; padding: 0; margin: 14px 0; } +/* + One full-width row, not a wide card: no grid cell, no hover border, no + stretched title. `flex-wrap` is what keeps long German/Russian action copy + moving below the label at 320-390px instead of clipping. +*/ +.integration-api-keys-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 12px 14px; margin: 14px 0 0; border: 1px solid var(--border); border-radius: var(--radius); background: var(--raised); } +.integration-api-keys-copy { display: flex; flex-direction: column; gap: 2px; flex: 1 1 220px; min-width: 0; } +.integration-api-keys-copy h4 { margin: 0; } +.integration-api-keys-row .integration-meta { margin: 0; min-height: 0; } .integration-card { position: relative; display: flex; flex-direction: column; gap: 8px; padding: 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--raised); } .integration-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .integration-card-head h4 { margin: 0; } diff --git a/gui/tests/api-key-count-loader.test.ts b/gui/tests/api-key-count-loader.test.ts new file mode 100644 index 000000000..9c5507e7f --- /dev/null +++ b/gui/tests/api-key-count-loader.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { loadApiKeyCount } from "../src/pages/integrations/integration-api"; + +/** + * `loadApiKeyCount` used to swallow every failure into `null` via + * `readOptional`. The overview then saw a SUCCESSFUL empty result, with no + * polling to correct it, and rendered "No keys issued" — a claim about the + * account of the user that a failed request cannot support. + * + * It now throws, which is what produces `failed-cold` / `failed-with-stale` and + * lets the row say "Key status unavailable" instead. These cases pin that + * contract, because the mounted overview test only exercises one of the five + * ways it can now fail. + */ + +const realFetch = globalThis.fetch; +afterEach(() => { globalThis.fetch = realFetch; }); + +function respondWith(body: string, init: ResponseInit = {}) { + globalThis.fetch = (async () => new Response(body, { + status: 200, + headers: { "content-type": "application/json" }, + ...init, + })) as typeof fetch; +} + +describe("loadApiKeyCount distinguishes empty from failed", () => { + test("an empty key list resolves 0 — zero is data, not a failure", async () => { + respondWith(JSON.stringify({ keys: [] })); + expect(await loadApiKeyCount("http://x")).toBe(0); + }); + + test("a populated list resolves its length", async () => { + respondWith(JSON.stringify({ keys: [{ key: "a" }, { key: "b" }] })); + expect(await loadApiKeyCount("http://x")).toBe(2); + }); + + test("a non-ok status rejects instead of reading as no keys", async () => { + respondWith("{}", { status: 500 }); + expect(loadApiKeyCount("http://x")).rejects.toThrow(/500/); + }); + + test("a malformed body rejects", async () => { + respondWith("not json at all"); + expect(loadApiKeyCount("http://x")).rejects.toThrow(/unexpected body/); + }); + + test("a non-array `keys` rejects — the case readOptional turned into 'no keys issued'", async () => { + respondWith(JSON.stringify({ keys: "nope" })); + expect(loadApiKeyCount("http://x")).rejects.toThrow(/unexpected body/); + }); + + test("a network rejection propagates rather than being swallowed", async () => { + globalThis.fetch = (async () => { throw new Error("offline"); }) as typeof fetch; + expect(loadApiKeyCount("http://x")).rejects.toThrow(/offline/); + }); +}); diff --git a/gui/tests/integrations-overview-rows.test.ts b/gui/tests/integrations-overview-rows.test.ts index 9f361373a..4cd926dc9 100644 --- a/gui/tests/integrations-overview-rows.test.ts +++ b/gui/tests/integrations-overview-rows.test.ts @@ -31,6 +31,7 @@ function sources(overrides: Partial = {}): OverviewSources { clientsSettled: true, codex: null, keyCount: null, + keyPhase: "settled", claude: null, claudeDesktop: null, grok: null, @@ -38,21 +39,22 @@ function sources(overrides: Partial = {}): OverviewSources { }; } -function rowById(rows: ReturnType, id: string) { - const found = rows.find(row => row.id === id); +function rowById(built: ReturnType, id: string) { + const found = built.rows.find(row => row.id === id); if (!found) throw new Error(`no row for ${id}`); return found; } test("a null source is unknown, never absent, and is counted in neither total", () => { - const rows = buildOverviewRows(sources()); - for (const id of ["codex", "keys", "claude", "claudeDesktop", "grok"]) { - expect(rowById(rows, id).state).toBe("unknown"); + const built = buildOverviewRows(sources()); + for (const id of ["codex", "claude", "claudeDesktop", "grok"]) { + expect(rowById(built, id).state).toBe("unknown"); } - const counts = countOverviewRows(rows); + const counts = countOverviewRows(built.rows); expect(counts.detected).toBe(0); expect(counts.applied).toBe(0); - expect(counts.unknown).toBe(5); + // Four, not five: keys is a credential surface and never a client row. + expect(counts.unknown).toBe(4); }); test("Codex reads routingInjected, not status", () => { @@ -119,7 +121,7 @@ test("file clients keep their existing badge and applied semantics", () => { expect(rowById(rows, "kimi").installed).toBe(false); expect(rowById(rows, "gajae").state).toBe("unsafe"); - const counts = countOverviewRows(rows); + const counts = countOverviewRows(rows.rows); expect(counts.detected).toBe(5); expect(counts.applied).toBe(2); expect(counts.stale).toBe(1); @@ -134,27 +136,28 @@ test("every client counts toward the summary, not just the file six", () => { claudeDesktop: { applied: true, stale: true, activeProfile: true }, grok: { present: true, models: [{}, {}] }, })); - const counts = countOverviewRows(rows); - // codex + keys + claude + desktop + grok + opencode - expect(counts.applied).toBe(6); + const counts = countOverviewRows(rows.rows); + // codex + claude + desktop + grok + opencode. Keys are deliberately absent: + // an issued credential is not an applied client. + expect(counts.applied).toBe(5); expect(counts.stale).toBe(1); expect(counts.unknown).toBe(0); }); test("an unsettled file list renders unknown rows instead of dropping them", () => { - const rows = buildOverviewRows(sources({ clients: [], clientsSettled: false })); - expect(rows).toHaveLength(11); - expect(rowById(rows, "kimi").state).toBe("unknown"); + const built = buildOverviewRows(sources({ clients: [], clientsSettled: false })); + expect(built.rows).toHaveLength(10); + expect(rowById(built, "kimi").state).toBe("unknown"); // Once settled, a client the server omitted is genuinely gone. const settled = buildOverviewRows(sources({ clients: [], clientsSettled: true })); - expect(settled).toHaveLength(5); + expect(settled.rows).toHaveLength(4); + expect(settled.rows.some(row => row.hash === "integrations/keys")).toBe(false); }); test("each row points at its own tab", () => { const rows = buildOverviewRows(sources({ clientsSettled: false })); expect(rowById(rows, "codex").hash).toBe("integrations/codex"); - expect(rowById(rows, "keys").hash).toBe("integrations/keys"); expect(rowById(rows, "claude").hash).toBe("integrations/claude"); expect(rowById(rows, "claudeDesktop").hash).toBe("integrations/claude/desktop"); expect(rowById(rows, "grok").hash).toBe("integrations/grok"); diff --git a/gui/tests/integrations-surfaces.test.tsx b/gui/tests/integrations-surfaces.test.tsx index 6d203f5e9..c4090792d 100644 --- a/gui/tests/integrations-surfaces.test.tsx +++ b/gui/tests/integrations-surfaces.test.tsx @@ -175,6 +175,22 @@ function buttons(): HTMLButtonElement[] { return Array.from(container.querySelectorAll("button")) as unknown as HTMLButtonElement[]; } +/** + * The switch belonging to ONE card. + * + * `buttons()[0]` used to be the file client's switch because it was the only + * card with one. The Codex card now renders too, so "the first switch" is + * whichever card sorts first — a fact about layout, not about the client under + * test. + */ +function switchFor(clientId: string): HTMLButtonElement | undefined { + const card = container.querySelector(`[data-client="${clientId}"]`); + if (!card) return undefined; + return Array.from(card.querySelectorAll("button")).find( + button => (button as HTMLButtonElement).className.includes("switch"), + ) as HTMLButtonElement | undefined; +} + function buttonByText(text: string): HTMLButtonElement | undefined { return buttons().find(button => (button.textContent ?? "").trim() === text); } @@ -518,7 +534,7 @@ test("a card toggles its own client without a trip to the sub-page", async () => stateResponse = () => json({ clients: [status({ state: "stale" })] }); await mountOverview(); - const sw = buttons().find(button => button.className.includes("switch")); + const sw = switchFor("hermes"); expect(sw).toBeDefined(); expect(sw!.getAttribute("aria-pressed")).toBe("true"); await act(async () => { sw!.click(); }); @@ -532,7 +548,7 @@ test("a card toggles its own client without a trip to the sub-page", async () => test("a card cannot toggle a client whose config is in conflict", async () => { stateResponse = () => json({ clients: [status({ state: "conflict", reason: "foreign-edit" })] }); await mountOverview(); - const sw = buttons().find(button => button.className.includes("switch")); + const sw = switchFor("hermes"); expect(sw?.disabled).toBe(true); }); @@ -566,15 +582,30 @@ test("every reachable client gets a card, not just the file six", async () => { const clientIds = Array.from(container.querySelectorAll(".integration-card")) .map(card => (card as unknown as HTMLElement).getAttribute("data-client")); expect(clientIds).toContain("codex"); - expect(clientIds).toContain("keys"); + // Keys deliberately absent: a credential is not a client card. It renders as + // its own row above the grid instead. + expect(clientIds).not.toContain("keys"); + expect(container.querySelector(".integration-cards [data-client='keys']")).toBeNull(); + expect(container.querySelector(".integration-api-keys-row")).not.toBeNull(); expect(clientIds).toContain("claude"); expect(clientIds).toContain("claudeDesktop"); expect(clientIds).toContain("grok"); expect(clientIds).toContain("hermes"); - // Only the file client carries a switch: the other five are navigation. - const switches = buttons().filter(button => button.className.includes("switch")); - expect(switches).toHaveLength(1); + /* + * Switches belong to the clients this build can toggle in place, and that set + * grew: the file client had the only one until Codex and Grok gained theirs. + * Naming the owners keeps the assertion about WHICH cards can toggle rather + * than about how many happen to today. + */ + const switchOwners = Array.from(container.querySelectorAll(".integration-cards [data-client]")) + .filter(card => Array.from(card.querySelectorAll("button")) + .some(button => (button as HTMLButtonElement).className.includes("switch"))) + .map(card => card.getAttribute("data-client")); + expect(switchOwners).toContain("hermes"); + expect(switchOwners).toContain("codex"); + // Navigation-only cards still have none. + expect(switchOwners).not.toContain("claudeDesktop"); // Claude Desktop opens Claude's nested route, not a tab of its own. const desktopLink = container.querySelector( @@ -594,12 +625,16 @@ test("a source that cannot be read is unknown, never 'not applied'", async () => failExtraSources = true; await mountOverview(); - for (const id of ["codex", "keys", "claude", "claudeDesktop", "grok"]) { + for (const id of ["codex", "claude", "claudeDesktop", "grok"]) { const badge = container.querySelector( `.integration-card[data-client='${id}'] .badge`, ) as unknown as HTMLElement | null; expect(badge?.getAttribute("data-integration-state")).toBe("unknown"); } + // The keys row says the same thing in credential words: a failed read is + // "unavailable", never "no keys issued". + const keysRow = container.querySelector(".integration-api-keys-row") as unknown as HTMLElement | null; + expect(keysRow?.getAttribute("data-key-state")).toBe("unavailable"); // The file client still reports its real state. const hermes = container.querySelector( ".integration-card[data-client='hermes'] .badge", diff --git a/gui/tests/overview-state-merge.test.ts b/gui/tests/overview-state-merge.test.ts index 5eb15da9e..e1697fa42 100644 --- a/gui/tests/overview-state-merge.test.ts +++ b/gui/tests/overview-state-merge.test.ts @@ -20,6 +20,7 @@ function sources(overrides: Partial = {}): OverviewSources { clientsSettled: true, codex: null, keyCount: null, + keyPhase: "settled", claude: { enabled: false, authMode: "subscription" }, claudeDesktop: null, grok: { present: false, models: [] }, @@ -30,7 +31,7 @@ function sources(overrides: Partial = {}): OverviewSources { } function row(overrides: Partial, id: "claude" | "grok") { - const found = buildOverviewRows(sources(overrides)).find(candidate => candidate.id === id); + const found = buildOverviewRows(sources(overrides)).rows.find(candidate => candidate.id === id); if (!found) throw new Error(`missing ${id} row`); return found; } diff --git a/src/cli/index.ts b/src/cli/index.ts index afaa44093..335221b7c 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,8 +1,8 @@ #!/usr/bin/env bun import { spawn } from "node:child_process"; -import { currentExternalCodexModelProvider, restoreNativeCodex, shouldInjectApiAuthHeader } from "../codex/inject"; +import { currentExternalCodexModelProvider, restoreNativeCodex, restoreNativeCodexAsync, shouldInjectApiAuthHeader } from "../codex/inject"; import { stripGrokConfig } from "../grok/inject"; -import { restoreLegacyOpenaiHistory } from "../codex/history-provider"; +import { resolveCodexHistoryJobTarget, runCodexHistoryJob } from "../codex/history-job"; import { reconcileJournal } from "../codex/journal"; import { codexAutoStartEnabled, @@ -41,6 +41,7 @@ import { maybeShowStarPrompt } from "./star-prompt"; import { scheduleCatalogPrewarm } from "./catalog-prewarm"; import { maybeShowUpdatePrompt } from "../update/notify"; import { syncModelsToCodex } from "../codex/sync"; +import { shouldSyncGrokOnStart, syncCodexOnStartIfEnabled } from "../codex/desired-state"; import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job"; import { collectOrcaCodexHomeDiagnostic } from "../codex/home"; import { removeOwnedConfigState } from "../lib/config-ownership"; @@ -316,7 +317,7 @@ async function handleStart(options: { block?: boolean } = {}) { installShellHook(); await maybeShowStarPrompt(); // once-only Yes/No GitHub-star prompt on first interactive start - await syncModelsToCodex(port).catch(() => {}); + await syncCodexOnStartIfEnabled(port, config); if (!currentExternalCodexModelProvider() && !shouldInjectApiAuthHeader(config) && config.syncResumeHistory !== false) { historyGuardian = startHistoryMigrationGuardian(); } @@ -336,7 +337,10 @@ async function handleStart(options: { block?: boolean } = {}) { // absent or the bind is non-loopback; removed again by stop/eject/uninstall/shutdown. // Deliberately a SIBLING of the Desktop-3P block above: nesting it there meant a catalog // failure skipped the fence entirely, even though syncGrokConfig handles that case itself. - try { + // + // Gated on the persisted switch: without this, turning Grok off lasted exactly + // one restart, because the toggle removed the fence and start wrote it back. + if (shouldSyncGrokOnStart(config)) try { const { syncGrokConfig } = await import("../grok/sync"); const r = await syncGrokConfig(port, config, config.hostname ? { hostname: config.hostname } : {}); if (r.changed) console.log(" + Grok Build config updated (~/.grok/config.toml)"); @@ -525,7 +529,7 @@ async function handleStop() { } } if (!ownershipBlocked) { - const r = restoreNativeCodex(); + const r = await restoreNativeCodexAsync(); if (r.success) console.log(`↩️ ${r.message}`); else { stopFailed = true; @@ -587,8 +591,8 @@ async function handleUninstall() { }); } - await runStep("native Codex restored", () => { - const r = restoreNativeCodex(); + await runStep("native Codex restored", async () => { + const r = await restoreNativeCodexAsync(); if (!r.success) throw new Error(r.message); }); @@ -715,13 +719,22 @@ async function handleStatus() { } } -function handleRecoverHistory() { +async function handleRecoverHistory() { if (args[1] !== "--legacy-openai") { console.error("Usage: ocx recover-history --legacy-openai"); console.error("Only use this if an older syncResumeHistory build already remapped OpenAI Codex App history to opencodex before backup support existed."); process.exit(1); } - const r = restoreLegacyOpenaiHistory(); + // Manifest-independent legacy ejection, serialized like every other history + // mutation. It is a separate operation from generic restore precisely because + // it must not read, consume or replace the backup manifest. + const outcome = await runCodexHistoryJob({ + ...resolveCodexHistoryJobTarget(), + operation: "recover-legacy-openai", + }); + const r = outcome.kind === "converged" + ? { rows: outcome.rows, files: outcome.files, failed: undefined } + : { rows: 0, files: 0, failed: true as const }; if (r.failed) { console.error( "⚠️ Recovery SKIPPED: the Codex history DB is locked (Codex app/IDE open?). Close it and rerun this command.", @@ -772,7 +785,7 @@ switch (command) { } let r: { success: boolean; message: string }; try { - r = restoreNativeCodex(); + r = await restoreNativeCodexAsync(); } catch (err) { r = { success: false, message: err instanceof Error ? err.message : String(err) }; } @@ -797,7 +810,7 @@ switch (command) { break; } case "recover-history": - handleRecoverHistory(); + await handleRecoverHistory(); break; case "uninstall": case "remove": @@ -855,9 +868,14 @@ switch (command) { } case "sync-cache": { const restartCodex = args.slice(1).includes("--restart-codex"); - const { invalidateCodexModelsCache } = await import("../codex/catalog"); + const { withCatalogWriteSerialization } = await import("../codex/catalog-write-serialization"); + const { invalidateCodexModelsCacheWithPermit } = await import("../codex/catalog/sync"); + const { getCodexHome } = await import("../codex/paths"); + const owningCodexHome = getCodexHome(); + const invalidated = withCatalogWriteSerialization(owningCodexHome, permit => + invalidateCodexModelsCacheWithPermit(permit, owningCodexHome)); // Only warn/restart when models_cache was actually rewritten from a readable catalog. - if (invalidateCodexModelsCache()) { + if (invalidated.kind === "completed" && invalidated.value) { const { afterCatalogWriteHandleAppServers } = await import("../codex/app-server-processes"); afterCatalogWriteHandleAppServers({ restart: restartCodex, log: console }); } diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index dabc64aad..0a4a84ed0 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -433,6 +433,55 @@ function outputBudgetFor(context: number): number { return Math.min(SCHEMA_REQUIRED_OUTPUT_BUDGET, context); } +/** + * Modalities a given client's schema will actually accept. + * + * Our internal vocabulary is `text | image | audio` (ALLOWED_INPUT_MODALITIES in + * src/server/management/model-routes.ts). Pi and Gajae accept only + * `text | image`, and both reject the WHOLE config file over one out-of-enum + * value — Gajae reports `/providers/opencodex/models/N/input/2: Invalid option` + * and falls back to its built-in list, Pi returns an empty model config. So a + * single `audio` model takes every routed model down with it. That is not + * hypothetical: zenmux/meta-muse-spark-1.1 advertises audio and did exactly + * this. It is also the same defect the Codex catalog had with `video`, where + * the app showed zero apps (tests/catalog-input-modality-enum.test.ts). + * + * UNKNOWN and INCOMPATIBLE are different inputs, and the Codex fix could + * conflate them safely only because its enum is wider. A model with nothing + * declared is unknown, and `text` is the honest floor — every routed model takes + * prompts. A model declaring `["audio"]` and nothing else is incompatible with a + * text|image client, and rewriting it to `["text"]` would advertise a capability + * it does not have. That input is reachable three ways: `ocx models add + * --modalities audio`, `/api/custom-models`, and provider discovery. + * + * So unknown falls back to text and incompatible returns null, which drops the + * row. Omitting a model costs the user a line in a picker; fabricating `text` + * costs them a model that fails at call time with no explanation. + * + * Deliberately NOT applied in `ExportModel` construction: the management and CLI + * boundaries carry catalog modalities verbatim on purpose, and stripping `audio` + * globally would destroy valid metadata before the destination is known. + */ +const CLIENT_INPUT_MODALITIES: Record<"pi" | "gajae", ReadonlySet> = { + pi: new Set(["text", "image"]), + gajae: new Set(["text", "image"]), +}; + +/** `null` means the model cannot be represented for this client — drop the row. */ +function inputModalitiesForClient( + client: "pi" | "gajae", + modalities: readonly string[] | undefined, +): string[] | null { + const declared = modalities ?? []; + if (declared.length === 0) return ["text"]; + const accepted = CLIENT_INPUT_MODALITIES[client]; + const kept: string[] = []; + for (const value of declared) { + if (accepted.has(value) && !kept.includes(value)) kept.push(value); + } + return kept.length > 0 ? kept : null; +} + /** * Label shared by every client: `" ()"`. The * provider suffix is what makes two same-named models from different upstreams @@ -646,25 +695,34 @@ export interface GajaeGeneratedConfig { * `reasoning` is a boolean in Pi while our catalog carries an effort list — mapping one * to the other would be a guess. * - * Pi's schema is UNVERIFIED against a real installation (001 §2); this contract is ours, - * not a claim about Pi's acceptance. + * Pi's input enum IS verified: its documented model configuration accepts only + * `text` and `image`, and a validation failure yields an EMPTY model config + * rather than dropping the offending entry — one bad value costs every routed + * model. The rest of this contract (omitting `cost` and `reasoning`) is still + * ours rather than a claim about Pi's acceptance. */ function buildPiClientConfig(ctx: ExportContext): PiGeneratedConfig { - const models: PiModelEntry[] = normalizeExportModels(ctx.models).map(model => { + const models: PiModelEntry[] = []; + for (const model of normalizeExportModels(ctx.models)) { + // Text is the one modality every routed model supports; anything richer must come + // from the catalog rather than an assumption — and must still be inside the enum + // Pi accepts, because one rejected value empties the whole config. + const input = inputModalitiesForClient("pi", model.inputModalities); + // An audio-only model has no honest representation here; claiming `text` + // would fail at call time instead, so the row is dropped. + if (input === null) continue; const entry: PiModelEntry = { id: model.namespaced, name: exportModelLabel(model), - // Text is the one modality every routed model supports; anything richer must come - // from the catalog rather than an assumption. - input: model.inputModalities && model.inputModalities.length > 0 ? [...model.inputModalities] : ["text"], + input, }; const context = authoritativeContextWindow(model.contextWindow); if (context !== undefined) { entry.contextWindow = context; entry.maxTokens = outputBudgetFor(context); } - return entry; - }); + models.push(entry); + } return { providers: { [OPENCODE_PROVIDER_ID]: { @@ -758,21 +816,24 @@ function buildKimiClientConfig(ctx: ExportContext): KimiGeneratedConfig { } function buildGajaeClientConfig(ctx: ExportContext): GajaeGeneratedConfig { - const models: GajaeModelEntry[] = normalizeExportModels(ctx.models).map(model => { + const models: GajaeModelEntry[] = []; + for (const model of normalizeExportModels(ctx.models)) { + // Gajae's enum is text|image and it rejects the whole file over one bad + // value, naming the offending index in the error. + const input = inputModalitiesForClient("gajae", model.inputModalities); + if (input === null) continue; const entry: GajaeModelEntry = { id: model.namespaced, name: exportModelLabel(model), - input: model.inputModalities && model.inputModalities.length > 0 - ? [...model.inputModalities] - : ["text"], + input, }; const context = authoritativeContextWindow(model.contextWindow); if (context !== undefined) { entry.contextWindow = context; entry.maxTokens = outputBudgetFor(context); } - return entry; - }); + models.push(entry); + } return { providers: { [OPENCODE_PROVIDER_ID]: { diff --git a/src/codex/admission.ts b/src/codex/admission.ts new file mode 100644 index 000000000..b8db8a5f5 --- /dev/null +++ b/src/codex/admission.ts @@ -0,0 +1,255 @@ +/** + * The `AdmissionSnapshot` producer. + * + * `AdmissionSnapshot` existed as a TYPE for the whole unit while nothing built + * one, which meant the write lock's API could only ever be exercised by a + * fabricated object — the reason WP11 was merged into WP12 rather than shipped + * as a mechanism with no consumer. + * + * What this is FOR: one decision uses one set of bytes. Everything the lock + * compares is captured here, once, before any artifact is created; the lock then + * re-reads it while holding N and refuses if the authority moved underneath. + * Passing the config around instead would let two reads of a changing file + * disagree inside one operation, which is the interruption hazard this unit + * exists to close. + * + * READS ONLY. Nothing here creates a directory, a database, or a marker: an + * admission that manufactures the state it is admitting cannot refuse. + * + * Design record: devlog/_plan/260804_codex_write_substrate/040_ownership_convergence.md. + */ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { getConfigDir, observeConfigGeneration, readConfigAdmissionSnapshot } from "../config"; +import { inspectNativeCodexOwnership } from "../integrations/native/ownership-preflight"; +import type { AdmissionSnapshot } from "./convergence-types"; +import { codexIntegrationEnabled } from "./desired-state"; +import { externalCodexModelProvider } from "./inject"; +import { JOURNAL_PATH } from "./journal"; +import { + CODEX_MODELS_CACHE_PATH, + CODEX_PROFILE_PATH, + DEFAULT_CATALOG_PATH, + getCodexHome, +} from "./paths"; + +export type CodexAdmission = + | { readonly kind: "admitted"; readonly snapshot: AdmissionSnapshot } + | { + readonly kind: "refused"; + readonly authority: "config" | "generation" | "service-home" | "external-provider"; + readonly message: string; + }; + +/** + * Seams, not conveniences. + * + * The ownership probe shells out to the platform service manager, and a test + * that reached the real one would be asserting against whatever the developer's + * machine happens to have installed. Injecting it is also what lets a test + * observe the EXACT argv the production probe emits, which is the only way to + * hold down "this never starts or stops anything". + */ +export interface AdmissionDeps { + readonly inspectOwnership?: typeof inspectNativeCodexOwnership; +} + +/** Hash of a file's exact bytes, or a stable marker for absence. */ +function contentIdentity(path: string): string { + try { + return createHash("sha256").update(readFileSync(path)).digest("hex").slice(0, 32); + } catch { + // Absence is EVIDENCE, not a hole. An absent journal and an unreadable one + // are different states and must not collapse to the same identity. + return existsSync(path) ? "unreadable" : "absent"; + } +} + +/** + * Capture everything one Codex write decision depends on. + * + * Refuses rather than guessing: a missing or malformed config, a coordinator + * that cannot report a generation, a service installed from another home, and an + * external `model_provider` each end the operation before it starts. The last + * two are separate authorities on purpose — "someone else's service owns this + * home" and "the user pointed Codex somewhere else" need different messages + * because they need different actions. + */ +export function admitCodexWrite(deps: AdmissionDeps = {}): CodexAdmission { + const persisted = readConfigAdmissionSnapshot(); + const diagnostics = persisted.diagnostics; + if (diagnostics.source !== "file") { + return { + kind: "refused", + authority: "config", + message: diagnostics.source === "default" + ? "No config file exists to admit a Codex write from." + : "The config file is malformed; refusing to write from it.", + }; + } + if (persisted.kind !== "read") { + // Unreachable through the union today — `source: "file"` only comes from a + // successful read — but stated rather than assumed, because the day that + // stops being true this should refuse, not proceed with no digest. + return { + kind: "refused", + authority: "config", + message: "The config file could not be read as bytes; refusing to write from it.", + }; + } + + /* + * OBSERVE, not read. + * + * `readConfigGeneration` opens the coordinator with `create: true`, so merely + * admitting would produce a `config-mutation.sqlite` in a home that had none — + * caught by the "creates nothing" test, which is what that test is for. Only a + * cooperating config WRITE may create and initialize the singleton. + */ + const observed = observeConfigGeneration(); + if (observed.kind === "unavailable") { + return { + kind: "refused", + authority: "generation", + message: observed.reason === "busy" + ? "The config coordinator is busy; the generation could not be read." + : "The config generation exists but could not be read; refusing to guess it.", + }; + } + const generation = observed.kind === "absent" + ? { present: false, value: 0 } + : { present: true, value: observed.generation.value }; + + /* + * Tri-state, and NOT `assertNativeTeardownOwned` — that one fails open, so a + * corrupt state file would arrive here as `owned`. Unattended writes refuse on + * both `foreign` and `unknown`: the first is someone else's home, the second + * is a question that could not be answered, and neither is permission. + */ + const ownership = (deps.inspectOwnership ?? inspectNativeCodexOwnership)(); + if (ownership.ownership !== "owned") { + return { + kind: "refused", + authority: "service-home", + message: ownership.ownership === "foreign" + ? `Refusing to write: ${ownership.reason}.` + : `Refusing to write because ownership could not be proven: ${ownership.reason}.`, + }; + } + + const codexHome = getCodexHome(); + const codexConfigPath = join(codexHome, "config.toml"); + let external: string | null = null; + try { + // Resolved at call time, not at module load: CODEX_CONFIG_PATH is a const + // fixed when the module was first imported, so it does not follow a + // CODEX_HOME that changed afterwards. + external = externalCodexModelProvider(readFileSync(codexConfigPath, "utf-8")); + } catch { + // No Codex config yet is not an external owner; it is simply nothing to read. + external = null; + } + if (external) { + return { + kind: "refused", + authority: "external-provider", + message: `Codex config.toml is owned by an external model_provider (${external}).`, + }; + } + + const config = diagnostics.config; + const opencodexHome = getConfigDir(); + const integrationRecord = join(opencodexHome, "integrations", "codex.json"); + const historyDb = join(codexHome, "state_5.sqlite"); + + const canonicalTargets = { + codexHome, + opencodexHome, + config: codexConfigPath, + profile: CODEX_PROFILE_PATH, + catalog: DEFAULT_CATALOG_PATH, + cache: CODEX_MODELS_CACHE_PATH, + // The journal's own constant. Re-deriving it here is what made this field + // watch a path nothing writes. + journal: JOURNAL_PATH, + integrationRecord, + // Backups and rollouts are enumerated by their owners, not guessed here. + catalogBackups: [] as readonly string[], + historyDb, + historyManifest: `${historyDb}.ocx-backup.json`, + historyRollouts: [] as readonly string[], + } as const; + + const snapshot: AdmissionSnapshot = { + config, + // The EXACT persisted bytes. Hashing the parsed object instead let a + // whitespace-only rewrite pass unnoticed, which is precisely the + // non-cooperating writer this comparison exists to catch. + configDigest: persisted.contentSha256, + intent: codexIntegrationEnabled(config) ? "on" : "off", + generation, + // Reached only when the projection above said `owned`; the other two states + // have already refused. This is the observed value, not a placeholder. + ownership: ownership.ownership, + externalProvider: null, + canonicalTargets, + journalIdentity: contentIdentity(JOURNAL_PATH), + provenanceIdentity: contentIdentity(integrationRecord), + authoritySnapshotId: "", + }; + + return { kind: "admitted", snapshot: { ...snapshot, authoritySnapshotId: hashAuthority(snapshot) } }; +} + +/** + * Collapse the generation to one authority token. + * + * An absent database and a present zero are the SAME authority — both mean no + * cooperating write has committed — and they must hash alike, because the lock + * compares this ID byte-for-byte. They cannot be observed alike: before the lock + * only absence is visible, and inside it only the present zero is, since the + * transaction that creates the row has not committed while a separate connection + * looks. Without this they would never match and every first write would refuse. + * + * Canonicalizing inside the hash rather than beside it, in a comparator: a + * comparator that treats one field specially has to be rewritten at every future + * comparison site, and the site that gets forgotten is the one that matters. + */ +function generationAuthority(generation: AdmissionSnapshot["generation"]): string { + const { present, value } = generation; + if (!Number.isSafeInteger(value) || value < 0) { + // NaN, Infinity, fractions, negatives and unsafe integers would otherwise + // all quietly become "gen:0" — the one value that means "safe to proceed". + throw new TypeError(`A config generation must be a non-negative safe integer, got ${String(value)}.`); + } + return present && value > 0 ? `gen:${value}` : "gen:0"; +} + +/** + * Digest every authority field, in a fixed order. + * + * The lock compares THIS and nothing else, so a field left out of the hash is a + * field that can change under the lock without anyone noticing. Adding one to + * the snapshot means adding it here. + */ +export function hashAuthority(snapshot: AdmissionSnapshot): string { + return createHash("sha256") + .update(JSON.stringify([ + snapshot.configDigest, + snapshot.intent, + generationAuthority(snapshot.generation), + snapshot.ownership, + snapshot.externalProvider, + snapshot.canonicalTargets, + snapshot.journalIdentity, + snapshot.provenanceIdentity, + ])) + .digest("hex"); +} + +/** Test seam: prove the identity of a path the way admission does. */ +export function admissionContentIdentityForTests(path: string): string { + return contentIdentity(path); +} diff --git a/src/codex/catalog-admission.ts b/src/codex/catalog-admission.ts new file mode 100644 index 000000000..df27a53b3 --- /dev/null +++ b/src/codex/catalog-admission.ts @@ -0,0 +1,197 @@ +/** + * Minimal catalog admission for the management refresh path. + * + * The r2 #1 catalog incident left gather and native writes in one awaited + * callback. WP9 needs generation and filesystem-target evidence before it can + * split those phases, but importing WP12 authority would make WP9 depend on a + * later phase. This reader therefore captures only the exact resident config, + * its cooperating generation, and identities for catalog-owned targets. + */ +import { createHmac, randomBytes } from "node:crypto"; +import { join, resolve } from "node:path"; + +import { observeConfigGeneration } from "../config"; +import type { OcxConfig } from "../types"; +import type { + CatalogAdmissionSnapshot, + CatalogConvergeRequestInput, + ConfigGeneration, + ConvergeRequest, +} from "./convergence-types"; +import { + catalogBackupPathFor, + legacyCatalogBackupPath, + samePath, +} from "./catalog/parsing"; +import { readRootTomlString } from "./paths"; +import { + acceptCatalogGatherSourcePath, + captureAndSealCatalogHomeSelection, + captureCatalogGatherTargetIdentity, + createCatalogGatherEvidenceSession, + readCatalogGatherSource, + sealCatalogGatherEvidenceSession, +} from "./catalog/filesystem-evidence"; + +/** + * Construct the one request shape permitted for management catalog refreshes. + * Callers choose the deadline only; they cannot widen scope or choose direction. + */ +export function createCatalogConvergeRequest({ + deadlineMs, +}: CatalogConvergeRequestInput): ConvergeRequest { + if (!Number.isSafeInteger(deadlineMs) || deadlineMs <= 0) { + throw new TypeError("Catalog convergence deadlineMs must be a positive safe integer."); + } + + return { + action: "converge", + scope: "catalog", + reason: "management-mutation", + mode: "automatic", + deadlineMs, + }; +} + +const CONFIG_IDENTITY_KEY = randomBytes(32); +const configReferenceIdentities = new WeakMap(); +let nextConfigReferenceIdentity = 0; + +function encodeLengthPrefixed(value: string): string { + return `${Buffer.byteLength(value, "utf8")}:${value}`; +} + +function canonicalConfigEncoding(value: unknown, ancestors = new Set()): string { + if (value === null) return "null"; + switch (typeof value) { + case "undefined": return "undefined"; + case "boolean": return value ? "boolean:1" : "boolean:0"; + case "string": return `string:${encodeLengthPrefixed(value)}`; + case "number": { + if (!Number.isFinite(value)) throw new TypeError("Catalog config identity cannot encode a non-finite number."); + return `number:${Object.is(value, -0) ? "-0" : String(value)}`; + } + case "bigint": + case "function": + case "symbol": + throw new TypeError(`Catalog config identity cannot encode ${typeof value}.`); + case "object": break; + } + + if (ancestors.has(value)) throw new TypeError("Catalog config identity cannot encode a cyclic graph."); + if (Object.getOwnPropertySymbols(value).length > 0) { + throw new TypeError("Catalog config identity cannot encode symbol keys."); + } + ancestors.add(value); + try { + if (Array.isArray(value)) { + const items = Array.from({ length: value.length }, (_, index) => ( + Object.hasOwn(value, index) + ? `item:${canonicalConfigEncoding(value[index], ancestors)}` + : "hole" + )); + return `array:${value.length}:${items.map(encodeLengthPrefixed).join("")}`; + } + const keys = Object.keys(value).sort(); + const entries = keys.map(key => { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) { + throw new TypeError("Catalog config identity cannot encode accessor properties."); + } + return `${encodeLengthPrefixed(key)}${encodeLengthPrefixed(canonicalConfigEncoding(descriptor.value, ancestors))}`; + }); + return `object:${keys.length}:${entries.join("")}`; + } finally { + ancestors.delete(value); + } +} + +function keyedConfigIdentity(domain: string, payload: string): string { + return createHmac("sha256", CONFIG_IDENTITY_KEY) + .update(encodeLengthPrefixed(domain)) + .update(encodeLengthPrefixed(payload)) + .digest("hex"); +} + +function catalogConfigIdentity( + config: Readonly, + generation: ConfigGeneration, +): CatalogAdmissionSnapshot["configIdentity"] { + let referenceIdentity = configReferenceIdentities.get(config); + if (!referenceIdentity) { + nextConfigReferenceIdentity += 1; + referenceIdentity = keyedConfigIdentity("catalog-config-reference-v1", String(nextConfigReferenceIdentity)); + configReferenceIdentities.set(config, referenceIdentity); + } + return Object.freeze({ + referenceIdentity, + generation: Object.freeze({ ...generation }), + snapshotIdentity: keyedConfigIdentity("catalog-config-snapshot-v1", canonicalConfigEncoding(config)), + }); +} + +/** + * Capture the catalog-only evidence WP9 can validate without consulting WP12. + * The config reference is retained verbatim; no persisted config re-read may + * replace the object already held by the management callback. + */ +export function captureCatalogAdmissionSnapshot( + config: Readonly, +): CatalogAdmissionSnapshot { + const generation = observeConfigGeneration(); + if (generation.kind === "absent") { + /* + * Absence is refused HERE and admitted elsewhere, and the difference is the + * lock. Codex write admission may carry an absent generation because it goes + * on to take the config transaction and read a real zero inside it. Catalog + * gather has no such transaction — by contract it holds no lock and writes + * nothing — so it has no way to turn absence into an observation. Refusing + * is the only honest answer available to it. + * + * The wording matters beyond this line: `admissionFailure` in + * management-convergence.ts classifies this throw by matching its MESSAGE, + * and an unrecognized one becomes a non-retryable `failed/disk` instead of + * the retryable skip a missing coordinator has always produced. Keeping the + * "config generation is database" phrasing preserves that projection — a + * caller that could simply try again should still be told to. + */ + throw new Error("Cannot capture Codex catalog admission: config generation is database (absent)."); + } + if (generation.kind !== "ready") { + throw new Error(`Cannot capture Codex catalog admission: config generation is ${generation.reason}.`); + } + + const evidenceSession = createCatalogGatherEvidenceSession(); + const homeSelection = captureAndSealCatalogHomeSelection(evidenceSession); + const configPath = join(homeSelection.canonicalCodexHome, "config.toml"); + acceptCatalogGatherSourcePath(evidenceSession, "catalog-target-selection", configPath); + const configBytes = readCatalogGatherSource(evidenceSession, "catalog-target-selection"); + const configuredCatalogPath = configBytes === null + ? null + : readRootTomlString(Buffer.from(configBytes).toString("utf8"), "model_catalog_json"); + const defaultCatalogPath = join(homeSelection.canonicalCodexHome, "opencodex-catalog.json"); + const catalogPath = configuredCatalogPath + ? resolve(homeSelection.canonicalCodexHome, configuredCatalogPath) + : defaultCatalogPath; + const backupPaths = [ + catalogBackupPathFor(catalogPath), + ...(samePath(catalogPath, defaultCatalogPath) ? [legacyCatalogBackupPath()] : []), + ]; + const targets = { + catalog: captureCatalogGatherTargetIdentity(evidenceSession, catalogPath), + cache: captureCatalogGatherTargetIdentity( + evidenceSession, + join(homeSelection.canonicalCodexHome, "models_cache.json"), + ), + catalogBackups: backupPaths.map(path => captureCatalogGatherTargetIdentity(evidenceSession, path)), + }; + const sourceEvidence = sealCatalogGatherEvidenceSession(evidenceSession); + + return { + config, + generation: generation.generation, + configIdentity: catalogConfigIdentity(config, generation.generation), + targets, + sourceEvidence, + }; +} diff --git a/src/codex/catalog-write-serialization.ts b/src/codex/catalog-write-serialization.ts new file mode 100644 index 000000000..f7a8f10bd --- /dev/null +++ b/src/codex/catalog-write-serialization.ts @@ -0,0 +1,241 @@ +/** + * K — the permanent catalog serialization primitive. + * + * Round 4 of the audit killed the idea that "the replacement happens under a + * lock" is enough. The retained `/api/sync` chain reads the active catalog, + * captures it, awaits provider gathering, and only then writes from that + * captured state (`src/codex/catalog/sync.ts:513,520,526,565`). If convergence + * publishes Y while that await is pending, taking a lock afterwards does not + * make the captured X fresh — the writer legally overwrites Y while holding it. + * So K serializes the whole read-transform-write transaction, either by + * recomputing under K or by revalidating complete evidence under K, not by + * guarding the last rename. + * + * The second defect that round found was subtler: an opaque TypeScript permit + * type proves a permit-bearing call path EXISTS. It cannot prove the callback + * still holds K. A leaked permit used after its callback returned type-checks + * perfectly. So the type is not the mechanism — this module owns a private + * active-permit registry, and every mutator asks it at runtime whether the + * permit it holds is still live for the home it is about to write. + * + * K is permanent and is NOT WP11's native write lock. It never reads or + * advances the native pair. Order is `N -> K -> C`; there is no `C -> K` and no + * `K -> N` (`005_contract.md:660-762`). + * + * Design record: devlog/_plan/260804_codex_write_substrate/005_contract.md §3. + */ +import { chmodSync, lstatSync, realpathSync } from "node:fs"; + +import { Database } from "bun:sqlite"; + +import { + CodexUserIdentityRefusal, + resolveCodexCatalogSerializationDatabasePath, + resolveEffectiveUserIdentity, +} from "./user-identity"; + +/** + * Authorization to perform the fixed sequence of catalog mutations inside ONE + * K acquisition. + * + * Deliberately carries no usable field. Holding this object is necessary but + * never sufficient: `assertCatalogWritePermit` is what actually decides, by + * looking the object up in a registry this module alone can write. A forged + * cast, a prototype copy, or a symbol clone produces a value of this type that + * every writer refuses. + */ +export interface CatalogWritePermit { + readonly [catalogWritePermitBrand]: true; +} + +declare const catalogWritePermitBrand: unique symbol; + +export type CatalogSerializationOutcome = + | { kind: "completed"; value: T } + | { kind: "unavailable"; reason: "busy" | "database" | "unsafe-path" }; + +export class CatalogWritePermitRefusal extends Error { + readonly code = "CODEX_CATALOG_WRITE_PERMIT_REFUSED"; + + constructor(message: string) { + super(message); + this.name = "CatalogWritePermitRefusal"; + } +} + +interface PermitRegistration { + /** The exact canonical CODEX_HOME whose artifacts this acquisition may write. */ + readonly canonicalCodexHome: string; + /** Identifies the acquisition, so a permit cannot outlive its transaction. */ + readonly transactionId: string; + live: boolean; +} + +/** + * The registry is a module-private WeakMap keyed by permit IDENTITY. There is + * no exported constructor, brand value, or registration API, so the only way to + * obtain a registered permit is to be inside a live callback. + */ +const activePermits = new WeakMap(); + +let acquisitionCounter = 0; + +function isBusy(error: unknown): boolean { + const code = error && typeof error === "object" && "code" in error + ? String((error as { code?: unknown }).code) + : ""; + const message = error instanceof Error ? error.message : String(error); + return code === "SQLITE_BUSY" || code === "SQLITE_LOCKED" + || /database (?:is|table is) locked/i.test(message); +} + +/** + * The runtime proof every low-level catalog/backup/cache mutator must obtain + * before its FIRST filesystem mutation — including temp creation, hardening, + * unlink, link, rename, truncate, or replacement. + * + * `owningCodexHome` is supplied by the caller rather than inferred from the + * target's parent directory, because an accepted configured catalog target may + * be absolute and outside CODEX_HOME entirely. + */ +export function assertCatalogWritePermit( + permit: CatalogWritePermit, + owningCodexHome: string, +): void { + const registration = activePermits.get(permit as unknown as object); + if (!registration) { + throw new CatalogWritePermitRefusal( + "The catalog write permit was not minted by the serialization owner.", + ); + } + if (!registration.live) { + throw new CatalogWritePermitRefusal( + "The catalog write permit belongs to a released acquisition.", + ); + } + if (registration.canonicalCodexHome !== owningCodexHome) { + throw new CatalogWritePermitRefusal( + "The catalog write permit authorizes a different CODEX_HOME.", + ); + } +} + +/** + * Acquire K for one canonical CODEX_HOME and run `write` while it is held. + * + * Synchronous by contract: the callback performs no provider request, runtime + * probe, OAuth refresh, subprocess, or awaited work. It may enter + * `withExpectedConfigGenerationSync` — that is the `K -> C` edge. + * + * `busy_timeout = 0` with `BEGIN IMMEDIATE` makes contention fail fast and + * typed; the outer async orchestration decides whether to retry within its + * deadline. Blocking here would hold K across an unbounded wait. + */ +export function withCatalogWriteSerialization( + canonicalCodexHome: string, + write: (permit: CatalogWritePermit) => T, +): CatalogSerializationOutcome { + let databasePath: string; + try { + databasePath = resolveCodexCatalogSerializationDatabasePath( + resolveEffectiveUserIdentity(), + canonicalCodexHome, + ); + } catch (error) { + if (error instanceof CodexUserIdentityRefusal) { + return { kind: "unavailable", reason: "unsafe-path" }; + } + return { kind: "unavailable", reason: "database" }; + } + + let database: Database | undefined; + let transactionOpen = false; + let registration: PermitRegistration | undefined; + let permit: CatalogWritePermit | undefined; + + try { + let databaseWasAbsent = false; + try { + const before = lstatSync(databasePath); + if (before.isSymbolicLink() || !before.isFile()) { + return { kind: "unavailable", reason: "unsafe-path" }; + } + if (process.platform !== "win32") { + const uid = process.getuid?.(); + if (uid === undefined || before.uid !== uid || (before.mode & 0o777) !== 0o600) { + return { kind: "unavailable", reason: "unsafe-path" }; + } + } + } catch (cause) { + const code = cause && typeof cause === "object" && "code" in cause + ? String((cause as { code?: unknown }).code) + : ""; + if (code !== "ENOENT") throw cause; + databaseWasAbsent = true; + } + + database = new Database(databasePath, { create: true }); + if (databaseWasAbsent) { + try { chmodSync(databasePath, 0o600); } catch { /* Windows applies ACLs in WP11. */ } + } + const opened = lstatSync(databasePath); + if (opened.isSymbolicLink() || !opened.isFile() + || realpathSync.native(databasePath) !== databasePath) { + return { kind: "unavailable", reason: "unsafe-path" }; + } + + database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + transactionOpen = true; + + acquisitionCounter += 1; + registration = { + canonicalCodexHome, + transactionId: `${process.pid}:${acquisitionCounter}`, + live: true, + }; + // A bare object: nothing about it is guessable or reconstructable, because + // authority lives in the registry entry rather than in the value. + permit = {} as CatalogWritePermit; + activePermits.set(permit as unknown as object, registration); + + let value: T; + try { + value = write(permit); + } finally { + // Revoke BEFORE the transaction is released, so a mutator racing the + // release can never find a live permit without a live lock. This runs on + // the throwing path too, which is the case a `finally`-less version gets + // wrong. + registration.live = false; + activePermits.delete(permit as unknown as object); + } + + database.exec("COMMIT"); + transactionOpen = false; + return { kind: "completed", value }; + } catch (error) { + if (transactionOpen) { + try { database?.exec("ROLLBACK"); } catch { /* close releases the transaction */ } + transactionOpen = false; + } + if (registration?.live) { + registration.live = false; + if (permit) activePermits.delete(permit as unknown as object); + } + if (error instanceof CodexUserIdentityRefusal) { + return { kind: "unavailable", reason: "unsafe-path" }; + } + if (isBusy(error)) return { kind: "unavailable", reason: "busy" }; + // A callback failure is the caller's error, not a lock outcome: K acquired + // fine. Reporting it as `unavailable` would tell the caller to retry + // something that will fail identically. + throw error; + } finally { + try { database?.close(); } catch { /* acquisition already finished */ } + } +} + +/** Test-only: prove a leaked permit is dead without reaching into the registry. */ +export function isCatalogWritePermitLive(permit: CatalogWritePermit): boolean { + return activePermits.get(permit as unknown as object)?.live === true; +} diff --git a/src/codex/catalog/bundled.ts b/src/codex/catalog/bundled.ts index 7003099a7..78afdb8b5 100644 --- a/src/codex/catalog/bundled.ts +++ b/src/codex/catalog/bundled.ts @@ -34,28 +34,121 @@ import upstreamModelsSnapshot from "../data/upstream-models.json"; import { activeCodexModelsCachePath, catalogBackupPathFor, findNativeTemplate, isDefaultCatalogPath, legacyCatalogBackupPath, parseCatalogJson, readCatalog, readCatalogBackup, readCodexCatalogPath } from "./parsing"; import type { RawCatalog, RawEntry } from "./parsing"; import { codexExecInvocation, isSpawnableCodexCandidate } from "../exec-invocation"; -import { resolveAndPersistCodexRuntime } from "../runtime"; -import type { EffortClampDiagnostic } from "../runtime"; +import { + parsePersistedCodexRuntime, + peekCodexRuntimeProcessCache, + resolveAndPersistCodexRuntime, +} from "../runtime"; +import type { + DeepReadonly, + EffortClampDiagnostic, + ResolvedCodexRuntime, +} from "../runtime"; +import type { + CatalogGatherEvidenceSession, + CatalogGatherReadableSourceRole, +} from "./filesystem-evidence"; export { isSpawnableCodexCandidate, codexExecInvocation } from "../exec-invocation"; +export type { + CatalogGatherEvidenceSession, + CatalogGatherReadableSourceRole, +} from "./filesystem-evidence"; export const BUNDLED_CATALOG_CACHE_MS = 60_000; -export let bundledCatalogCache: { +export type ReadonlyRawCatalog = DeepReadonly; + +interface BundledCatalogMemo { /** Selected runtime identity; must change when doctor/sync picks a different binary. */ - key: string; - expiresAt: number; - value: RawCatalog | null; -} | null = null; + readonly key: string; + readonly expiresAt: number; + readonly epoch: number; + readonly valueIdentity: string; + readonly value: ReadonlyRawCatalog | null; +} + +export interface BundledCatalogCacheState { + readonly epoch: number; + readonly valueIdentity: string | null; +} + +let bundledCatalogEpoch = 0; +let bundledCatalogCache: BundledCatalogMemo | null = null; + +function cloneAndDeepFreeze(value: T): DeepReadonly { + const clone = (current: unknown): unknown => { + if (Array.isArray(current)) return current.map(clone); + if (current && typeof current === "object") { + const out: Record = {}; + for (const [key, child] of Object.entries(current)) out[key] = clone(child); + return out; + } + return current; + }; + const freeze = (current: unknown): unknown => { + if (!current || typeof current !== "object" || Object.isFrozen(current)) return current; + for (const child of Object.values(current)) freeze(child); + return Object.freeze(current); + }; + return freeze(clone(value)) as DeepReadonly; +} + +function bundledRuntimeKey( + runtime: Pick, + opencodexHome: string = process.env.OPENCODEX_HOME ?? "", +): string { + return [runtime.command, runtime.version ?? "", opencodexHome].join("\0"); +} + +function publishBundledCatalogCache( + key: string, + expiresAt: number, + value: RawCatalog | null, +): void { + const epoch = ++bundledCatalogEpoch; + bundledCatalogCache = { + key, + expiresAt, + epoch, + valueIdentity: `bundled:${epoch}`, + value: value === null ? null : cloneAndDeepFreeze(value), + }; +} + +function clearBundledCatalogCache(): void { + bundledCatalogEpoch += 1; + bundledCatalogCache = null; +} + +export function bundledCatalogCacheState(): Readonly { + return Object.freeze({ + epoch: bundledCatalogEpoch, + valueIdentity: bundledCatalogCache?.valueIdentity ?? null, + }); +} /** Test-only: clear the bundled-catalog cache (owned here; sync.ts calls this instead of assigning the import). */ export function resetBundledCatalogCacheForTests(): void { - bundledCatalogCache = null; + clearBundledCatalogCache(); } /** Drop the process-local bundled catalog memo (e.g. after runtime selection changes). */ export function invalidateBundledCatalogCache(): void { - bundledCatalogCache = null; + clearBundledCatalogCache(); +} + +/** Test-only owner mutation seam; input is cloned and frozen before publication. */ +export function setBundledCatalogCacheForTests( + runtime: Pick, + value: RawCatalog | null, + options: Readonly<{ expiresAt?: number; opencodexHome?: string }> = {}, +): void { + publishBundledCatalogCache( + bundledRuntimeKey(runtime, options.opencodexHome), + options.expiresAt ?? Date.now() + BUNDLED_CATALOG_CACHE_MS, + value, + ); } export type ExecFile = ( @@ -143,7 +236,7 @@ export function runCodexDebugModels( }); } -export function loadBundledCodexCatalog(deps: BundledCatalogDeps = {}): RawCatalog | null { +export function loadBundledCodexCatalog(deps: BundledCatalogDeps = {}): ReadonlyRawCatalog | null { const useCache = !deps.commandCandidates && !deps.execFileSync && !deps.configDir && !deps.env; const execFile = deps.execFileSync ?? (execFileSync as unknown as ExecFile); // Prefer the single resolved runtime so sync/clamp never probe a different binary @@ -168,11 +261,7 @@ export function loadBundledCodexCatalog(deps: BundledCatalogDeps = {}): RawCatal discoverAlternatives: deps.discoverAlternatives ?? false, }); if (useCache) { - cacheKey = [ - resolved.runtime.command, - resolved.runtime.version ?? "", - process.env.OPENCODEX_HOME ?? "", - ].join("\0"); + cacheKey = bundledRuntimeKey(resolved.runtime); } return [resolved.runtime.command]; })(); @@ -183,33 +272,193 @@ export function loadBundledCodexCatalog(deps: BundledCatalogDeps = {}): RawCatal && bundledCatalogCache.key === cacheKey && bundledCatalogCache.expiresAt > Date.now() ) { - return bundledCatalogCache.value; + return bundledCatalogCache.value === null + ? null + : cloneAndDeepFreeze(bundledCatalogCache.value); } for (const command of unique(candidates)) { try { const catalog = parseCatalogJson(runCodexDebugModels(command, execFile, deps)); if (catalog && findNativeTemplate(catalog)) { if (useCache && cacheKey) { - bundledCatalogCache = { - key: cacheKey, - expiresAt: Date.now() + BUNDLED_CATALOG_CACHE_MS, - value: catalog, - }; + publishBundledCatalogCache( + cacheKey, + Date.now() + BUNDLED_CATALOG_CACHE_MS, + catalog, + ); + return cloneAndDeepFreeze(bundledCatalogCache!.value!); } - return catalog; + return cloneAndDeepFreeze(catalog); } } catch { /* try next candidate */ } } if (useCache && cacheKey) { - bundledCatalogCache = { - key: cacheKey, - expiresAt: Date.now() + BUNDLED_CATALOG_CACHE_MS, - value: null, - }; + publishBundledCatalogCache( + cacheKey, + Date.now() + BUNDLED_CATALOG_CACHE_MS, + null, + ); } return null; } +export type CatalogGatherProcessLocalObservation = + | Readonly<{ state: "unused" }> + | Readonly<{ state: "used"; epoch: number; valueIdentity: string }>; + +export type CodexRuntimeForCatalogGather = + | Readonly<{ + kind: "available"; + origin: "process-cache" | "persisted"; + runtime: DeepReadonly; + processLocal: CatalogGatherProcessLocalObservation; + }> + | Readonly<{ + kind: "runtime-unavailable"; + processLocal: Readonly<{ state: "unused" }>; + }>; + +export type CatalogSourceForGather = + | Readonly<{ + kind: "available"; + source: + | "bundled-catalog-template" + | "active-catalog-merge" + | "hashed-backup-fallback" + | "legacy-backup-fallback" + | "models-cache-fallback"; + catalog: ReadonlyRawCatalog; + processLocal: Readonly<{ + runtime: CatalogGatherProcessLocalObservation; + bundledCatalog: CatalogGatherProcessLocalObservation; + }>; + }> + | Readonly<{ + kind: "catalog-unavailable"; + processLocal: Readonly<{ + runtime: Readonly<{ state: "unused" }>; + bundledCatalog: Readonly<{ state: "unused" }>; + }>; + }>; + +const UNUSED_PROCESS_LOCAL = Object.freeze({ state: "unused" as const }); + +function sameRuntimeIdentity( + left: Pick, + right: Pick, +): boolean { + return left.command === right.command && (left.version ?? null) === (right.version ?? null); +} + +/** + * Observe an already-resolved runtime only. The evidence owner supplies the + * exact persisted bytes (or observed absence); this path never probes or writes. + */ +export function peekCodexRuntimeForCatalogGather( + evidenceSession: CatalogGatherEvidenceSession, +): CodexRuntimeForCatalogGather { + const persistedBytes = evidenceSession.readSource("runtime-selection"); + const persisted = persistedBytes === null + ? null + : parsePersistedCodexRuntime(persistedBytes); + const processMemo = peekCodexRuntimeProcessCache(); + + if (processMemo.kind === "available") { + const runtime = processMemo.value.runtime; + if (persistedBytes === null || (persisted && sameRuntimeIdentity(runtime, { + command: persisted.command, + version: persisted.selectedVersion ?? null, + }))) { + return cloneAndDeepFreeze({ + kind: "available" as const, + origin: "process-cache" as const, + runtime, + processLocal: { + state: "used" as const, + epoch: processMemo.epoch, + valueIdentity: processMemo.valueIdentity, + }, + }); + } + } + + if (persisted) { + return cloneAndDeepFreeze({ + kind: "available" as const, + origin: "persisted" as const, + runtime: { + command: persisted.command, + version: persisted.selectedVersion ?? null, + source: persisted.source, + }, + processLocal: UNUSED_PROCESS_LOCAL, + }); + } + + return Object.freeze({ + kind: "runtime-unavailable" as const, + processLocal: UNUSED_PROCESS_LOCAL, + }); +} + +/** + * Resolve only already-observed catalog sources. A cold process cache falls + * through to evidence-owned persisted sources and never becomes probe authority. + */ +export function resolveCatalogSourceForGather( + evidenceSession: CatalogGatherEvidenceSession, +): CatalogSourceForGather { + const bundledMemo = bundledCatalogCache; + if (bundledMemo?.value && bundledMemo.expiresAt > Date.now()) { + const runtime = peekCodexRuntimeForCatalogGather(evidenceSession); + if (runtime.kind === "available" && bundledMemo.key === bundledRuntimeKey(runtime.runtime)) { + return cloneAndDeepFreeze({ + kind: "available" as const, + source: "bundled-catalog-template" as const, + catalog: bundledMemo.value, + processLocal: { + runtime: runtime.processLocal, + bundledCatalog: { + state: "used" as const, + epoch: bundledMemo.epoch, + valueIdentity: bundledMemo.valueIdentity, + }, + }, + }); + } + } + + const roles = [ + "active-catalog-merge", + "hashed-backup-fallback", + "legacy-backup-fallback", + "models-cache-fallback", + ] as const; + for (const role of roles) { + const bytes = evidenceSession.readSource(role); + if (bytes === null) continue; + const catalog = parseCatalogJson(Buffer.from(bytes).toString("utf8")); + if (!catalog || !findNativeTemplate(catalog)) continue; + return cloneAndDeepFreeze({ + kind: "available" as const, + source: role, + catalog, + processLocal: { + runtime: UNUSED_PROCESS_LOCAL, + bundledCatalog: UNUSED_PROCESS_LOCAL, + }, + }); + } + + return Object.freeze({ + kind: "catalog-unavailable" as const, + processLocal: Object.freeze({ + runtime: UNUSED_PROCESS_LOCAL, + bundledCatalog: UNUSED_PROCESS_LOCAL, + }), + }); +} + export function materializeBundledCodexCatalog(path: string, deps: BundledCatalogDeps = {}): RawCatalog | null { const catalog = loadBundledCodexCatalog(deps); if (!catalog) return null; @@ -219,7 +468,7 @@ export function materializeBundledCodexCatalog(path: string, deps: BundledCatalo } catch { return null; } - return catalog; + return JSON.parse(JSON.stringify(catalog)) as RawCatalog; } export function loadCatalogForSync(path: string): RawCatalog | null { @@ -236,16 +485,17 @@ export function loadCatalogForSync(path: string): RawCatalog | null { export function readCurrentCatalogOrCache(): RawCatalog | null { const path = readCodexCatalogPath(); - return (isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null) - ?? readCatalog(path) - ?? readCatalog(activeCodexModelsCachePath()); + const bundled = isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null; + if (bundled) return JSON.parse(JSON.stringify(bundled)) as RawCatalog; + return readCatalog(path) ?? readCatalog(activeCodexModelsCachePath()); } export function loadCatalogTemplate(): RawEntry | null { const catalogPath = readCodexCatalogPath(); + const bundled = loadBundledCodexCatalog(); const native = findNativeTemplate(readCatalog(catalogPath)) ?? findNativeTemplate(readCatalogBackup(catalogPath)) ?? findNativeTemplate(readCatalog(activeCodexModelsCachePath())) - ?? findNativeTemplate(loadBundledCodexCatalog()); + ?? findNativeTemplate(bundled ? JSON.parse(JSON.stringify(bundled)) as RawCatalog : null); return native ? JSON.parse(JSON.stringify(native)) : null; } diff --git a/src/codex/catalog/filesystem-evidence.ts b/src/codex/catalog/filesystem-evidence.ts new file mode 100644 index 000000000..fc3ef0a2d --- /dev/null +++ b/src/codex/catalog/filesystem-evidence.ts @@ -0,0 +1,302 @@ +import { createHash } from "node:crypto"; +import { readFileSync, realpathSync, statSync } from "node:fs"; +import { basename, dirname, resolve } from "node:path"; + +import { expandUserPath } from "../../config"; +import type { + CatalogConditionalSourceObservations, + CatalogConditionalSourceRole, + CatalogFilesystemIdentity, + CatalogHomeSelectionObservation, + CatalogRequiredSourceObservations, + CatalogRequiredSourceRole, + CatalogSourceEvidence, + CatalogSourceObservation, + CatalogSourceRole, +} from "../convergence-types"; +import { defaultCodexHome } from "../home"; + +export type CatalogGatherReadableSourceRole = + | "active-catalog-merge" + | "hashed-backup-fallback" + | "legacy-backup-fallback" + | "models-cache-fallback" + | "runtime-selection"; + +/** The minimal observe-only source interface consumed by bundled catalog selection. */ +export interface CatalogGatherEvidenceSession { + readSource(role: CatalogGatherReadableSourceRole): Uint8Array | null; +} + +/** The exact observe-only auth input consumed by provider discovery. */ +export interface CatalogGatherProviderAuthEvidence { + readonly authStoreBuffer: Uint8Array | null; +} + +export type CatalogFilesystemEvidenceSession = CatalogGatherEvidenceSession + & CatalogGatherProviderAuthEvidence; + +const CONDITIONAL_SOURCE_ROLES = [ + "bundled-catalog-template", + "active-catalog-merge", + "hashed-backup-fallback", + "legacy-backup-fallback", + "models-cache-fallback", + "native-catalog-selection", + "runtime-selection", + "provider-auth-selection", +] as const satisfies readonly CatalogConditionalSourceRole[]; + +interface MutableSessionState { + homeSelection: CatalogHomeSelectionObservation | null; + readonly sourcePaths: Map; + requiredTargetSelection: CatalogSourceObservation | null; + readonly conditional: Record; + sealed: boolean; +} + +const sessionStates = new WeakMap(); + +function filesystemIdentity( + entry: Readonly<{ dev: bigint; ino: bigint }>, +): CatalogFilesystemIdentity { + return Object.freeze({ volume: String(entry.dev), fileId: String(entry.ino) }); +} + +function sessionState(session: CatalogFilesystemEvidenceSession): MutableSessionState { + const state = sessionStates.get(session); + if (!state) { + throw new TypeError("Catalog filesystem evidence session was not created by its owner."); + } + return state; +} + +function assertOpen(state: MutableSessionState): void { + if (state.sealed) throw new Error("Catalog filesystem evidence session is already sealed."); +} + +function observedHomeSelection(): CatalogHomeSelectionObservation { + const environmentSelector = process.env.CODEX_HOME?.trim(); + const selector = environmentSelector + ? { kind: "environment" as const, raw: environmentSelector } + : { kind: "default" as const, raw: defaultCodexHome() }; + const selectedPath = selector.kind === "environment" + ? resolve(expandUserPath(selector.raw)) + : resolve(selector.raw); + const canonicalCodexHome = realpathSync.native(selectedPath); + const root = statSync(canonicalCodexHome, { bigint: true }); + if (!root.isDirectory()) { + throw new Error(`Codex home is not a directory: ${selectedPath}`); + } + return Object.freeze({ + selector: Object.freeze(selector), + canonicalCodexHome, + rootIdentity: filesystemIdentity(root), + }); +} + +/** Capture and freeze the selector/root authority before any derived path is admitted. */ +export function captureAndSealCatalogHomeSelection( + session: CatalogFilesystemEvidenceSession, +): CatalogHomeSelectionObservation { + const state = sessionState(session); + assertOpen(state); + if (state.homeSelection) return state.homeSelection; + state.homeSelection = observedHomeSelection(); + return state.homeSelection; +} + +/** Bind the next consultation for a closed role without exposing the evidence arrays. */ +export function acceptCatalogGatherSourcePath( + session: CatalogFilesystemEvidenceSession, + role: CatalogSourceRole, + logicalPath: string, +): void { + const state = sessionState(session); + assertOpen(state); + if (!state.homeSelection) { + throw new Error("Catalog home selection must be sealed before accepting a derived path."); + } + state.sourcePaths.set(role, resolve(logicalPath)); +} + +function isMissing(error: unknown): boolean { + return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT"); +} + +function observeSource( + role: R, + logicalPath: string, +): Readonly<{ bytes: Uint8Array | null; observation: CatalogSourceObservation }> { + const canonicalParent = realpathSync.native(dirname(logicalPath)); + const parent = statSync(canonicalParent, { bigint: true }); + if (!parent.isDirectory()) { + throw new Error(`Catalog source parent is not a directory: ${dirname(logicalPath)}`); + } + const parentIdentity = Object.freeze({ + canonicalPath: canonicalParent, + ...filesystemIdentity(parent), + }); + + let bytes: Uint8Array; + try { + bytes = Uint8Array.from(readFileSync(logicalPath)); + } catch (error) { + if (!isMissing(error)) throw error; + return Object.freeze({ + bytes: null, + observation: Object.freeze({ + state: "absent" as const, + role, + logicalPath, + canonicalPath: resolve(canonicalParent, basename(logicalPath)), + parentIdentity, + fileIdentity: null, + }), + }); + } + + const canonicalPath = realpathSync.native(logicalPath); + const file = statSync(canonicalPath, { bigint: true }); + if (!file.isFile()) throw new Error(`Catalog source is not a regular file: ${logicalPath}`); + return Object.freeze({ + bytes, + observation: Object.freeze({ + state: "present" as const, + role, + logicalPath, + canonicalPath, + parentIdentity, + fileIdentity: filesystemIdentity(file), + sha256: createHash("sha256").update(bytes).digest("hex"), + }), + }); +} + +/** Read once, record PRESENT/ABSENT first, then return the exact hashed buffer. */ +export function readCatalogGatherSource( + session: CatalogFilesystemEvidenceSession, + role: R, +): Uint8Array | null { + const state = sessionState(session); + assertOpen(state); + const logicalPath = state.sourcePaths.get(role); + if (!logicalPath) throw new Error(`No catalog source path was accepted for role ${role}.`); + if (role === "catalog-target-selection" && state.requiredTargetSelection) { + throw new Error(`Required catalog source role ${role} was already observed.`); + } + + const observed = observeSource(role, logicalPath); + if (role === "catalog-target-selection") { + state.requiredTargetSelection = observed.observation as CatalogSourceObservation<"catalog-target-selection">; + } else { + state.conditional[role as CatalogConditionalSourceRole].push(observed.observation); + } + return observed.bytes; +} + +function optionalFileIdentity(path: string): Readonly<{ device: string; inode: string }> | null { + try { + const entry = statSync(path, { bigint: true }); + return { device: String(entry.dev), inode: String(entry.ino) }; + } catch (error) { + if (isMissing(error)) return null; + throw error; + } +} + +/** Probe a candidate write target without allowing filesystem mutation. */ +export function captureCatalogGatherTargetIdentity( + session: CatalogFilesystemEvidenceSession, + path: string, +): string { + const state = sessionState(session); + assertOpen(state); + if (!state.homeSelection) { + throw new Error("Catalog home selection must be sealed before accepting a derived path."); + } + const textualPath = resolve(path); + const canonicalParent = realpathSync.native(dirname(textualPath)); + const parent = statSync(canonicalParent, { bigint: true }); + return JSON.stringify({ + path: textualPath, + canonicalParent, + parentIdentity: { device: String(parent.dev), inode: String(parent.ino) }, + fileIdentity: optionalFileIdentity(textualPath), + }); +} + +function cloneObservation( + observation: CatalogSourceObservation, +): CatalogSourceObservation { + if (observation.state === "absent") { + return Object.freeze({ + ...observation, + parentIdentity: Object.freeze({ ...observation.parentIdentity }), + fileIdentity: null, + }); + } + return Object.freeze({ + ...observation, + parentIdentity: Object.freeze({ ...observation.parentIdentity }), + fileIdentity: Object.freeze({ ...observation.fileIdentity }), + }); +} + +/** Seal one complete owner-created session into detached, recursively frozen evidence. */ +export function sealCatalogGatherEvidenceSession( + session: CatalogFilesystemEvidenceSession, +): CatalogSourceEvidence { + const state = sessionState(session); + assertOpen(state); + if (!state.homeSelection) throw new Error("Catalog home selection is required before sealing."); + const targetSelection = state.requiredTargetSelection; + if (!targetSelection) { + throw new Error("Required catalog source role catalog-target-selection was not observed."); + } + for (const role of CONDITIONAL_SOURCE_ROLES) { + if (!Object.hasOwn(state.conditional, role)) { + throw new Error(`Conditional catalog source role ${role} is missing.`); + } + } + + const conditional = Object.fromEntries(CONDITIONAL_SOURCE_ROLES.map(role => [ + role, + Object.freeze(state.conditional[role].map(observation => cloneObservation(observation))), + ])) as unknown as CatalogConditionalSourceObservations; + const required = Object.freeze({ + "catalog-target-selection": cloneObservation(targetSelection), + }) as CatalogRequiredSourceObservations; + const evidence = Object.freeze({ + homeSelection: Object.freeze({ + ...state.homeSelection, + selector: Object.freeze({ ...state.homeSelection.selector }), + rootIdentity: Object.freeze({ ...state.homeSelection.rootIdentity }), + }), + required, + conditional: Object.freeze(conditional), + }); + state.sealed = true; + return evidence; +} + +/** Create an opaque session; all mutable evidence state remains module-private. */ +export function createCatalogGatherEvidenceSession(): CatalogFilesystemEvidenceSession { + let session: CatalogFilesystemEvidenceSession; + session = Object.freeze({ + readSource: (role: CatalogGatherReadableSourceRole) => readCatalogGatherSource(session, role), + get authStoreBuffer(): Uint8Array | null { + return readCatalogGatherSource(session, "provider-auth-selection"); + }, + }); + sessionStates.set(session, { + homeSelection: null, + sourcePaths: new Map(), + requiredTargetSelection: null, + conditional: Object.fromEntries( + CONDITIONAL_SOURCE_ROLES.map(role => [role, [] as CatalogSourceObservation[]]), + ) as unknown as MutableSessionState["conditional"], + sealed: false, + }); + return session; +} diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 71bc27227..b88220c15 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1,8 +1,8 @@ import { execFileSync } from "node:child_process"; -import { createHash } from "node:crypto"; +import { createHash, createHmac, randomBytes } from "node:crypto"; import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs"; import { delimiter, dirname, join, resolve } from "node:path"; -import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../../config"; +import { atomicWriteFile, expandUserPath, getConfigDir, resolveEnvValue, websocketsEnabled } from "../../config"; import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "../paths"; import { clearModelCache, @@ -18,7 +18,12 @@ import { setCached, type ProviderModelDiscoveryFailure, } from "../model-cache"; -import { buildModelsRequest, resolveModelsAuthToken } from "../../oauth"; +import { + buildModelsRequest, + observeActiveOAuthAccessToken, + resolveModelsAuthToken, + type OAuthActiveTokenObservation, +} from "../../oauth"; import type { OcxConfig, OcxProviderConfig } from "../../types"; import { modelInList } from "../../types"; import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; @@ -51,6 +56,7 @@ import { resolveProviderModelDiscovery, type ModelDiscoveryResponseFailure, type ProviderModelsApiItem, + type ResolvedProviderModelDiscovery, } from "../../providers/model-discovery"; import upstreamModelsSnapshot from "../data/upstream-models.json"; import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } from "../../lib/admission"; @@ -61,15 +67,125 @@ import type { CatalogModel } from "./parsing"; import { disabledNativeSlugs, hasComboTargets, nativeInputModalities, nativeOpenAiContextWindow, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata"; import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation"; import type { ComboCatalogOmission } from "./aggregation"; +import type { CatalogGatherProviderAuthEvidence } from "./filesystem-evidence"; +import type { + CatalogAdmissionSnapshot, + CatalogDiscoveryPolicyField, + CatalogGatherAuthorityIdentity, + CatalogProviderDiscoveryPolicySnapshot, + CatalogProcessLocalEvidence, + CatalogSourceEvidence, + CatalogTrustedOpenAiApiPolicySnapshot, +} from "../convergence-types"; + +export type { CatalogGatherProviderAuthEvidence } from "./filesystem-evidence"; /** Concurrent gatherRoutedModels callers with the same catalog identity share one live discovery. * Keyed by gatherFlightKey so a different config cannot join or evict the wrong flight. */ +export interface CatalogGatherProviderAuthOutcome { + readonly provider: string; + readonly state: OAuthActiveTokenObservation["kind"]; +} + +export interface GatherRoutedModelsOptions { + comboOmissions?: ComboCatalogOmission[]; + providerAuthOutcomes?: CatalogGatherProviderAuthOutcome[]; + /** Internal convergence sink for the immutable policy that produced the returned rows. */ + discoveryPolicySnapshots?: CatalogProviderDiscoveryPolicySnapshot[]; +} + interface GatherFlightResult { models: CatalogModel[]; comboOmissions: ComboCatalogOmission[]; + providerAuthOutcomes: readonly CatalogGatherProviderAuthOutcome[]; + discoveryPolicySnapshots: readonly CatalogProviderDiscoveryPolicySnapshot[]; +} + +interface ModelsAuthResolution { + readonly apiKey: string | undefined; + readonly observed: boolean; + readonly oauthApiBaseUrl?: string; } -const gatherInflight = new Map>(); +type ModelsAuthResolver = + | { readonly kind: "refreshing" } + | { + readonly kind: "observed"; + readonly resolve: (name: string, provider: OcxProviderConfig) => ModelsAuthResolution; + }; + +type ModelsAuthResolverFactory = ( + outcomes: CatalogGatherProviderAuthOutcome[], +) => ModelsAuthResolver; + +interface CapturedModelsRequest { + readonly method: "GET"; + readonly url: string; + readonly headersWithoutCredential: Readonly>; + readonly headersWithCredential: Readonly>; +} + +interface CapturedProviderGather { + readonly name: string; + readonly provider: OcxProviderConfig; + readonly discovery: ResolvedProviderModelDiscovery; + readonly policy: CatalogProviderDiscoveryPolicySnapshot; + readonly request: CapturedModelsRequest; + readonly observedAuth?: ModelsAuthResolution; +} + +interface GatherFlightCapture { + readonly discoveryPolicyIdentity: string; + readonly authIdentity: string; + readonly providerGraphIdentity: string; + readonly discoveryPolicySnapshots: readonly CatalogProviderDiscoveryPolicySnapshot[]; + readonly providers: readonly CapturedProviderGather[]; + readonly authResolver: ModelsAuthResolver; + readonly providerAuthOutcomes: readonly CatalogGatherProviderAuthOutcome[]; + readonly openAiApiPolicy: CatalogTrustedOpenAiApiPolicySnapshot; +} + +interface GatherInflightEntry { + readonly discoveryPolicyIdentity: string; + /** + * The credential half of the join decision. + * + * `gatherFlightKey`'s fingerprint carries endpoints and model lists but no + * `authMode`, key or headers, and discovery policy does not carry them either. + * Two admissions differing ONLY in credential therefore produced the same key + * and the same policy, so the second joined the first and published rows the + * old key had fetched — reproduced against the real routes by rotating a key + * through `/api/providers/keys` mid-flight. + * + * Now REDUNDANT with `providerGraphIdentity`, which hashes the whole provider + * row and therefore covers `apiKey` too: removing this term alone leaves the + * credential regression green. It is kept deliberately, for two reasons. It + * covers what the graph cannot — the RESOLVED auth (`observedAuth`) and the + * final materialized headers, which are derived rather than stored, so an + * OAuth token that changes while the row is byte-identical still separates + * admissions. And it states the credential rule where a reader looks for it, + * instead of leaving it as an emergent property of hashing everything. + */ + readonly authIdentity: string; + /** + * The whole admitted provider graph, not a chosen subset. + * + * `providerCatalogFingerprint` is an ALLOW-LIST, so every field it forgot was + * silently treated as equivalence: credentials leaked a flight until + * `authIdentity` landed, and `reasoningEfforts` leaked one after that — both + * reproduced against real routes. Enumerating fields cannot converge, because + * the next field added to a provider row inherits the same defect. This + * identity therefore covers the enriched, frozen provider objects the flight + * actually gathered from, so a join is refused unless the admissions agree on + * everything rather than on everything somebody remembered to list. + */ + readonly providerGraphIdentity: string; + readonly promise: Promise; +} + +const gatherInflight = new Map(); +const CATALOG_GATHER_AUTHORITY_KEY = randomBytes(32); +const REQUEST_CREDENTIAL_SENTINEL = `ocx-catalog-credential-${randomBytes(16).toString("hex")}`; const MAX_CONCURRENT_CATALOG_GATHERS = 8; const gatherGate = createAdmissionGate("catalog_gathers", MAX_CONCURRENT_CATALOG_GATHERS); @@ -95,6 +211,268 @@ function stableJson(value: unknown): string { }); } +function framed(value: string): string { + return `${Buffer.byteLength(value, "utf8")}:${value}`; +} + +function canonicalAuthorityEncoding(value: unknown): string { + if (value === null) return "null"; + if (value === undefined) return "undefined"; + if (typeof value === "string") return `string${framed(value)}`; + if (typeof value === "boolean") return value ? "boolean1" : "boolean0"; + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new TypeError("Catalog authority cannot encode a non-finite number."); + const encoded = Object.is(value, -0) ? "-0" : String(value); + return `number${framed(encoded)}`; + } + if (Array.isArray(value)) { + return `array${value.length}:${value.map(item => framed(canonicalAuthorityEncoding(item))).join("")}`; + } + if (typeof value === "object") { + const record = value as Record; + const keys = Object.keys(record).sort((left, right) => left.localeCompare(right)); + return `object${keys.length}:${keys.map(key => ( + `${framed(key)}${framed(canonicalAuthorityEncoding(record[key]))}` + )).join("")}`; + } + throw new TypeError(`Catalog authority cannot encode ${typeof value}.`); +} + +function keyedGatherIdentity(domain: string, value: unknown): string { + return createHmac("sha256", CATALOG_GATHER_AUTHORITY_KEY) + .update(framed(domain)) + .update(framed(canonicalAuthorityEncoding(value))) + .digest("hex"); +} + +function keyedGatherBytesIdentity(domain: string, value: Uint8Array): string { + return createHmac("sha256", CATALOG_GATHER_AUTHORITY_KEY) + .update(framed(domain)) + .update(`${value.byteLength}:`) + .update(value) + .digest("hex"); +} + +export function createCatalogGatherAuthorityIdentity( + snapshot: CatalogAdmissionSnapshot, + sourceEvidence: CatalogSourceEvidence, + processLocal: CatalogProcessLocalEvidence, + discoveryPolicies: readonly CatalogProviderDiscoveryPolicySnapshot[], +): CatalogGatherAuthorityIdentity { + const sourceEvidenceIdentity = keyedGatherIdentity("catalog-source-evidence-v1", sourceEvidence); + const processLocalEvidenceIdentity = keyedGatherIdentity("catalog-process-local-v1", processLocal); + const discoveryPolicyIdentity = keyedGatherIdentity("catalog-discovery-policy-v1", discoveryPolicies); + return Object.freeze({ + version: 1 as const, + authorityId: keyedGatherIdentity("catalog-authority-v1", { + admittedConfig: snapshot.configIdentity, + discoveryPolicyIdentity, + sourceEvidenceIdentity, + processLocalEvidenceIdentity, + }), + admittedConfig: Object.freeze({ + ...snapshot.configIdentity, + generation: Object.freeze({ ...snapshot.configIdentity.generation }), + }), + authSnapshotIdentity: keyedGatherIdentity( + "catalog-auth-v1", + sourceEvidence.conditional["provider-auth-selection"], + ), + discoveryPolicyIdentity, + nativeCatalogSourceIdentity: keyedGatherIdentity( + "catalog-native-v1", + sourceEvidence.conditional["native-catalog-selection"], + ), + sourceEvidenceIdentity, + processLocalEvidenceIdentity, + }); +} + +function detachedClone(value: T): T { + if (Array.isArray(value)) return value.map(item => detachedClone(item)) as T; + if (value && typeof value === "object") { + const clone: Record = {}; + for (const key of Object.keys(value)) { + clone[key] = detachedClone((value as Record)[key]); + } + return clone as T; + } + return value; +} + +function recursivelyFreeze(value: T): T { + if (!value || typeof value !== "object" || Object.isFrozen(value)) return value; + for (const nested of Object.values(value as Record)) recursivelyFreeze(nested); + return Object.freeze(value); +} + +function detachedFrozen(value: T): T { + return recursivelyFreeze(detachedClone(value)); +} + +function capturedField( + value: T | undefined, + key: K, +): CatalogDiscoveryPolicyField { + if (!value || !Object.hasOwn(value, key)) return Object.freeze({ state: "absent" }); + return detachedFrozen({ state: "present" as const, value: value[key] }); +} + +function captureTrustedOpenAiApiPolicy( + name: string, + registryTransportMatch: boolean, +): CatalogTrustedOpenAiApiPolicySnapshot { + if (name !== OPENAI_API_PROVIDER_ID) return Object.freeze({ state: "unused" }); + if (!registryTransportMatch) return Object.freeze({ state: "transport-mismatch" }); + const entry = getProviderRegistryEntry(name); + if (!entry?.models) return Object.freeze({ state: "registry-models-absent" }); + return detachedFrozen({ + state: "captured" as const, + models: entry.models, + ...(entry.modelContextWindows ? { modelContextWindows: entry.modelContextWindows } : {}), + ...(entry.modelMaxInputTokens ? { modelMaxInputTokens: entry.modelMaxInputTokens } : {}), + ...(entry.modelInputModalities ? { modelInputModalities: entry.modelInputModalities } : {}), + ...(entry.modelReasoningEfforts ? { modelReasoningEfforts: entry.modelReasoningEfforts } : {}), + }); +} + +function captureModelsRequest( + name: string, + provider: OcxProviderConfig, + observedAuth: ModelsAuthResolution | undefined, +): CapturedModelsRequest { + const observed = observedAuth + ? { oauthApiBaseUrl: observedAuth.oauthApiBaseUrl } + : undefined; + const withoutCredential = buildModelsRequest(provider, undefined, name, observed); + const withCredential = buildModelsRequest(provider, REQUEST_CREDENTIAL_SENTINEL, name, observed); + if (withoutCredential.url !== withCredential.url) { + throw new TypeError(`Provider model discovery URL for ${name} depends on credential bytes.`); + } + return detachedFrozen({ + method: "GET" as const, + url: withoutCredential.url, + headersWithoutCredential: withoutCredential.headers, + headersWithCredential: withCredential.headers, + }); +} + +function captureProviderGather( + name: string, + configured: OcxProviderConfig, + authResolver: ModelsAuthResolver, +): CapturedProviderGather { + const enriched = detachedClone(configured); + enrichProviderFromRegistry(name, enriched); + const provider = recursivelyFreeze(enriched); + const observedAuth = authResolver.kind === "observed" + && provider.authMode !== "forward" + && provider.liveModels !== false + ? authResolver.resolve(name, provider) + : undefined; + const request = captureModelsRequest(name, provider, observedAuth); + const resolved = resolveProviderModelDiscovery(name, provider); + const discovery = detachedFrozen({ + ...(resolved.spec ? { spec: resolved.spec } : {}), + maxResponseBytes: resolved.maxResponseBytes, + maxModels: resolved.maxModels, + }); + const registryTransportMatch = providerMatchesRegistryTransport(name, provider); + const trustedOpenAiApi = captureTrustedOpenAiApiPolicy(name, registryTransportMatch); + const policy = detachedFrozen({ + provider: name, + registryTransportMatch, + location: { + spec: discovery.spec ? "present" as const : "absent" as const, + url: capturedField(discovery.spec, "url"), + path: capturedField(discovery.spec, "path"), + query: capturedField(discovery.spec, "query"), + }, + finalMethod: request.method, + finalUrl: request.url, + filter: capturedField(discovery.spec, "filter"), + maxResponseBytes: discovery.maxResponseBytes, + maxModels: discovery.maxModels, + trustedOpenAiApi, + }); + return Object.freeze({ + name, + provider, + discovery, + policy, + request, + ...(observedAuth ? { observedAuth: Object.freeze({ ...observedAuth }) } : {}), + }); +} + +function captureGatherFlight( + config: OcxConfig, + createAuthResolver: ModelsAuthResolverFactory, +): GatherFlightCapture { + const providerAuthOutcomes: CatalogGatherProviderAuthOutcome[] = []; + const authResolver = createAuthResolver(providerAuthOutcomes); + const providers = Object.entries(config.providers) + .filter(([, provider]) => provider.disabled !== true) + .map(([name, provider]) => captureProviderGather(name, provider, authResolver)); + const discoveryPolicySnapshots = Object.freeze(providers.map(provider => provider.policy)); + return Object.freeze({ + discoveryPolicyIdentity: keyedGatherIdentity("catalog-discovery-policy-v1", discoveryPolicySnapshots), + // Credentials are hashed under the same unexported per-process key, never + // stored or compared in the clear: this value can reach a map key and must + // not disclose a token. The final headers are included because a static + // header can carry authority just as an `apiKey` can. + authIdentity: keyedGatherIdentity("catalog-gather-auth-v1", providers.map(provider => ({ + name: provider.name, + authMode: provider.provider.authMode ?? null, + liveModels: provider.provider.liveModels ?? null, + credential: provider.provider.apiKey ?? null, + observedAuth: provider.observedAuth ?? null, + headers: provider.request.headersWithCredential, + url: provider.request.url, + }))), + // Every enriched provider row the flight will gather from, in admission order. + // Anything that can change a catalog row lives in here by construction. + providerGraphIdentity: keyedGatherIdentity("catalog-gather-provider-graph-v1", + providers.map(provider => ({ + name: provider.name, + // `fetch` is a caller-owned transport executor, not admitted state: the + // outbound transport honors it so a caller can supply its own HTTP path. + // It is the one member of a provider row that is legitimately a function, + // so it is dropped here rather than allowed to break every encode. + provider: omitProviderTransportExecutor(provider.provider), + }))), + discoveryPolicySnapshots, + providers: Object.freeze(providers), + authResolver, + providerAuthOutcomes: Object.freeze([...providerAuthOutcomes]), + openAiApiPolicy: providers.find(provider => provider.name === OPENAI_API_PROVIDER_ID)?.policy.trustedOpenAiApi + ?? Object.freeze({ state: "unused" as const }), + }); +} + +/** + * Drop the caller-owned transport executor before hashing a provider row. + * + * Fails closed on anything ELSE that cannot be encoded: the point of hashing the + * whole row is that no field escapes the comparison, so a second function member + * must surface as an encode error rather than being quietly skipped here. + */ +function omitProviderTransportExecutor(provider: OcxProviderConfig): Record { + const entries = Object.entries(provider).filter(([key]) => key !== "fetch"); + return Object.fromEntries(entries); +} + +function materializeCapturedHeaders( + request: CapturedModelsRequest, + apiKey: string | undefined, +): Record { + const source = apiKey ? request.headersWithCredential : request.headersWithoutCredential; + return Object.fromEntries(Object.entries(source).map(([name, value]) => [ + name, + apiKey ? value.split(REQUEST_CREDENTIAL_SENTINEL).join(apiKey) : value, + ])); +} + function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Record { return { n: name, @@ -407,7 +785,39 @@ function boundedOwnedBy(value: unknown): string | undefined { return value; } -export async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs: number, contextCap?: number): Promise { +const refreshingModelsAuthResolver: ModelsAuthResolver = { kind: "refreshing" }; + +function observedModelsAuthResolver( + authStoreBuffer: Uint8Array | null, + outcomes: CatalogGatherProviderAuthOutcome[], +): ModelsAuthResolver { + return { + kind: "observed", + resolve(name, provider) { + if (provider.authMode === "forward") return { apiKey: undefined, observed: true }; + if (provider.authMode !== "oauth") { + return { apiKey: resolveEnvValue(provider.apiKey), observed: true }; + } + + const observation = observeActiveOAuthAccessToken(name, authStoreBuffer); + outcomes.push({ provider: name, state: observation.kind }); + if (observation.kind !== "available") return { apiKey: undefined, observed: true }; + return { + apiKey: observation.snapshot.accessToken, + observed: true, + ...(observation.snapshot.apiBaseUrl ? { oauthApiBaseUrl: observation.snapshot.apiBaseUrl } : {}), + }; + }, + }; +} + +async function fetchProviderModelsWithAuth( + captured: CapturedProviderGather, + ttlMs: number, + contextCap: number | undefined, + resolveAuth: ModelsAuthResolver, +): Promise { + const { name, provider: prov, discovery, request } = captured; if (prov.authMode === "forward") return []; // ChatGPT backend has no /models const seedVertexDefault = prov.adapter === "google" && prov.googleMode === "vertex" @@ -425,7 +835,10 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, clearProviderDiscoveryStatus(name); return configured; } - const apiKey = await resolveModelsAuthToken(name, prov); + const auth: ModelsAuthResolution = captured.observedAuth ?? (resolveAuth.kind === "refreshing" + ? { apiKey: await resolveModelsAuthToken(name, prov), observed: false } + : resolveAuth.resolve(name, prov)); + const apiKey = auth.apiKey; // A configured default is a real callable selector and must remain discoverable when a // compatible provider's live /models request fails (issue #308). Keep this separate from the // explicit static list: `liveModels: false` + empty `models[]` intentionally publishes zero @@ -486,8 +899,8 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, const stale = getStaleCached(name); return stale ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) : failedDiscoveryConfigured; } - const discovery = resolveProviderModelDiscovery(name, prov); - const { url, headers } = buildModelsRequest(prov, apiKey, name); + const url = request.url; + const headers = materializeCapturedHeaders(request, apiKey); const urlClass = new URL(url).hostname.endsWith("aiplatform.googleapis.com") ? "vertex-aiplatform" : "provider-models"; @@ -633,6 +1046,16 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, } } +export async function fetchProviderModels( + name: string, + prov: OcxProviderConfig, + ttlMs: number, + contextCap?: number, +): Promise { + const captured = captureProviderGather(name, prov, refreshingModelsAuthResolver); + return fetchProviderModelsWithAuth(captured, ttlMs, contextCap, refreshingModelsAuthResolver); +} + export function shouldExposeProviderModel(providerName: string, modelId: string): boolean { if (providerName === "opencode-free") return modelId === "big-pickle" || modelId.endsWith("-free"); return true; @@ -669,35 +1092,104 @@ export function filterCatalogVisibleModels( export async function gatherRoutedModels( config: OcxConfig, - options?: { comboOmissions?: ComboCatalogOmission[] }, + options?: GatherRoutedModelsOptions, ): Promise { - const key = gatherFlightKey(config); - let promise = gatherInflight.get(key); - if (!promise) { + return gatherRoutedModelsWithAuth( + config, + `refreshing:${gatherFlightKey(config)}`, + () => refreshingModelsAuthResolver, + options, + ); +} + +/** + * Catalog-gather model discovery using only auth-store bytes already captured by the + * filesystem-evidence owner. This entry point never reaches the refreshing resolver. + */ +export async function gatherRoutedModelsForCatalogGather( + config: OcxConfig, + evidence: CatalogGatherProviderAuthEvidence, + options?: GatherRoutedModelsOptions, +): Promise { + const authStoreBuffer = evidence.authStoreBuffer === null + ? null + : Uint8Array.from(evidence.authStoreBuffer); + const authIdentity = authStoreBuffer === null + ? "absent" + : keyedGatherBytesIdentity("catalog-observed-auth-v1", authStoreBuffer); + return gatherRoutedModelsWithAuth( + config, + `observed:${authIdentity}:${gatherFlightKey(config)}`, + outcomes => observedModelsAuthResolver(authStoreBuffer, outcomes), + options, + ); +} + +async function gatherRoutedModelsWithAuth( + config: OcxConfig, + key: string, + createAuthResolver: ModelsAuthResolverFactory, + options?: GatherRoutedModelsOptions, +): Promise { + const capture = captureGatherFlight(config, createAuthResolver); + const bucket = gatherInflight.get(key) ?? []; + let entry = bucket.find(candidate => ( + candidate.discoveryPolicyIdentity === capture.discoveryPolicyIdentity + && candidate.authIdentity === capture.authIdentity + && candidate.providerGraphIdentity === capture.providerGraphIdentity + )); + if (!entry) { const lease = gatherGate.tryAcquire(); if (!lease) throw new CatalogGatherBusyError(); // Claim the slot synchronously before any await so same-key callers join this flight. - // Distinct keys keep their own entries — a second config must not evict the first. - const flight = gatherRoutedModelsUncached(config).finally(() => { - if (gatherInflight.get(key) === flight) gatherInflight.delete(key); + // Distinct authorities retain separate entries even when their legacy bucket matches. + let ownedEntry!: GatherInflightEntry; + const flight = gatherRoutedModelsUncached(config, capture).finally(() => { + const current = gatherInflight.get(key); + const index = current?.indexOf(ownedEntry) ?? -1; + if (current && index >= 0) current.splice(index, 1); + if (current?.length === 0) gatherInflight.delete(key); lease.release(); }); - gatherInflight.set(key, flight); - promise = flight; + ownedEntry = Object.freeze({ + discoveryPolicyIdentity: capture.discoveryPolicyIdentity, + authIdentity: capture.authIdentity, + providerGraphIdentity: capture.providerGraphIdentity, + promise: flight, + }); + bucket.push(ownedEntry); + gatherInflight.set(key, bucket); + entry = ownedEntry; } - const { models, comboOmissions } = await promise; + const { + models, + comboOmissions, + providerAuthOutcomes, + discoveryPolicySnapshots, + } = await entry.promise; if (options?.comboOmissions) { options.comboOmissions.length = 0; options.comboOmissions.push(...comboOmissions); } + if (options?.providerAuthOutcomes) { + options.providerAuthOutcomes.length = 0; + options.providerAuthOutcomes.push(...providerAuthOutcomes); + } + if (options?.discoveryPolicySnapshots) { + options.discoveryPolicySnapshots.length = 0; + options.discoveryPolicySnapshots.push(...discoveryPolicySnapshots); + } return models; } async function gatherRoutedModelsUncached( config: OcxConfig, + capture: GatherFlightCapture, ): Promise { // Flight-local list: joiners copy from the resolved promise, not a process-global last write. const localOmissions: ComboCatalogOmission[] = []; + const localProviderAuthOutcomes = capture.providerAuthOutcomes; + const resolveAuth = capture.authResolver; const ttlMs = config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS; // Persisted provider entries can predate newer registry fields (noVisionModels, // modelInputModalities, ...). The ROUTER merges registry seeds at request time @@ -705,18 +1197,21 @@ async function gatherRoutedModelsUncached( // same merged view or its advertisements drift from actual proxy behavior (e.g. a // vision-sidecar model advertised text-only, blocking image attachments app-side). // Enrich a CLONE: hydrated defaults must never leak into the persisted config. - const activeProviders = Object.entries(config.providers) - .filter(([, prov]) => prov.disabled !== true) - .map(([name, prov]): [string, OcxProviderConfig] => { - const enriched = { ...prov }; - enrichProviderFromRegistry(name, enriched); - return [name, enriched]; - }); + const activeProviders = capture.providers; const lists = await Promise.all( - activeProviders.map(([name, prov]) => fetchProviderModels(name, prov, ttlMs, providerContextCap(config, name))), + activeProviders.map(provider => fetchProviderModelsWithAuth( + provider, + ttlMs, + providerContextCap(config, provider.name), + resolveAuth, + )), + ); + const apiAugmented = augmentRoutedModelsWithCapturedOpenAiApiRows( + lists.flat(), + config, + capture.openAiApiPolicy, ); - const apiAugmented = augmentRoutedModelsWithRegistryOpenAiApiRows(lists.flat(), config); - const all = augmentRoutedModelsWithJawcodeMetadata(apiAugmented, activeProviders.map(([name]) => name), config.providers, config) + const all = augmentRoutedModelsWithJawcodeMetadata(apiAugmented, activeProviders.map(provider => provider.name), config.providers, config) // Drop image/video generation models (e.g. Grok image/video) by default. Cursor's static catalog // intentionally mirrors Cursor's public model table, including Gemini image preview, so the // exposure decision goes through shouldExposeRoutedModel (single choke point). @@ -781,7 +1276,7 @@ async function gatherRoutedModelsUncached( all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider))); // Enriched (registry-hydrated) provider clones, keyed by name — the same view used above so // custom rows get the same noVisionModels / inputModalities treatment as discovered rows. - const enrichedByName = new Map(activeProviders); + const enrichedByName = new Map(activeProviders.map(provider => [provider.name, provider.provider])); // Provider-derived rows keyed by their Codex-facing slug: a custom override replaces the row // with the same slug below, so that row's provider capability metadata is the inheritance source. const replacedByRoutedSlug = new Map(all.map(model => [routedSlug(model.provider, model.id), model])); @@ -833,7 +1328,12 @@ async function gatherRoutedModelsUncached( // Custom rows override discovered rows that encode to the same Codex-facing slug. const customKeys = new Set(customModels.map(c => routedSlug(c.provider, c.id))); const deduped = all.filter(m => !customKeys.has(routedSlug(m.provider, m.id))); - return { models: [...deduped, ...customModels], comboOmissions: localOmissions }; + return { + models: [...deduped, ...customModels], + comboOmissions: localOmissions, + providerAuthOutcomes: localProviderAuthOutcomes, + discoveryPolicySnapshots: capture.discoveryPolicySnapshots, + }; } export function augmentRoutedModelsWithRegistryOpenAiApiRows( @@ -842,15 +1342,28 @@ export function augmentRoutedModelsWithRegistryOpenAiApiRows( ): CatalogModel[] { const configured = config.providers[OPENAI_API_PROVIDER_ID]; if (!configured || configured.disabled === true || !providerMatchesRegistryTransport(OPENAI_API_PROVIDER_ID, configured)) return models; - const entry = getProviderRegistryEntry(OPENAI_API_PROVIDER_ID); - if (!entry?.models) return models; + return augmentRoutedModelsWithCapturedOpenAiApiRows( + models, + config, + captureTrustedOpenAiApiPolicy(OPENAI_API_PROVIDER_ID, true), + ); +} + +function augmentRoutedModelsWithCapturedOpenAiApiRows( + models: CatalogModel[], + config: OcxConfig, + policy: CatalogTrustedOpenAiApiPolicySnapshot, +): CatalogModel[] { + if (policy.state !== "captured" || !policy.models) return models; + const configured = config.providers[OPENAI_API_PROVIDER_ID]; + if (!configured || configured.disabled === true) return models; const existingById = new Map( models.filter(model => model.provider === OPENAI_API_PROVIDER_ID).map(model => [model.id, model]), ); - const trustedRows = entry.models.map((id): CatalogModel => { - const officialContext = entry.modelContextWindows?.[id]; - const officialMaxInput = entry.modelMaxInputTokens?.[id]; + const trustedRows = policy.models.map((id): CatalogModel => { + const officialContext = policy.modelContextWindows?.[id]; + const officialMaxInput = policy.modelMaxInputTokens?.[id]; const userContext = configured.modelContextWindows?.[id] ?? configured.contextWindow; const userMaxInput = configured.modelMaxInputTokens?.[id]; const providerCap = providerContextCap(config, OPENAI_API_PROVIDER_ID); @@ -866,8 +1379,8 @@ export function augmentRoutedModelsWithRegistryOpenAiApiRows( owned_by: OPENAI_API_PROVIDER_ID, ...(contextWindow ? { contextWindow } : {}), ...(maxInputTokens ? { maxInputTokens } : {}), - ...(entry.modelInputModalities?.[id] ? { inputModalities: [...entry.modelInputModalities[id]!] } : {}), - ...(entry.modelReasoningEfforts?.[id] ? { reasoningEfforts: [...entry.modelReasoningEfforts[id]!] } : {}), + ...(policy.modelInputModalities?.[id] ? { inputModalities: [...policy.modelInputModalities[id]!] } : {}), + ...(policy.modelReasoningEfforts?.[id] ? { reasoningEfforts: [...policy.modelReasoningEfforts[id]!] } : {}), }; }); diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 70ac8c270..bb29e8826 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -1,9 +1,9 @@ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { delimiter, dirname, join, resolve } from "node:path"; -import { atomicWriteFile, expandUserPath, getConfigDir, readConfigDiagnostics, websocketsEnabled } from "../../config"; -import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "../paths"; +import { expandUserPath, readConfigDiagnostics, websocketsEnabled } from "../../config"; +import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, getCodexHome, readRootTomlString, resolveCodexConfigPath } from "../paths"; import { clearModelCache, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, markModelsFetchFailure, setCached } from "../model-cache"; import { buildModelsRequest, resolveModelsAuthToken } from "../../oauth"; import type { OcxConfig, OcxProviderConfig } from "../../types"; @@ -30,15 +30,30 @@ import { redactSecretString } from "../../lib/redact"; import upstreamModelsSnapshot from "../data/upstream-models.json"; -import { activeCodexModelsCachePath, applyJawcodeCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, catalogModelSlug, ensureCatalogBackup, ensureStrictCatalogFields, findNativeTemplate, isRoutedModelCompatibilityExcluded, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing"; -import type { CatalogModel, MultiAgentMode, RawEntry } from "./parsing"; -import { applyNativeVisibility, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, nativeOpenAiSlugs, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata"; -import { loadCatalogForSync, resetBundledCatalogCacheForTests } from "./bundled"; +import { activeCodexModelsCachePath, applyJawcodeCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing"; +import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing"; +import { applyNativeVisibility, disabledNativeSlugs, isUnsupportedOpenAiNativeSlug, nativeOpenAiSlugs, NATIVE_OPENAI_MODELS, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata"; +import { + bundledCatalogCacheState, + loadBundledCodexCatalog, + resetBundledCatalogCacheForTests, +} from "./bundled"; import { isMultiAgentV2Enabled } from "../features"; import { applyCatalogModelMetadata, applyReasoningLevels, catalogEntryEfforts, clampCatalogModelsToCodexSupport, ensureGpt56ReasoningLevels, ensureUltraReasoningLevel, isGpt56NativeSlug } from "./effort"; import { clearGatherRoutedModelsInflight, filterCatalogVisibleModels, gatherRoutedModels, lastDropWarnSignature } from "./provider-fetch"; import { accountSelectorShadowCollisionWarnings, clearLastComboCatalogOmissions, comboCatalogWarningSignatures, comboMasqueradeCollisionWarnings, exactComboCatalogSlugs, openAiApiCollisionWarnings, resolveSlugAliasCollisions, slugAliasCollisionWarnings, warnAccountSelectorShadowedProviderOnce, warnComboMasqueradeCollisionOnce } from "./aggregation"; import type { ComboCatalogOmission } from "./aggregation"; +import { + withCatalogWriteSerialization, + type CatalogWritePermit, +} from "../catalog-write-serialization"; +import { + publishHashedCodexCatalogBackup, + publishLegacyCodexCatalogBackup, + replaceActiveCodexCatalog, + replaceCodexModelsCache, +} from "../internal/catalog-writer"; +import { codexRuntimeStatePath } from "../runtime"; import { accountBoundNativeDisplayName, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./account-models"; export const MAX_SPAWN_AGENT_MODEL_OVERRIDES = 5; @@ -601,30 +616,176 @@ export function mergeCatalogEntriesForSync( ); } -export async function syncCatalogModels(config: OcxConfig): Promise<{ +interface RetainedCatalogSyncRead { + readonly catalogPath: string; + readonly catalog: RawCatalog; + readonly onDiskCatalog: RawCatalog | null; + readonly evidence: string; + /** + * Process-local epochs, baselined AFTER our own gather rather than with the + * filesystem bytes above. See `retainedCatalogProcessEvidence`. + */ + readonly processEvidence: string; +} + +interface RetainedCatalogSyncResult { added: number; path: string; catalogWritten: boolean; comboOmissions: ComboCatalogOmission[]; -}> { +} + +interface RetainedCatalogSyncWrite { + readonly config: OcxConfig; + readonly goModels: CatalogModel[]; + readonly comboOmissions: ComboCatalogOmission[]; + readonly read: RetainedCatalogSyncRead; + readonly permit: CatalogWritePermit; + readonly owningCodexHome: string; +} + +function optionalFileBytes(path: string): string | null { + try { + return readFileSync(path).toString("base64"); + } catch (error) { + if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") return null; + throw error; + } +} + +function loadCatalogForRetainedSync(path: string): RawCatalog | null { + const bundled = isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null; + if (bundled) return JSON.parse(JSON.stringify(bundled)) as RawCatalog; + const active = readCatalog(path); + if (active && findNativeTemplate(active)) return active; + return readCatalog(catalogBackupPathFor(path)) + ?? (isDefaultCatalogPath(path) ? readCatalog(legacyCatalogBackupPath()) : null) + ?? readCatalog(activeCodexModelsCachePath()) + ?? active; +} + +function retainedCatalogSyncEvidence( + config: OcxConfig, + catalogPath: string, + catalog: RawCatalog, +): string { + return JSON.stringify({ + config, + catalogPath, + catalog, + catalogBytes: optionalFileBytes(catalogPath), + hashedBackupBytes: optionalFileBytes(catalogBackupPathFor(catalogPath)), + legacyBackupBytes: isDefaultCatalogPath(catalogPath) + ? optionalFileBytes(legacyCatalogBackupPath()) : null, + modelsCacheBytes: optionalFileBytes(activeCodexModelsCachePath()), + // The persisted runtime selection is a pre-await filesystem input, not a + // process epoch: another PROCESS can move runtime authority by rewriting this + // file, and that move is invisible to our in-process memo. Recorded PRESENT or + // ABSENT, because its absence is what makes the resolver fall back. + runtimeStateBytes: optionalFileBytes(codexRuntimeStatePath()), + }); +} + +/** + * The bundled-template half of the same evidence, observed separately. + * + * The runtime process memo is deliberately NOT here, and that exclusion took three + * attempts to get honest. Gathering resolves the Codex runtime lazily and under its + * own cache key, so this path cannot pre-settle that memo: baselining it before the + * await always detected our own side effect and refused every write, and baselining + * it after the await captured a runtime that ANOTHER process had moved as though it + * were ours — a catalog prepared from R1 committing after authority reached R2. + * + * Runtime authority is covered where it is actually durable instead: the persisted + * `codex-runtime.json` bytes sit in the pre-await filesystem evidence, PRESENT or + * ABSENT, so a cross-process runtime move is caught. What is left uncovered, and is + * written down rather than papered over, is a same-process in-memory runtime swap + * that never touches that file — WP11 owns the lock that makes that case decidable. + */ +function retainedCatalogProcessEvidence(): string { + return JSON.stringify({ + bundledCatalogCache: bundledCatalogCacheState(), + }); +} + +/** + * Capture every local catalog input the retained sync path consults before its + * provider await. The exact evidence is compared after K acquisition; a newer + * catalog/backup/cache or target selection makes this attempt a no-write. + */ +function readRetainedCatalogSync(config: OcxConfig): RetainedCatalogSyncRead | null { const catalogPath = readCodexCatalogPath(); - const catalog = loadCatalogForSync(catalogPath); - if (!catalog) return { added: 0, path: catalogPath, catalogWritten: false, comboOmissions: [] }; + const catalog = loadCatalogForRetainedSync(catalogPath); + if (!catalog) return null; // The bundled catalog is a reliable native template on the default path, but it is not the // merge source. Preservation must inspect the file that this sync is about to overwrite; // otherwise an empty/partial provider gather cannot see routed or user-native rows on disk. const onDiskCatalog = readCatalog(catalogPath); - const catalogModelsForMerge = onDiskCatalog?.models ?? catalog.models ?? []; + const evidence = retainedCatalogSyncEvidence(config, catalogPath, catalog); + // `processEvidence` is filled in after the provider await, not here. + return { catalogPath, catalog, onDiskCatalog, evidence, processEvidence: "" }; +} + +function revalidateRetainedCatalogSync( + config: OcxConfig, + prepared: RetainedCatalogSyncRead, +): RetainedCatalogSyncRead | null { + const catalogPath = readCodexCatalogPath(); + if (catalogPath !== prepared.catalogPath) return null; + const evidence = retainedCatalogSyncEvidence(config, catalogPath, prepared.catalog); + if (evidence !== prepared.evidence) return null; + if (retainedCatalogProcessEvidence() !== prepared.processEvidence) return null; + return { + catalogPath, + catalog: JSON.parse(JSON.stringify(prepared.catalog)) as RawCatalog, + onDiskCatalog: readCatalog(catalogPath), + evidence, + processEvidence: prepared.processEvidence, + }; +} + +function pristineCatalogBytes(read: RetainedCatalogSyncRead): string | null { + if (read.onDiskCatalog && !catalogHasRoutedEntries(read.onDiskCatalog)) { + try { + return readFileSync(read.catalogPath, "utf8"); + } catch { + return null; + } + } + return catalogHasRoutedEntries(read.catalog) + ? null + : `${JSON.stringify(read.catalog, null, 2)}\n`; +} +function writeRetainedCatalogSync({ + config, + goModels, + comboOmissions, + read, + permit, + owningCodexHome, +}: RetainedCatalogSyncWrite): RetainedCatalogSyncResult { + const { catalogPath, catalog, onDiskCatalog } = read; + const catalogModelsForMerge = onDiskCatalog?.models ?? catalog.models ?? []; const template = findNativeTemplate(catalog); - const comboOmissions: ComboCatalogOmission[] = []; - const goModels = await gatherRoutedModels(config, { comboOmissions }); try { // Once-only: preserve the PRISTINE pre-opencodex catalog as the native-priority baseline // (later syncs would otherwise overwrite it with featured-modified priorities). - ensureCatalogBackup(catalogPath, catalog); + const pristine = pristineCatalogBytes(read); + if (pristine !== null) { + publishHashedCodexCatalogBackup(permit, owningCodexHome, { + path: catalogBackupPathFor(catalogPath), + content: pristine, + }); + if (isDefaultCatalogPath(catalogPath)) { + publishLegacyCodexCatalogBackup(permit, owningCodexHome, { + path: legacyCatalogBackupPath(), + content: pristine, + }); + } + } } catch { /* backup best-effort */ } // Hide disabled models from Codex, then feature the chosen subagent models (native OR routed) @@ -697,7 +858,10 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ ); clampCatalogModelsToCodexSupport(catalog.models); - atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n"); + replaceActiveCodexCatalog(permit, owningCodexHome, { + path: catalogPath, + content: `${JSON.stringify(catalog, null, 2)}\n`, + }); return { added: goEntries.length + accountBoundEntries.length, path: catalogPath, @@ -754,7 +918,58 @@ function currentDisabledModelsForRestore(): Set | null { } } -export function restoreCodexCatalog(): { removed: number; kept: number; path: string } { +export async function syncCatalogModels(config: OcxConfig): Promise { + const owningCodexHome = getCodexHome(); + const preflightRead = readRetainedCatalogSync(config); + if (preflightRead === null) { + return { + added: 0, + path: readCodexCatalogPath(), + catalogWritten: false, + comboOmissions: [], + }; + } + + const comboOmissions: ComboCatalogOmission[] = []; + // Settle the bundled template, then baseline, and only then await. Reading it + // here makes the memo ours before anyone else can move it, so a bundled swap + // during the await is an outside change rather than our own side effect. + // + // The persisted runtime selection is covered by the filesystem evidence above + // rather than by a process epoch; see `retainedCatalogProcessEvidence` for why + // the in-memory runtime memo cannot be baselined honestly from this path. + loadBundledCodexCatalog(); + const prepared: RetainedCatalogSyncRead = { + ...preflightRead, + evidence: retainedCatalogSyncEvidence(config, preflightRead.catalogPath, preflightRead.catalog), + processEvidence: retainedCatalogProcessEvidence(), + }; + const goModels = await gatherRoutedModels(config, { comboOmissions }); + const committed = withCatalogWriteSerialization(owningCodexHome, permit => { + const current = revalidateRetainedCatalogSync(config, prepared); + if (current === null) return null; + return writeRetainedCatalogSync({ + config, + goModels, + comboOmissions, + read: current, + permit, + owningCodexHome, + }); + }); + if (committed.kind === "completed" && committed.value !== null) return committed.value; + return { + added: 0, + path: prepared.catalogPath, + catalogWritten: false, + comboOmissions, + }; +} + +export function restoreCodexCatalogWithPermit( + permit: CatalogWritePermit, + owningCodexHome: string, +): { removed: number; kept: number; path: string } { const catalogPath = readCodexCatalogPath(); const catalog = readCatalog(catalogPath); if (!catalog || !Array.isArray(catalog.models)) return { removed: 0, kept: 0, path: catalogPath }; @@ -775,7 +990,10 @@ export function restoreCodexCatalog(): { removed: number; kept: number; path: st ...backup, models: [...backup.models, ...userNativeAdditions], }; - atomicWriteFile(catalogPath, JSON.stringify(restored, null, 2) + "\n"); + replaceActiveCodexCatalog(permit, owningCodexHome, { + path: catalogPath, + content: `${JSON.stringify(restored, null, 2)}\n`, + }); return { removed, kept: restored.models.length, path: catalogPath }; } const before = catalog.models.length; @@ -787,13 +1005,30 @@ export function restoreCodexCatalog(): { removed: number; kept: number; path: st const removed = before - native.length; if (removed > 0) { catalog.models = native; - atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n"); + replaceActiveCodexCatalog(permit, owningCodexHome, { + path: catalogPath, + content: `${JSON.stringify(catalog, null, 2)}\n`, + }); } return { removed, kept: native.length, path: catalogPath }; } +export function restoreCodexCatalog(): { removed: number; kept: number; path: string } { + const owningCodexHome = getCodexHome(); + const outcome = withCatalogWriteSerialization( + owningCodexHome, + permit => restoreCodexCatalogWithPermit(permit, owningCodexHome), + ); + return outcome.kind === "completed" + ? outcome.value + : { removed: 0, kept: 0, path: readCodexCatalogPath() }; +} + /** Force Codex's models_cache stale from the on-disk catalog. Returns whether a cache write occurred. */ -export function invalidateCodexModelsCache(): boolean { +export function invalidateCodexModelsCacheWithPermit( + permit: CatalogWritePermit, + owningCodexHome: string, +): boolean { try { const catalogPath = readCodexCatalogPath(); if (!existsSync(catalogPath)) return false; @@ -804,9 +1039,21 @@ export function invalidateCodexModelsCache(): boolean { client_version: "0.0.0", models, }; - atomicWriteFile(activeCodexModelsCachePath(), JSON.stringify(wrapper, null, 2) + "\n"); + replaceCodexModelsCache(permit, owningCodexHome, { + path: activeCodexModelsCachePath(), + content: `${JSON.stringify(wrapper, null, 2)}\n`, + }); return true; } catch { return false; } } + +export function invalidateCodexModelsCache(): boolean { + const owningCodexHome = getCodexHome(); + const outcome = withCatalogWriteSerialization( + owningCodexHome, + permit => invalidateCodexModelsCacheWithPermit(permit, owningCodexHome), + ); + return outcome.kind === "completed" && outcome.value; +} diff --git a/src/codex/codex-write-lock.ts b/src/codex/codex-write-lock.ts new file mode 100644 index 000000000..82316bf3d --- /dev/null +++ b/src/codex/codex-write-lock.ts @@ -0,0 +1,372 @@ +/** + * N — one bounded writer per canonical `CODEX_HOME`. + * + * The failure this closes is lock SPLITTING plus event-loop denial, not a + * missing mutex. Two spellings of one Codex home can reach different textual + * paths, and a lock held across provider discovery or history walking recreates + * the 10.5-second listener stall that killed the previous design. + * + * Three decisions are load-bearing and each was forced by a defect: + * + * - The namespace keys on the EFFECTIVE USER (uid/SID), never on a home path. + * Under Bun 1.3.14 both `os.homedir()` and `os.userInfo().homedir` follow the + * environment, so a service and a CLI for one account can see different homes + * and coordinate through different databases — exclusion defeated, silently. + * `resolveCodexCoordinatorDatabasePath` owns that; this module consumes its + * result verbatim and appends nothing. + * + * - The commit callback is SYNCHRONOUS. Provider I/O, subprocesses, history + * walking and retry sleeps are forbidden beneath it; a thenable that slips + * through the type is detected, rolled back, and rejected. + * + * - Acquisition is finite and typed. `busy` means try again; `refused` means + * never. Collapsing them is how a permanent denial becomes an endless retry + * loop wearing the costume of contention. + * + * Lock order is `N -> C`. C is `withConfigMutationLockSync`, entered while N is + * held and released before N commits. There is no `C -> N`. + * + * Design record: devlog/_plan/260804_codex_write_substrate/030_lock_protocol.md. + */ +import { AsyncLocalStorage } from "node:async_hooks"; +import { createHash } from "node:crypto"; +import { lstatSync, realpathSync } from "node:fs"; +import { isAbsolute, resolve } from "node:path"; +import { homedir } from "node:os"; + +import { withConfigMutationLockSync } from "../config"; +import type { + CodexCoordinatorTransaction, + CommitExpectation, +} from "./convergence-types"; +import { getCodexHome } from "./paths"; +import { nativeMainOwnerFilesystemSupported } from "./native-main-owner"; +import { openCodexCoordinatorTransaction } from "./transition-state"; +import { + CodexUserIdentityRefusal, + resolveCodexCoordinatorDatabasePath, + resolveEffectiveUserIdentity, +} from "./user-identity"; + +export const CODEX_WRITE_LOCK_MAX_TIMEOUT_MS = 30_000; + +/** Uniform, small, and jittered: a fixed interval makes contenders resonate. */ +const RETRY_MIN_MS = 25; +const RETRY_MAX_MS = 75; + +export type CodexWriteLockRefusalReason = + | "codex_home_missing" + | "codex_home_unsafe" + | "authority_not_proven" + | "namespace_unsafe" + | "lock_path_unsafe" + | "unsupported_filesystem" + | "reentrant" + | "lock_unavailable"; + +export type CodexWriteLockResult = + | { status: "acquired"; value: T; waitedMs: number; lockId: string } + | { status: "busy"; reason: "deadline" | "cancelled"; retryable: true; waitedMs: number } + | { + status: "refused"; + reason: CodexWriteLockRefusalReason; + retryable: false; + message: string; + }; + +export interface CodexWriteLockOptions { + /** Defaults to the ambient home. An explicit value must MATCH it — see below. */ + codexHome?: string; + timeoutMs: number; + signal?: AbortSignal; + /** + * Read-only witness obtained before any namespace creation. + * + * Typed by what the lock actually uses — one comparable id — rather than by + * `AdmissionSnapshot`, so a caller that coordinates a write WITHOUT gating on + * admission can hold the lock honestly. `AdmissionSnapshot` satisfies this + * structurally through its `authoritySnapshotId`, so existing callers are + * unchanged; see `write-coordination.ts` for why the two are not the same + * claim. + */ + admitted: CodexWriteWitness; + /** Authoritative synchronous re-read while N and C are both held. */ + readAdmissionUnderLock(): CodexWriteWitness; +} + +/** + * The only thing the lock compares. + * + * Kept deliberately minimal: widening it would let the lock depend on evidence + * a caller cannot re-read under N and C, which is how a comparison starts + * matching itself instead of detecting drift. + */ +export interface CodexWriteWitness { + readonly authoritySnapshotId: string; +} + +export interface CodexWriteCommitContext { + readonly canonicalCodexHome: string; + readonly lockId: string; + readonly admission: CodexWriteWitness; + readonly expectation: CommitExpectation; + /** + * The `currentTxId` the coordinator row holds RIGHT NOW. + * + * `CommitExpectation` carries the generation pair and the NEW txId, but the + * conditional row update also matches on the existing one. Without this the + * caller has to guess it — and guessing `null` works only on a row nobody has + * ever transitioned, so the guess passes on a fresh machine and fails on a + * real one. + */ + readonly currentTxId: string | null; + /** Opaque authority over the ALREADY-OPEN transaction. Not SQLite. */ + readonly coordinator: CodexCoordinatorTransaction; +} + +/** Rejects an `async` callback at typecheck; a cast thenable is caught at runtime. */ +type Synchronous = T extends PromiseLike ? never : T; + +/** + * Same-task reentrancy detection. + * + * NOT the exclusion mechanism — `busy_timeout = 0` before `BEGIN IMMEDIATE` + * already makes a second open in this process fail with SQLITE_BUSY. This exists + * only to turn that indistinguishable `busy` into a typed `refused/reentrant`, + * so a caller is told "you already hold this" rather than "try again forever". + */ +const heldHomes = new AsyncLocalStorage>(); + +function refuse(reason: CodexWriteLockRefusalReason, message: string): CodexWriteLockResult { + return { status: "refused", reason, retryable: false, message }; +} + +function isBusyError(error: unknown): boolean { + const code = error && typeof error === "object" && "code" in error + ? String((error as { code?: unknown }).code) + : ""; + const message = error instanceof Error ? error.message : String(error); + return code === "SQLITE_BUSY" || code === "SQLITE_LOCKED" + || /database (?:is|table is) locked/i.test(message); +} + +function expandLeadingTilde(path: string): string { + if (path === "~") return homedir(); + if (path.startsWith("~/") || path.startsWith("~\\")) return resolve(homedir(), path.slice(2)); + return path; +} + +export interface CanonicalCodexHome { + readonly path: string; + /** Stable id for one home, independent of which spelling reached us. */ + readonly lockId: string; +} + +/** + * Canonicalize a `CODEX_HOME` so every spelling of one directory takes one lock. + * + * Default, explicit, absolute, tilde and symlinked spellings must contend; two + * genuinely different directories must not. A MISSING home is refused rather + * than resolved optimistically: keeping an unresolved suffix would either split + * one future home on a case-insensitive filesystem or alias two on a + * case-sensitive one. + */ +export function canonicalizeCodexHome( + candidate: string, +): { ok: true; home: CanonicalCodexHome } | { ok: false; reason: CodexWriteLockRefusalReason; message: string } { + const expanded = resolve(expandLeadingTilde(candidate)); + if (!isAbsolute(expanded)) { + return { ok: false, reason: "codex_home_unsafe", message: "CODEX_HOME did not resolve to an absolute path." }; + } + let entry; + try { + entry = lstatSync(expanded); + } catch { + return { ok: false, reason: "codex_home_missing", message: `CODEX_HOME does not exist: ${expanded}` }; + } + // A symlinked home is fine — realpath collapses it below, which is how two + // spellings end up on one lock. A home that is a FILE is not. + if (!entry.isDirectory() && !entry.isSymbolicLink()) { + return { ok: false, reason: "codex_home_unsafe", message: `CODEX_HOME is not a directory: ${expanded}` }; + } + let canonical: string; + try { + canonical = realpathSync.native(expanded); + if (!lstatSync(canonical).isDirectory()) { + return { ok: false, reason: "codex_home_unsafe", message: `CODEX_HOME is not a directory: ${canonical}` }; + } + } catch { + return { ok: false, reason: "codex_home_missing", message: `CODEX_HOME does not exist: ${expanded}` }; + } + if (!nativeMainOwnerFilesystemSupported(canonical)) { + return { + ok: false, + reason: "unsupported_filesystem", + message: `CODEX_HOME is on a filesystem that cannot hold this lock: ${canonical}`, + }; + } + // Windows paths are case-insensitive, so two spellings of one directory must + // hash alike there; POSIX paths are not, so they must not be folded. + const normalized = process.platform === "win32" ? canonical.toLowerCase() : canonical; + const lockId = createHash("sha256") + .update("opencodex-codex-write-lock-v1\0") + .update(normalized) + .digest("hex"); + return { ok: true, home: { path: canonical, lockId } }; +} + +function sleepJittered(remainingMs: number, signal?: AbortSignal): Promise { + const span = RETRY_MAX_MS - RETRY_MIN_MS; + const wait = Math.min(remainingMs, RETRY_MIN_MS + Math.floor(Math.random() * (span + 1))); + return new Promise(done => { + const timer = setTimeout(finish, Math.max(1, wait)); + function finish(): void { + clearTimeout(timer); + signal?.removeEventListener("abort", finish); + done(); + } + signal?.addEventListener("abort", finish, { once: true }); + }); +} + +/** + * Hold N for one canonical `CODEX_HOME` and run `commit` while it is held. + * + * Returns `acquired` with the callback's value, `busy` when the deadline or the + * signal ended the wait, or `refused` when nothing about waiting would help. A + * caller exception is NOT converted into either: it propagates after rollback, + * because a failed commit is the caller's error and not a lock outcome. + */ +export async function withCodexWriteLock( + options: CodexWriteLockOptions, + commit: (context: CodexWriteCommitContext) => Synchronous, +): Promise> { + const { timeoutMs, signal } = options; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > CODEX_WRITE_LOCK_MAX_TIMEOUT_MS) { + return refuse("authority_not_proven", + `timeoutMs must be an integer between 0 and ${CODEX_WRITE_LOCK_MAX_TIMEOUT_MS}.`); + } + + /* + * The ambient home is resolved ONCE, and an explicit home must equal it. + * + * `classifyNativeRoutedResidue` — the guard that decides whether the + * coordinator row may be created — resolves its own home from the ambient + * environment, while the lock path keys on the home WE were given. Forwarding a + * different explicit home would lock one directory while a different one was + * safety-checked. Until that guard takes the target as a parameter (WP12), the + * only honest answer is to refuse the mismatch. + */ + const ambient = canonicalizeCodexHome(getCodexHome()); + if (!ambient.ok) return refuse(ambient.reason, ambient.message); + + let target = ambient.home; + if (options.codexHome !== undefined) { + const trimmed = options.codexHome.trim(); + if (!trimmed) return refuse("codex_home_unsafe", "An explicit codexHome must not be blank."); + const explicit = canonicalizeCodexHome(trimmed); + if (!explicit.ok) return refuse(explicit.reason, explicit.message); + if (explicit.home.path !== ambient.home.path) { + return refuse("authority_not_proven", + "The requested CODEX_HOME is not the one this process would inspect for safety."); + } + target = explicit.home; + } + + const held = heldHomes.getStore(); + if (held?.has(target.lockId)) { + return refuse("reentrant", "This task already holds the Codex write lock for this CODEX_HOME."); + } + + let databasePath: string; + try { + databasePath = resolveCodexCoordinatorDatabasePath(resolveEffectiveUserIdentity(), target.path); + } catch (error) { + return refuse("namespace_unsafe", + error instanceof CodexUserIdentityRefusal ? error.message : "The lock namespace could not be resolved."); + } + + const started = performance.now(); + const deadline = started + timeoutMs; + const waited = (): number => Math.round(performance.now() - started); + + for (;;) { + if (signal?.aborted) return { status: "busy", reason: "cancelled", retryable: true, waitedMs: waited() }; + + let transaction: ReturnType | undefined; + try { + transaction = openCodexCoordinatorTransaction(databasePath); + } catch (error) { + // Only contention retries. A malformed database, an unsafe path, or an + // identity failure will fail identically forever; telling a caller to retry + // that is how a UI spins on a problem only the user can fix. + if (!isBusyError(error)) { + if (error instanceof CodexUserIdentityRefusal) return refuse("lock_path_unsafe", error.message); + return refuse("lock_unavailable", + error instanceof Error ? error.message : "The Codex write lock could not be opened."); + } + if (performance.now() >= deadline) { + return { status: "busy", reason: "deadline", retryable: true, waitedMs: waited() }; + } + await sleepJittered(deadline - performance.now(), signal); + continue; + } + + // N is held from here. Everything below is synchronous until commit/rollback. + try { + const expectation = transaction.expectation(); + const version = transaction.version(); + const nextHeld = new Set(held ?? []); + nextHeld.add(target.lockId); + + const value = heldHomes.run(nextHeld, () => withConfigMutationLockSync(() => { + const current = options.readAdmissionUnderLock(); + if (current.authoritySnapshotId !== options.admitted.authoritySnapshotId) { + throw new CodexWriteLockStaleAdmission(); + } + const result = commit({ + canonicalCodexHome: target.path, + lockId: target.lockId, + admission: current, + expectation, + currentTxId: version.currentTxId, + coordinator: transaction!.capability, + }); + // A cast `async` callback would return a thenable here. Awaiting it is + // impossible — C is synchronous — so the only safe answer is to reject. + if (result && typeof (result as { then?: unknown }).then === "function") { + throw new TypeError("The Codex write-lock commit callback must be synchronous."); + } + return result; + })); + + transaction.assertPublished(expectation); + transaction.commit(); + return { status: "acquired", value: value as T, waitedMs: waited(), lockId: target.lockId }; + } catch (error) { + transaction.rollback(); + if (error instanceof CodexWriteLockStaleAdmission) { + return refuse("authority_not_proven", + "The admitted state changed before the commit could be made under the lock."); + } + if (isBusyError(error)) { + if (performance.now() >= deadline) { + return { status: "busy", reason: "deadline", retryable: true, waitedMs: waited() }; + } + await sleepJittered(deadline - performance.now(), signal); + continue; + } + throw error; + } finally { + transaction.close(); + } + } +} + +/** Internal: the under-lock re-read disagreed with what was admitted. */ +class CodexWriteLockStaleAdmission extends Error { + constructor() { + super("The admitted authority snapshot is stale."); + this.name = "CodexWriteLockStaleAdmission"; + } +} diff --git a/src/codex/convergence-types.ts b/src/codex/convergence-types.ts new file mode 100644 index 000000000..d8a4ad449 --- /dev/null +++ b/src/codex/convergence-types.ts @@ -0,0 +1,593 @@ +/** + * Shared types for the Codex safe-interruption write substrate. + * + * This module is the SINGLE owner of every surface WP9-WP13 share. Two prior + * attempts failed audit because four phase documents each invented their share + * of the record schema, the /api/sync contract, and the convergence entry + * point; the contract is centralized here so a consumer can only import. + * + * Design record: devlog/_plan/260804_codex_write_substrate/005_contract.md + * Audit trail: 006, 007, 008, 009, 010 audit syntheses in the same unit. + * + * TYPES ONLY. WP8b deliberately rewires nothing: WP9 supplies the first + * `ConvergeCodex` implementation. Every phase must typecheck and preserve + * behavior at its own commit, which a runtime placeholder here would break. + */ +import type { OcxConfig } from "../types"; +import type { ProviderModelDiscoveryFilter } from "../providers/registry"; + +/** + * The non-CAS JSON record for the Codex integration. + * + * ONE owner. WP12 writes provenance here through `updateIntegrationRecord` — + * never its own read/merge/write. Cross-process transition state is deliberately + * absent; it belongs to the CODEX_HOME-keyed SQLite row below. + * + * Provenance is OPTIONAL at v1. A record written before WP12 is valid, and + * unknown extension sections from a newer writer remain valid and preserved. + */ +export interface CodexIntegrationRecord { + version: 1; + provenance?: CodexProvenanceLedger; + /** Unknown keys from a newer writer survive every older-writer update. */ + readonly [extra: string]: unknown; +} + +export interface CodexHistoryState { + status: "converged" | "pending" | "running" | "blocked" | "unknown" | "not-evaluated"; + /** + * Why it is not converged, when it is not. These are terminal observations + * for one attempt, not reasons to collapse the durable retry schedule. + */ + reason?: + | "db-busy" + | "permission" + | "unreadable" + | "schema" + | "timeout" + | "shutdown-cancelled" + | "worker-died" + | "overtaken" + | "record-write-failed"; + attempts: number; + /** null means "no timer armed"; see 020 — it must never mean "never again". */ + nextRetryAt: string | null; + /** The transition this state belongs to, so an overtaken job is detectable. */ + txId: string | null; + /** null means the final probe could not produce a trustworthy row count. */ + pendingRows: number | null; + /** null means the final probe could not produce a trustworthy manifest count. */ + backupEntries: number | null; + /** Unknown keys from a newer writer, preserved verbatim. */ + readonly [extra: string]: unknown; +} + +/** + * Every mutable Codex artifact for which the provenance ledger can authorize a + * restore. Embedded config fragments share the `config` entry because they are + * committed and restored as one file. Dynamic history ids name the exact row or + * rollout whose semantic pre-image is retained. + */ +export type CodexArtifactId = + | { readonly kind: "config"; readonly [extra: string]: unknown } + | { readonly kind: "generated-profile"; readonly [extra: string]: unknown } + | { readonly kind: "active-catalog"; readonly canonicalPath: string; + readonly [extra: string]: unknown } + | { readonly kind: "catalog-backup"; readonly form: "hashed" | "legacy"; + readonly canonicalPath: string; readonly [extra: string]: unknown } + | { readonly kind: "models-cache"; readonly [extra: string]: unknown } + | { readonly kind: "injection-journal"; readonly [extra: string]: unknown } + | { readonly kind: "history-row"; readonly stateDbId: string; readonly threadId: string; + readonly [extra: string]: unknown } + | { readonly kind: "history-manifest"; readonly stateDbId: string; + readonly canonicalPath: string; readonly [extra: string]: unknown } + | { readonly kind: "history-manifest-entry"; readonly stateDbId: string; + readonly threadId: string; readonly [extra: string]: unknown } + | { readonly kind: "history-rollout"; readonly stateDbId: string; + readonly canonicalPath: string; readonly [extra: string]: unknown }; + +export interface CodexProvenanceEntry { + artifact: CodexArtifactId; + baseline: + | { kind: "absent"; readonly [extra: string]: unknown } + | { kind: "present"; sha256: string; bytesBase64: string; + readonly [extra: string]: unknown }; + /** Hash of what WE wrote. null when the write did not complete. */ + postImage: string | null; + txId: string; + at: string; + /** Entry-level extensions are preserved, not only ledger/top-level keys. */ + readonly [extra: string]: unknown; +} + +export interface CodexProvenanceLedger { + entries: readonly CodexProvenanceEntry[]; + readonly [extra: string]: unknown; +} + +export type CodexArtifactObservation = + | "applied" + | "absent" + | "missing" + | "residue" + | "drifted" + | "unreadable" + | "invalid" + | "not-evaluated" + | "unknown"; + +/** + * Read-only proof of what Codex has now, not what persisted intent requests. + * `isApplied` is true only for aggregate `applied`; a partial surface can never + * be flattened into true. OFF is operationally converged only at `absent`. + */ +export interface CodexObservedState { + aggregate: "applied" | "absent" | "partial" | "external" | "blocked" | "not-evaluated"; + /** null only for a catalog-scoped request that deliberately did not observe. */ + isApplied: boolean | null; + desired: "on" | "off" | "unknown"; + /** null only when aggregate is `not-evaluated`. */ + converged: boolean | null; + authority: { + service: "owned" | "foreign" | "unknown"; + externalProvider: string | null; + }; + surfaces: { + config: CodexArtifactObservation; + profile: CodexArtifactObservation; + catalog: CodexArtifactObservation; + cache: CodexArtifactObservation; + journal: "absent" | "pending" | "live" | "invalid" | "unknown" | "not-evaluated"; + history: { + state: CodexHistoryState; + database: CodexArtifactObservation; + manifest: CodexArtifactObservation; + rollouts: CodexArtifactObservation; + }; + provenance: { + state: "verified" | "missing" | "conflict" | "unreadable" | "unknown" | "not-evaluated"; + nativeGeneration: number | null; + currentTxId: string | null; + }; + }; +} + +export type CatalogNotice = "provider-auth" | "provider-network" | "fallback"; + +/** Sanitized catalog fact safe to append to management mutation responses. */ +export type CatalogDisposition = + | { status: "committed"; changed: boolean; degraded: boolean; + notices: readonly CatalogNotice[] } + | { status: "skipped"; + reason: "not-requested" | "catalog-unavailable" | "busy" | "stale" | "refused"; + retryable: boolean } + | { status: "failed"; reason: "provider-auth" | "provider-network" | "disk"; + phase: "gather" | "commit"; retryable: boolean; partialWrite: boolean }; + +/** + * The ONLY way Codex-owned bytes are written. Startup, ensure, /api/sync, the + * CLI verbs and all 16 management mutation callbacks funnel here. + * + * The funnel is the point: admission, generation checks and the lock live in one + * place, so a new caller cannot forget them. Round 1's 16 callers each held + * their own path to a commit. + */ +export type ConvergeCodex = ( + request: ConvergeRequest, +) => Promise; + +export interface ConvergeRequest { + /** + * The caller says WHEN, never WHICH WAY. + * + * Round 2 N1: an `apply | remove` request let `/api/sync` skip while desired + * state was OFF instead of removing residue, which violates C11 and + * contradicts the rule that callers cannot supply desired state. The + * direction is derived from admitted persisted intent, full stop. + * + * `observe` writes nothing and is the status read. + */ + action: "converge" | "observe"; + /** + * WP9 management mutations use `catalog`; explicit/lifecycle convergence uses + * `full`. Scope limits work, but still never lets the caller choose direction. + */ + scope: "catalog" | "full"; + /** Why, for the record and for log attribution. */ + reason: "startup" | "ensure" | "api-sync" | "cli" | "management-mutation"; + /** Automatic callers fail fast and defer; explicit ones may wait. See §5. */ + mode: "automatic" | "explicit"; + deadlineMs: number; +} + +/** Caller-controlled input for the fixed management catalog request shape. */ +export interface CatalogConvergeRequestInput { + deadlineMs: number; +} + +export type ConvergeOutcome = + | { kind: "catalog-only"; changed: boolean; + observed: CodexObservedState; catalogRefresh: CatalogDisposition; + history: CodexHistoryState } + | { kind: "converged"; direction: "applied" | "removed"; changed: boolean; + observed: CodexObservedState; nativeGeneration: number; + currentTxId: string; + catalogRefresh: CatalogDisposition; history: CodexHistoryState } + | { kind: "skipped"; reason: "already-converged"; + observed: CodexObservedState; catalogRefresh: CatalogDisposition; history: CodexHistoryState } + | { kind: "refused"; authority: "service-home" | "external-provider" | "journal" | "provenance"; + message: string; observed: CodexObservedState } + | { kind: "busy"; surface: "lock" | "history" | "config"; retryAfterMs: number } + | { kind: "deferred"; direction: "applied" | "removed"; changed: boolean; + unresolved: readonly UnresolvedSurface[]; + nativeGeneration: number; currentTxId: string; + observed: CodexObservedState; catalogRefresh: CatalogDisposition; history: CodexHistoryState } + | { kind: "failed"; surface: string; message: string }; + +export type CatalogOnlyOutcome = Extract; + +export interface ProjectCatalogOnlyOutcomeInput { + changed: boolean; + catalogRefresh: CatalogDisposition; +} + +/** + * Note what is NOT here: `desired-off`. Desired OFF is not a skip — it is a + * `converged` with `direction: "removed"`. That is round 2 N1: the old shape let + * a sync while OFF return "skipped" and leave routed residue on disk. + */ +export type UnresolvedSurface = + | "config" + | "native" + | "catalog" + | "cache" + | "journal" + | "provenance" + | "history"; + +/** Bumped by every cooperating CONFIG write. Owned by src/config.ts. */ +export interface ConfigGeneration { readonly value: number; } + +/** Bumped by every cooperating NATIVE commit. Owned by transition-state.ts. */ +export interface NativeGeneration { readonly value: number; } + +export type ConfigGenerationRead = + | { kind: "ready"; generation: ConfigGeneration } + | { kind: "unavailable"; reason: "busy" | "database" }; + +export type ConfigGenerationBump = + | { kind: "updated"; generation: ConfigGeneration } + | { kind: "conflict"; current: ConfigGeneration } + | { kind: "unavailable"; reason: "busy" | "database" }; + +export type ExpectedConfigGenerationSyncResult = + | { kind: "matched"; generation: ConfigGeneration; value: T } + | { kind: "conflict"; current: ConfigGeneration } + | { kind: "unavailable"; reason: "busy" | "database" }; + +export type ReadConfigGeneration = () => ConfigGenerationRead; +export type BumpConfigGeneration = (expected: ConfigGeneration) => ConfigGenerationBump; +export type WithExpectedConfigGenerationSync = ( + expected: ConfigGeneration, + commit: () => T, +) => ExpectedConfigGenerationSyncResult; + +export interface CommitExpectation { + /** Read at admission. */ + readonly nativeBefore: number; + /** What OUR commit will produce. Always nativeBefore + 1. */ + readonly nativeAfter: number; + /** Identifies the commit that performed the bump. */ + readonly txId: string; +} + +/** The authoritative pair and history schedule stored under canonical CODEX_HOME. */ +export interface CodexTransitionVersion { + readonly nativeGeneration: number; + readonly currentTxId: string | null; +} + +export interface CodexTransitionState extends CodexTransitionVersion { + readonly history: CodexHistoryState; + readonly historySchedule: null | Readonly<{ + direction: "apply" | "remove"; + authoritySnapshotId: string; + }>; +} + +export type TransitionStateRead = + | { kind: "ready"; state: CodexTransitionState } + | { kind: "legacy-ambiguous"; message: string } + | { kind: "unavailable"; reason: "busy" | "unsafe-path" | "database" }; + +export type TransitionStateUpdate = + | { kind: "updated"; state: CodexTransitionState } + | { kind: "conflict"; current: CodexTransitionState } + | { kind: "unavailable"; reason: "busy" | "unsafe-path" | "database" }; + +export interface BeginCodexTransitionNext { + readonly txId: string; + readonly direction: "apply" | "remove"; + readonly authoritySnapshotId: string; + readonly nextRetryAt: string; +} + +export type BeginCodexTransition = ( + expected: CodexTransitionVersion, + next: BeginCodexTransitionNext, +) => TransitionStateUpdate; + +export type ReadCodexTransitionState = () => TransitionStateRead; + +export type UpdateCodexHistoryTransition = ( + expected: CodexTransitionVersion, + history: CodexHistoryState, +) => TransitionStateUpdate; + +/** + * A coordinator transaction never exposes its SQLite connection. The runtime + * owner adds a private brand so only its one-shot factory can create one. + */ +export interface CodexCoordinatorTransaction { + readonly beginTransition: BeginCodexTransition; +} + +export interface CodexCoordinatorTransactionController { + readonly capability: CodexCoordinatorTransaction; + expectation(): CommitExpectation; + /** + * The pair the row holds right now, read on the ALREADY-OPEN transaction. + * + * A holder needs `currentTxId` to build the conditional update, and + * `CommitExpectation` carries only the generation pair plus the new txId. + * Opening a second connection to read it would contend with this + * transaction's own `BEGIN IMMEDIATE`. + */ + version(): CodexTransitionVersion; + assertPublished(expectation: CommitExpectation): void; + assertStablePath(): void; + commit(): void; + rollback(): void; + close(): void; +} + +/** Why a filesystem observation influenced catalog preparation. Closed by contract. */ +export type CatalogRequiredSourceRole = "catalog-target-selection"; + +export type CatalogConditionalSourceRole = + | "bundled-catalog-template" + | "active-catalog-merge" + | "hashed-backup-fallback" + | "legacy-backup-fallback" + | "models-cache-fallback" + | "native-catalog-selection" + | "runtime-selection" + | "provider-auth-selection"; + +export type CatalogSourceRole = + | CatalogRequiredSourceRole + | CatalogConditionalSourceRole; + +/** Portable normalized identity: POSIX dev/inode or Windows volume/file id. */ +export interface CatalogFilesystemIdentity { + readonly volume: string; + readonly fileId: string; +} + +export interface CatalogParentIdentity extends CatalogFilesystemIdentity { + readonly canonicalPath: string; +} + +/** Required evidence for the selector that chose every CODEX_HOME-derived path. */ +export interface CatalogHomeSelectionObservation { + readonly selector: Readonly<{ + readonly kind: "environment" | "default"; + /** Exact pre-canonicalization selector string used by the production resolver. */ + readonly raw: string; + }>; + readonly canonicalCodexHome: string; + readonly rootIdentity: CatalogFilesystemIdentity; +} + +export type CatalogProcessLocalObservation = + | { readonly state: "unused" } + | { readonly state: "used"; readonly epoch: number; readonly valueIdentity: string }; + +/** Candidate-bound evidence for mutable process-local authority, never file evidence. */ +export interface CatalogProcessLocalEvidence { + readonly runtime: CatalogProcessLocalObservation; + readonly bundledCatalog: CatalogProcessLocalObservation; +} + +export type CatalogDiscoveryPolicyField = + | Readonly<{ state: "absent" }> + | Readonly<{ state: "present"; value: T }>; + +export interface CatalogTrustedOpenAiApiPolicySnapshot { + readonly state: "unused" | "transport-mismatch" | "registry-models-absent" | "captured"; + readonly models?: readonly string[]; + readonly modelContextWindows?: Readonly>; + readonly modelMaxInputTokens?: Readonly>; + readonly modelInputModalities?: Readonly>; + readonly modelReasoningEfforts?: Readonly>; +} + +/** Detached effective policy consumed by one enabled provider inside a gather flight. */ +export interface CatalogProviderDiscoveryPolicySnapshot { + readonly provider: string; + readonly registryTransportMatch: boolean; + readonly location: Readonly<{ + readonly spec: "absent" | "present"; + readonly url: CatalogDiscoveryPolicyField; + readonly path: CatalogDiscoveryPolicyField; + readonly query: CatalogDiscoveryPolicyField> | undefined>; + }>; + readonly finalMethod: "GET"; + readonly finalUrl: string; + readonly filter: CatalogDiscoveryPolicyField; + readonly maxResponseBytes: number; + readonly maxModels: number; + readonly trustedOpenAiApi: CatalogTrustedOpenAiApiPolicySnapshot; +} + +/** Non-secret-bearing identity of every authority input admitted to one gather flight. */ +export interface CatalogGatherAuthorityIdentity { + readonly version: 1; + /** Process-local keyed HMAC over every component below; never a raw content hash. */ + readonly authorityId: string; + readonly admittedConfig: Readonly<{ + /** Opaque WeakMap identity of the exact resident Readonly reference. */ + readonly referenceIdentity: string; + readonly generation: ConfigGeneration; + /** Keyed HMAC of the exact canonical config snapshot, including secret-bearing fields. */ + readonly snapshotIdentity: string; + }>; + readonly authSnapshotIdentity: string; + readonly discoveryPolicyIdentity: string; + readonly nativeCatalogSourceIdentity: string; + readonly sourceEvidenceIdentity: string; + readonly processLocalEvidenceIdentity: string; +} + +/** Exact gather-time evidence for one consulted filesystem source. */ +export type CatalogSourceObservation = + | { + readonly state: "present"; + readonly role: R; + readonly logicalPath: string; + readonly canonicalPath: string; + readonly parentIdentity: CatalogParentIdentity; + readonly fileIdentity: CatalogFilesystemIdentity; + /** Digest of the exact buffer returned to gather. */ + readonly sha256: string; + } + | { + readonly state: "absent"; + readonly role: R; + readonly logicalPath: string; + readonly canonicalPath: string; + readonly parentIdentity: CatalogParentIdentity; + readonly fileIdentity: null; + }; + +export type CatalogRequiredSourceObservations = Readonly<{ + [R in CatalogRequiredSourceRole]: CatalogSourceObservation; +}>; + +export type CatalogConditionalSourceObservations = Readonly<{ + [R in CatalogConditionalSourceRole]: readonly CatalogSourceObservation[]; +}>; + +export interface CatalogSourceEvidence { + /** Required before any CODEX_HOME-derived target or source path is accepted. */ + readonly homeSelection: CatalogHomeSelectionObservation; + readonly required: CatalogRequiredSourceObservations; + /** Every role is a required key; an empty list means the role was not consulted. */ + readonly conditional: CatalogConditionalSourceObservations; +} + +/** The shared WP8b/WP9 snapshot; it authorizes catalog work only. */ +export interface CatalogAdmissionSnapshot { + config: Readonly; + generation: ConfigGeneration; + /** Exact retained-reference/generation/snapshot identity used by gather authority. */ + readonly configIdentity: CatalogGatherAuthorityIdentity["admittedConfig"]; + targets: Readonly<{ + catalog: string; + cache: string; + catalogBackups: readonly string[]; + }>; + /** Candidate-bound present/absent evidence, produced by the sole read owner. */ + sourceEvidence: CatalogSourceEvidence; +} + +export interface AdmissionSnapshot { + config: Readonly; + configDigest: string; + intent: "on" | "off"; + /** + * `present:false` means the coordinator database did not exist when this was + * captured. It is not a baseline: it authorizes a write only when the read + * taken inside the config transaction returns exactly 0. + */ + generation: Readonly<{ present: boolean; value: number }>; + ownership: "owned" | "foreign" | "unknown"; + externalProvider: string | null; + canonicalTargets: Readonly<{ + codexHome: string; + opencodexHome: string; + config: string; + profile: string; + catalog: string; + cache: string; + journal: string; + integrationRecord: string; + catalogBackups: readonly string[]; + historyDb: string; + historyManifest: string; + historyRollouts: readonly string[]; + }>; + journalIdentity: string; + provenanceIdentity: string; + /** Digest of every authority field above; passed to the history Worker. */ + authoritySnapshotId: string; +} + +/** + * Effective-user identity for the lock namespace. + * + * NOT a home path. Bun 1.3.14 returns an environment-controlled home from both + * os.homedir() AND os.userInfo().homedir, so any home-derived namespace can be + * split by a service and a CLI that see different HOME values — which defeats + * exclusion entirely, silently. + */ +export type UserIdentity = + | { platform: "posix"; uid: number } + | { platform: "win32"; sid: string }; + +/** + * Resolve the effective account from operating-system identity APIs only. + * Failure is a typed namespace refusal; username/home/environment fallback is + * forbidden because it can split one account across two lock databases. + */ +export type ResolveEffectiveUserIdentity = () => UserIdentity; + +/** + * Return the FINAL SQLite coordinator database path for this exact canonical + * CODEX_HOME. Consumers append no uid/SID, version, directory or filename. + */ +export type ResolveCodexCoordinatorDatabasePath = ( + identity: UserIdentity, + canonicalCodexHome: string, +) => string; + +/** + * Return K's FINAL database path; this is never the native coordinator path. + * + * Catalog serialization is a separate ownership surface from N. `K -> C` is a + * legal order and `N -> K` nests, so one shared database would self-contend. + * Consumers append no uid/SID, version, directory or filename. + */ +export type ResolveCodexCatalogSerializationDatabasePath = ( + identity: UserIdentity, + canonicalCodexHome: string, +) => string; + +/** + * H's FINAL database path. Never N's and never K's. + * + * History exclusion is keyed by the CANONICAL STATE DB as well as the canonical + * home, because one `CODEX_HOME` can name a different `state_5.sqlite` through a + * relative or retargeted path, and two operations against different history + * databases are not the same exclusion. N and K key on the home alone: they guard + * routing and catalog bytes, which the home fully determines. + * + * `H -> N` is the real order — the Worker reads the coordinator and writes its + * terminal row while holding H — so H must be its own database or that read would + * self-contend. Consumers append nothing to the returned path. + */ +export type ResolveCodexHistorySerializationDatabasePath = ( + identity: UserIdentity, + canonicalCodexHome: string, + canonicalStateDbPath: string, +) => string; diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts new file mode 100644 index 000000000..5194967eb --- /dev/null +++ b/src/codex/convergence.ts @@ -0,0 +1,441 @@ +import { join } from "node:path"; + +import { getConfigDir, websocketsEnabled, withExpectedConfigGenerationSync } from "../config"; +import { COMBO_NAMESPACE } from "../combos"; +import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; +import { getAuthStorePath } from "../oauth/store"; +import type { OcxConfig } from "../types"; +import { captureCatalogAdmissionSnapshot } from "./catalog-admission"; +import { + type CatalogSourceForGather, + bundledCatalogCacheState, + resolveCatalogSourceForGather, +} from "./catalog/bundled"; +import { + acceptCatalogGatherSourcePath, + captureAndSealCatalogHomeSelection, + captureCatalogGatherTargetIdentity, + createCatalogGatherEvidenceSession, + readCatalogGatherSource, + sealCatalogGatherEvidenceSession, + type CatalogFilesystemEvidenceSession, +} from "./catalog/filesystem-evidence"; +import { + CatalogGatherBusyError, + createCatalogGatherAuthorityIdentity, + filterCatalogVisibleModels, + gatherRoutedModelsForCatalogGather, + type CatalogGatherProviderAuthOutcome, +} from "./catalog/provider-fetch"; +import { + catalogBackupPathFor, + catalogHasRoutedEntries, + findNativeTemplate, + legacyCatalogBackupPath, + parseCatalogJson, + type RawCatalog, +} from "./catalog/parsing"; +import { + buildCatalogEntries, + orderForSubagents, +} from "./catalog/sync"; +import { exactComboCatalogSlugs } from "./catalog/aggregation"; +import { disabledNativeSlugs } from "./catalog/metadata"; +import { codexRuntimeStatePath, peekCodexRuntimeProcessCache } from "./runtime"; +import { withCatalogWriteSerialization } from "./catalog-write-serialization"; +import { + publishHashedCodexCatalogBackup, + publishLegacyCodexCatalogBackup, + replaceActiveCodexCatalog, + replaceCodexModelsCache, + type PreparedCatalogFileWrite, +} from "./internal/catalog-writer"; +import type { + CatalogAdmissionSnapshot, + CatalogDisposition, + CatalogGatherAuthorityIdentity, + CatalogNotice, + CatalogProviderDiscoveryPolicySnapshot, + CatalogProcessLocalEvidence, + CatalogSourceEvidence, + CatalogSourceRole, + ConvergeRequest, +} from "./convergence-types"; + +export interface CatalogWriteReceipt { + readonly keyedBackup: "written" | "preserved" | "not-requested"; + readonly legacyBackup: "written" | "preserved" | "not-requested"; + readonly catalog: "written" | "not-written"; + readonly cache: "written" | "not-written"; +} + +export type CodexCatalogCommitResult = + | { readonly kind: "committed"; readonly changed: boolean; readonly writes: CatalogWriteReceipt } + | { readonly kind: "stale"; readonly reason: "generation" | "home-selection" | "source-observation" | "process-local" | "target-identity" | "candidate-consumed" } + | { readonly kind: "refused"; readonly reason: "source-unreadable" | "source-ambiguous" | "target-unsafe" } + | { readonly kind: "failed"; readonly surface: "disk"; readonly writes: CatalogWriteReceipt }; + +declare const catalogCandidateBrand: unique symbol; +export interface CodexCatalogCandidate { readonly [catalogCandidateBrand]: true } + +export type CodexCatalogGatherResult = + | { readonly kind: "candidate"; readonly candidate: CodexCatalogCandidate } + | { readonly kind: "disposition"; readonly disposition: CatalogDisposition }; + +type CommitAttempt = CodexCatalogCommitResult | { readonly kind: "busy" }; + +interface CandidateState { + consumed: boolean; + readonly generation: CatalogAdmissionSnapshot["generation"]; + readonly authority: CatalogGatherAuthorityIdentity; + readonly sourceEvidence: CatalogSourceEvidence; + readonly processLocal: CatalogProcessLocalEvidence; + readonly home: string; + readonly targets: CatalogAdmissionSnapshot["targets"]; + readonly catalog: PreparedCatalogFileWrite; + readonly cache: PreparedCatalogFileWrite; + readonly keyedBackup?: PreparedCatalogFileWrite; + readonly legacyBackup?: PreparedCatalogFileWrite; + readonly changed: boolean; + readonly notices: readonly CatalogNotice[]; +} + +const candidateStates = new WeakMap(); +function same(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function targetPath(identity: string): string { + const parsed = JSON.parse(identity) as { path?: unknown }; + if (typeof parsed.path !== "string") throw new TypeError("Catalog target identity has no path."); + return parsed.path; +} + +function catalogFrom(bytes: Uint8Array | null): RawCatalog | null { + return bytes === null ? null : parseCatalogJson(Buffer.from(bytes).toString("utf8")); +} + +function catalogBytes(catalog: RawCatalog): string { + return `${JSON.stringify(catalog, null, 2)}\n`; +} + +interface ReadonlyRawCatalogLike { + readonly models?: readonly Readonly>[]; +} + +function hasRoutedEntries(catalog: ReadonlyRawCatalogLike): boolean { + return (catalog.models ?? []).some(entry => typeof entry.slug === "string" && entry.slug.includes("/")); +} + +function processEvidence(source: CatalogSourceForGather): CatalogProcessLocalEvidence { + return Object.freeze({ + runtime: Object.freeze({ ...source.processLocal.runtime }), + bundledCatalog: Object.freeze({ ...source.processLocal.bundledCatalog }), + }); +} + +function bindGatherPaths( + session: CatalogFilesystemEvidenceSession, + snapshot: CatalogAdmissionSnapshot, +): Readonly<{ catalog: string; cache: string; keyedBackup: string; legacyBackup?: string }> { + const catalog = targetPath(snapshot.targets.catalog); + const cache = targetPath(snapshot.targets.cache); + const keyedBackup = targetPath(snapshot.targets.catalogBackups[0]!); + const legacyBackup = snapshot.targets.catalogBackups[1] + ? targetPath(snapshot.targets.catalogBackups[1]) : undefined; + const configPath = snapshot.sourceEvidence.required["catalog-target-selection"].logicalPath; + + acceptCatalogGatherSourcePath(session, "catalog-target-selection", configPath); + readCatalogGatherSource(session, "catalog-target-selection"); + acceptCatalogGatherSourcePath(session, "active-catalog-merge", catalog); + acceptCatalogGatherSourcePath(session, "hashed-backup-fallback", keyedBackup); + acceptCatalogGatherSourcePath(session, "legacy-backup-fallback", legacyBackup ?? legacyCatalogBackupPath()); + acceptCatalogGatherSourcePath(session, "models-cache-fallback", cache); + acceptCatalogGatherSourcePath(session, "runtime-selection", codexRuntimeStatePath(getConfigDir())); + acceptCatalogGatherSourcePath(session, "provider-auth-selection", getAuthStorePath()); + acceptCatalogGatherSourcePath(session, "native-catalog-selection", catalog); + return { catalog, cache, keyedBackup, ...(legacyBackup ? { legacyBackup } : {}) }; +} + +function prepareCatalog( + config: Readonly, + source: Extract, + active: RawCatalog | null, + routedModels: Awaited>, +): RawCatalog { + const catalog = JSON.parse(JSON.stringify(source.catalog)) as RawCatalog; + const template = findNativeTemplate(catalog); + const enabled = filterCatalogVisibleModels(routedModels, config); + const featured = config.subagentModels ?? []; + const ordered = orderForSubagents(enabled, featured); + const multiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" + ? config.multiAgentMode : "default"; + const exactComboSlugs = exactComboCatalogSlugs(config); + const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE); + const enabledProviders = Object.entries(config.providers).filter(([, provider]) => provider.disabled !== true); + const includeNativeOpenAi = enabledProviders.length === 0 || enabledProviders.some(([name, provider]) => ( + name === "openai" && isCanonicalOpenAiForwardProvider(provider) + )); + const disabledNative = disabledNativeSlugs(config); + const nativeSlugs = includeNativeOpenAi + ? [...new Set((active?.models ?? catalog.models ?? []).flatMap(entry => ( + typeof entry.slug === "string" && !entry.slug.includes("/") && !disabledNative.has(entry.slug) + ? [entry.slug] : [] + )))] + : []; + const entries = buildCatalogEntries( + template ? JSON.parse(JSON.stringify(template)) : null, + nativeSlugs, ordered, featured, websocketsEnabled(config), multiAgentMode, exactComboSlugs, + ); + if (entries.length === nativeSlugs.length) { + const configuredProviders = new Set(enabledProviders.map(([name]) => name)); + const preserved = (active?.models ?? []).filter(entry => { + if (typeof entry.slug !== "string" || !entry.slug.includes("/")) return false; + const provider = entry.slug.slice(0, entry.slug.indexOf("/")); + const description = typeof entry.description === "string" ? entry.description : ""; + return configuredProviders.has(provider) || !description.startsWith("Routed via opencodex → "); + }); + entries.push(...preserved); + } + if (!hasPhysicalComboProvider) { + const exact = exactComboSlugs; + catalog.models = entries.filter(entry => ( + typeof entry.slug !== "string" || !exact.has(entry.slug) + || (Array.isArray(entry.input_modalities) && entry.input_modalities.length > 0) + )); + } else { + catalog.models = entries; + } + return catalog; +} + +export async function gatherCodexCatalogCandidate( + snapshot: CatalogAdmissionSnapshot, +): Promise { + let providerGatherStarted = false; + try { + const session = createCatalogGatherEvidenceSession(); + const home = captureAndSealCatalogHomeSelection(session); + if (!same(home, snapshot.sourceEvidence.homeSelection)) { + return { kind: "disposition", disposition: { status: "skipped", reason: "stale", retryable: true } }; + } + const paths = bindGatherPaths(session, snapshot); + const source = resolveCatalogSourceForGather(session); + if (source.kind === "catalog-unavailable") { + return { kind: "disposition", disposition: { status: "skipped", reason: "catalog-unavailable", retryable: false } }; + } + + const activeBytes = readCatalogGatherSource(session, "active-catalog-merge"); + const cacheBytes = readCatalogGatherSource(session, "models-cache-fallback"); + const keyedBackupBytes = readCatalogGatherSource(session, "hashed-backup-fallback"); + const legacyBackupBytes = paths.legacyBackup + ? readCatalogGatherSource(session, "legacy-backup-fallback") : null; + if (Object.keys(snapshot.config.combos ?? {}).length > 0) { + readCatalogGatherSource(session, "native-catalog-selection"); + } + const authOutcomes: CatalogGatherProviderAuthOutcome[] = []; + const discoveryPolicies: CatalogProviderDiscoveryPolicySnapshot[] = []; + providerGatherStarted = true; + const routedModels = await gatherRoutedModelsForCatalogGather(snapshot.config, session, { + providerAuthOutcomes: authOutcomes, + discoveryPolicySnapshots: discoveryPolicies, + }); + const processLocal = processEvidence(source); + const sourceEvidence = sealCatalogGatherEvidenceSession(session); + if (!same(sourceEvidence.required, snapshot.sourceEvidence.required)) { + return { kind: "disposition", disposition: { status: "skipped", reason: "stale", retryable: true } }; + } + + const current = captureCatalogAdmissionSnapshot(snapshot.config); + if (!same(current.configIdentity, snapshot.configIdentity) + || !same(current.targets, snapshot.targets) + || !same(current.sourceEvidence.homeSelection, snapshot.sourceEvidence.homeSelection) + || !same(current.sourceEvidence.required, snapshot.sourceEvidence.required)) { + return { kind: "disposition", disposition: { status: "skipped", reason: "stale", retryable: true } }; + } + + const active = catalogFrom(activeBytes); + const preparedCatalog = prepareCatalog(snapshot.config, source, active, routedModels); + const preparedCatalogBytes = catalogBytes(preparedCatalog); + const preparedCacheBytes = `${JSON.stringify({ + fetched_at: "2000-01-01T00:00:00Z", + client_version: "0.0.0", + models: preparedCatalog.models ?? [], + }, null, 2)}\n`; + const pristineBytes = active && !catalogHasRoutedEntries(active) + ? Buffer.from(activeBytes!).toString("utf8") + : !hasRoutedEntries(source.catalog) ? `${JSON.stringify(source.catalog, null, 2)}\n` : null; + const notices = new Set(); + if (source.source !== "bundled-catalog-template") notices.add("fallback"); + if (authOutcomes.some(outcome => outcome.state !== "available")) notices.add("provider-auth"); + const candidate = {} as CodexCatalogCandidate; + candidateStates.set(candidate, { + consumed: false, + generation: snapshot.generation, + authority: createCatalogGatherAuthorityIdentity( + snapshot, + sourceEvidence, + processLocal, + discoveryPolicies, + ), + sourceEvidence, + processLocal, + home: home.canonicalCodexHome, + targets: snapshot.targets, + catalog: { path: paths.catalog, content: preparedCatalogBytes }, + cache: { path: paths.cache, content: preparedCacheBytes }, + ...(pristineBytes ? { keyedBackup: { path: paths.keyedBackup, content: pristineBytes } } : {}), + ...(pristineBytes && paths.legacyBackup + ? { legacyBackup: { path: paths.legacyBackup, content: pristineBytes } } : {}), + changed: Buffer.from(activeBytes ?? []).toString("utf8") !== preparedCatalogBytes + || Buffer.from(cacheBytes ?? []).toString("utf8") !== preparedCacheBytes, + notices: Object.freeze([...notices]), + }); + return { kind: "candidate", candidate }; + } catch (error) { + if (error instanceof CatalogGatherBusyError) { + return { kind: "disposition", disposition: { status: "skipped", reason: "busy", retryable: true } }; + } + if (!providerGatherStarted) { + return { kind: "disposition", disposition: { status: "skipped", reason: "refused", retryable: false } }; + } + return { + kind: "disposition", + disposition: { status: "failed", reason: "provider-network", phase: "gather", retryable: true, partialWrite: false }, + }; + } +} + +function revalidateCandidate(state: CandidateState): CodexCatalogCommitResult | null { + let session: CatalogFilesystemEvidenceSession; + let validatingTargets = false; + try { + session = createCatalogGatherEvidenceSession(); + const home = captureAndSealCatalogHomeSelection(session); + if (!same(home, state.sourceEvidence.homeSelection)) return { kind: "stale", reason: "home-selection" }; + validatingTargets = true; + const currentTargets = { + catalog: captureCatalogGatherTargetIdentity(session, state.catalog.path), + cache: captureCatalogGatherTargetIdentity(session, state.cache.path), + catalogBackups: state.targets.catalogBackups.map(identity => ( + captureCatalogGatherTargetIdentity(session, targetPath(identity)) + )), + }; + if (!same(currentTargets, state.targets)) return { kind: "stale", reason: "target-identity" }; + validatingTargets = false; + for (const observation of [state.sourceEvidence.required["catalog-target-selection"]]) { + acceptCatalogGatherSourcePath(session, observation.role, observation.logicalPath); + readCatalogGatherSource(session, observation.role); + } + for (const [role, observations] of Object.entries(state.sourceEvidence.conditional)) { + for (const observation of observations) { + acceptCatalogGatherSourcePath(session, role as CatalogSourceRole, observation.logicalPath); + readCatalogGatherSource(session, role as CatalogSourceRole); + } + } + if (!same(sealCatalogGatherEvidenceSession(session), state.sourceEvidence)) { + return { kind: "stale", reason: "source-observation" }; + } + } catch { + return { kind: "refused", reason: validatingTargets ? "target-unsafe" : "source-unreadable" }; + } + + if (state.processLocal.runtime.state === "used") { + const current = peekCodexRuntimeProcessCache(); + if (current.kind !== "available" || current.epoch !== state.processLocal.runtime.epoch + || current.valueIdentity !== state.processLocal.runtime.valueIdentity) { + return { kind: "stale", reason: "process-local" }; + } + } + if (state.processLocal.bundledCatalog.state === "used") { + const current = bundledCatalogCacheState(); + if (current.epoch !== state.processLocal.bundledCatalog.epoch + || current.valueIdentity !== state.processLocal.bundledCatalog.valueIdentity) { + return { kind: "stale", reason: "process-local" }; + } + } + return null; +} + +function fixedCommit(state: CandidateState, permit: Parameters[0]): CodexCatalogCommitResult { + let writes: CatalogWriteReceipt = { + keyedBackup: "not-requested", + legacyBackup: "not-requested", + catalog: "not-written", + cache: "not-written", + }; + try { + if (state.keyedBackup) { + writes = { ...writes, keyedBackup: publishHashedCodexCatalogBackup(permit, state.home, state.keyedBackup) }; + } + if (state.legacyBackup) { + writes = { ...writes, legacyBackup: publishLegacyCodexCatalogBackup(permit, state.home, state.legacyBackup) }; + } + replaceActiveCodexCatalog(permit, state.home, state.catalog); + writes = { ...writes, catalog: "written" }; + replaceCodexModelsCache(permit, state.home, state.cache); + writes = { ...writes, cache: "written" }; + return { kind: "committed", changed: state.changed, writes }; + } catch { + return { kind: "failed", surface: "disk", writes }; + } +} + +export async function commitCodexCatalogCandidate( + candidate: CodexCatalogCandidate, + deadlineMs: number, +): Promise { + const state = candidateStates.get(candidate as object); + if (!state) return { kind: "refused", reason: "source-ambiguous" }; + if (state.consumed) return { kind: "stale", reason: "candidate-consumed" }; + const deadline = Date.now() + Math.max(0, deadlineMs); + while (true) { + const acquired = withCatalogWriteSerialization(state.home, permit => { + state.consumed = true; + const guarded = withExpectedConfigGenerationSync(state.generation, () => { + const invalid = revalidateCandidate(state); + return invalid ?? fixedCommit(state, permit); + }); + if (guarded.kind === "conflict") return { kind: "stale", reason: "generation" } as const; + if (guarded.kind === "unavailable") return { kind: "busy" } as const; + return guarded.value; + }); + if (acquired.kind === "completed") return acquired.value; + if (acquired.reason !== "busy" || Date.now() >= deadline) return { kind: "busy" }; + await Bun.sleep(Math.min(10, Math.max(1, deadline - Date.now()))); + } +} + +function projectCommit(result: CommitAttempt, notices: readonly CatalogNotice[]): CatalogDisposition { + if (result.kind === "busy") return { status: "skipped", reason: "busy", retryable: true }; + if (result.kind === "committed") { + return { status: "committed", changed: result.changed, degraded: notices.length > 0, notices }; + } + if (result.kind === "stale") return { status: "skipped", reason: "stale", retryable: true }; + if (result.kind === "refused") return { status: "skipped", reason: "refused", retryable: false }; + const partialWrite = result.writes.keyedBackup === "written" + || result.writes.legacyBackup === "written" || result.writes.catalog === "written"; + return { status: "failed", reason: "disk", phase: "commit", retryable: false, partialWrite }; +} + +export async function convergeCodexCatalog( + snapshot: CatalogAdmissionSnapshot, + request: ConvergeRequest, + lifecycle: Readonly<{ onCommitBegin?: () => void }> = {}, +): Promise> { + if (request.scope !== "catalog" || request.action !== "converge") { + return { + changed: false, + catalogRefresh: { status: "failed", reason: "disk", phase: "gather", retryable: false, partialWrite: false }, + }; + } + const gathered = await gatherCodexCatalogCandidate(snapshot); + if (gathered.kind === "disposition") return { changed: false, catalogRefresh: gathered.disposition }; + const state = candidateStates.get(gathered.candidate as object)!; + lifecycle.onCommitBegin?.(); + const committed = await commitCodexCatalogCandidate(gathered.candidate, request.deadlineMs); + return { + changed: committed.kind === "committed" ? committed.changed : false, + catalogRefresh: projectCommit(committed, state.notices), + }; +} diff --git a/src/codex/desired-state.ts b/src/codex/desired-state.ts new file mode 100644 index 000000000..5999d4873 --- /dev/null +++ b/src/codex/desired-state.ts @@ -0,0 +1,177 @@ +/** + * Durable desired state for the native Codex integration. + * + * The switch itself was never the hard part — `ocx restore` already returns Codex + * to its native path without stopping the proxy. What was missing is that the + * decision did not survive: `ocx start` force-synced unconditionally, so an OFF + * lasted exactly until the next start. That is the defect this module closes, and + * it is the same one Grok's shipped toggle still has. + * + * ABSENT MEANS ON. A user who never touched a switch, a config written by an + * older binary, and an explicit `true` are the same state, and none of them may + * be read as "the user turned this off". Only an explicit `false` is OFF. + * + * This module does NOT own linearization. The plan that predates + * `src/codex/user-identity.ts` proposed a second per-home lock at + * `tmpdir()/opencodex-native-locks/sha256(home).sqlite`; that keys on the home + * alone, carries no proof of effective user, and would collide or split + * depending on the temp root. Convergence takes the write lock; this module only + * records intent through the config coordinator. + * + * Design record: devlog/_plan/260803_codex_desktop_toggle/030_desired_state.md. + */ +import { loadConfig, mutatePersistedConfig } from "../config"; +import type { OcxClientIntegrationsConfig, OcxConfig } from "../types"; + +/** Clients whose durable intent this module owns. */ +export type DurableIntentClientId = keyof OcxClientIntegrationsConfig; + +/** Injectable for tests; production passes the real sync. */ +export type CodexStartupSync = (port: number) => Promise; + +export type CodexDesiredStateResult = + | { readonly ok: true; readonly status: "committed" | "unchanged"; readonly enabled: boolean } + | { + readonly ok: false; + readonly reason: "missing" | "invalid" | "conflict"; + readonly retryable: boolean; + readonly message: string; + }; + +/** + * Is native Codex integration wanted? + * + * Takes the config rather than reading it, so a caller that already holds an + * admitted snapshot cannot accidentally answer from a fresher one — the whole + * point of admission is that one decision uses one set of bytes. + */ +export function integrationEnabled( + config: Pick, + client: DurableIntentClientId, +): boolean { + return config.clientIntegrations?.[client] !== false; +} + +export function codexIntegrationEnabled(config: Pick): boolean { + return integrationEnabled(config, "codex"); +} + +/** + * Grok's toggle SHIPPED without this, which is the bug: it strips the fence in + * `~/.grok/config.toml` and records nothing, so the next `ocx start` calls + * `syncGrokConfig` unconditionally and writes the fence straight back. + */ +export function grokIntegrationEnabled(config: Pick): boolean { + return integrationEnabled(config, "grok"); +} + +/** The same question when no snapshot is in hand. Reads the persisted config. */ +export function codexIntegrationEnabledNow(): boolean { + return codexIntegrationEnabled(loadConfig()); +} + +/** + * Persist the desired state, touching only that one field. + * + * Field-scoped on purpose: the callback runs on a freshly rebased snapshot inside + * the config coordinator, so a concurrent provider or model edit is preserved + * instead of being clobbered by a whole-config write built from a stale read. + */ +export function setIntegrationEnabled( + client: DurableIntentClientId, + enabled: boolean, +): CodexDesiredStateResult { + const outcome = mutatePersistedConfig(config => { + const current = integrationEnabled(config, client); + if (current === enabled) return { changed: false, value: enabled }; + const integrations = { ...(config.clientIntegrations ?? {}) }; + if (enabled) { + // ON is the absence, not a stored `true`: writing `true` would make an + // untouched config and a re-enabled one differ in bytes for no reason, and + // every later reader has to treat them identically anyway. + delete integrations[client]; + } else { + integrations[client] = false; + } + // Drop the key entirely once nothing is left in it, so enabling twice does + // not leave `"clientIntegrations": {}` behind in the user's file. + if (Object.keys(integrations).length === 0) delete config.clientIntegrations; + else config.clientIntegrations = integrations; + return { changed: true, value: enabled }; + }); + + if (outcome.status !== "unavailable") { + return { ok: true, status: outcome.status, enabled }; + } + // `conflict` is the only retryable one: it means a competing writer won the + // rebase, so the same call can succeed. A missing or malformed config will fail + // identically forever, and telling a caller to retry that is how a UI ends up + // spinning on a problem only the user can fix. + const retryable = outcome.reason === "conflict"; + return { + ok: false, + reason: outcome.reason, + retryable, + message: outcome.reason === "conflict" + ? "Another process changed the config while this switch was being written." + : outcome.reason === "missing" + ? "No config file exists to record the switch in." + : "The config file is malformed; refusing to overwrite it.", + }; +} + +export function setCodexIntegrationEnabled(enabled: boolean): CodexDesiredStateResult { + return setIntegrationEnabled("codex", enabled); +} + +export function setGrokIntegrationEnabled(enabled: boolean): CodexDesiredStateResult { + return setIntegrationEnabled("grok", enabled); +} + +/** + * The startup gate, as a function rather than an `if` buried in `handleStart`. + * + * `ocx start` used to call `syncModelsToCodex(port).catch(() => {})` + * unconditionally, which is exactly why turning Codex off lasted until the next + * start: the restore worked, and then start put the routing straight back. It + * lived inline in a 600-line startup function that opens sockets and installs + * services, so nothing could test it — and an untestable gate is how the + * unconditional version survived this long. + * + * The `.catch` is deliberate and stays: a provider fetch failing at startup must + * not stop the proxy from coming up. Swallowing a failure to APPLY is tolerable. + * Swallowing the user's decision was not. + * + * Returns whether the sync ran, so a caller — or a test — can tell "skipped + * because the user turned it off" from "ran and quietly failed". + */ +export async function syncCodexOnStartIfEnabled( + port: number, + config: Pick, + sync: CodexStartupSync = defaultStartupSync, +): Promise { + if (!codexIntegrationEnabled(config)) return false; + await sync(port).catch(() => {}); + return true; +} + +async function defaultStartupSync(port: number): Promise { + const { syncModelsToCodex } = await import("./sync"); + return syncModelsToCodex(port); +} + +/** + * The Grok startup gate. + * + * Grok's toggle shipped and then `ocx start` called `syncGrokConfig` + * unconditionally, so switching Grok off lasted exactly one restart — the fence + * came out of `~/.grok/config.toml` and the next start wrote it straight back. + * Same defect as Codex had, in a different file. + * + * The caller keeps its own try/catch, because a Grok failure must never block + * startup and its diagnostic is worth printing. This only answers whether to + * attempt the sync at all. + */ +export function shouldSyncGrokOnStart(config: Pick): boolean { + return grokIntegrationEnabled(config); +} diff --git a/src/codex/generation.ts b/src/codex/generation.ts new file mode 100644 index 000000000..0e03da480 --- /dev/null +++ b/src/codex/generation.ts @@ -0,0 +1,202 @@ +/** + * Durable generation ownership for OpenCodex config bytes. + * + * The WP8b C-phase review found that config ABA and moved configuration could + * not be detected because cooperating saves had no durable counter. This module + * owns the singleton schema and conditional increment in the existing config + * mutation database; callers that already hold its transaction reuse that + * Database handle so bytes and generation remain one cooperating commit. + * + * A hand edit from a non-cooperating writer deliberately does not increment this + * counter. The convergence contract detects that case with its post-commit file + * observation instead of pretending SQLite can coordinate an external editor. + */ +import { chmodSync, statSync } from "node:fs"; + +import { Database } from "bun:sqlite"; + +import type { + ConfigGeneration, + ConfigGenerationBump, + ConfigGenerationRead, +} from "./convergence-types"; + +const CREATE_CONFIG_GENERATION = ` + CREATE TABLE IF NOT EXISTS config_generation ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + value INTEGER NOT NULL CHECK (value >= 0) + )`; +const INITIALIZE_CONFIG_GENERATION = ` + INSERT OR IGNORE INTO config_generation (singleton, value) VALUES (1, 0)`; +const SELECT_CONFIG_GENERATION = ` + SELECT value FROM config_generation WHERE singleton = 1`; +const BUMP_CONFIG_GENERATION = ` + UPDATE config_generation + SET value = value + 1 + WHERE singleton = 1 AND value = ?`; + +interface ConfigGenerationRow { + value: unknown; +} + +interface SchemaVersionRow { + schema_version: unknown; +} + +/** + * Observation adds exactly one variant to `ConfigGenerationRead`: `absent`. + * + * This used to be deliberately absent itself, on the reasoning that a caller + * who may only LOOK must not be handed something it could mistake for a + * known-good baseline of zero. That reasoning still holds, and `absent` does + * not violate it — because `absent` is not a baseline. It authorizes nothing on + * its own. A caller may only promote it after taking the config transaction and + * reading a real zero THERE (`readConfigGenerationInCurrentMutationTransaction`), + * at which point the zero is observed rather than assumed. A caller with no + * transaction to open, such as catalog gather, must keep refusing it. + * + * What forced the distinction: refusing on absence meant refusing every Codex + * write on any home whose config predates this database — a permanent refusal + * for existing users, not a fixture problem. + * + * `absent` is returned ONLY for ENOENT on the initial `statSync`. A file that + * exists but cannot be read, a directory in its place, a bad schema version, or + * corrupt SQLite all stay `unavailable`, because those are reasons to stop, and + * collapsing them into absence is how a corrupt coordinator would become a + * licence to write. + */ +export type ConfigGenerationObservation = + | ConfigGenerationRead + | { kind: "absent" }; + +function errorCode(error: unknown): string { + return error && typeof error === "object" && "code" in error + ? String((error as { code?: unknown }).code) + : ""; +} + +function isBusy(error: unknown): boolean { + const code = errorCode(error); + const message = error instanceof Error ? error.message : ""; + return code === "SQLITE_BUSY" + || code === "SQLITE_LOCKED" + || /database (?:is|table is) locked/i.test(message); +} + +function unavailable(error: unknown): Extract { + return { kind: "unavailable", reason: isBusy(error) ? "busy" : "database" }; +} + +export function initializeConfigGeneration(database: Database): void { + database.exec(CREATE_CONFIG_GENERATION); + database.exec(INITIALIZE_CONFIG_GENERATION); +} + +export function readConfigGenerationInTransaction(database: Database): ConfigGeneration { + const row = database.query(SELECT_CONFIG_GENERATION).get(); + if (!row || !Number.isSafeInteger(row.value) || Number(row.value) < 0) { + throw new Error("The config generation singleton is missing or invalid."); + } + return { value: Number(row.value) }; +} + +export function bumpConfigGenerationInTransaction( + database: Database, + expected: ConfigGeneration, +): ConfigGenerationBump { + const result = database.query(BUMP_CONFIG_GENERATION).run(expected.value); + if (result.changes === 1) { + return { kind: "updated", generation: { value: expected.value + 1 } }; + } + return { kind: "conflict", current: readConfigGenerationInTransaction(database) }; +} + +export function bumpCurrentConfigGeneration(database: Database): ConfigGeneration { + const current = readConfigGenerationInTransaction(database); + const result = bumpConfigGenerationInTransaction(database, current); + if (result.kind !== "updated") { + throw new Error("The config generation changed inside its owning transaction."); + } + return result.generation; +} + +function runGenerationTransaction(databasePath: string, operation: (database: Database) => T): T { + let database: Database | undefined; + let transactionOpen = false; + try { + database = new Database(databasePath, { create: true }); + try { chmodSync(databasePath, 0o600); } catch { /* platform may ignore chmod */ } + database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + transactionOpen = true; + initializeConfigGeneration(database); + const result = operation(database); + database.exec("COMMIT"); + transactionOpen = false; + return result; + } catch (error) { + if (transactionOpen) { + try { database?.exec("ROLLBACK"); } catch { /* close releases the transaction */ } + } + throw error; + } finally { + try { database?.close(); } catch { /* operation already completed */ } + } +} + +export function readConfigGenerationAtPath(databasePath: string): ConfigGenerationRead { + try { + return { + kind: "ready", + generation: runGenerationTransaction(databasePath, readConfigGenerationInTransaction), + }; + } catch (error) { + return unavailable(error); + } +} + +/** + * Observe generation state without preparing the mutation database in any way. + * Missing storage is a first-class state: only cooperating config writes have + * authority to create and initialize the generation singleton. + */ +export function observeConfigGenerationAtPath( + databasePath: string, +): ConfigGenerationObservation { + try { + statSync(databasePath); + } catch (error) { + // ENOENT alone means absent. Everything else — EACCES, ENOTDIR, EIO — is a + // reason the question could not be answered, which is not the same answer. + return errorCode(error) === "ENOENT" ? { kind: "absent" } : unavailable(error); + } + + let database: Database | undefined; + try { + database = new Database(databasePath, { readonly: true }); + const schema = database.query("PRAGMA schema_version").get(); + if (!schema || !Number.isSafeInteger(schema.schema_version)) { + throw new Error("The config generation schema version is invalid."); + } + return { + kind: "ready", + generation: readConfigGenerationInTransaction(database), + }; + } catch (error) { + return unavailable(error); + } finally { + try { database?.close(); } catch { /* observation already completed */ } + } +} + +export function bumpConfigGenerationAtPath( + databasePath: string, + expected: ConfigGeneration, +): ConfigGenerationBump { + try { + return runGenerationTransaction(databasePath, database => ( + bumpConfigGenerationInTransaction(database, expected) + )); + } catch (error) { + return unavailable(error); + } +} diff --git a/src/codex/history-job.ts b/src/codex/history-job.ts new file mode 100644 index 000000000..06bb429f7 --- /dev/null +++ b/src/codex/history-job.ts @@ -0,0 +1,257 @@ +/** + * The parent half of the history unit: derive the operation, dispatch the + * Worker, and never let its failure become the caller's stall. + * + * The operation is DERIVED here from what the caller already decided — its + * config and the direction its native mutation just took — and then handed down + * as a fixed value. It is not a request field the Worker trusts, because the + * distinctions are real: `syncResumeHistory: false` means leave history alone, + * apply targets opencodex only in legacy mode, and legacy recovery must not + * touch the manifest that generic restore consumes. + * + * Every exit is typed. A Worker that errors, dies, or overruns its watchdog + * produces an outcome the caller can record, because the alternative — an + * exception crossing back into a route that already persisted its mutation — is + * how a successful change gets reported as a 500. + * + * Design record: devlog/_plan/260804_codex_write_substrate/020_history_isolation.md. + */ +import { randomUUID } from "node:crypto"; +import { join } from "node:path"; + +import type { + CodexHistoryWorkerOperation, + HistoryWorkerResult, +} from "./history-worker"; +import { historyBackupPathFor } from "./history-provider"; +import { getCodexHome } from "./paths"; + +/** Where Codex keeps its resume history, and the manifest that shadows it. */ +const STATE_DB_FILE = "state_5.sqlite"; + +/** + * Resolve the paths a history job needs, at CALL time. + * + * `history-provider.ts` resolves its equivalents at module load (`:16`, `:22`), + * which is fine in one process and wrong for a Worker: the Worker does not + * inherit them, so anything derived from those constants would address a + * different home than the caller intended. Resolving here also means a test that + * moves `CODEX_HOME` is honoured rather than ignored. + */ +export function resolveCodexHistoryJobTarget(): { + readonly canonicalCodexHome: string; + readonly canonicalStateDbPath: string; + readonly canonicalBackupPath: string; +} { + const home = getCodexHome(); + const stateDb = join(home, STATE_DB_FILE); + return { + canonicalCodexHome: home, + canonicalStateDbPath: stateDb, + // Derived by the provider's own rule rather than guessed: the manifest lives + // in the config directory under a hash of the state database, so a + // hand-built path would address a different file entirely. + canonicalBackupPath: historyBackupPathFor(stateDb), + }; +} + +/** How long a history unit may run before the parent stops waiting on it. */ +const WORKER_TIMEOUT_MS = 30_000; + +export interface CodexHistoryJobRequest { + readonly canonicalCodexHome: string; + readonly canonicalStateDbPath: string; + readonly canonicalBackupPath: string; + readonly operation: CodexHistoryWorkerOperation; +} + +export type CodexHistoryJobOutcome = + | { readonly kind: "converged"; readonly rows: number; readonly files: number } + | { readonly kind: "skipped" } + | { readonly kind: "blocked"; readonly reason: "busy" | "database" | "unsafe-path" } + | { readonly kind: "failed"; readonly reason: "worker-error" | "worker-died" | "timeout"; + readonly message: string }; + +/** + * Derive the durable history operation from admitted intent. + * + * `resumeHistory === false` is the user's explicit opt-out and outranks the + * direction entirely — an apply that quietly migrated history anyway would be + * the setting failing silently. `legacyMode` is the only case that routes + * history TO opencodex; the ordinary apply migrates to native so a later restore + * has nothing to undo. + */ +/** + * Test seam: the boundary suite exercises the validator without standing up a + * Worker whose malformed message it cannot easily emit from inside a test. + */ +export function isPlausibleWorkerResultForTests( + message: Record, + requestId: string, + jobId: string, +): boolean { + return isPlausibleWorkerResult(message, requestId, jobId); +} + +/** + * Reject a message that names a recognized type but lacks its payload. + * + * Without this, `{requestId, type:"done"}` reached an unchecked cast and read + * as `converged` with undefined fields — a success report for work that may not + * have happened. Each type is checked for the fields it actually carries, and + * the ids are matched against the request in flight, not merely present. + */ +function isPlausibleWorkerResult( + message: Record, + requestId: string, + jobId: string, +): boolean { + if (message.requestId !== requestId || message.jobId !== jobId) return false; + switch (message.type) { + case "done": + return (message.outcome === "converged" || message.outcome === "skipped") + && typeof message.rows === "number" + && typeof message.files === "number"; + case "blocked": + return message.reason === "busy" || message.reason === "database" || message.reason === "unsafe-path"; + case "error": + return typeof message.message === "string"; + default: + return false; + } +} + +export function deriveCodexHistoryOperation(intent: { + readonly direction: "apply" | "restore"; + readonly resumeHistory: boolean; + readonly legacyMode: boolean; +}): CodexHistoryWorkerOperation { + if (!intent.resumeHistory) return "skip"; + if (intent.direction === "restore") return "restore-openai"; + return intent.legacyMode ? "apply-opencodex" : "migrate-openai"; +} + +function classifyWorkerResult(result: HistoryWorkerResult): CodexHistoryJobOutcome { + if (result.type === "blocked") return { kind: "blocked", reason: result.reason }; + if (result.type === "error") { + return { kind: "failed", reason: "worker-error", message: result.message }; + } + return result.outcome === "skipped" + ? { kind: "skipped" } + : { kind: "converged", rows: result.rows, files: result.files }; +} + +/** + * Run one history unit in a Worker and join it before returning. + * + * The join is not optional politeness: returning while the thread may still be + * mutating CODEX_HOME would let a caller's next step observe a half-applied + * transition, and would let a test suite reach its next file with a worker still + * exiting behind it. + */ +export async function runCodexHistoryJob( + request: CodexHistoryJobRequest, + options: { readonly timeoutMs?: number } = {}, +): Promise { + const requestId = randomUUID(); + const jobId = randomUUID(); + const timeoutMs = options.timeoutMs ?? WORKER_TIMEOUT_MS; + + // `skip` writes nothing, so spawning a thread to decide that would be pure + // cost. It still returns a recorded outcome rather than silence. + if (request.operation === "skip") return { kind: "skipped" }; + + let worker: Worker; + try { + worker = new Worker(new URL("./history-worker.ts", import.meta.url).href); + } catch (error) { + return { + kind: "failed", + reason: "worker-died", + message: error instanceof Error ? error.message : "history_worker_spawn_failed", + }; + } + + return new Promise(resolve => { + let settled = false; + const finish = (outcome: CodexHistoryJobOutcome) => { + if (settled) return; + settled = true; + clearTimeout(timer); + // Terminate and join before settling: see the note above. + const done = () => resolve(outcome); + try { + const terminated = worker.terminate() as unknown; + if (terminated && typeof (terminated as Promise).then === "function") { + void (terminated as Promise).then(done, done); + return; + } + } catch { /* already gone */ } + done(); + }; + + const timer = setTimeout(() => { + finish({ kind: "failed", reason: "timeout", message: "history_worker_timeout" }); + }, timeoutMs); + + const died = (detail: string): void => { + finish({ kind: "failed", reason: "worker-died", message: detail }); + }; + + worker.onmessage = (event: MessageEvent) => { + const data = event.data; + if (!data || typeof data !== "object") { + // Not the shape at all: this is a death signal, not silence. Ignoring it + // would let the watchdog call a dead Worker a timeout. + died("history_worker_malformed_message"); + return; + } + const message = data as Record; + // A reply for a different request is somebody else's; ignoring it is not + // the same as accepting it. + if (message.requestId !== requestId) return; + if (message.type !== "done" && message.type !== "blocked" && message.type !== "error") { + died("history_worker_unknown_message_type"); + return; + } + if (!isPlausibleWorkerResult(message, requestId, jobId)) { + // A recognized type with a missing payload read as `converged` with + // undefined fields once — success for work that may not have happened. + died("history_worker_malformed_payload"); + return; + } + finish(classifyWorkerResult(message as unknown as HistoryWorkerResult)); + }; + + /* + * A Worker that exits early — without erroring — is not an error event, so + * without these it surfaced as `timeout` after the full wait. Both are death + * signals. Neither can overturn a settled success: `finish` is idempotent, + * and the Worker always closes after posting its result. + */ + worker.addEventListener("messageerror", () => died("history_worker_unserializable_message")); + worker.addEventListener("close", () => died("history_worker_closed_early")); + + worker.onerror = (event: ErrorEvent) => { + finish({ + kind: "failed", + reason: "worker-died", + message: event.message || "history_worker_failed", + }); + }; + + worker.postMessage({ + type: "run", + requestId, + jobId, + operation: request.operation, + canonicalCodexHome: request.canonicalCodexHome, + canonicalStateDbPath: request.canonicalStateDbPath, + canonicalBackupPath: request.canonicalBackupPath, + env: { + ...(process.env.CODEX_HOME ? { CODEX_HOME: process.env.CODEX_HOME } : {}), + ...(process.env.OPENCODEX_HOME ? { OPENCODEX_HOME: process.env.OPENCODEX_HOME } : {}), + }, + }); + }); +} diff --git a/src/codex/history-lock.ts b/src/codex/history-lock.ts new file mode 100644 index 000000000..3352eea52 --- /dev/null +++ b/src/codex/history-lock.ts @@ -0,0 +1,241 @@ +/** + * H — the cross-process history serialization primitive. + * + * Today a native apply or restore performs SQLite rows, the backup manifest and + * every rollout file on the caller thread, and the manifest and rollout writes + * sit OUTSIDE the provider's SQLite transaction + * (`src/codex/history-provider.ts:606-648,656-698`). A SQLite busy timeout + * therefore serializes one third of a state transition and explains one stall. + * H is held across the whole unit — DB, manifest and rollouts together — so an + * opposite-direction process cannot overtake through the unguarded files. + * + * Two ways H differs from K, both forced by review: + * + * `H -> N` is a real edge, not a violation. The Worker reads the coordinator row + * and writes its terminal state while holding H, and both take BEGIN IMMEDIATE + * on N. That is why H must be its own database: sharing N's would make the + * Worker contend with itself. `N -> H`, `K -> H` and `C -> H` are forbidden, and + * the resulting `H -> N -> K -> C` order was checked acyclic in review round 3. + * + * H is keyed by the canonical state database as well as the canonical home, + * because one CODEX_HOME can name a different `state_5.sqlite` and those are not + * the same exclusion (`src/codex/user-identity.ts`). + * + * The permit is a runtime registration, not a type. An opaque TypeScript brand + * proves a permit-bearing call path exists; it cannot prove the callback still + * holds the lock, and a permit leaked past its callback type-checks perfectly. + * So every history mutator asks this module at runtime whether the permit it was + * handed is still live for the state database it is about to write. + * + * Design record: devlog/_plan/260804_codex_write_substrate/005_contract.md §6. + */ +import { chmodSync, lstatSync, realpathSync } from "node:fs"; + +import { Database } from "bun:sqlite"; + +import { + CodexUserIdentityRefusal, + resolveCodexHistorySerializationDatabasePath, + resolveEffectiveUserIdentity, +} from "./user-identity"; + +/** + * Authorization to perform history mutations inside ONE H acquisition. + * + * Deliberately carries no usable field. Holding this object is necessary and + * never sufficient: `assertHistoryWritePermit` decides, by looking the object up + * in a registry only this module can write. A forged cast, a prototype copy or a + * symbol clone produces a value of this type that every writer refuses. + */ +export interface HistoryWritePermit { + readonly [historyWritePermitBrand]: true; +} + +declare const historyWritePermitBrand: unique symbol; + +export type HistorySerializationOutcome = + | { kind: "completed"; value: T } + | { kind: "unavailable"; reason: "busy" | "database" | "unsafe-path" }; + +export class HistoryWritePermitRefusal extends Error { + readonly code = "CODEX_HISTORY_WRITE_PERMIT_REFUSED"; + + constructor(message: string) { + super(message); + this.name = "HistoryWritePermitRefusal"; + } +} + +interface PermitRegistration { + readonly canonicalStateDbPath: string; + readonly transactionId: string; + live: boolean; +} + +/** + * Registrations are marked dead, never deleted. + * + * Deleting would make a leaked permit indistinguishable from a forged one, and + * those are different bugs: "used after its acquisition released" points at a + * caller that kept a permit past its callback, while "never minted here" points + * at a cast or a clone. Both are refused; only the diagnosis differs. The map is + * weak, so a dead entry costs nothing once the permit is unreachable. + */ +const activePermits = new WeakMap(); +let acquisitionCounter = 0; + +function isBusy(error: unknown): boolean { + const code = error && typeof error === "object" && "code" in error + ? String((error as { code?: unknown }).code) + : ""; + const message = error instanceof Error ? error.message : String(error); + return code === "SQLITE_BUSY" || code === "SQLITE_LOCKED" + || /database (?:is|table is) locked/i.test(message); +} + +/** + * Refuse unless this exact permit is live for this exact state database. + * + * Every history mutator calls this BEFORE touching the database, the manifest or + * a rollout file. The state database is named by the caller rather than derived, + * because a rollout path can point anywhere the manifest recorded. + */ +export function assertHistoryWritePermit( + permit: HistoryWritePermit, + canonicalStateDbPath: string, +): void { + const registration = activePermits.get(permit as unknown as object); + if (!registration) { + throw new HistoryWritePermitRefusal( + "The history write permit was not minted by the serialization owner.", + ); + } + if (!registration.live) { + throw new HistoryWritePermitRefusal( + "The history write permit belongs to a released acquisition.", + ); + } + if (registration.canonicalStateDbPath !== canonicalStateDbPath) { + throw new HistoryWritePermitRefusal( + "The history write permit authorizes a different Codex state database.", + ); + } +} + +/** + * Acquire H for one canonical state database and run `work` while it is held. + * + * The callback may enter N — that is the `H -> N` edge the Worker needs to read + * the coordinator row and publish its terminal state. It must never re-enter H. + * + * `busy_timeout = 0` with `BEGIN IMMEDIATE` makes contention fail fast and + * typed. Blocking here would hold the acquisition across an unbounded wait, + * which is the stall this phase exists to remove. + */ +export function withHistoryWriteSerialization( + canonicalCodexHome: string, + canonicalStateDbPath: string, + work: (permit: HistoryWritePermit) => T, +): HistorySerializationOutcome { + let databasePath: string; + try { + databasePath = resolveCodexHistorySerializationDatabasePath( + resolveEffectiveUserIdentity(), + canonicalCodexHome, + canonicalStateDbPath, + ); + } catch (error) { + if (error instanceof CodexUserIdentityRefusal) { + return { kind: "unavailable", reason: "unsafe-path" }; + } + return { kind: "unavailable", reason: "database" }; + } + + let database: Database | undefined; + let transactionOpen = false; + let registration: PermitRegistration | undefined; + let permit: HistoryWritePermit | undefined; + + try { + let databaseWasAbsent = false; + try { + const before = lstatSync(databasePath); + if (before.isSymbolicLink() || !before.isFile()) { + return { kind: "unavailable", reason: "unsafe-path" }; + } + if (process.platform !== "win32") { + const uid = process.getuid?.(); + if (uid === undefined || before.uid !== uid || (before.mode & 0o777) !== 0o600) { + return { kind: "unavailable", reason: "unsafe-path" }; + } + } + } catch (cause) { + const code = cause && typeof cause === "object" && "code" in cause + ? String((cause as { code?: unknown }).code) + : ""; + if (code !== "ENOENT") throw cause; + databaseWasAbsent = true; + } + + database = new Database(databasePath, { create: true }); + if (databaseWasAbsent) { + try { chmodSync(databasePath, 0o600); } catch { /* Windows applies ACLs in WP11. */ } + } + const opened = lstatSync(databasePath); + if (opened.isSymbolicLink() || !opened.isFile() + || realpathSync.native(databasePath) !== databasePath) { + return { kind: "unavailable", reason: "unsafe-path" }; + } + + database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + transactionOpen = true; + + acquisitionCounter += 1; + registration = { + canonicalStateDbPath, + transactionId: `${process.pid}:${acquisitionCounter}`, + live: true, + }; + // A bare object: nothing about it is guessable or reconstructable, because + // the authority lives in the registry entry rather than in the value. + permit = {} as HistoryWritePermit; + activePermits.set(permit as unknown as object, registration); + + let value: T; + try { + value = work(permit); + } finally { + // Revoke BEFORE releasing the transaction, so a mutator racing the release + // can never find a live permit without a live lock. This runs on the + // throwing path too, which is what a `finally`-less version gets wrong. + registration.live = false; + } + + database.exec("COMMIT"); + transactionOpen = false; + return { kind: "completed", value }; + } catch (error) { + if (transactionOpen) { + try { database?.exec("ROLLBACK"); } catch { /* close releases the transaction */ } + transactionOpen = false; + } + if (registration?.live) { + registration.live = false; + } + if (error instanceof CodexUserIdentityRefusal) { + return { kind: "unavailable", reason: "unsafe-path" }; + } + if (isBusy(error)) return { kind: "unavailable", reason: "busy" }; + // A callback failure is the caller's error, not a lock outcome: H acquired + // fine. Reporting it as `unavailable` would tell the caller to retry + // something that fails identically. + throw error; + } finally { + try { database?.close(); } catch { /* acquisition already finished */ } + } +} + +/** Test-only: prove a leaked permit is dead without reaching into the registry. */ +export function isHistoryWritePermitLive(permit: HistoryWritePermit): boolean { + return activePermits.get(permit as unknown as object)?.live === true; +} diff --git a/src/codex/history-migration-guardian.ts b/src/codex/history-migration-guardian.ts index 8145caeb7..e25ea97bf 100644 --- a/src/codex/history-migration-guardian.ts +++ b/src/codex/history-migration-guardian.ts @@ -1,4 +1,5 @@ import { countPendingOpencodexHistory, migrateHistoryToOpenai } from "./history-provider"; +import { resolveCodexHistoryJobTarget, runCodexHistoryJob } from "./history-job"; /** * Daemon-side retry for the one-time Design-B history migration. @@ -23,7 +24,8 @@ export interface HistoryMigrationGuardianHandle { export interface HistoryMigrationGuardianDeps { countFn?: typeof countPendingOpencodexHistory; - migrateFn?: () => ReturnType; + migrateFn?: () => ReturnType + | Promise>; log?: Pick; tickMs?: number; maxTicks?: number; @@ -42,7 +44,18 @@ function defaultSchedule(fn: () => void, ms: number): { cancel(): void } { export function startHistoryMigrationGuardian(deps: HistoryMigrationGuardianDeps = {}): HistoryMigrationGuardianHandle { const countFn = deps.countFn ?? countPendingOpencodexHistory; - const migrateFn = deps.migrateFn ?? (() => migrateHistoryToOpenai(undefined, undefined, { attempts: 1 })); + // The default migration goes through the history job, so the guardian's timer + // thread never performs the transition itself. A background repair that races + // an apply or a restore is exactly what H exists to order. + const migrateFn = deps.migrateFn ?? (async () => { + const outcome = await runCodexHistoryJob({ + ...resolveCodexHistoryJobTarget(), + operation: "migrate-openai", + }); + return outcome.kind === "converged" + ? { rows: outcome.rows, files: outcome.files } + : { rows: 0, files: 0, failed: true as const }; + }); const log = deps.log ?? console; const tickMs = deps.tickMs ?? DEFAULT_TICK_MS; const maxTicks = deps.maxTicks ?? DEFAULT_MAX_TICKS; @@ -56,7 +69,7 @@ export function startHistoryMigrationGuardian(deps: HistoryMigrationGuardianDeps pending = (deps.scheduleFn ?? defaultSchedule)(tick, tickMs); }; - const tick = () => { + const tick = async () => { if (stopped) return; ticks++; try { @@ -66,9 +79,9 @@ export function startHistoryMigrationGuardian(deps: HistoryMigrationGuardianDeps return; } // Locked probe or pending work: attempt one migration pass. - const result = migrateFn(); + const result = await migrateFn(); if (!result.failed) { - const moved = result.rows + (result.ejectedRows ?? 0); + const moved = result.rows + ((result as { ejectedRows?: number }).ejectedRows ?? 0); if (moved > 0) { log.log(`🩹 history-migration: ${moved} legacy opencodex thread(s) migrated back to openai.`); } diff --git a/src/codex/history-provider.ts b/src/codex/history-provider.ts index 4297b1b94..3916380cd 100644 --- a/src/codex/history-provider.ts +++ b/src/codex/history-provider.ts @@ -14,7 +14,14 @@ import { atomicWriteFile, getConfigDir } from "../config"; export const MAX_ROLLOUT_ZST_DECOMPRESSED_BYTES = 64 * 1024 * 1024; const STATE_DB_PATH = join(CODEX_HOME, "state_5.sqlite"); -function historyBackupPathFor(stateDbPath: string): string { +/** + * The manifest that shadows one state database. + * + * Exported because the history job must resolve it at CALL time for a Worker + * that does not inherit this module's load-time constants — and must resolve it + * the same way, since a manifest addressed differently is a different manifest. + */ +export function historyBackupPathFor(stateDbPath: string): string { const normalized = process.platform === "win32" ? resolve(stateDbPath).toLowerCase() : resolve(stateDbPath); const id = createHash("sha256").update(normalized).digest("hex").slice(0, 16); return join(getConfigDir(), `codex-history-backup-${id}.json`); @@ -157,7 +164,7 @@ function patchFirstLineProviderInPlace(path: string, expectedId: string, provide } } -type CodexHistoryProvider = "openai" | "opencodex"; +export type CodexHistoryProvider = "openai" | "opencodex"; export interface CodexHistorySyncResult { rows: number; diff --git a/src/codex/history-transition.ts b/src/codex/history-transition.ts new file mode 100644 index 000000000..1d96e9382 --- /dev/null +++ b/src/codex/history-transition.ts @@ -0,0 +1,105 @@ +/** + * Resolve the transition a history job belongs to. + * + * `updateCodexHistoryTransition` had no production caller, so every completed or + * skipped job left the coordinator row permanently `pending` — a transition was + * published and never resolved. This is that caller. + * + * The classification is `020_history_isolation.md:564-572`, applied rather than + * reinvented. The two unions do not line up one-to-one, so each outcome is + * mapped by hand: a cast across them would compile on the words they share and + * lie about the rest. + */ +import type { CodexHistoryJobOutcome } from "./history-job"; +import type { CodexHistoryState, CodexTransitionVersion } from "./convergence-types"; +import { updateCodexHistoryTransition } from "./transition-state"; + +/** The retry budget for a contended terminal CAS. Bounded; never unbounded. */ +const TERMINAL_RETRY_ATTEMPTS = 3; +const TERMINAL_RETRY_DELAY_MS = 250; + +function sleep(ms: number): void { + // The caller path here is synchronous after the awaited job, so a spin is the + // honest tool — awaiting would be, and this is short. + const until = Date.now() + ms; + while (Date.now() < until) { /* bounded */ } +} + +function classify(outcome: CodexHistoryJobOutcome, txId: string | null): CodexHistoryState { + switch (outcome.kind) { + case "converged": + return { + status: "converged", + attempts: 1, + nextRetryAt: null, + txId, + // Mutation counts, not probe counts. The durable counts come from the + // final probe, which this path does not run — null rather than a + // manufactured zero. + pendingRows: null, + backupEntries: null, + }; + case "skipped": + // The user opting out is a completed decision, not a failure — converged + // with no reason. Marking it blocked would retry what they asked not to do. + return { status: "converged", attempts: 0, nextRetryAt: null, txId, pendingRows: null, backupEntries: null }; + case "blocked": + return { + status: outcome.reason === "busy" ? "pending" : "blocked", + reason: outcome.reason === "busy" ? "db-busy" + : outcome.reason === "database" ? "unreadable" + : "permission", + attempts: 1, + // A busy unit retries; the others do not reschedule themselves. + nextRetryAt: outcome.reason === "busy" + ? new Date(Date.now() + TERMINAL_RETRY_DELAY_MS * 10).toISOString() + : null, + txId, + pendingRows: null, + backupEntries: null, + }; + case "failed": + return { + status: "unknown", + reason: outcome.reason === "timeout" ? "timeout" + : outcome.reason === "worker-died" ? "worker-died" + : "record-write-failed", + attempts: 1, + nextRetryAt: null, + txId, + pendingRows: null, + backupEntries: null, + }; + } +} + +/** + * Publish the terminal state of a history job against the transition it belongs + * to. A `conflict` means a newer transition won and is deliberately left alone. + * A `busy` terminal CAS retries a bounded number of times; when that exhausts, + * the previously persisted `pending` schedule is left intact — the same update + * needs the lock that was busy, so it cannot record its own failure + * (`005_contract.md:277`), and the retry being out of attempts is itself a + * terminal observation. + */ +export function resolveCodexHistoryTransition( + receipt: CodexTransitionVersion, + outcome: CodexHistoryJobOutcome, +): void { + const state = classify(outcome, receipt.currentTxId); + for (let attempt = 0; attempt < TERMINAL_RETRY_ATTEMPTS; attempt += 1) { + const result = updateCodexHistoryTransition(receipt, state); + if (result.kind === "updated") return; + if (result.kind === "conflict") { + // A newer transition already owns the row. Overwriting it would be the + // overtaken job overwriting the winner. + return; + } + if (result.reason === "busy") { + if (attempt + 1 < TERMINAL_RETRY_ATTEMPTS) sleep(TERMINAL_RETRY_DELAY_MS * (attempt + 1)); + continue; + } + // database / unsafe-path: retried CAS will not help. + return; + } +} diff --git a/src/codex/history-worker.ts b/src/codex/history-worker.ts new file mode 100644 index 000000000..ec8636844 --- /dev/null +++ b/src/codex/history-worker.ts @@ -0,0 +1,176 @@ +/** + * Worker-thread entry for the Codex history unit. + * + * Everything mutable about history happens here, behind H: the SQLite rows, the + * backup manifest, and every rollout patch. Those three do not share a + * transaction — sync writes the manifest before its database transaction, and + * restore writes rollouts, then the database, then the manifest + * (`src/codex/history-provider.ts:606-648,656-698`) — so a busy timeout only + * ever serialized a third of a state transition. Holding H across the whole unit + * is what stops an opposite-direction process overtaking through the other two. + * + * The message is deliberately thin, and it does NOT carry a direction. A caller + * saying which way history should move is exactly what the durable operation + * exists to prevent: `syncResumeHistory: false` means leave history alone, apply + * targets opencodex only in legacy mode, and legacy recovery is a different + * operation from generic restore. So the Worker is told which JOB to run and + * reads the operation from the coordinator row itself. + * + * Homes are carried explicitly because a Worker is a separate process: it does + * not inherit the module-load `CODEX_HOME` that `history-provider.ts` resolves + * at import time, so a request that leaned on those constants would silently + * address the wrong home. + * + * Design record: devlog/_plan/260804_codex_write_substrate/020_history_isolation.md. + */ +import { withHistoryWriteSerialization } from "./history-lock"; +import { + writeHistoryProviderTransition, + writeLegacyOpenaiHistoryRecovery, + type HistoryWriteTarget, +} from "./internal/history-writer"; + +/** + * The durable operation, mirrored into the request for diagnostics only. + * + * The Worker validates this against what it reads and refuses a mismatch rather + * than trusting it — a tampered copy must not be able to turn a restore into an + * apply. Structured-clone safe by construction: a closed set of string literals. + */ +export type CodexHistoryWorkerOperation = + | "skip" + | "apply-opencodex" + | "migrate-openai" + | "restore-openai" + | "recover-legacy-openai"; + +export interface HistoryWorkerRunMessage { + readonly type: "run"; + readonly requestId: string; + /** Opaque durable job identity; the Worker refuses work that is not current. */ + readonly jobId: string; + readonly operation: CodexHistoryWorkerOperation; + readonly canonicalCodexHome: string; + readonly canonicalStateDbPath: string; + readonly canonicalBackupPath: string; + /** Env snapshot: a Worker may not observe parent mutations on every platform. */ + readonly env?: { readonly CODEX_HOME?: string; readonly OPENCODEX_HOME?: string }; +} + +export type HistoryWorkerResult = + | { readonly type: "done"; readonly requestId: string; readonly jobId: string; + readonly outcome: "converged" | "skipped"; + readonly rows: number; readonly files: number } + | { readonly type: "blocked"; readonly requestId: string; readonly jobId: string; + readonly reason: "busy" | "database" | "unsafe-path" } + | { readonly type: "error"; readonly requestId: string; readonly jobId: string; + readonly message: string }; + +const OPERATIONS: ReadonlySet = new Set([ + "skip", + "apply-opencodex", + "migrate-openai", + "restore-openai", + "recover-legacy-openai", +]); + +/** + * Validate the message before it can reach a writer. + * + * A malformed message is dropped rather than coerced. Every field is required + * and non-empty: an absent path would otherwise fall back to a module-load + * constant that points at the wrong home in this process. + */ +export function isHistoryWorkerRunMessage(data: unknown): data is HistoryWorkerRunMessage { + if (!data || typeof data !== "object" || Array.isArray(data)) return false; + const message = data as Record; + const nonEmpty = (value: unknown): value is string => + typeof value === "string" && value.trim().length > 0; + return message.type === "run" + && nonEmpty(message.requestId) + && nonEmpty(message.jobId) + && typeof message.operation === "string" + && OPERATIONS.has(message.operation) + && nonEmpty(message.canonicalCodexHome) + && nonEmpty(message.canonicalStateDbPath) + && nonEmpty(message.canonicalBackupPath); +} + +/** + * Run one history operation under H. + * + * Exported so the unit can be exercised in-process; the Worker entry below is a + * thin adapter over it. `skip` is a real outcome rather than an absence: opting + * out of history resume must be recorded, not inferred from nothing happening. + */ +export function runHistoryUnitUnderLock( + message: HistoryWorkerRunMessage, +): HistoryWorkerResult { + const { requestId, jobId, operation } = message; + const target: HistoryWriteTarget = { + canonicalStateDbPath: message.canonicalStateDbPath, + canonicalBackupPath: message.canonicalBackupPath, + }; + + if (operation === "skip") { + return { type: "done", requestId, jobId, outcome: "skipped", rows: 0, files: 0 }; + } + + const acquired = withHistoryWriteSerialization( + message.canonicalCodexHome, + message.canonicalStateDbPath, + permit => { + if (operation === "recover-legacy-openai") { + return writeLegacyOpenaiHistoryRecovery(permit, target); + } + // apply-opencodex routes history to opencodex; migrate/restore return it to + // native. The provider is derived from the operation, never from a caller. + const provider = operation === "apply-opencodex" ? "opencodex" : "openai"; + return writeHistoryProviderTransition(permit, target, provider); + }, + ); + + if (acquired.kind !== "completed") { + return { type: "blocked", requestId, jobId, reason: acquired.reason }; + } + const result = acquired.value; + if (result.failed === true) { + return { type: "error", requestId, jobId, message: "history_transition_failed" }; + } + return { + type: "done", + requestId, + jobId, + outcome: "converged", + rows: result.rows, + files: result.files, + }; +} + +declare const self: Worker; + +// Guarded so the module can be imported directly by tests without a Worker host. +if (typeof self !== "undefined" && typeof (self as { onmessage?: unknown }) === "object") { + self.onmessage = (event: MessageEvent) => { + if (!isHistoryWorkerRunMessage(event.data)) return; + const message = event.data; + try { + if (message.env?.CODEX_HOME) process.env.CODEX_HOME = message.env.CODEX_HOME; + if (message.env?.OPENCODEX_HOME) process.env.OPENCODEX_HOME = message.env.OPENCODEX_HOME; + self.postMessage(runHistoryUnitUnderLock(message)); + } catch (error) { + self.postMessage({ + type: "error", + requestId: message.requestId, + jobId: message.jobId, + message: error instanceof Error ? error.message : "history_worker_failed", + } satisfies HistoryWorkerResult); + } finally { + // Close from inside the Worker so the thread begins exiting before the + // parent's terminate() races isolate realm reclaim on Windows. + try { + (self as unknown as { close?: () => void }).close?.(); + } catch { /* already closing */ } + } + }; +} diff --git a/src/codex/inject-coordination.ts b/src/codex/inject-coordination.ts new file mode 100644 index 000000000..28c58f8b0 --- /dev/null +++ b/src/codex/inject-coordination.ts @@ -0,0 +1,245 @@ +/** + * Coordination helpers for the native Codex write section. + * + * Split out of `inject.ts` so the injection function keeps reading as the + * sequence it is, rather than doubling in length around the lock. + */ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; + +import { atomicWriteFile } from "../config"; +import type { CodexWriteLockResult } from "./codex-write-lock"; +import { JOURNAL_PATH } from "./journal"; +import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths"; +import { + codexWriteCoordination, + type CodexWriteCandidate, + type CodexWriteCoordination, + type CodexWriteEvidence, +} from "./write-coordination"; + +/** Bounded so a stuck holder cannot wedge `ocx start` indefinitely. */ +export const DEFAULT_INJECT_LOCK_TIMEOUT_MS = 5_000; + +/** + * Can this home be coordinated at all, decided BEFORE any lock attempt? + * + * The order matters and is not stylistic. `assertInitialStateCanBeCreated` + * refuses to create the first coordinator row while native routing residue + * exists (`transition-state.ts:268-280`) — correctly, because installing + * `{0,null}` over routed bytes would erase the only evidence that an + * interrupted transition needs salvage. But "already routed, no coordinator + * row" is the state of every install predating this substrate, so a "try the + * lock, fall back on refusal" shape would attempt acquisition on the entire + * installed base. Deciding first means that refusal path is never entered. + * + * `legacy-uncoordinated` is a temporary boundary, not a design: the + * compatibility-adoption contract (`005_contract.md:709-779`) records an + * existing routed home into the coordinator, and once that lands this branch + * narrows to homes not yet adopted. + */ +export type CodexWriteCoordinationEligibility = + | { kind: "coordinated" } + | { kind: "legacy-uncoordinated"; reason: string } + | { kind: "refused"; reason: string }; + +export function codexWriteCoordinationEligibility(deps: { + coordinatorPath: () => string; + residue: () => { kind: string }; + integrationRecord: () => { kind: string }; +}): CodexWriteCoordinationEligibility { + let coordinatorExists: boolean; + try { + coordinatorExists = existsSync(deps.coordinatorPath()); + } catch (error) { + return { kind: "refused", reason: `the coordinator path could not be resolved: ${String(error)}` }; + } + + // An existing coordinator is authoritative, and the lock owns validating it — + // including the unversioned and rowless cases it must refuse rather than adopt. + if (coordinatorExists) return { kind: "coordinated" }; + + const record = deps.integrationRecord(); + if (record.kind === "invalid") { + return { kind: "refused", reason: "the Codex integration record is invalid" }; + } + + const residue = deps.residue(); + if (residue.kind === "clean") return { kind: "coordinated" }; + /* + * Everything else keeps the path it has always had. + * + * `residue` is a pre-substrate routed home; `indeterminate` means the + * classifier could not read what is there — a profile it cannot parse, for + * instance, which is an ordinary re-injection over an older file rather than + * a hazard. + * + * Neither may CREATE a coordinator row: doing that over unclassified or + * routed bytes would erase the evidence an interrupted transition needs. But + * refusing the injection outright, which an earlier draft of this function + * did for `indeterminate`, breaks re-injection on homes that work today — + * caught by the shipped restore tests rather than by review. Declining to + * coordinate is the correct scope of the refusal; declining to write is not. + */ + return { + kind: "legacy-uncoordinated", + reason: residue.kind === "residue" + ? "this home was routed before write coordination existed and has not been adopted yet" + : "the existing native Codex state could not be classified, so it cannot seed a coordinator row", + }; +} + +/** The transition row rejected this publication; a conflict, not an exception. */ +export class CodexWriteConflictError extends Error { + readonly code = "CODEX_WRITE_CONFLICT"; + constructor(message: string) { + super(message); + this.name = "CodexWriteConflictError"; + } +} + +/** + * A write failed AND its compensation failed. + * + * Carries which surfaces are unrestored, never their contents — this reaches + * logs and HTTP responses, and config bytes carry credentials. + */ +export class CodexPartialWriteError extends Error { + readonly code = "CODEX_PARTIAL_WRITE"; + constructor(readonly unrestored: readonly string[]) { + super(`Native Codex files are in a partial state; unrestored: ${unrestored.join(", ")}.`); + this.name = "CodexPartialWriteError"; + } +} + +function contentIdentity(path: string): string { + try { + return createHash("sha256").update(readFileSync(path)).digest("hex").slice(0, 32); + } catch { + return existsSync(path) ? "unreadable" : "absent"; + } +} + +export interface CodexPreImages { + readonly config: string | null; + readonly profile: string | null; + readonly journal: string | null; +} + +/** `null` means the file was ABSENT, which restoration must reproduce exactly. */ +function readOrNull(path: string): string | null { + try { + return readFileSync(path, "utf-8"); + } catch { + return null; + } +} + +export function captureCodexPreImages(): CodexPreImages { + return { + config: readOrNull(CODEX_CONFIG_PATH), + profile: readOrNull(CODEX_PROFILE_PATH), + journal: readOrNull(JOURNAL_PATH), + }; +} + +/** + * Put back exactly what was there, and report honestly when that fails. + * + * Every surface is attempted even after one fails: a second failure is worth + * knowing about, and stopping early would leave more unrestored than necessary. + */ +export function restoreCodexPreImages( + pre: CodexPreImages, +): { complete: boolean; unrestored: readonly string[] } { + const unrestored: string[] = []; + const surfaces: readonly [string, string, string | null][] = [ + ["config", CODEX_CONFIG_PATH, pre.config], + ["profile", CODEX_PROFILE_PATH, pre.profile], + ["journal", JOURNAL_PATH, pre.journal], + ]; + for (const [name, path, bytes] of surfaces) { + try { + if (bytes === null) { + // Absent before, so absent after. A leftover file is not a restoration. + if (existsSync(path)) require("node:fs").unlinkSync(path); + } else if (readOrNull(path) !== bytes) { + atomicWriteFile(path, bytes); + } + } catch { + unrestored.push(name); + } + } + return { complete: unrestored.length === 0, unrestored }; +} + +export function buildInjectWitness( + candidate: CodexWriteCandidate, + nativeInput: string, + persistedIdentity: string, + generation: CodexWriteEvidence["generation"], + observedOwnership: CodexWriteCoordination["observedOwnership"], +): CodexWriteCoordination { + return codexWriteCoordination( + candidate, + { + nativeInputIdentity: createHash("sha256").update(nativeInput).digest("hex"), + persistedIdentity, + generation, + journalIdentity: contentIdentity(JOURNAL_PATH), + canonicalTargets: { + config: CODEX_CONFIG_PATH, + profile: CODEX_PROFILE_PATH, + journal: JOURNAL_PATH, + }, + }, + observedOwnership, + ); +} + +/** + * The under-lock re-read. + * + * The candidate bytes are fixed — they were computed before acquisition and do + * not change — so what is re-read is the EVIDENCE: the native input on disk, the + * journal, the generation from the open transaction. A comparison that copied + * those forward would match itself and prove nothing. + */ +export function recomputeInjectWitness(options: { + candidate: CodexWriteCandidate; + canonicalTargets: CodexWriteEvidence["canonicalTargets"]; + persistedIdentity: string; + generation: CodexWriteEvidence["generation"]; + observedOwnership: CodexWriteCoordination["observedOwnership"]; +}): CodexWriteCoordination { + const nativeInput = readOrNull(options.canonicalTargets.config) ?? ""; + return codexWriteCoordination( + options.candidate, + { + nativeInputIdentity: createHash("sha256").update(nativeInput).digest("hex"), + persistedIdentity: options.persistedIdentity, + generation: options.generation, + journalIdentity: contentIdentity(options.canonicalTargets.journal), + canonicalTargets: options.canonicalTargets, + }, + options.observedOwnership, + ); +} + +/** Project a non-acquired lock result into the injection result shape. */ +export function codexInjectLockOutcome( + result: Exclude, { status: "acquired" }>, +): { success: false; message: string; retryable: boolean } { + if (result.status === "busy") { + return { + success: false, + retryable: true, + message: `Another process is writing Codex configuration right now (waited ${result.waitedMs}ms). Retry shortly.`, + }; + } + return { + success: false, + retryable: false, + message: `Codex configuration was not written: ${result.message}`, + }; +} diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 7ad937754..0e5304a64 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -1,8 +1,45 @@ import { existsSync, readFileSync, unlinkSync } from "node:fs"; -import { atomicWriteFile, loadConfig, subagentDefaultSyncEffective, websocketsEnabled } from "../config"; -import { markJournalInjectedState, removeJournal, restoreJournalState, writeJournal } from "./journal"; -import { restoreCodexCatalog } from "./catalog"; -import { migrateHistoryToOpenai, syncCodexHistoryProvider } from "./history-provider"; +import { + atomicWriteFile, + loadConfig, + observeConfigGeneration, + readConfigAdmissionSnapshot, + subagentDefaultSyncEffective, + websocketsEnabled, +} from "../config"; +import { withCodexWriteLock } from "./codex-write-lock"; +import { resolveCodexHistoryTransition } from "./history-transition"; +import { + buildInjectWitness, + captureCodexPreImages, + codexInjectLockOutcome, + codexWriteCoordinationEligibility, + CodexPartialWriteError, + CodexWriteConflictError, + DEFAULT_INJECT_LOCK_TIMEOUT_MS, + recomputeInjectWitness, + restoreCodexPreImages, +} from "./inject-coordination"; +import { readIntegrationRecord } from "./integration-record"; +import { classifyNativeRoutedResidue } from "./native-residue"; +import { + resolveCodexCoordinatorDatabasePath, + resolveEffectiveUserIdentity, +} from "./user-identity"; +import { + markJournalInjectedState, + removeJournal, + restoreJournalState, + writeJournal, +} from "./journal"; +import { withCatalogWriteSerialization } from "./catalog-write-serialization"; +import { restoreCodexCatalogWithPermit } from "./catalog/sync"; +import { syncCodexHistoryProvider } from "./history-provider"; +import { + deriveCodexHistoryOperation, + resolveCodexHistoryJobTarget, + runCodexHistoryJob, +} from "./history-job"; import { OCX_SECTION_MARKER, hasInjectedCodexRouting, @@ -13,7 +50,16 @@ import { rootTomlString, tomlStringPattern, } from "./injected-marker"; -import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH, parseTomlString, readRootTomlString, resolveCodexConfigPath, tomlString } from "./paths"; +import { + CODEX_CONFIG_PATH, + CODEX_PROFILE_PATH, + DEFAULT_CATALOG_PATH, + getCodexHome, + parseTomlString, + readRootTomlString, + resolveCodexConfigPath, + tomlString, +} from "./paths"; import { resolveEffectiveProjectModelProvider } from "./project-config-warnings"; import { transformManagedSubagentDefaults, @@ -27,7 +73,9 @@ export { hasInjectedCodexRouting, hasInjectedOpenaiBaseUrl }; export function externalCodexModelProvider(content: string): string | null { const provider = resolveEffectiveProjectModelProvider(content).provider; - return provider && provider !== "openai" && provider !== "opencodex" ? provider : null; + return provider && provider !== "openai" && provider !== "opencodex" + ? provider + : null; } export function currentExternalCodexModelProvider(): string | null { @@ -70,15 +118,29 @@ export interface InjectCodexOptions { * failing on a missing model_catalog_json file. */ catalogPath?: string | null; + /** + * How long to wait for the Codex write lock before reporting contention. + * + * Bounded by default so a stuck holder cannot wedge `ocx start`; an explicit + * caller that is willing to wait can raise it. + */ + lockTimeoutMs?: number; } function configuredManagedSubagentDefaults( - config: Pick | undefined, + config: + | Pick< + OcxConfig, + "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults" + > + | undefined, ): ManagedSubagentDefaults | null { if (!subagentDefaultSyncEffective(config ?? {})) return null; return { model: config!.injectionModel!.trim(), - ...(config!.injectionEffort?.trim() ? { reasoningEffort: config!.injectionEffort.trim() } : {}), + ...(config!.injectionEffort?.trim() + ? { reasoningEffort: config!.injectionEffort.trim() } + : {}), }; } @@ -98,7 +160,13 @@ function configuredManagedSubagentDefaults( */ export function isLoopbackHostname(hostname: string | undefined): boolean { const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase(); - return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]"; + return ( + normalized === "" || + normalized === "localhost" || + normalized === "127.0.0.1" || + normalized === "::1" || + normalized === "[::1]" + ); } export function providerBaseHost(hostname: string | undefined): string { @@ -107,16 +175,29 @@ export function providerBaseHost(hostname: string | undefined): string { // Match what the server actually binds. Writing "localhost" while binding IPv4-only // 127.0.0.1 breaks on Windows, where localhost commonly resolves to ::1 first. if (lower === "::1" || lower === "[::1]") return "[::1]"; - if (isLoopbackHostname(trimmed) || trimmed === "0.0.0.0" || trimmed === "::" || trimmed === "[::]") return "127.0.0.1"; + if ( + isLoopbackHostname(trimmed) || + trimmed === "0.0.0.0" || + trimmed === "::" || + trimmed === "[::]" + ) + return "127.0.0.1"; if (trimmed.startsWith("[") && trimmed.endsWith("]")) return trimmed; return trimmed.includes(":") ? `[${trimmed}]` : trimmed; } -export function shouldInjectApiAuthHeader(config: Pick | undefined): boolean { +export function shouldInjectApiAuthHeader( + config: Pick | undefined, +): boolean { return !isLoopbackHostname(config?.hostname); } -export function buildProviderTableBlock(port: number, supportsWebsockets = false, includeApiAuthHeader = false, hostname?: string): string { +export function buildProviderTableBlock( + port: number, + supportsWebsockets = false, + includeApiAuthHeader = false, + hostname?: string, +): string { const host = providerBaseHost(hostname); const lines = [ "", @@ -128,13 +209,18 @@ export function buildProviderTableBlock(port: number, supportsWebsockets = false "requires_openai_auth = true", ]; if (includeApiAuthHeader) { - lines.push('env_http_headers = { "x-opencodex-api-key" = "OPENCODEX_API_AUTH_TOKEN" }'); + lines.push( + 'env_http_headers = { "x-opencodex-api-key" = "OPENCODEX_API_AUTH_TOKEN" }', + ); } if (supportsWebsockets) lines.push("supports_websockets = true"); return lines.join("\n") + "\n"; } -export function buildOpenaiBaseUrlLine(port: number, hostname?: string): string { +export function buildOpenaiBaseUrlLine( + port: number, + hostname?: string, +): string { return `openai_base_url = "http://${providerBaseHost(hostname)}:${port}/v1"`; } @@ -144,9 +230,13 @@ export function buildOpenaiBaseUrlLine(port: number, hostname?: string): string * in place. A user's OWN root `openai_base_url` (no marker above it) is respected — we keep it * and inject nothing, reporting `keptUserBaseUrl` so the caller can surface it. */ -export function setRootOpenaiBaseUrl(content: string, port: number, hostname?: string): { content: string; keptUserBaseUrl: boolean } { +export function setRootOpenaiBaseUrl( + content: string, + port: number, + hostname?: string, +): { content: string; keptUserBaseUrl: boolean } { const lines = content.split("\n"); - const firstTable = lines.findIndex(l => /^\s*\[/.test(l)); + const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); const rootEnd = firstTable === -1 ? lines.length : firstTable; const key = buildOpenaiBaseUrlLine(port, hostname); @@ -159,7 +249,16 @@ export function setRootOpenaiBaseUrl(content: string, port: number, hostname?: s } if (firstTable === -1) { - return { content: content.replace(/\n+$/, "") + "\n" + OCX_SECTION_MARKER + "\n" + key + "\n", keptUserBaseUrl: false }; + return { + content: + content.replace(/\n+$/, "") + + "\n" + + OCX_SECTION_MARKER + + "\n" + + key + + "\n", + keptUserBaseUrl: false, + }; } let insertAt = firstTable; while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt--; @@ -174,7 +273,7 @@ export function setRootOpenaiBaseUrl(content: string, port: number, hostname?: s */ export function stripInjectedOpenaiBaseUrl(content: string): string { const lines = content.split("\n"); - const firstTable = lines.findIndex(l => /^\s*\[/.test(l)); + const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); const rootEnd = firstTable === -1 ? lines.length : firstTable; const drop = new Set(); for (let i = 0; i < rootEnd; i++) { @@ -190,7 +289,8 @@ export function stripInjectedOpenaiBaseUrl(content: string): string { return lines.filter((_, i) => !drop.has(i)).join("\n"); } -export type CodexRoutingKind = "native" | "opencodex-local" | "custom-local" | "custom-remote" | "unknown"; +export type CodexRoutingKind = + "native" | "opencodex-local" | "custom-local" | "custom-remote" | "unknown"; type RoutingEndpointKind = "local" | "remote" | "unknown"; @@ -198,7 +298,7 @@ function ipv4Octets(hostname: string): number[] | null { const dotted = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname); if (dotted) { const octets = dotted.slice(1).map(Number); - return octets.some(octet => octet > 255) ? null : octets; + return octets.some((octet) => octet > 255) ? null : octets; } const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(hostname); if (!mapped) return null; @@ -211,13 +311,18 @@ function classifyRoutingEndpoint(value: string): RoutingEndpointKind { try { const url = new URL(value); if (url.protocol !== "http:" && url.protocol !== "https:") return "unknown"; - const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, ""); + const hostname = url.hostname + .toLowerCase() + .replace(/^\[|\]$/g, "") + .replace(/\.$/, ""); if (!hostname) return "unknown"; - if (hostname === "localhost" || hostname.endsWith(".localhost")) return "local"; - if (hostname === "::" || hostname === "::1" || hostname === "0.0.0.0") return "local"; + if (hostname === "localhost" || hostname.endsWith(".localhost")) + return "local"; + if (hostname === "::" || hostname === "::1" || hostname === "0.0.0.0") + return "local"; const octets = ipv4Octets(hostname); if (octets) { - if (octets.every(octet => octet === 0)) return "local"; + if (octets.every((octet) => octet === 0)) return "local"; if (octets[0] === 127) return "local"; return "remote"; } @@ -239,15 +344,25 @@ export function classifyCodexRouting(content: string): CodexRoutingKind { } const rootProvider = rootTomlString(content, "model_provider"); if (rootProvider) { - const providerTableExists = providerTableStart(content.split("\n"), rootProvider) !== -1; - const providerBaseUrl = providerTableString(content, rootProvider, "base_url"); + const providerTableExists = + providerTableStart(content.split("\n"), rootProvider) !== -1; + const providerBaseUrl = providerTableString( + content, + rootProvider, + "base_url", + ); if (providerBaseUrl) { const endpoint = classifyRoutingEndpoint(providerBaseUrl); if (endpoint === "unknown") return "unknown"; if (rootProvider === "opencodex") return "opencodex-local"; return endpoint === "local" ? "custom-local" : "custom-remote"; } - if (rootProvider === "opencodex" || providerTableExists || rootProvider !== "openai") return "unknown"; + if ( + rootProvider === "opencodex" || + providerTableExists || + rootProvider !== "openai" + ) + return "unknown"; } return "native"; } @@ -282,7 +397,7 @@ export function getCodexRoutingKind(): CodexRoutingKind { */ function stripExistingModelProvider(content: string): string { const lines = content.split("\n"); - const firstTable = lines.findIndex(l => /^\s*\[/.test(l)); + const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); const out: string[] = []; lines.forEach((line, i) => { if (/^\s*model_provider\s*=/.test(line)) { @@ -303,7 +418,7 @@ function stripExistingModelProvider(content: string): string { */ export function stripRootContextWindowOverrides(content: string): string { const lines = content.split("\n"); - const firstTable = lines.findIndex(l => /^\s*\[/.test(l)); + const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); return lines .filter((line, i) => { const isRoot = firstTable === -1 || i < firstTable; @@ -314,7 +429,7 @@ export function stripRootContextWindowOverrides(content: string): string { function stripRootRoutedModel(content: string): string { const lines = content.split("\n"); - const firstTable = lines.findIndex(l => /^\s*\[/.test(l)); + const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); return lines .filter((line, i) => { const isRoot = firstTable === -1 || i < firstTable; @@ -333,7 +448,7 @@ function stripRootRoutedModel(content: string): string { */ function setRootModelProvider(content: string): string { const lines = content.split("\n"); - const firstTable = lines.findIndex(l => /^\s*\[/.test(l)); + const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); const key = 'model_provider = "opencodex"'; if (firstTable === -1) { return content.replace(/\n+$/, "") + "\n" + key + "\n"; @@ -350,11 +465,13 @@ function readRootModelCatalogPath(content: string): string | null { function setRootModelCatalogPath(content: string, catalogPath: string): string { const lines = content.split("\n"); - const firstTable = lines.findIndex(l => /^\s*\[/.test(l)); + const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); const key = `model_catalog_json = ${tomlString(catalogPath)}`; const rootEnd = firstTable === -1 ? lines.length : firstTable; for (let i = 0; i < rootEnd; i++) { - const m = lines[i].match(/^\s*model_catalog_json\s*=\s*("(?:\\.|[^"])*"|'[^']*')\s*$/); + const m = lines[i].match( + /^\s*model_catalog_json\s*=\s*("(?:\\.|[^"])*"|'[^']*')\s*$/, + ); if (!m) continue; const existing = parseTomlString(m[1]); if (isOpencodexCatalogPath(existing)) { @@ -390,11 +507,19 @@ function removeProfileSection(content: string): string { } filtered.push(line); } - return filtered.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n"; + return ( + filtered + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trimEnd() + "\n" + ); } function normalizeServiceTier(content: string): string { - return content.replace(/^(\s*service_tier\s*=\s*)["']priority["']\s*$/gm, '$1"fast"'); + return content.replace( + /^(\s*service_tier\s*=\s*)["']priority["']\s*$/gm, + '$1"fast"', + ); } function ensureFastModeFeature(content: string, fastMode?: boolean): string { @@ -412,7 +537,9 @@ function ensureFastModeFeature(content: string, fastMode?: boolean): string { return content.trimEnd() + "\n\n[features]\nfast_mode = " + (fastMode ? "true" : "false") + "\n"; } - const nextTable = lines.findIndex((line, index) => index > featuresStart && /^\s*\[/.test(line)); + const nextTable = lines.findIndex( + (line, index) => index > featuresStart && /^\s*\[/.test(line), + ); const featuresEnd = nextTable === -1 ? lines.length : nextTable; for (let i = featuresStart + 1; i < featuresEnd; i++) { if (fastModeKey.test(lines[i])) { @@ -436,8 +563,10 @@ function isOpencodexCatalogPath(path: string): boolean { function stripOpencodexCatalogPath(content: string): string { return content .split("\n") - .filter(line => { - const m = line.match(/^\s*model_catalog_json\s*=\s*("(?:\\.|[^"])*"|'[^']*')\s*$/); + .filter((line) => { + const m = line.match( + /^\s*model_catalog_json\s*=\s*("(?:\\.|[^"])*"|'[^']*')\s*$/, + ); return !m || !isOpencodexCatalogPath(parseTomlString(m[1])); }) .join("\n"); @@ -470,13 +599,17 @@ export function buildProfileFile(port: number, catalogPath?: string | null, supp return lines.join("\n"); } -export function chooseCatalogPathForInjection(content: string, requested?: string | null): string | null { +export function chooseCatalogPathForInjection( + content: string, + requested?: string | null, +): string | null { if (requested !== undefined) return requested; const existing = readRootModelCatalogPath(content); if (existing) { const resolved = resolveCodexConfigPath(existing); - if (!isOpencodexCatalogPath(resolved) || existsSync(resolved)) return existing; + if (!isOpencodexCatalogPath(resolved) || existsSync(resolved)) + return existing; } return existsSync(DEFAULT_CATALOG_PATH) ? DEFAULT_CATALOG_PATH : null; @@ -488,9 +621,16 @@ export interface CodexInjectResult { nativeSubagentDefaultsWarning?: string; } -export async function injectCodexConfig(port: number, config?: OcxConfig, options: InjectCodexOptions = {}): Promise { +export async function injectCodexConfig( + port: number, + config?: OcxConfig, + options: InjectCodexOptions = {}, +): Promise { if (!existsSync(CODEX_CONFIG_PATH)) { - return { success: false, message: `Codex config not found at ${CODEX_CONFIG_PATH}. Is Codex installed?` }; + return { + success: false, + message: `Codex config not found at ${CODEX_CONFIG_PATH}. Is Codex installed?`, + }; } const rawContent = readFileSync(CODEX_CONFIG_PATH, "utf-8"); @@ -499,13 +639,18 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option // A launcher may have journaled before the provider manager took ownership. Never let shutdown // replay that stale snapshot over externally managed config. removeJournal(); - const nativeSubagentDefaultsWarning = configuredManagedSubagentDefaults(config) + const nativeSubagentDefaultsWarning = configuredManagedSubagentDefaults( + config, + ) ? `Native Codex sub-agent defaults were not injected: external model_provider ${tomlString(activeProvider)} owns config.toml.` : undefined; return { success: true, - ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}), - message: `⚠️ Codex routing NOT injected: config.toml selects the external model_provider ${tomlString(activeProvider)}.\n` + + ...(nativeSubagentDefaultsWarning + ? { nativeSubagentDefaultsWarning } + : {}), + message: + `⚠️ Codex routing NOT injected: config.toml selects the external model_provider ${tomlString(activeProvider)}.\n` + ` OpenCodex preserves external provider configuration so existing ${tomlString(activeProvider)} session history stays visible.\n` + ` Configure that provider for Responses passthrough at http://${providerBaseHost(config?.hostname)}:${port}/v1` + `${shouldInjectApiAuthHeader(config) ? ` with x-opencodex-api-key from OPENCODEX_API_AUTH_TOKEN` : ""}.\n` + @@ -518,23 +663,37 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option // root routing key: inserting that key ahead of a marker-owned first table // would otherwise separate the table marker from its header. Ambiguous // markers fail closed without writing config, profile, or journal state. - const nativeDefaultsBaseline = transformManagedSubagentDefaults(rawContent, null); + const nativeDefaultsBaseline = transformManagedSubagentDefaults( + rawContent, + null, + ); if (!nativeDefaultsBaseline.ok) { return { success: false, - message: `Codex config injection refused: existing OpenCodex-managed native sub-agent defaults are ambiguous: ${nativeDefaultsBaseline.error}. ` - + `No files were changed; inspect ${CODEX_CONFIG_PATH}.`, + message: + `Codex config injection refused: existing OpenCodex-managed native sub-agent defaults are ambiguous: ${nativeDefaultsBaseline.error}. ` + + `No files were changed; inspect ${CODEX_CONFIG_PATH}.`, }; } const baselineContent = nativeDefaultsBaseline.content; - // Classify and journal the same bytes: a native config is a valid original and - // supersedes a stale snapshot (#477), while an injected one must never become - // one — that is how opencodex routing would survive `ocx stop`. - writeJournal({ - currentStateIsNative: !hasInjectedCodexRouting(rawContent), - configContent: baselineContent, - }); + /* + * The journal write used to happen HERE, before the transforms. It now happens + * inside the write lock further down, and the transforms were hoisted above it + * rather than the lock being narrowed to the three file writes. + * + * Why: the lock's witness hashes the CANDIDATE BYTES, and those are not final + * until `profileContent` and the EOL-applied `content` exist. Opening the lock + * before them would leave nothing to hash; keeping the journal outside the + * lock would leave the first artifact-creating write unserialized, which is + * the hole this edge exists to close. + * + * The move is safe because the region between here and the writes performs no + * filesystem mutation — its only touch is `existsSync` on the catalog paths + * (`chooseCatalogPathForInjection`) — and because `writeJournal` is called + * with `configContent`, so it snapshots the baseline it is handed rather than + * rereading `config.toml` underneath the transforms. + */ // EOL boundary: transforms below are LF-pure; preserve the file's dominant ending on write. const eol = dominantEol(rawContent); let content = applyEol(baselineContent, "\n"); @@ -554,8 +713,13 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option content = normalizeServiceTier(content); content = ensureFastModeFeature(content, config?.fastMode); - const catalogPath = chooseCatalogPathForInjection(content, options.catalogPath); - content = catalogPath ? setRootModelCatalogPath(content, catalogPath) : stripOpencodexCatalogPath(content); + const catalogPath = chooseCatalogPathForInjection( + content, + options.catalogPath, + ); + content = catalogPath + ? setRootModelCatalogPath(content, catalogPath) + : stripOpencodexCatalogPath(content); const legacyMode = shouldInjectApiAuthHeader(config); let keptUserBaseUrl = false; @@ -565,7 +729,15 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option // 1) Root key BEFORE the first table header (must be a global, not nested under a table). content = setRootModelProvider(content); // 2) Provider table appended at EOF (position-independent). - content = content.trimEnd() + "\n" + buildProviderTableBlock(port, websocketsEnabled(config ?? {}), true, config?.hostname); + content = + content.trimEnd() + + "\n" + + buildProviderTableBlock( + port, + websocketsEnabled(config ?? {}), + true, + config?.hostname, + ); } else { // Design B (loopback): a single root override; codex keeps its native `openai` provider id // so thread history is never remapped. Any legacy form was already stripped above. @@ -576,66 +748,280 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option } const desiredSubagentDefaults = configuredManagedSubagentDefaults(config); - const routingOwnershipWarning = keptUserBaseUrl && desiredSubagentDefaults - ? "Native Codex sub-agent defaults were not injected: a user-owned root openai_base_url prevents OpenCodex from managing active Codex routing." - : undefined; + const routingOwnershipWarning = + keptUserBaseUrl && desiredSubagentDefaults + ? "Native Codex sub-agent defaults were not injected: a user-owned root openai_base_url prevents OpenCodex from managing active Codex routing." + : undefined; const managedDefaults = transformManagedSubagentDefaults( content, keptUserBaseUrl ? null : desiredSubagentDefaults, ); let nativeSubagentDefaultsWarning = routingOwnershipWarning; - let managedDefaultsMessage = routingOwnershipWarning ? ` ⚠️ ${routingOwnershipWarning}\n` : ""; + let managedDefaultsMessage = routingOwnershipWarning + ? ` ⚠️ ${routingOwnershipWarning}\n` + : ""; if (managedDefaults.ok) { content = managedDefaults.content; if (desiredSubagentDefaults && managedDefaults.conflicts.length > 0) { - const keys = managedDefaults.conflicts.map(conflict => `agents.${conflict.key}`).join(", "); + const keys = managedDefaults.conflicts + .map((conflict) => `agents.${conflict.key}`) + .join(", "); nativeSubagentDefaultsWarning = `Native Codex sub-agent defaults were not injected: user-owned ${keys} preserved.`; managedDefaultsMessage = ` ⚠️ ${nativeSubagentDefaultsWarning}\n`; } } else { - const action = desiredSubagentDefaults && !keptUserBaseUrl - ? "were not injected" - : "could not be safely removed"; + const action = + desiredSubagentDefaults && !keptUserBaseUrl + ? "were not injected" + : "could not be safely removed"; nativeSubagentDefaultsWarning = `Native Codex sub-agent defaults ${action}: ${managedDefaults.error}.`; managedDefaultsMessage = ` ⚠️ ${nativeSubagentDefaultsWarning}\n`; } const profileContent = buildProfileFile(port, catalogPath, websocketsEnabled(config ?? {}), legacyMode, config?.hostname, config?.fastMode); content = applyEol(content, eol); - atomicWriteFile(CODEX_CONFIG_PATH, content); - atomicWriteFile(CODEX_PROFILE_PATH, profileContent); - markJournalInjectedState(content, profileContent); + + /* + * The witness, built from the FINAL bytes. Everything it hashes is either the + * output about to be written or evidence that can be re-read under the lock; + * ownership rides along as recorded context because it is not re-observed + * there — see `write-coordination.ts`. + */ + const persisted = readConfigAdmissionSnapshot(); + const persistedIdentity = + persisted.kind === "read" ? persisted.contentSha256 : "unreadable"; + const observedGeneration = observeConfigGeneration(); + const generation = + observedGeneration.kind === "ready" + ? { present: true, value: observedGeneration.generation.value } + : { present: false, value: 0 }; + const candidate = { + configBytes: content, + profileBytes: profileContent, + catalogPath, + }; + const witness = buildInjectWitness( + candidate, + rawContent, + persistedIdentity, + generation, + "unknown", + ); + + /* + * THE COORDINATED SECTION. + * + * This is the write lock's first production caller. Everything above is + * classification and pure transformation; everything from here to the end of + * the callback replaces files, and two processes doing it at once is the + * interruption hazard this substrate exists to close. + * + * The witness hashes the bytes about to be written rather than the inputs that + * produced them, so two operations intending different output cannot share an + * id no matter which input differed. + */ + /* + * Eligibility BEFORE acquisition, never "try and fall back". + * + * A home routed before this substrate existed cannot have its first + * coordinator row created — the guard that refuses is correct — and that + * describes every pre-substrate install. Attempting the lock there would enter + * a refusal path on the entire installed base, so the decision happens first + * and those homes keep the write sequence they have always used. + */ + const eligibility = codexWriteCoordinationEligibility({ + coordinatorPath: () => + resolveCodexCoordinatorDatabasePath( + resolveEffectiveUserIdentity(), + getCodexHome(), + ), + residue: () => classifyNativeRoutedResidue(), + integrationRecord: () => readIntegrationRecord(), + }); + if (eligibility.kind === "refused") { + return { + success: false, + message: `Codex configuration was not written: ${eligibility.reason}.`, + }; + } + + const applyNativeArtifacts = (): void => { + writeJournal({ + currentStateIsNative: !hasInjectedCodexRouting(rawContent), + configContent: baselineContent, + }); + atomicWriteFile(CODEX_CONFIG_PATH, content); + atomicWriteFile(CODEX_PROFILE_PATH, profileContent); + markJournalInjectedState(content, profileContent); + }; + + /* + * Set only on the coordinated path: the generation/txId the transition just + * committed. The terminal history update CASes against this, so a job that + * was overtaken cannot overwrite the winner. Stays undefined for a + * legacy-uncoordinated home, which publishes no transition to resolve. + */ + let transitionReceipt: { nativeGeneration: number; currentTxId: string } | undefined; + + if (eligibility.kind === "legacy-uncoordinated") { + // Unchanged behavior for homes the coordinator cannot yet adopt. Stated + // rather than implied: this is the boundary, and adoption is its own phase. + applyNativeArtifacts(); + } else { + const coordinated = await withCodexWriteLock( + { + timeoutMs: options.lockTimeoutMs ?? DEFAULT_INJECT_LOCK_TIMEOUT_MS, + admitted: { authoritySnapshotId: witness.comparisonId }, + readAdmissionUnderLock: () => ({ + authoritySnapshotId: recomputeInjectWitness({ + candidate: witness.candidate, + canonicalTargets: witness.evidence.canonicalTargets, + persistedIdentity, + generation, + observedOwnership: witness.observedOwnership, + }).comparisonId, + }), + }, + (ctx) => { + /* + * Publish BEFORE touching the filesystem. `assertPublished` runs after this + * callback returns and throws unless a transition was recorded, so writing + * first would replace every file and only then fail — with SQLite rolling + * back and the filesystem staying changed. + * + * `beginTransition` returns a conflict rather than throwing, so its result + * is checked here; ignoring it would reach the same failure by a slower + * route. + */ + const published = ctx.coordinator.beginTransition( + { + nativeGeneration: ctx.expectation.nativeBefore, + currentTxId: ctx.currentTxId, + }, + { + txId: ctx.expectation.txId, + direction: "apply", + authoritySnapshotId: ctx.admission.authoritySnapshotId, + nextRetryAt: new Date().toISOString(), + }, + ); + if (published.kind !== "updated") { + throw new CodexWriteConflictError( + `The Codex transition could not be published: ${published.kind}.`, + ); + } + + /* + * Exact pre-images, captured under the lock and used for compensation. + * + * A rolled-back coordinator row is not a rolled-back filesystem: each + * `atomicWriteFile` is atomic alone, never across the three together, so a + * failure partway leaves earlier replacements in place. `restoreJournalState` + * cannot be the undo — it restores whichever journal occupies the path, + * which need not be the one this operation wrote. + */ + const preImages = captureCodexPreImages(); + try { + applyNativeArtifacts(); + } catch (error) { + // Compensate, then ALWAYS throw. Returning a partial result would let the + // lock commit a row describing an apply that did not finish. + const restored = restoreCodexPreImages(preImages); + if (!restored.complete) { + throw new CodexPartialWriteError(restored.unrestored); + } + throw error; + } + return { + kind: "applied" as const, + /* + * The receipt the terminal update matches on. The transition commits + * when the callback returns, so this pair is what the post-job + * `updateCodexHistoryTransition` CASes against — an overtaken job + * cannot overwrite a winner. + */ + receipt: { + nativeGeneration: ctx.expectation.nativeAfter, + currentTxId: ctx.expectation.txId, + }, + }; + }, + ); + + if (coordinated.status !== "acquired") { + return codexInjectLockOutcome(coordinated); + } + transitionReceipt = coordinated.value.receipt; + } // Legacy mode still forward-tags history so re-tagged threads stay listable. Design B needs // the opposite: a one-time migration of previously re-tagged threads BACK to openai (restore // machinery; cheap no-op when there is nothing to migrate). - const history = config?.syncResumeHistory !== false - ? (legacyMode ? syncCodexHistoryProvider("opencodex") : migrateHistoryToOpenai()) - : { rows: 0, files: 0 }; + // History runs in a Worker under H, not on this thread. + // + // The three surfaces it touches — the SQLite rows, the backup manifest, and the + // rollout files — do not share a transaction, so a busy timeout only ever + // serialized one of them and an opposite-direction process could overtake + // through the other two. The operation is derived from admitted intent here and + // handed down fixed; the Worker never takes a direction from its caller. + const historyOutcome = await runCodexHistoryJob({ + ...resolveCodexHistoryJobTarget(), + operation: deriveCodexHistoryOperation({ + direction: "apply", + resumeHistory: config?.syncResumeHistory !== false, + legacyMode, + }), + }); + // A blocked or failed unit is reported, not silently counted as zero work: + // `failed` is what makes the caller's message say so. + const history: { rows: number; files: number; failed?: true } = + historyOutcome.kind === "converged" + ? { rows: historyOutcome.rows, files: historyOutcome.files } + : historyOutcome.kind === "skipped" + ? { rows: 0, files: 0 } + : { rows: 0, files: 0, failed: true }; + + /* + * Resolve the transition this job belongs to, on the coordinated path only. + * + * `updateCodexHistoryTransition` had no production caller since it was + * written, so every completed or skipped job left the row permanently + * `pending` — the transition was published and never resolved. This is the + * first time the durable row reflects what actually happened. The CAS on the + * receipt means an overtaken job's late write loses and is not overwritten. + */ + if (transitionReceipt) { + resolveCodexHistoryTransition(transitionReceipt, historyOutcome); + } const catalogMessage = catalogPath ? ` Codex model catalog: ${catalogPath}\n` : ` Codex model catalog not injected because no opencodex catalog file exists yet.\n`; - const migratedRows = (history.rows ?? 0) + ("ejectedRows" in history ? history.ejectedRows ?? 0 : 0); - const historyMessage = config?.syncResumeHistory === false - ? ` Codex resume history: left unchanged (syncResumeHistory=false).\n` - : history.failed - ? (legacyMode - ? ` ⚠️ Codex resume history sync SKIPPED: the history DB is locked (Codex app/IDE open?). Close it and rerun 'ocx start'.\n` - // Honest in every caller context: the daemon retries in the background while it runs, - // and this inject path re-runs the migration on every future start/sync anyway. - : ` ⚠️ Codex resume history migration deferred: the history DB is locked (Codex app/IDE open?). It is retried automatically (while the proxy runs and on every 'ocx start'); to force it now, close the Codex app and run 'ocx sync'.\n`) - : legacyMode - ? ` Codex resume history: ${history.rows} thread(s) made visible for opencodex; originals backed up for restore.\n` - : migratedRows > 0 - ? ` Codex resume history: ${migratedRows} legacy opencodex-tagged thread(s) migrated back to openai (one-time).\n` - : ` Codex resume history: untouched (threads keep their native openai tag).\n`; + const ejected = (history as { ejectedRows?: number }).ejectedRows ?? 0; + const migratedRows = (history.rows ?? 0) + ejected; + const historyMessage = + config?.syncResumeHistory === false + ? ` Codex resume history: left unchanged (syncResumeHistory=false).\n` + : history.failed + ? legacyMode + ? ` ⚠️ Codex resume history sync SKIPPED: the history DB is locked (Codex app/IDE open?). Close it and rerun 'ocx start'.\n` + : // Honest in every caller context: the daemon retries in the background while it runs, + // and this inject path re-runs the migration on every future start/sync anyway. + ` ⚠️ Codex resume history migration deferred: the history DB is locked (Codex app/IDE open?). It is retried automatically (while the proxy runs and on every 'ocx start'); to force it now, close the Codex app and run 'ocx sync'.\n` + : legacyMode + ? ` Codex resume history: ${history.rows} thread(s) made visible for opencodex; originals backed up for restore.\n` + : migratedRows > 0 + ? ` Codex resume history: ${migratedRows} legacy opencodex-tagged thread(s) migrated back to openai (one-time).\n` + : ` Codex resume history: untouched (threads keep their native openai tag).\n`; // A user-owned root openai_base_url means we did NOT install routing — say so honestly // instead of claiming the proxy route is active (catalog/fast_mode were still written). if (keptUserBaseUrl) { return { success: true, - ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}), - message: `⚠️ Codex routing NOT injected: your config already sets a root openai_base_url, and opencodex never overwrites a user-owned override.\n` + + ...(nativeSubagentDefaultsWarning + ? { nativeSubagentDefaultsWarning } + : {}), + message: + `⚠️ Codex routing NOT injected: your config already sets a root openai_base_url, and opencodex never overwrites a user-owned override.\n` + catalogMessage + historyMessage + managedDefaultsMessage + @@ -649,7 +1035,8 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option return { success: true, ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}), - message: headline + + message: + headline + catalogMessage + historyMessage + managedDefaultsMessage + @@ -667,14 +1054,20 @@ function removeOcxSection(content: string): string { const filtered: string[] = []; let inOcxSection = false; for (const line of lines) { - if (line.includes(OCX_SECTION_MARKER) || line.trim() === "[model_providers.opencodex]") { + if ( + line.includes(OCX_SECTION_MARKER) || + line.trim() === "[model_providers.opencodex]" + ) { inOcxSection = true; continue; } if (inOcxSection) { // End the injected section at the next table header that ISN'T our own — exact match so a // user's "[model_providers.opencodex_backup]" (or similar) is preserved, not swallowed. - if (/^\s*\[/.test(line) && line.trim() !== "[model_providers.opencodex]") { + if ( + /^\s*\[/.test(line) && + line.trim() !== "[model_providers.opencodex]" + ) { inOcxSection = false; filtered.push(line); } @@ -682,7 +1075,12 @@ function removeOcxSection(content: string): string { } filtered.push(line); } - return filtered.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n"; + return ( + filtered + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trimEnd() + "\n" + ); } interface StripOpencodexConfigResult { @@ -695,9 +1093,12 @@ interface StripOpencodexConfigResult { * ambiguous: keep the associated value, but return the transform error so the * caller cannot report a complete restore. */ -function stripOpencodexConfigResult(content: string): StripOpencodexConfigResult { +function stripOpencodexConfigResult( + content: string, +): StripOpencodexConfigResult { let out = content; - const hadRootOcxProvider = readRootTomlString(out, "model_provider") === "opencodex"; + const hadRootOcxProvider = + readRootTomlString(out, "model_provider") === "opencodex"; const hadInjectedBaseUrl = hasInjectedOpenaiBaseUrl(out); out = stripInjectedOpenaiBaseUrl(out); // before removeOcxSection — it keys on the marker line too if (out.includes("[model_providers.opencodex]")) { @@ -706,7 +1107,10 @@ function stripOpencodexConfigResult(content: string): StripOpencodexConfigResult out = removeProfileSection(out); // Regex (not exact-string) removal so compact `model_provider="opencodex"` is stripped too — // must match the detection regex above, or a detected line could survive un-removed. - out = out.split("\n").filter(l => !/^\s*model_provider\s*=\s*"opencodex"\s*$/.test(l)).join("\n"); + out = out + .split("\n") + .filter((l) => !/^\s*model_provider\s*=\s*"opencodex"\s*$/.test(l)) + .join("\n"); // Routed root model ids (`model = "provider/slug"`) only make sense while the proxy serves // them — strip on both the legacy re-tag form and the Design B injected-base-url form. if (hadRootOcxProvider || hadInjectedBaseUrl) out = stripRootRoutedModel(out); @@ -725,14 +1129,19 @@ export function stripOpencodexConfig(content: string): string { } function hasOpencodexRouting(content: string): boolean { - return content.includes("[model_providers.opencodex]") - || /^\s*model_provider\s*=\s*"opencodex"/m.test(content) - || hasInjectedOpenaiBaseUrl(content); + return ( + content.includes("[model_providers.opencodex]") || + /^\s*model_provider\s*=\s*"opencodex"/m.test(content) || + hasInjectedOpenaiBaseUrl(content) + ); } -export function removeCodexConfig(options: { preserveProfile?: boolean } = {}): { success: boolean; message: string } { +export function removeCodexConfig( + options: { preserveProfile?: boolean } = {}, +): { success: boolean; message: string } { if (!existsSync(CODEX_CONFIG_PATH)) { - if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH)) unlinkSync(CODEX_PROFILE_PATH); + if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH)) + unlinkSync(CODEX_PROFILE_PATH); return { success: true, message: `Codex config not found; no native restore was needed${options.preserveProfile ? "." : ", and the opencodex profile was removed if present."}`, @@ -748,15 +1157,19 @@ export function removeCodexConfig(options: { preserveProfile?: boolean } = {}): if (had || stripped.content !== content) { atomicWriteFile(CODEX_CONFIG_PATH, applyEol(stripped.content, eol)); } - if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH)) unlinkSync(CODEX_PROFILE_PATH); + if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH)) + unlinkSync(CODEX_PROFILE_PATH); const removedMessage = had ? `Removed opencodex routing from Codex config${options.preserveProfile ? "." : " + profile."}` : "opencodex not present in Codex config."; if (stripped.managedDefaultsError) { - const routingMessage = had ? removedMessage : "No opencodex routing was present in Codex config."; + const routingMessage = had + ? removedMessage + : "No opencodex routing was present in Codex config."; return { success: false, - message: `${routingMessage} Native Codex sub-agent defaults could not be safely removed: ${stripped.managedDefaultsError}. ` + + message: + `${routingMessage} Native Codex sub-agent defaults could not be safely removed: ${stripped.managedDefaultsError}. ` + "The ambiguous marker and adjacent value were preserved; inspect $CODEX_HOME/config.toml before using native Codex.", }; } @@ -771,17 +1184,66 @@ export function removeCodexConfig(options: { preserveProfile?: boolean } = {}): * so plain `codex` works when the proxy is stopped. Called by `ocx stop`, the proxy shutdown * handler, and `ocx restore`. Idempotent + atomic. */ -export function restoreNativeCodex(): { success: boolean; message: string } { +/** + * Restore native Codex, running history in a Worker under H. + * + * Prefer this everywhere. The synchronous variant below exists only for the + * process-exit path, where awaiting a thread is its own hazard. + */ +export async function restoreNativeCodexAsync(): Promise<{ + success: boolean; + message: string; +}> { + const inline = restoreNativeCodex({ skipHistory: true }); + const outcome = await runCodexHistoryJob({ + ...resolveCodexHistoryJobTarget(), + operation: deriveCodexHistoryOperation({ + direction: "restore", + // Restore always returns history to native when it runs at all; the + // opt-out belongs to apply, which is what put opencodex there. + resumeHistory: true, + legacyMode: false, + }), + }); + const historyMsg = + outcome.kind === "converged" + ? outcome.rows > 0 + ? ` Resume history restored from opencodex backup (${outcome.rows} thread(s)).` + : "" + : outcome.kind === "skipped" + ? "" + : // A lock we could not take is reported, never counted as nothing to do. + ` ⚠️ Codex resume history could NOT be restored — the Codex app appears to be holding the history database. Close Codex and run \`ocx restore\` again.`; + return { success: inline.success, message: `${inline.message}${historyMsg}` }; +} + +export function restoreNativeCodex(options: { skipHistory?: boolean } = {}): { + success: boolean; + message: string; +} { const activeProvider = currentExternalCodexModelProvider(); if (activeProvider) { removeJournal(); - return { success: true, message: `External Codex provider ${tomlString(activeProvider)} preserved; no native restore was needed.` }; + return { + success: true, + message: `External Codex provider ${tomlString(activeProvider)} preserved; no native restore was needed.`, + }; } const journal = restoreJournalState(); const cfg = journal.configRestored - ? { success: true, message: "Codex config restored from opencodex journal." } - : removeCodexConfig({ preserveProfile: journal.profileRestored || journal.profileChanged }); - const cat = restoreCodexCatalog(); + ? { + success: true, + message: "Codex config restored from opencodex journal.", + } + : removeCodexConfig({ + preserveProfile: journal.profileRestored || journal.profileChanged, + }); + const owningCodexHome = getCodexHome(); + const restoredCatalog = withCatalogWriteSerialization(owningCodexHome, permit => restoreCodexCatalogWithPermit(permit, owningCodexHome)); + const cat = + restoredCatalog.kind === "completed" + ? restoredCatalog.value + : { removed: 0, kept: 0, path: DEFAULT_CATALOG_PATH }; // Design B (loopback) steady state: threads are already tagged openai, so prove the // no-op with a readonly probe instead of write-opening a DB the Codex app may hold // (Windows: WAL writer lock -> seconds of stalling + a false warning on every stop). @@ -789,11 +1251,20 @@ export function restoreNativeCodex(): { success: boolean; message: string } { let skipWhenProvablyNoop = false; try { skipWhenProvablyNoop = !shouldInjectApiAuthHeader(loadConfig()); - } catch { /* unreadable config: keep the conservative write-open restore */ } - const history = syncCodexHistoryProvider("openai", undefined, undefined, { skipWhenProvablyNoop }); - const msg = cat.removed > 0 - ? `${cfg.message} Catalog restored to ${cat.kept} native model(s) (dropped ${cat.removed} proxy-routed).` - : cfg.message; + } catch { + /* unreadable config: keep the conservative write-open restore */ + } + // `skipHistory` is how the async wrapper takes this work for itself: the + // native files come down here, and history runs in the Worker under H. + const history = options.skipHistory + ? { rows: 0, files: 0 } + : syncCodexHistoryProvider("openai", undefined, undefined, { + skipWhenProvablyNoop, + }); + const msg = + cat.removed > 0 + ? `${cfg.message} Catalog restored to ${cat.kept} native model(s) (dropped ${cat.removed} proxy-routed).` + : cfg.message; const historyMsg = history.failed ? ` ⚠️ Codex resume history could NOT be restored — the Codex app appears to be holding the history DB. Close the Codex app/IDE and run 'ocx stop' again; until then routed threads stay hidden in the native app.` : history.rows > 0 diff --git a/src/codex/integration-record.ts b/src/codex/integration-record.ts new file mode 100644 index 000000000..e595d81a5 --- /dev/null +++ b/src/codex/integration-record.ts @@ -0,0 +1,266 @@ +/** + * The sole reader/writer for integrations/codex.json. + * + * Provenance once disappeared when a corrupt record was treated as an empty one + * (005_disable_leaves_a_broken_file.md). Reads therefore validate the complete + * known shape, and updates refuse malformed bytes instead of manufacturing a new + * baseline over evidence we can no longer trust. + */ +import { mkdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { atomicWriteFile, getConfigDir, withConfigMutationLockSync } from "../config"; +import type { + CodexArtifactId, + CodexIntegrationRecord, + CodexProvenanceEntry, + CodexProvenanceLedger, +} from "./convergence-types"; + +const RECORD_FILENAME = "codex.json"; +const LEGACY_TRANSITION_KEYS = new Set([ + "nativeGeneration", + "currentTxId", + "generation", + "history", + "historySchedule", +]); + +function recordPath(): string { + return join(getConfigDir(), "integrations", RECORD_FILENAME); +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isMissingPathError(error: unknown): boolean { + return error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT"; +} + +function invalid(message: string) { + return { kind: "invalid" as const, message }; +} + +function validateArtifact(value: unknown): value is CodexArtifactId { + if (!isPlainRecord(value) || typeof value.kind !== "string") return false; + switch (value.kind) { + case "config": + case "generated-profile": + case "models-cache": + case "injection-journal": + return true; + case "active-catalog": + case "history-manifest": + case "history-rollout": + return typeof value.canonicalPath === "string" + && (value.kind === "active-catalog" || typeof value.stateDbId === "string"); + case "catalog-backup": + return (value.form === "hashed" || value.form === "legacy") + && typeof value.canonicalPath === "string"; + case "history-row": + case "history-manifest-entry": + return typeof value.stateDbId === "string" && typeof value.threadId === "string"; + default: + return false; + } +} + +function validateBaseline(value: unknown): boolean { + if (!isPlainRecord(value)) return false; + if (value.kind === "absent") return true; + return value.kind === "present" + && typeof value.sha256 === "string" + && typeof value.bytesBase64 === "string"; +} + +function validateEntry(value: unknown): value is CodexProvenanceEntry { + return isPlainRecord(value) + && validateArtifact(value.artifact) + && validateBaseline(value.baseline) + && (typeof value.postImage === "string" || value.postImage === null) + && typeof value.txId === "string" + && typeof value.at === "string"; +} + +function validateLedger(value: unknown): value is CodexProvenanceLedger { + return isPlainRecord(value) + && Array.isArray(value.entries) + && value.entries.every(validateEntry); +} + +function validateRecord(value: unknown): value is CodexIntegrationRecord { + return isPlainRecord(value) + && value.version === 1 + && !Object.keys(value).some(key => LEGACY_TRANSITION_KEYS.has(key)) + && (value.provenance === undefined || validateLedger(value.provenance)); +} + +function readIntegrationRecordUnlocked() { + let raw: string; + try { + raw = readFileSync(recordPath(), "utf8"); + } catch (error) { + if (isMissingPathError(error)) return { kind: "missing" as const, record: null }; + return invalid(`Codex integration record is unreadable: ${error instanceof Error ? error.message : String(error)}`); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw.replace(/^\uFEFF/, "")); + } catch { + return invalid("Codex integration record contains invalid JSON"); + } + if (!validateRecord(parsed)) { + return invalid("Codex integration record has an unsupported or malformed v1 shape"); + } + return { kind: "ready" as const, record: parsed }; +} + +export const readIntegrationRecord = readIntegrationRecordUnlocked; + +function copyUnknown( + previous: Record, + next: Record, + knownKeys: ReadonlySet, +): Record { + const merged: Record = {}; + for (const [key, value] of Object.entries(previous)) { + if (!knownKeys.has(key)) merged[key] = value; + } + return { ...merged, ...next }; +} + +function artifactKnownKeys(artifact: CodexArtifactId): ReadonlySet { + switch (artifact.kind) { + case "config": + case "generated-profile": + case "models-cache": + case "injection-journal": + return new Set(["kind"]); + case "active-catalog": + return new Set(["kind", "canonicalPath"]); + case "catalog-backup": + return new Set(["kind", "form", "canonicalPath"]); + case "history-row": + case "history-manifest-entry": + return new Set(["kind", "stateDbId", "threadId"]); + case "history-manifest": + case "history-rollout": + return new Set(["kind", "stateDbId", "canonicalPath"]); + } +} + +function mergeArtifact(previous: CodexArtifactId, next: CodexArtifactId): CodexArtifactId { + return copyUnknown( + previous as CodexArtifactId & Record, + next as CodexArtifactId & Record, + artifactKnownKeys(previous), + ) as CodexArtifactId; +} + +function mergeBaseline( + previous: CodexProvenanceEntry["baseline"], + next: CodexProvenanceEntry["baseline"], +): CodexProvenanceEntry["baseline"] { + const known = previous.kind === "present" + ? new Set(["kind", "sha256", "bytesBase64"]) + : new Set(["kind"]); + return copyUnknown(previous, next, known) as CodexProvenanceEntry["baseline"]; +} + +function knownArtifactIdentity(artifact: CodexArtifactId): string { + switch (artifact.kind) { + case "config": + case "generated-profile": + case "models-cache": + case "injection-journal": + return artifact.kind; + case "active-catalog": + return `${artifact.kind}\0${artifact.canonicalPath}`; + case "catalog-backup": + return `${artifact.kind}\0${artifact.form}\0${artifact.canonicalPath}`; + case "history-row": + case "history-manifest-entry": + return `${artifact.kind}\0${artifact.stateDbId}\0${artifact.threadId}`; + case "history-manifest": + case "history-rollout": + return `${artifact.kind}\0${artifact.stateDbId}\0${artifact.canonicalPath}`; + } +} + +function mergeEntry(previous: CodexProvenanceEntry, next: CodexProvenanceEntry): CodexProvenanceEntry { + const merged = copyUnknown( + previous, + next, + new Set(["artifact", "baseline", "postImage", "txId", "at"]), + ); + return { + ...merged, + artifact: mergeArtifact(previous.artifact, next.artifact), + baseline: mergeBaseline(previous.baseline, next.baseline), + } as CodexProvenanceEntry; +} + +function mergeLedger(previous: CodexProvenanceLedger, next: CodexProvenanceLedger): CodexProvenanceLedger { + const unused = new Set(previous.entries.map((_, index) => index)); + const entries = next.entries.map((entry, nextIndex) => { + let previousIndex = previous.entries.findIndex((candidate, index) => + unused.has(index) + && candidate.txId === entry.txId + && candidate.at === entry.at + && knownArtifactIdentity(candidate.artifact) === knownArtifactIdentity(entry.artifact)); + if (previousIndex < 0 && unused.has(nextIndex)) previousIndex = nextIndex; + if (previousIndex < 0) return entry; + unused.delete(previousIndex); + return mergeEntry(previous.entries[previousIndex]!, entry); + }); + return { + ...copyUnknown(previous, next, new Set(["entries"])), + entries, + } as CodexProvenanceLedger; +} + +function preserveExtensions( + previous: CodexIntegrationRecord, + next: CodexIntegrationRecord, +): CodexIntegrationRecord { + const merged = copyUnknown(previous, next, new Set(["version", "provenance"])); + if (previous.provenance && next.provenance) { + merged.provenance = mergeLedger(previous.provenance, next.provenance); + } + return merged as CodexIntegrationRecord; +} + +/** + * Keep read, extension merge, and atomic replacement inside the config mutation + * coordinator. WP12 must call this function instead of recreating the incident's + * stale read/merge/write sequence at its own call site. + */ +export const updateIntegrationRecord = ( + mutate: (record: CodexIntegrationRecord) => CodexIntegrationRecord, +) => { + try { + return withConfigMutationLockSync(() => { + const read = readIntegrationRecordUnlocked(); + if (read.kind === "invalid") return read; + const previous: CodexIntegrationRecord = read.kind === "ready" ? read.record : { version: 1 }; + const proposed = mutate(previous); + if (!validateRecord(proposed)) { + return { kind: "invalid", message: "Codex integration record update produced a malformed v1 shape" }; + } + const record = preserveExtensions(previous, proposed); + if (!validateRecord(record)) { + return { kind: "invalid", message: "Codex integration record extension merge produced a malformed v1 shape" }; + } + const path = recordPath(); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + atomicWriteFile(path, `${JSON.stringify(record, null, 2)}\n`); + return { kind: "updated", record }; + }); + } catch (error) { + return { + kind: "invalid", + message: `Codex integration record update failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } +}; diff --git a/src/codex/internal/catalog-writer.ts b/src/codex/internal/catalog-writer.ts new file mode 100644 index 000000000..370bbda95 --- /dev/null +++ b/src/codex/internal/catalog-writer.ts @@ -0,0 +1,203 @@ +import { chmodSync, linkSync, mkdirSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; + +import { + AtomicWriteResidualTempError, + AtomicWriteSecretResidualError, + atomicWriteFile, + resolveWriteTarget, + type AtomicWriteIO, +} from "../../config"; +import { + assertCatalogWritePermit, + type CatalogWritePermit, +} from "../catalog-write-serialization"; +import { + forgetEphemeralSecretPath, + hardenSecretPath, +} from "../../lib/windows-secret-acl"; + +export interface PreparedCatalogFileWrite { + readonly path: string; + readonly content: string; +} + +export type CatalogBackupPublication = "written" | "preserved"; + +export interface CatalogBackupWriteIO { + readonly resolveTarget: (path: string) => string; + readonly write: (path: string, content: string) => void; + readonly harden: (path: string) => void; + /** Must fail with EEXIST rather than replacing an existing destination. */ + readonly publishNoReplace: (source: string, destination: string) => void; + readonly truncate: (path: string) => void; + readonly unlink: (path: string) => void; +} + +let backupTempSequence = 0; + +function isMissingPathError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT"; +} + +function isExistingPathError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | undefined)?.code === "EEXIST"; +} + +function defaultBackupWriteIO(destinationPath: string): CatalogBackupWriteIO { + return { + resolveTarget: resolveWriteTarget, + write: (path, content) => writeFileSync(path, content, { encoding: "utf8", mode: 0o600 }), + harden: path => { + try { chmodSync(path, 0o600); } catch { /* platform may ignore chmod */ } + if (process.platform === "win32") { + hardenSecretPath(path, { required: true, timeoutMemoKey: destinationPath }); + } + }, + publishNoReplace: linkSync, + truncate: path => truncateSync(path, 0), + unlink: unlinkSync, + }; +} + +function removePublishedTemp(tempPath: string, io: CatalogBackupWriteIO): void { + try { + io.unlink(tempPath); + forgetEphemeralSecretPath(tempPath); + return; + } catch (firstError) { + if (isMissingPathError(firstError)) { + forgetEphemeralSecretPath(tempPath); + return; + } + try { + io.unlink(tempPath); + forgetEphemeralSecretPath(tempPath); + return; + } catch (retryError) { + if (isMissingPathError(retryError)) { + forgetEphemeralSecretPath(tempPath); + return; + } + throw new AtomicWriteResidualTempError(tempPath, true, { cause: retryError }); + } + } +} + +function scrubAndRemoveUnpublishedTemp( + tempPath: string, + hardened: boolean, + io: CatalogBackupWriteIO, + cause: unknown, +): void { + let scrubbed = false; + try { + io.truncate(tempPath); + scrubbed = true; + } catch (error) { + if (isMissingPathError(error)) scrubbed = true; + else { + try { + io.write(tempPath, ""); + scrubbed = true; + } catch { /* removal may still succeed */ } + } + } + + let removed = false; + try { + io.unlink(tempPath); + removed = true; + } catch (error) { + if (isMissingPathError(error)) removed = true; + else { + try { + io.unlink(tempPath); + removed = true; + } catch (retryError) { + if (isMissingPathError(retryError)) removed = true; + } + } + } + + if (!removed && !scrubbed) { + throw new AtomicWriteSecretResidualError(tempPath, { cause }); + } + if (!removed && !hardened) { + try { + io.harden(tempPath); + hardened = true; + } catch { /* zero-byte residual is reported honestly */ } + } + if (removed) forgetEphemeralSecretPath(tempPath); + if (!removed) throw new AtomicWriteResidualTempError(tempPath, hardened, { cause }); +} + +function publishCatalogBackup( + prepared: PreparedCatalogFileWrite, + suppliedIo?: CatalogBackupWriteIO, +): CatalogBackupPublication { + const io = suppliedIo ?? defaultBackupWriteIO(prepared.path); + const target = io.resolveTarget(prepared.path); + if (!suppliedIo) mkdirSync(dirname(target), { recursive: true, mode: 0o700 }); + const tempPath = `${target}.ocx.${process.pid}.backup.${++backupTempSequence}.tmp`; + let hardened = false; + + try { + io.write(tempPath, prepared.content); + io.harden(tempPath); + hardened = true; + io.publishNoReplace(tempPath, target); + } catch (error) { + scrubAndRemoveUnpublishedTemp(tempPath, hardened, io, error); + if (isExistingPathError(error)) return "preserved"; + throw error; + } + + removePublishedTemp(tempPath, io); + return "written"; +} + +/** Replace the active catalog with the caller's already-prepared bytes. */ +export function replaceActiveCodexCatalog( + permit: CatalogWritePermit, + owningCodexHome: string, + prepared: PreparedCatalogFileWrite, + io?: AtomicWriteIO, +): void { + assertCatalogWritePermit(permit, owningCodexHome); + atomicWriteFile(prepared.path, prepared.content, io); +} + +/** Atomically publish the catalog-path-keyed immutable backup without clobbering. */ +export function publishHashedCodexCatalogBackup( + permit: CatalogWritePermit, + owningCodexHome: string, + prepared: PreparedCatalogFileWrite, + io?: CatalogBackupWriteIO, +): CatalogBackupPublication { + assertCatalogWritePermit(permit, owningCodexHome); + return publishCatalogBackup(prepared, io); +} + +/** Atomically publish the legacy immutable backup without clobbering. */ +export function publishLegacyCodexCatalogBackup( + permit: CatalogWritePermit, + owningCodexHome: string, + prepared: PreparedCatalogFileWrite, + io?: CatalogBackupWriteIO, +): CatalogBackupPublication { + assertCatalogWritePermit(permit, owningCodexHome); + return publishCatalogBackup(prepared, io); +} + +/** Replace Codex's models cache with the caller's already-prepared bytes. */ +export function replaceCodexModelsCache( + permit: CatalogWritePermit, + owningCodexHome: string, + prepared: PreparedCatalogFileWrite, + io?: AtomicWriteIO, +): void { + assertCatalogWritePermit(permit, owningCodexHome); + atomicWriteFile(prepared.path, prepared.content, io); +} diff --git a/src/codex/internal/history-writer.ts b/src/codex/internal/history-writer.ts new file mode 100644 index 000000000..820c91211 --- /dev/null +++ b/src/codex/internal/history-writer.ts @@ -0,0 +1,80 @@ +/** + * The history mutation boundary. Every byte Codex history owns is written here, + * and only while H is held. + * + * This module exists because the mutations are spread across three surfaces that + * do not share a transaction: SQLite rows, the backup manifest, and the rollout + * files. `syncCodexHistoryProvider` writes the manifest BEFORE its database + * transaction and patches rollouts inside it; restore writes rollouts, then the + * database, then the manifest, then ejects again + * (`src/codex/history-provider.ts:606-648,656-698`). A SQLite busy timeout + * serializes exactly one of those three, which is why an opposite-direction + * process could overtake through the other two. + * + * The permit argument is not decoration and not a type-level claim. Each entry + * point asks the lock owner at RUNTIME whether the permit it was handed is still + * live for the state database about to be written, because a permit leaked past + * its callback type-checks perfectly. A writer reached without H therefore fails + * closed rather than racing. + * + * Reachability: `src/codex/history-worker.ts` is the only permitted production + * root. Readers and probes stay in `history-provider.ts`; nothing in the CLI, the + * server, the guardian, `inject.ts` or `sync.ts` may reach these symbols. + * + * Design record: devlog/_plan/260804_codex_write_substrate/020_history_isolation.md. + */ +import { assertHistoryWritePermit, type HistoryWritePermit } from "../history-lock"; +import { + restoreLegacyOpenaiHistory, + syncCodexHistoryProvider, + type CodexHistoryProvider, + type CodexHistorySyncResult, +} from "../history-provider"; + +/** + * Everything a history mutation needs, with no ambient state. + * + * The paths are explicit because a Worker is a separate process: it does not + * inherit the module-load `CODEX_HOME` that `history-provider.ts` resolves at + * import time (`:16`, `:22`), so a request that relied on those constants would + * silently address the wrong home. + */ +export interface HistoryWriteTarget { + /** Canonical, absolute; the same identity H was acquired for. */ + readonly canonicalStateDbPath: string; + /** Canonical, absolute path of the backup manifest for that database. */ + readonly canonicalBackupPath: string; +} + +/** + * Apply opencodex routing to resumable history, or restore it to native. + * + * `provider` is the DURABLE operation's direction, resolved by the caller from + * the coordinator row — never a caller-supplied preference. The Worker passes + * what the row said, which is what stops a request from turning a restore into + * an apply. + */ +export function writeHistoryProviderTransition( + permit: HistoryWritePermit, + target: HistoryWriteTarget, + provider: CodexHistoryProvider, +): CodexHistorySyncResult { + assertHistoryWritePermit(permit, target.canonicalStateDbPath); + return syncCodexHistoryProvider(provider, target.canonicalStateDbPath, target.canonicalBackupPath); +} + +/** + * Manifest-independent legacy ejection. + * + * Distinct from the generic restore above: it never reads, consumes, deletes or + * replaces the backup manifest. It does patch rollout metadata and returns a + * `files` count, so calling it "DB-only" would be wrong — the name says what it + * actually avoids. + */ +export function writeLegacyOpenaiHistoryRecovery( + permit: HistoryWritePermit, + target: HistoryWriteTarget, +): { rows: number; files: number; failed?: true } { + assertHistoryWritePermit(permit, target.canonicalStateDbPath); + return restoreLegacyOpenaiHistory(target.canonicalStateDbPath); +} diff --git a/src/codex/journal.ts b/src/codex/journal.ts index f0985c144..910861cd3 100644 --- a/src/codex/journal.ts +++ b/src/codex/journal.ts @@ -5,7 +5,16 @@ import { atomicWriteFile } from "../config"; import { hasInjectedCodexRouting } from "./injected-marker"; import { CODEX_HOME, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths"; -const JOURNAL_PATH = join(CODEX_HOME, "opencodex-journal.json"); +/** + * Exported so that anything reasoning ABOUT the journal points at the journal. + * + * The Codex admission snapshot re-derived this path by hand and got it wrong in + * both halves — wrong directory and wrong filename — so its "journal identity" + * watched a file nothing writes. The fixture re-derived it the same wrong way, + * agreed with the producer, and the pair stayed green. One exported constant + * removes the opportunity. + */ +export const JOURNAL_PATH = join(CODEX_HOME, "opencodex-journal.json"); interface Journal { version: 1; diff --git a/src/codex/management-convergence.ts b/src/codex/management-convergence.ts new file mode 100644 index 000000000..b9ad903dc --- /dev/null +++ b/src/codex/management-convergence.ts @@ -0,0 +1,114 @@ +import type { OcxConfig } from "../types"; +import { captureCatalogAdmissionSnapshot } from "./catalog-admission"; +import { convergeCodexCatalog } from "./convergence"; +import type { + CatalogDisposition, + CatalogOnlyOutcome, + CodexHistoryState, + CodexObservedState, + ConvergeCodex, + ProjectCatalogOnlyOutcomeInput, +} from "./convergence-types"; + +function notEvaluatedHistory(): CodexHistoryState { + return { + status: "not-evaluated", + attempts: 0, + nextRetryAt: null, + txId: null, + pendingRows: null, + backupEntries: null, + }; +} + +function notEvaluatedObserved(history: CodexHistoryState): CodexObservedState { + return { + aggregate: "not-evaluated", + isApplied: null, + desired: "unknown", + converged: null, + authority: { service: "unknown", externalProvider: null }, + surfaces: { + config: "not-evaluated", + profile: "not-evaluated", + catalog: "not-evaluated", + cache: "not-evaluated", + journal: "not-evaluated", + history: { + state: history, + database: "not-evaluated", + manifest: "not-evaluated", + rollouts: "not-evaluated", + }, + provenance: { + state: "not-evaluated", + nativeGeneration: null, + currentTxId: null, + }, + }, + }; +} + +function unexpectedCatalogFailure(commitBegan: boolean): CatalogDisposition { + return { + status: "failed", + reason: "disk", + phase: commitBegan ? "commit" : "gather", + retryable: false, + partialWrite: commitBegan, + }; +} + +function admissionFailure(error: unknown): CatalogDisposition { + const message = error instanceof Error ? error.message : ""; + if (message.includes("config generation is busy") || message.includes("config generation is database")) { + return { status: "skipped", reason: "busy", retryable: true }; + } + return unexpectedCatalogFailure(false); +} + +/** Project catalog work into the shared no-change/not-evaluated outcome shape. */ +export function projectCatalogOnlyOutcome({ + changed, + catalogRefresh, +}: ProjectCatalogOnlyOutcomeInput): CatalogOnlyOutcome { + const history = notEvaluatedHistory(); + return { + kind: "catalog-only", + changed, + observed: notEvaluatedObserved(history), + catalogRefresh, + history, + }; +} + +/** + * Bind the management callback's exact config authority to a catalog-only + * funnel. This module is intentionally not re-exported by a public Codex facade. + */ +export function createManagementConvergeCodex( + config: Readonly, +): ConvergeCodex { + const retainedConfig = config; + return async request => { + let commitBegan = false; + try { + if (request.scope !== "catalog" || request.action !== "converge") { + return projectCatalogOnlyOutcome({ + changed: false, + catalogRefresh: unexpectedCatalogFailure(false), + }); + } + const snapshot = captureCatalogAdmissionSnapshot(retainedConfig); + const result = await convergeCodexCatalog(snapshot, request, { + onCommitBegin: () => { commitBegan = true; }, + }); + return projectCatalogOnlyOutcome(result); + } catch (error) { + return projectCatalogOnlyOutcome({ + changed: false, + catalogRefresh: commitBegan ? unexpectedCatalogFailure(true) : admissionFailure(error), + }); + } + }; +} diff --git a/src/codex/native-main-claim.ts b/src/codex/native-main-claim.ts index 63096425e..0a722eb2e 100644 --- a/src/codex/native-main-claim.ts +++ b/src/codex/native-main-claim.ts @@ -78,7 +78,13 @@ async function openClaimDatabase( file = openStableLockFile(path, platform); const identity = `${file.dev}:${file.ino}`; if (hardenedIdentities.get(path) !== identity) { - await (options.hardenPath ?? hardenStableLockFile)(path); + // The resolved platform is threaded into the DEFAULT hardener, not left to + // `hardenStableLockFile`'s own `process.platform` read. Otherwise a test + // that forces `platform` here still exercises the host's branch, and the + // production default — the thing that actually hardens a coordinator + // database — stays unproved. An audit deleted this call entirely and 89 + // tests stayed green, because nearly every claim test injects `hardenPath`. + await (options.hardenPath ?? ((target: string) => hardenStableLockFile(target, platform)))(path); assertStableLockFile(path, file); hardenedIdentities.set(path, identity); } diff --git a/src/codex/native-main-lock-file.ts b/src/codex/native-main-lock-file.ts index 92021562b..af22cda20 100644 --- a/src/codex/native-main-lock-file.ts +++ b/src/codex/native-main-lock-file.ts @@ -124,9 +124,35 @@ export function assertStableLockFile(path: string, handle: StableLockFile): void } } -export async function hardenStableLockFile(path: string): Promise { - try { chmodSync(path, 0o600); } catch { /* Windows ACL below is authoritative there. */ } - if (process.platform === "win32") { - await hardenSecretPathAsync(path, { required: true, timeoutMemoKey: path }); +/** + * Harden the stable lock file: chmod everywhere, per-user NTFS ACLs on Windows. + * + * The platform is a parameter rather than a direct `process.platform` read so the + * Windows branch is reachable from a test. It was not, and an audit deleted the + * whole ACL delegation without a single test noticing — the primitive underneath + * had 73 tests while the call edge that activates it had none. That is the same + * absence-as-guarantee defect one layer up: proving a mechanism works says nothing + * about whether production still calls it. + * + * `required: true` is deliberate: a failure to apply the ACL must reject rather + * than leave a coordinator database readable by other accounts. + */ +export async function hardenStableLockFile( + path: string, + platform: NodeJS.Platform = process.platform, +): Promise { + if (platform === "win32") { + // Best-effort here: POSIX modes are not authoritative on NTFS, and the + // required ACL hardening below is what actually decides. + try { chmodSync(path, 0o600); } catch { /* ACL below is authoritative. */ } + await hardenSecretPathAsync(path, { required: true }); + return; } + // On POSIX the mode IS the mechanism, so a failure may not be swallowed. + // + // The previous unconditional catch was a real fail-open: a coordinator + // database that already existed with permissive bits stayed permissive, and + // the caller was told the lock file had been hardened. Creation mode 0600 does + // not repair an existing file, and there is no ACL fallback outside Windows. + chmodSync(path, 0o600); } diff --git a/src/codex/native-main-owner.ts b/src/codex/native-main-owner.ts index 4736ccbe8..70aed9cb6 100644 --- a/src/codex/native-main-owner.ts +++ b/src/codex/native-main-owner.ts @@ -144,7 +144,13 @@ async function prepareOwnerDatabase(entry: OwnerEntry): Promise { try { file = openStableLockFile(entry.lockPath, entry.options.platform); database = new Database(entry.lockPath, { create: true }); - await (entry.options.hardenPath ?? hardenStableLockFile)(entry.lockPath); + // The resolved platform is threaded into the DEFAULT hardener for the same + // reason the claim path does it: otherwise a test that forces `platform` + // here still exercises the host's branch, and the production default — the + // thing that actually hardens the owner's lock file — stays unproved. An + // audit replaced this fallback with a no-op and 91 tests stayed green. + await (entry.options.hardenPath + ?? ((target: string) => hardenStableLockFile(target, entry.options.platform)))(entry.lockPath); assertStableLockFile(entry.lockPath, file); entry.file = file; file = undefined; diff --git a/src/codex/native-residue.ts b/src/codex/native-residue.ts new file mode 100644 index 000000000..1f3bd6551 --- /dev/null +++ b/src/codex/native-residue.ts @@ -0,0 +1,557 @@ +import { createHash } from "node:crypto"; +import { lstatSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs"; +import type { Stats } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; + +import { Database } from "bun:sqlite"; + +import { getConfigDir } from "../config"; +import { catalogHasRoutedEntries, parseCatalogJson } from "./catalog/parsing"; +import { + hasInjectedCodexRouting, + OCX_SECTION_MARKER, + providerTableString, + rootTomlString, +} from "./injected-marker"; +import { + CODEX_CONFIG_PATH, + CODEX_MODELS_CACHE_PATH, + CODEX_PROFILE_PATH, + DEFAULT_CATALOG_PATH, + getCodexHome, + readRootTomlString, +} from "./paths"; + +export type NativeResidueSurface = + | "config" + | "profile" + | "catalog" + | "models-cache" + | "journal" + | "partial-write" + | "history" + | "history-backup"; + +export type NativeRoutedResidueResult = + | { kind: "clean" } + | { kind: "residue"; surface: NativeResidueSurface; path: string } + | { kind: "indeterminate"; surface: NativeResidueSurface; path: string; reason: string }; + +type ReadResult = + | { kind: "absent" } + | { kind: "content"; content: string; path: string } + | { kind: "indeterminate"; reason: string }; + +type PathResult = + | { kind: "absent" } + | { kind: "path"; path: string; stat: Stats } + | { kind: "indeterminate"; reason: string }; + +type CatalogTarget = { + path: string; + configured: boolean; +}; + +type ConfigObservation = { + classification: NativeRoutedResidueResult; + catalogTargets: CatalogTarget[]; +}; + +type RolloutReference = { + id: string; + path: string; +}; + +const CONFIG_FILE_NAME = basename(CODEX_CONFIG_PATH); +const PROFILE_FILE_NAME = basename(CODEX_PROFILE_PATH); +const CATALOG_FILE_NAME = basename(DEFAULT_CATALOG_PATH); +const MODELS_CACHE_FILE_NAME = basename(CODEX_MODELS_CACHE_PATH); +const JOURNAL_FILE_NAME = "opencodex-journal.json"; +const HISTORY_DATABASE_FILE_NAME = "state_5.sqlite"; +const ROUTED_CATALOG_DESCRIPTION_PREFIX = "Routed via opencodex → "; + +function errorCode(error: unknown): string | undefined { + return (error as NodeJS.ErrnoException | undefined)?.code; +} + +function errorReason(error: unknown): string { + if (error instanceof Error) return `${error.name}: ${error.message}`; + return String(error); +} + +function sameStat( + left: Stats, + right: Stats, +): boolean { + return left.dev === right.dev + && left.ino === right.ino + && left.size === right.size + && left.mtimeMs === right.mtimeMs; +} + +function resolveRegularFile(path: string): PathResult { + let entry; + try { + entry = lstatSync(path); + } catch (error) { + if (errorCode(error) === "ENOENT") return { kind: "absent" }; + return { kind: "indeterminate", reason: errorReason(error) }; + } + + let target = path; + if (entry.isSymbolicLink()) { + try { + target = realpathSync.native(path); + } catch (error) { + return { kind: "indeterminate", reason: `unresolvable symlink: ${errorReason(error)}` }; + } + } + + try { + const before = statSync(target); + if (!before.isFile()) { + return { kind: "indeterminate", reason: "surface is not a regular file" }; + } + return { kind: "path", path: target, stat: before }; + } catch (error) { + return { kind: "indeterminate", reason: errorReason(error) }; + } +} + +function readRegularFile(path: string): ReadResult { + const resolved = resolveRegularFile(path); + if (resolved.kind !== "path") return resolved; + try { + const content = readFileSync(resolved.path, "utf8"); + const after = statSync(resolved.path); + if (!sameStat(resolved.stat, after)) { + return { kind: "indeterminate", reason: "surface changed while it was being observed" }; + } + return { kind: "content", content, path: resolved.path }; + } catch (error) { + return { kind: "indeterminate", reason: errorReason(error) }; + } +} + +function indeterminate( + surface: NativeResidueSurface, + path: string, + reason: string, +): NativeRoutedResidueResult { + return { kind: "indeterminate", surface, path, reason }; +} + +function classifyToml( + surface: "config" | "profile", + path: string, + classify: (content: string) => "clean" | "residue" | "indeterminate", +): NativeRoutedResidueResult { + const read = readRegularFile(path); + if (read.kind === "absent") return { kind: "clean" }; + if (read.kind === "indeterminate") return indeterminate(surface, path, read.reason); + try { + Bun.TOML.parse(read.content); + } catch (error) { + return indeterminate(surface, path, `malformed TOML: ${errorReason(error)}`); + } + const result = classify(read.content); + if (result === "residue") return { kind: "residue", surface, path: read.path }; + if (result === "indeterminate") { + return indeterminate(surface, read.path, "OpenCodex-shaped TOML does not match a complete routed grammar"); + } + return { kind: "clean" }; +} + +function catalogPathKey(path: string): string { + const normalized = resolve(path); + return process.platform === "win32" ? normalized.toLowerCase() : normalized; +} + +function catalogTargets( + codexHome: string, + configuredPaths: readonly string[] = [], +): CatalogTarget[] { + const targets = new Map(); + const add = (path: string, configured: boolean) => { + const key = catalogPathKey(path); + const existing = targets.get(key); + targets.set(key, { path: resolve(path), configured: configured || existing?.configured === true }); + }; + for (const configuredPath of configuredPaths) { + add(resolve(codexHome, configuredPath), true); + } + add(join(codexHome, CATALOG_FILE_NAME), false); + return [...targets.values()]; +} + +function inspectConfig(codexHome: string, path: string): ConfigObservation { + const read = readRegularFile(path); + if (read.kind === "absent") { + return { classification: { kind: "clean" }, catalogTargets: catalogTargets(codexHome) }; + } + if (read.kind === "indeterminate") { + return { + classification: indeterminate("config", path, read.reason), + catalogTargets: catalogTargets(codexHome), + }; + } + + const productionConfiguredPath = readRootTomlString(read.content, "model_catalog_json"); + const productionConfiguredPaths = productionConfiguredPath === null + ? [] + : [productionConfiguredPath]; + let parsed: unknown; + try { + parsed = Bun.TOML.parse(read.content.replace(/^\uFEFF/, "")); + } catch (error) { + return { + classification: indeterminate("config", read.path, `malformed TOML: ${errorReason(error)}`), + catalogTargets: catalogTargets(codexHome, productionConfiguredPaths), + }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { + classification: indeterminate("config", read.path, "TOML root is not a table"), + catalogTargets: catalogTargets(codexHome, productionConfiguredPaths), + }; + } + + const document = parsed as Record; + let targets: CatalogTarget[]; + if (!Object.hasOwn(document, "model_catalog_json")) { + targets = catalogTargets(codexHome, productionConfiguredPaths); + } else if (typeof document.model_catalog_json !== "string" || !document.model_catalog_json.trim()) { + return { + classification: indeterminate("config", read.path, "model_catalog_json must be one non-empty string"), + catalogTargets: catalogTargets(codexHome, productionConfiguredPaths), + }; + } else { + try { + targets = catalogTargets(codexHome, [ + ...productionConfiguredPaths, + document.model_catalog_json, + ]); + } catch (error) { + return { + classification: indeterminate("config", read.path, `model_catalog_json cannot be resolved: ${errorReason(error)}`), + catalogTargets: catalogTargets(codexHome, productionConfiguredPaths), + }; + } + } + + let classification: NativeRoutedResidueResult = { kind: "clean" }; + if (hasInjectedCodexRouting(read.content)) { + classification = { kind: "residue", surface: "config", path: read.path }; + } else { + const hasMarker = read.content.includes(OCX_SECTION_MARKER); + const provider = rootTomlString(read.content, "model_provider"); + const providerBaseUrl = providerTableString(read.content, "opencodex", "base_url"); + if (hasMarker || provider === "opencodex" || providerBaseUrl !== null) { + classification = indeterminate( + "config", + read.path, + "OpenCodex-shaped TOML does not match a complete routed grammar", + ); + } + } + return { classification, catalogTargets: targets }; +} + +function classifyProfile(path: string): NativeRoutedResidueResult { + return classifyToml("profile", path, content => { + const generatedFallback = content.startsWith("# OpenCodex proxy fallback config (Design B)") + && rootTomlString(content, "openai_base_url") !== null; + const generatedNamedProfile = content.startsWith("# OpenCodex proxy profile — use with:") + && hasInjectedCodexRouting(content); + if (generatedFallback || generatedNamedProfile) return "residue"; + return "indeterminate"; + }); +} + +function isOcxRoutedCatalogEntry(entry: Record): boolean { + return typeof entry.description === "string" + && entry.description.startsWith(ROUTED_CATALOG_DESCRIPTION_PREFIX); +} + +function classifyCatalogLike( + surface: "catalog" | "models-cache", + path: string, + configured = false, +): NativeRoutedResidueResult { + const read = readRegularFile(path); + if (read.kind === "absent") { + return configured + ? indeterminate(surface, path, "configured catalog target is absent") + : { kind: "clean" }; + } + if (read.kind === "indeterminate") return indeterminate(surface, path, read.reason); + const catalog = parseCatalogJson(read.content); + if (!catalog) return indeterminate(surface, path, "malformed catalog JSON"); + if ((catalog.models ?? []).some(isOcxRoutedCatalogEntry)) { + return { kind: "residue", surface, path: read.path }; + } + if (catalogHasRoutedEntries(catalog)) { + return indeterminate(surface, read.path, "routed catalog rows lack the OpenCodex authorship signature"); + } + return { kind: "clean" }; +} + +function isJournal(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const journal = value as Record; + return journal.version === 1 + && typeof journal.originalConfig === "string" + && (journal.originalProfile === null || typeof journal.originalProfile === "string") + && typeof journal.pid === "number" + && Number.isInteger(journal.pid) + && typeof journal.timestamp === "string"; +} + +function classifyJournal(path: string): NativeRoutedResidueResult { + const read = readRegularFile(path); + if (read.kind === "absent") return { kind: "clean" }; + if (read.kind === "indeterminate") return indeterminate("journal", path, read.reason); + let parsed: unknown; + try { + parsed = JSON.parse(read.content); + } catch (error) { + return indeterminate("journal", read.path, `malformed journal JSON: ${errorReason(error)}`); + } + return isJournal(parsed) + ? { kind: "residue", surface: "journal", path: read.path } + : indeterminate("journal", read.path, "journal JSON has an unknown or partial shape"); +} + +function classifyPartialWrites(targetPaths: string[]): NativeRoutedResidueResult { + const targetsByParent = new Map }>(); + const addTarget = (path: string) => { + const parent = dirname(path); + const key = catalogPathKey(parent); + const observed = targetsByParent.get(key) ?? { path: parent, names: new Set() }; + observed.names.add(basename(path)); + targetsByParent.set(key, observed); + }; + for (const path of targetPaths) { + addTarget(path); + const resolved = resolveRegularFile(path); + if (resolved.kind === "path") addTarget(resolved.path); + } + + for (const target of targetsByParent.values()) { + let names: string[]; + try { + names = readdirSync(target.path); + } catch (error) { + return indeterminate("partial-write", target.path, errorReason(error)); + } + for (const name of names) { + const match = /^(.*)\.ocx\.\d+\.\d+\.tmp$/.exec(name); + if (match?.[1] && target.names.has(match[1])) { + return indeterminate("partial-write", join(target.path, name), "OpenCodex atomic-write artifact is still present"); + } + } + } + return { kind: "clean" }; +} + +function classifyReferencedRollout( + surface: "history" | "history-backup", + reference: RolloutReference, +): NativeRoutedResidueResult { + const read = readRegularFile(reference.path); + if (read.kind === "absent") { + return indeterminate(surface, reference.path, "referenced rollout is absent"); + } + if (read.kind === "indeterminate") return indeterminate(surface, reference.path, read.reason); + + let first: Record | undefined; + let latest: Record | undefined; + for (const line of read.content.split("\n")) { + if (!line.trim()) continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (error) { + return indeterminate(surface, read.path, `malformed rollout JSONL: ${errorReason(error)}`); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return indeterminate(surface, read.path, "rollout JSONL record is not an object"); + } + const record = parsed as Record; + if (record.type !== "session_meta") continue; + if (!record.payload || typeof record.payload !== "object" || Array.isArray(record.payload)) { + return indeterminate(surface, read.path, "session_meta payload has an unknown shape"); + } + const payload = record.payload as Record; + first ??= payload; + latest = payload; + } + + if (!first || !latest) { + return indeterminate(surface, read.path, "referenced rollout has no session_meta metadata"); + } + for (const [position, payload] of [["first", first], ["latest", latest]] as const) { + if (payload.id !== reference.id) { + return indeterminate(surface, read.path, `${position} session_meta does not identify the referenced thread`); + } + if (typeof payload.model_provider !== "string" || !payload.model_provider) { + return indeterminate(surface, read.path, `${position} session_meta has no provider metadata`); + } + if (payload.model_provider === "opencodex") { + return { kind: "residue", surface, path: read.path }; + } + } + return { kind: "clean" }; +} + +function classifyReferencedRollouts( + surface: "history" | "history-backup", + references: RolloutReference[], +): NativeRoutedResidueResult { + for (const reference of references) { + const result = classifyReferencedRollout(surface, reference); + if (result.kind !== "clean") return result; + } + return { kind: "clean" }; +} + +function classifyHistoryDatabase(path: string): NativeRoutedResidueResult { + const resolved = resolveRegularFile(path); + if (resolved.kind === "absent") { + for (const suffix of ["-wal", "-shm"]) { + const sidecar = resolveRegularFile(`${path}${suffix}`); + if (sidecar.kind !== "absent") { + const reason = sidecar.kind === "indeterminate" + ? sidecar.reason + : "SQLite sidecar exists without its history database"; + return indeterminate("history", `${path}${suffix}`, reason); + } + } + return { kind: "clean" }; + } + if (resolved.kind === "indeterminate") return indeterminate("history", path, resolved.reason); + let database: Database | undefined; + try { + database = new Database(resolved.path, { readonly: true }); + database.exec("PRAGMA busy_timeout = 100"); + const rows = database.query<{ id: string; rollout_path: string; model_provider: string }, []>(` + SELECT id, rollout_path, model_provider + FROM threads + `).all(); + for (const row of rows) { + if (typeof row.id !== "string" || !row.id || typeof row.rollout_path !== "string" || !row.rollout_path) { + return indeterminate("history", resolved.path, "history row has an unknown rollout reference"); + } + if (typeof row.model_provider !== "string" || !row.model_provider) { + return indeterminate("history", resolved.path, "history row has no provider metadata"); + } + } + const rollouts = classifyReferencedRollouts( + "history", + rows.map(row => ({ id: row.id, path: row.rollout_path })), + ); + if (rollouts.kind !== "clean") return rollouts; + const after = statSync(resolved.path); + if (!sameStat(resolved.stat, after)) { + return indeterminate("history", resolved.path, "history database changed while it was being observed"); + } + return rows.some(row => row.model_provider === "opencodex") + ? { kind: "residue", surface: "history", path: resolved.path } + : { kind: "clean" }; + } catch (error) { + return indeterminate("history", resolved.path, `unreadable history database: ${errorReason(error)}`); + } finally { + try { database?.close(); } catch { /* the observation already failed closed */ } + } +} + +function historyBackupPath(stateDatabasePath: string): string { + const normalized = process.platform === "win32" + ? resolve(stateDatabasePath).toLowerCase() + : resolve(stateDatabasePath); + const id = createHash("sha256").update(normalized).digest("hex").slice(0, 16); + return join(getConfigDir(), `codex-history-backup-${id}.json`); +} + +function classifyHistoryBackup(path: string, stateDatabasePath: string): NativeRoutedResidueResult { + const read = readRegularFile(path); + if (read.kind === "absent") return { kind: "clean" }; + if (read.kind === "indeterminate") return indeterminate("history-backup", path, read.reason); + let parsed: unknown; + try { + parsed = JSON.parse(read.content); + } catch (error) { + return indeterminate("history-backup", read.path, `malformed history backup JSON: ${errorReason(error)}`); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return indeterminate("history-backup", read.path, "history backup has an unknown shape"); + } + const manifest = parsed as Record; + if (manifest.version !== 1 || !manifest.entries || typeof manifest.entries !== "object" || Array.isArray(manifest.entries)) { + return indeterminate("history-backup", read.path, "history backup has an unknown shape"); + } + if (typeof manifest.stateDbPath === "string") { + const expected = process.platform === "win32" ? resolve(stateDatabasePath).toLowerCase() : resolve(stateDatabasePath); + const actual = process.platform === "win32" ? resolve(manifest.stateDbPath).toLowerCase() : resolve(manifest.stateDbPath); + if (actual !== expected) { + return indeterminate("history-backup", read.path, "history backup names a different state database"); + } + } + const entries = Object.values(manifest.entries as Record); + const references: RolloutReference[] = []; + for (const entry of entries) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return indeterminate("history-backup", read.path, "history backup entry has an unknown shape"); + } + const candidate = entry as Record; + if (typeof candidate.id !== "string" || !candidate.id + || typeof candidate.rolloutPath !== "string" || !candidate.rolloutPath) { + return indeterminate("history-backup", read.path, "history backup entry has an unknown rollout reference"); + } + references.push({ id: candidate.id, path: candidate.rolloutPath }); + } + const rollouts = classifyReferencedRollouts("history-backup", references); + if (rollouts.kind !== "clean") return rollouts; + return entries.length > 0 + ? { kind: "residue", surface: "history-backup", path: read.path } + : { kind: "clean" }; +} + +/** Read-only, fail-closed observation of every OpenCodex-routed Codex surface. */ +export function classifyNativeRoutedResidue(): NativeRoutedResidueResult { + let codexHome: string; + try { + codexHome = getCodexHome(); + } catch (error) { + const unresolved = process.env.CODEX_HOME?.trim() || "CODEX_HOME"; + return indeterminate("partial-write", unresolved, `CODEX_HOME cannot be resolved: ${errorReason(error)}`); + } + + const stateDatabasePath = join(codexHome, HISTORY_DATABASE_FILE_NAME); + const configPath = join(codexHome, CONFIG_FILE_NAME); + const profilePath = join(codexHome, PROFILE_FILE_NAME); + const modelsCachePath = join(codexHome, MODELS_CACHE_FILE_NAME); + const journalPath = join(codexHome, JOURNAL_FILE_NAME); + const config = inspectConfig(codexHome, configPath); + const atomicWriteTargets = [ + configPath, + profilePath, + modelsCachePath, + journalPath, + ...config.catalogTargets.map(target => target.path), + ]; + const classifiers = [ + () => classifyPartialWrites(atomicWriteTargets), + () => config.classification, + () => classifyProfile(profilePath), + ...config.catalogTargets.map(target => () => classifyCatalogLike("catalog", target.path, target.configured)), + () => classifyCatalogLike("models-cache", modelsCachePath), + () => classifyJournal(journalPath), + () => classifyHistoryDatabase(stateDatabasePath), + () => classifyHistoryBackup(historyBackupPath(stateDatabasePath), stateDatabasePath), + ]; + const results = classifiers.map(classify => classify()); + return results.find(result => result.kind === "indeterminate") + ?? results.find(result => result.kind === "residue") + ?? { kind: "clean" }; +} diff --git a/src/codex/runtime.ts b/src/codex/runtime.ts index 6978da569..9680cc4db 100644 --- a/src/codex/runtime.ts +++ b/src/codex/runtime.ts @@ -13,32 +13,38 @@ export type CodexRuntimeSource = | "path" | "fallback"; +export type DeepReadonly = + T extends (...args: never[]) => unknown ? T + : T extends readonly (infer U)[] ? readonly DeepReadonly[] + : T extends object ? { readonly [K in keyof T]: DeepReadonly } + : T; + export interface ResolvedCodexRuntime { - command: string; - version: string | null; - source: CodexRuntimeSource; + readonly command: string; + readonly version: string | null; + readonly source: CodexRuntimeSource; } export interface RuntimeProbeFailure { - command: string; - source: CodexRuntimeSource; - reason: string; + readonly command: string; + readonly source: CodexRuntimeSource; + readonly reason: string; } export interface EffortClampDiagnostic { - runtimePath: string; - runtimeVersion: string | null; - removedEfforts: string[]; - affectedModels: string[]; + readonly runtimePath: string; + readonly runtimeVersion: string | null; + readonly removedEfforts: readonly string[]; + readonly affectedModels: readonly string[]; } export interface ResolveCodexRuntimeResult { - runtime: ResolvedCodexRuntime; - failures: RuntimeProbeFailure[]; - replacedConfigured?: { from: ResolvedCodexRuntime; reason: string }; - newerAvailable?: ResolvedCodexRuntime; + readonly runtime: ResolvedCodexRuntime; + readonly failures: readonly RuntimeProbeFailure[]; + readonly replacedConfigured?: Readonly<{ from: ResolvedCodexRuntime; reason: string }>; + readonly newerAvailable?: ResolvedCodexRuntime; /** Set when the selected runtime could not be written to codex-runtime.json. */ - persistError?: string; + readonly persistError?: string; } export type RuntimeExecFile = ( @@ -70,17 +76,43 @@ export interface ResolveCodexRuntimeDeps { discoverAlternatives?: boolean; } -interface PersistedRuntimeState { - version: 1; - command: string; - source: CodexRuntimeSource; - selectedVersion: string | null; - updatedAt: string; +export interface PersistedCodexRuntimeState { + readonly version: 1; + readonly command: string; + readonly source: CodexRuntimeSource; + readonly selectedVersion?: string | null; + readonly updatedAt: string; } const PERSIST_FILE = "codex-runtime.json"; const CLAMP_PERSIST_FILE = "codex-runtime-clamp.json"; +function cloneAndDeepFreeze(value: T): DeepReadonly { + const clone = (current: unknown): unknown => { + if (Array.isArray(current)) return current.map(clone); + if (current && typeof current === "object") { + const out: Record = {}; + for (const [key, child] of Object.entries(current)) out[key] = clone(child); + return out; + } + return current; + }; + const freeze = (current: unknown): unknown => { + if (!current || typeof current !== "object" || Object.isFrozen(current)) return current; + for (const child of Object.values(current)) freeze(child); + return Object.freeze(current); + }; + return freeze(clone(value)) as DeepReadonly; +} + +function isCodexRuntimeSource(value: unknown): value is CodexRuntimeSource { + return value === "environment" + || value === "configured" + || value === "shim" + || value === "path" + || value === "fallback"; +} + export function codexRuntimeStatePath(configDir: string = getConfigDir()): string { return join(configDir, PERSIST_FILE); } @@ -94,9 +126,16 @@ interface PersistedClampState extends EffortClampDiagnostic { updatedAt: string; } +interface LoadedEffortClampDiagnostic { + runtimePath: string; + runtimeVersion: string | null; + removedEfforts: string[]; + affectedModels: string[]; +} + export function loadLastEffortClamp( deps: ResolveCodexRuntimeDeps = {}, -): EffortClampDiagnostic | null { +): LoadedEffortClampDiagnostic | null { const configDir = deps.configDir ?? getConfigDir(); const read = deps.readFileSync ?? ((path, encoding) => readFileSync(path, encoding)); try { @@ -196,15 +235,31 @@ export function compareCodexVersions(a: string | null, b: string | null): number return 0; } +/** Parse already-observed runtime-selection bytes without consulting the filesystem. */ +export function parsePersistedCodexRuntime( + bytes: string | Uint8Array, +): DeepReadonly | null { + try { + const text = typeof bytes === "string" ? bytes : Buffer.from(bytes).toString("utf8"); + const raw = JSON.parse(text) as Partial; + if (raw.version !== 1 || typeof raw.command !== "string" || !raw.command.trim()) return null; + if (!isCodexRuntimeSource(raw.source) || typeof raw.updatedAt !== "string") return null; + if (raw.selectedVersion !== undefined + && raw.selectedVersion !== null + && typeof raw.selectedVersion !== "string") return null; + return cloneAndDeepFreeze(raw as PersistedCodexRuntimeState); + } catch { + return null; + } +} + export function loadPersistedCodexRuntime( deps: ResolveCodexRuntimeDeps = {}, -): PersistedRuntimeState | null { +): DeepReadonly | null { const configDir = deps.configDir ?? getConfigDir(); const read = deps.readFileSync ?? ((path, encoding) => readFileSync(path, encoding)); try { - const raw = JSON.parse(read(codexRuntimeStatePath(configDir), "utf8")) as PersistedRuntimeState; - if (raw?.version !== 1 || typeof raw.command !== "string" || !raw.command.trim()) return null; - return raw; + return parsePersistedCodexRuntime(read(codexRuntimeStatePath(configDir), "utf8")); } catch { return null; } @@ -216,16 +271,16 @@ export function persistCodexRuntime( ): void { const configDir = deps.configDir ?? getConfigDir(); mkdirSync(configDir, { recursive: true, mode: 0o700 }); - const payload: PersistedRuntimeState = { + const payload: PersistedCodexRuntimeState = { version: 1, command: runtime.command, source: runtime.source, selectedVersion: runtime.version, updatedAt: new Date((deps.now ?? Date.now)()).toISOString(), }; + // Invalidate process authority before the persisted replacement is visible. + clearCodexRuntimeResolveCache(); atomicWriteFile(codexRuntimeStatePath(configDir), `${JSON.stringify(payload, null, 2)}\n`); - // Same-process consumers must re-resolve; catalog cache is keyed by runtime identity. - resolveCache = null; } function probeVersion( @@ -360,15 +415,68 @@ export function effortClampAppliesToRuntime( } const RESOLVE_CACHE_MS = 15_000; -let resolveCache: { key: string; at: number; value: ResolveCodexRuntimeResult } | null = null; +interface ResolveCacheMemo { + readonly key: string; + readonly at: number; + readonly epoch: number; + readonly valueIdentity: string; + readonly value: DeepReadonly; +} + +export type CodexRuntimeProcessCachePeek = + | Readonly<{ + kind: "available"; + epoch: number; + valueIdentity: string; + value: DeepReadonly; + }> + | Readonly<{ kind: "unavailable"; epoch: number }>; + +let resolveCacheEpoch = 0; +let resolveCache: ResolveCacheMemo | null = null; + +function publishResolveCache(key: string, at: number, value: ResolveCodexRuntimeResult): void { + const epoch = ++resolveCacheEpoch; + resolveCache = { + key, + at, + epoch, + valueIdentity: `runtime:${epoch}`, + value: cloneAndDeepFreeze(value), + }; +} + +function clearResolveCache(): void { + resolveCacheEpoch += 1; + resolveCache = null; +} + +/** Clear process-local runtime authority without resolving a replacement. */ +export function clearCodexRuntimeResolveCache(): void { + clearResolveCache(); +} + +/** Observe only an unexpired successful process memo; never resolves or probes. */ +export function peekCodexRuntimeProcessCache(): CodexRuntimeProcessCachePeek { + const memo = resolveCache; + if (!memo || Date.now() - memo.at >= RESOLVE_CACHE_MS) { + return Object.freeze({ kind: "unavailable" as const, epoch: resolveCacheEpoch }); + } + return Object.freeze({ + kind: "available" as const, + epoch: memo.epoch, + valueIdentity: memo.valueIdentity, + value: cloneAndDeepFreeze(memo.value), + }); +} function persistedRuntimeCacheStamp(deps: ResolveCodexRuntimeDeps): string { // Include on-disk selection so doctor --fix in another process busts this memo. const configDir = deps.configDir ?? getConfigDir(); const read = deps.readFileSync ?? ((path, encoding) => readFileSync(path, encoding)); try { - const raw = JSON.parse(read(codexRuntimeStatePath(configDir), "utf8")) as PersistedRuntimeState; - if (raw?.version !== 1 || typeof raw.command !== "string") return ""; + const raw = parsePersistedCodexRuntime(read(codexRuntimeStatePath(configDir), "utf8")); + if (!raw) return ""; return `${raw.command}|${raw.selectedVersion ?? ""}|${raw.updatedAt ?? ""}`; } catch { return ""; @@ -397,17 +505,30 @@ function resolveCacheKey(deps: ResolveCodexRuntimeDeps): string | null { export function resolveCodexRuntime(deps: ResolveCodexRuntimeDeps = {}): ResolveCodexRuntimeResult { const cacheKey = resolveCacheKey(deps); if (cacheKey && resolveCache && resolveCache.key === cacheKey && Date.now() - resolveCache.at < RESOLVE_CACHE_MS) { - return resolveCache.value; + return cloneAndDeepFreeze(resolveCache.value); } const result = resolveCodexRuntimeUncached(deps); - if (cacheKey) resolveCache = { key: cacheKey, at: Date.now(), value: result }; - return result; + if (cacheKey) { + publishResolveCache(cacheKey, Date.now(), result); + return cloneAndDeepFreeze(resolveCache!.value); + } + return cloneAndDeepFreeze(result); } /** Test-only: drop the short-lived process resolve cache. */ export function resetCodexRuntimeResolveCacheForTests(): void { - resolveCache = null; + clearCodexRuntimeResolveCache(); +} + +/** Test-only owner mutation seam; input is cloned and frozen before publication. */ +export function setCodexRuntimeResolveCacheForTests( + value: ResolveCodexRuntimeResult, + deps: ResolveCodexRuntimeDeps = {}, +): void { + const key = resolveCacheKey(deps); + if (!key) throw new TypeError("Injected runtime dependencies cannot populate the process memo."); + publishResolveCache(key, Date.now(), value); } function resolveCodexRuntimeUncached(deps: ResolveCodexRuntimeDeps = {}): ResolveCodexRuntimeResult { @@ -468,7 +589,7 @@ function resolveCodexRuntimeUncached(deps: ResolveCodexRuntimeDeps = {}): Resolv replacedConfigured = { from: { command: persisted.command, - version: persisted.selectedVersion, + version: persisted.selectedVersion ?? null, source: "configured", }, reason: failures.find(item => sameRuntimeCommand(item.command, persisted.command))?.reason @@ -518,7 +639,7 @@ export function resolveAndPersistCodexRuntime( const message = error instanceof Error ? error.message : String(error); const persistError = redactUserPath(redactSecretString(message)).slice(0, 200); console.warn(`[opencodex] Failed to persist Codex runtime selection: ${persistError}`); - return { ...result, persistError }; + return cloneAndDeepFreeze({ ...result, persistError }); } } return result; diff --git a/src/codex/transition-state.ts b/src/codex/transition-state.ts new file mode 100644 index 000000000..6ced64384 --- /dev/null +++ b/src/codex/transition-state.ts @@ -0,0 +1,604 @@ +/** + * CODEX_HOME-keyed transition state and coordinator transaction ownership. + * + * The original JSON read/compare/replace was called a CAS while native and + * history writers held different locks. It was not one: an old Worker could + * replace N+1 with stale N. This module owns the SQLite row, the conditional + * UPDATE, and the opaque one-shot capability backed by an already-open + * `BEGIN IMMEDIATE` transaction. + * + * Design record: devlog/_plan/260804_codex_write_substrate/005_contract.md §1. + */ +import { randomUUID } from "node:crypto"; +import { chmodSync, lstatSync, realpathSync } from "node:fs"; + +import { Database } from "bun:sqlite"; + +import type { + BeginCodexTransition, + CodexCoordinatorTransaction, + CodexCoordinatorTransactionController, + CodexHistoryState, + CodexTransitionState, + CodexTransitionVersion, + CommitExpectation, + ReadCodexTransitionState, + TransitionStateRead, + TransitionStateUpdate, + UpdateCodexHistoryTransition, +} from "./convergence-types"; +import { resolveCodexHomeDir } from "./home"; +import { readIntegrationRecord } from "./integration-record"; +import { classifyNativeRoutedResidue } from "./native-residue"; +import { + CodexUserIdentityRefusal, + resolveCodexCoordinatorDatabasePath, + resolveEffectiveUserIdentity, +} from "./user-identity"; + +const COORDINATOR_SCHEMA_VERSION = 1; +const DURABLE_HISTORY_STATUSES = new Set(["converged", "pending", "running", "blocked", "unknown"]); +const DURABLE_HISTORY_REASONS = new Set([ + "db-busy", + "permission", + "unreadable", + "schema", + "timeout", + "shutdown-cancelled", + "worker-died", + "overtaken", + "record-write-failed", +]); + +const CREATE_TRANSITION_TABLE = ` + CREATE TABLE IF NOT EXISTS codex_transition_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + native_generation INTEGER NOT NULL CHECK (native_generation >= 0), + current_tx_id TEXT, + history_status TEXT NOT NULL, + history_reason TEXT, + history_attempts INTEGER NOT NULL CHECK (history_attempts >= 0), + history_next_retry_at TEXT, + history_tx_id TEXT, + history_direction TEXT CHECK (history_direction IN ('apply', 'remove')), + history_authority_snapshot_id TEXT, + history_pending_rows INTEGER, + history_backup_entries INTEGER, + updated_at TEXT NOT NULL, + CHECK (history_status IN ('converged', 'pending', 'running', 'blocked', 'unknown')), + CHECK (history_reason IS NULL OR history_reason IN + ('db-busy', 'permission', 'unreadable', 'schema', 'timeout', + 'shutdown-cancelled', 'worker-died', 'overtaken', 'record-write-failed')), + CHECK (history_pending_rows IS NULL OR history_pending_rows >= 0), + CHECK (history_backup_entries IS NULL OR history_backup_entries >= 0), + CHECK ((native_generation = 0 AND current_tx_id IS NULL) + OR (native_generation > 0 AND length(trim(current_tx_id)) > 0)), + CHECK ((native_generation = 0 + AND history_tx_id IS NULL + AND history_direction IS NULL + AND history_authority_snapshot_id IS NULL) + OR (native_generation > 0 + AND history_tx_id = current_tx_id + AND history_direction IS NOT NULL + AND length(trim(history_authority_snapshot_id)) > 0)), + CHECK (native_generation > 0 OR + (history_status = 'unknown' + AND history_reason IS NULL + AND history_attempts = 0 + AND history_next_retry_at IS NULL + AND history_pending_rows IS NULL + AND history_backup_entries IS NULL)) + )`; + +const INITIALIZE_TRANSITION_ROW = ` + INSERT OR IGNORE INTO codex_transition_state ( + singleton, native_generation, current_tx_id, + history_status, history_reason, history_attempts, + history_next_retry_at, history_tx_id, history_direction, + history_authority_snapshot_id, history_pending_rows, + history_backup_entries, updated_at + ) VALUES (1, 0, NULL, 'unknown', NULL, 0, NULL, NULL, NULL, NULL, NULL, NULL, ?)`; + +const SELECT_TRANSITION_ROW = ` + SELECT native_generation, current_tx_id, + history_status, history_reason, history_attempts, + history_next_retry_at, history_tx_id, history_direction, + history_authority_snapshot_id, history_pending_rows, + history_backup_entries + FROM codex_transition_state + WHERE singleton = 1`; + +const BEGIN_TRANSITION = ` + UPDATE codex_transition_state + SET native_generation = ?, current_tx_id = ?, + history_status = 'pending', history_reason = NULL, + history_attempts = 0, history_next_retry_at = ?, history_tx_id = ?, + history_direction = ?, history_authority_snapshot_id = ?, + history_pending_rows = NULL, history_backup_entries = NULL, + updated_at = ? + WHERE singleton = 1 + AND native_generation = ? + AND current_tx_id IS ?`; + +const UPDATE_HISTORY = ` + UPDATE codex_transition_state + SET history_status = ?, history_reason = ?, history_attempts = ?, + history_next_retry_at = ?, history_tx_id = ?, + history_pending_rows = ?, history_backup_entries = ?, updated_at = ? + WHERE singleton = 1 + AND native_generation = ? + AND current_tx_id IS ? + AND history_tx_id IS ? + AND (native_generation = 0 OR history_direction IS NOT NULL)`; + +interface TransitionRow { + native_generation: unknown; + current_tx_id: unknown; + history_status: unknown; + history_reason: unknown; + history_attempts: unknown; + history_next_retry_at: unknown; + history_tx_id: unknown; + history_direction: unknown; + history_authority_snapshot_id: unknown; + history_pending_rows: unknown; + history_backup_entries: unknown; +} + +const codexCoordinatorTransactionBrand: unique symbol = Symbol("CodexCoordinatorTransaction"); + +interface BrandedCodexCoordinatorTransaction extends CodexCoordinatorTransaction { + readonly [codexCoordinatorTransactionBrand]: true; +} + +export class CodexCoordinatorTransactionError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "CodexCoordinatorTransactionError"; + } +} + +class CodexCoordinatorLegacyAmbiguousError extends CodexCoordinatorTransactionError { + constructor(message: string) { + super(message); + this.name = "CodexCoordinatorLegacyAmbiguousError"; + } +} + +function errorCode(error: unknown): string { + return error && typeof error === "object" && "code" in error + ? String((error as { code?: unknown }).code) + : ""; +} + +function isBusy(error: unknown): boolean { + const code = errorCode(error); + const message = error instanceof Error ? error.message : String(error); + return code === "SQLITE_BUSY" || code === "SQLITE_LOCKED" || /database (?:is|table is) locked/i.test(message); +} + +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +function nullableString(value: unknown): value is string | null { + return value === null || typeof value === "string"; +} + +function nullableCount(value: unknown): value is number | null { + return value === null || isNonNegativeInteger(value); +} + +function rowToState(row: TransitionRow | null): CodexTransitionState { + if (!row) throw new CodexCoordinatorTransactionError("The coordinator transition row is missing."); + if (!isNonNegativeInteger(row.native_generation) + || !nullableString(row.current_tx_id) + || typeof row.history_status !== "string" + || !DURABLE_HISTORY_STATUSES.has(row.history_status) + || !nullableString(row.history_reason) + || (row.history_reason !== null && !DURABLE_HISTORY_REASONS.has(row.history_reason)) + || !isNonNegativeInteger(row.history_attempts) + || !nullableString(row.history_next_retry_at) + || !nullableString(row.history_tx_id) + || !nullableString(row.history_direction) + || !nullableString(row.history_authority_snapshot_id) + || !nullableCount(row.history_pending_rows) + || !nullableCount(row.history_backup_entries)) { + throw new CodexCoordinatorTransactionError("The coordinator transition row is malformed."); + } + + const generation = row.native_generation; + if (generation === 0) { + if (row.current_tx_id !== null || row.history_tx_id !== null + || row.history_direction !== null || row.history_authority_snapshot_id !== null) { + throw new CodexCoordinatorTransactionError("The initial coordinator row contains transition metadata."); + } + } else if (!row.current_tx_id?.trim() + || row.history_tx_id !== row.current_tx_id + || (row.history_direction !== "apply" && row.history_direction !== "remove") + || !row.history_authority_snapshot_id?.trim()) { + throw new CodexCoordinatorTransactionError("The positive coordinator row lacks its complete history schedule."); + } + + const history: CodexHistoryState = { + status: row.history_status as Exclude, + attempts: row.history_attempts, + nextRetryAt: row.history_next_retry_at, + txId: row.history_tx_id, + pendingRows: row.history_pending_rows, + backupEntries: row.history_backup_entries, + ...(row.history_reason === null ? {} : { reason: row.history_reason as NonNullable }), + }; + return { + nativeGeneration: generation, + currentTxId: row.current_tx_id, + history, + historySchedule: generation === 0 ? null : { + direction: row.history_direction as "apply" | "remove", + authoritySnapshotId: row.history_authority_snapshot_id as string, + }, + }; +} + +function readState(database: Database): CodexTransitionState { + const row = database.query(SELECT_TRANSITION_ROW).get(); + return rowToState(row); +} + +function validateHistoryWrite(expected: CodexTransitionVersion, history: CodexHistoryState): void { + if (history.status === "not-evaluated" || !DURABLE_HISTORY_STATUSES.has(history.status)) { + throw new CodexCoordinatorTransactionError("Ephemeral history state cannot be persisted."); + } + if (!isNonNegativeInteger(history.attempts) + || !nullableCount(history.pendingRows) + || !nullableCount(history.backupEntries) + || (history.reason !== undefined && !DURABLE_HISTORY_REASONS.has(history.reason))) { + throw new CodexCoordinatorTransactionError("The history update is malformed."); + } + if (history.txId !== expected.currentTxId) { + throw new CodexCoordinatorTransactionError("The history update does not belong to the expected transition."); + } +} + +/** + * The missing-row incident proved that absence is not authority: installing + * `{0,null}` over a legacy JSON pair or routed native bytes loses the only + * evidence that an interrupted transition still needs salvage. + */ +function assertInitialStateCanBeCreated(): void { + const integration = readIntegrationRecord(); + if (integration.kind === "invalid") { + throw new CodexCoordinatorLegacyAmbiguousError( + "A missing coordinator row cannot be initialized over legacy or invalid Codex integration state.", + ); + } + if (classifyNativeRoutedResidue().kind !== "clean") { + throw new CodexCoordinatorLegacyAmbiguousError( + "A missing coordinator row cannot be initialized while native Codex routing residue exists.", + ); + } +} + +function initialize(database: Database, databaseWasAbsent: boolean): void { + const version = database.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version; + if (version !== 0 && version !== COORDINATOR_SCHEMA_VERSION) { + throw new CodexCoordinatorTransactionError("The coordinator database schema version is unsupported."); + } + if (!databaseWasAbsent && version === 0) { + throw new CodexCoordinatorLegacyAmbiguousError( + "An existing unversioned coordinator database cannot be adopted automatically.", + ); + } + database.exec(CREATE_TRANSITION_TABLE); + const existing = database.query(SELECT_TRANSITION_ROW).get(); + if (!existing && !databaseWasAbsent) { + throw new CodexCoordinatorLegacyAmbiguousError( + "The existing coordinator database has no authoritative transition row.", + ); + } + if (!existing) { + assertInitialStateCanBeCreated(); + database.query(INITIALIZE_TRANSITION_ROW).run(new Date().toISOString()); + } + if (version === 0) database.exec(`PRAGMA user_version = ${COORDINATOR_SCHEMA_VERSION}`); + readState(database); +} + +function createCapability( + database: Database, + onResult: (result: TransitionStateUpdate) => void, +): BrandedCodexCoordinatorTransaction { + let consumed = false; + return { + [codexCoordinatorTransactionBrand]: true, + beginTransition(expected, next) { + if (consumed) { + throw new CodexCoordinatorTransactionError("The coordinator capability has already been consumed."); + } + consumed = true; + if (!isNonNegativeInteger(expected.nativeGeneration) + || (expected.currentTxId !== null && !expected.currentTxId.trim()) + || !next.txId.trim() + || (next.direction !== "apply" && next.direction !== "remove") + || !next.authoritySnapshotId.trim()) { + throw new CodexCoordinatorTransactionError("The transition update is malformed."); + } + + const result = database.query(BEGIN_TRANSITION).run( + expected.nativeGeneration + 1, + next.txId, + next.nextRetryAt, + next.txId, + next.direction, + next.authoritySnapshotId, + new Date().toISOString(), + expected.nativeGeneration, + expected.currentTxId, + ); + const state = readState(database); + const update: TransitionStateUpdate = result.changes === 1 + ? { kind: "updated", state } + : { kind: "conflict", current: state }; + onResult(update); + return update; + }, + }; +} + +export function openCodexCoordinatorTransaction(finalDatabasePath: string): CodexCoordinatorTransactionController { + let database: Database | undefined; + let transactionOpen = false; + let closed = false; + let lastResult: TransitionStateUpdate | undefined; + let initialIdentity: string | undefined; + let databaseWasAbsent = false; + + try { + try { + const before = lstatSync(finalDatabasePath); + if (before.isSymbolicLink() || !before.isFile()) { + throw new CodexUserIdentityRefusal("The coordinator database path is not a real file."); + } + if (process.platform !== "win32") { + const uid = process.getuid?.(); + // Ownership is decided here; MODE is not. + // + // Two processes reaching first use together both observe ENOENT, and the + // loser can lstat the winner's file in the window between its creation + // and its chmod. Refusing on mode here read as a permission problem when + // it was a schedule — a real 1-in-12 flake on a 16-core box. Our own file + // is ours to narrow, so the mode decision moves below the open, where it + // can tighten once and then judge the settled state. A file owned by + // somebody else is still refused immediately: that is not a race, and no + // amount of waiting makes it ours. + if (uid === undefined || before.uid !== uid) { + throw new CodexUserIdentityRefusal( + "The coordinator database has unsafe ownership or permissions.", + ); + } + } + } catch (cause) { + if (errorCode(cause) !== "ENOENT") throw cause; + databaseWasAbsent = true; + } + database = new Database(finalDatabasePath, { create: true }); + if (databaseWasAbsent) { + try { chmodSync(finalDatabasePath, 0o600); } catch { /* Windows applies ACLs in WP11. */ } + } + // Re-check ownership and mode AFTER the open, not only before it. + // + // Two processes reaching first use together both see ENOENT, and the loser + // opens the winner's file in the window before the winner's chmod lands. Its + // pre-open check had already passed (the file did not exist), so without this + // the loser refused with `unsafe-path` — a real flake, reproduced 1-in-12 on + // a 16-core box, that read as a permission problem when it was a schedule. + // + // Narrowing our own descriptor's mode is safe and idempotent; a file that is + // still wrong afterwards is genuinely wrong, not merely early. + if (process.platform !== "win32") { + const uid = process.getuid?.(); + let current = lstatSync(finalDatabasePath); + if ((current.mode & 0o777) !== 0o600) { + try { chmodSync(finalDatabasePath, 0o600); } catch { /* refused below */ } + current = lstatSync(finalDatabasePath); + } + if (uid === undefined || current.uid !== uid || (current.mode & 0o777) !== 0o600) { + throw new CodexUserIdentityRefusal( + "The coordinator database has unsafe ownership or permissions.", + ); + } + } + const opened = lstatSync(finalDatabasePath); + if (opened.isSymbolicLink() || !opened.isFile()) { + throw new CodexUserIdentityRefusal("The coordinator database path changed during open."); + } + initialIdentity = `${opened.dev}:${opened.ino}`; + database.exec("PRAGMA busy_timeout = 0; PRAGMA locking_mode = NORMAL; BEGIN IMMEDIATE"); + transactionOpen = true; + initialize(database, databaseWasAbsent); + } catch (cause) { + if (transactionOpen) { + try { database?.exec("ROLLBACK"); } catch { /* close releases the transaction */ } + } + try { database?.close(); } catch { /* acquisition already failed */ } + throw cause; + } + + const db = database; + const requireOpen = (): void => { + if (closed || !transactionOpen) throw new CodexCoordinatorTransactionError("The coordinator transaction is closed."); + }; + const assertStablePath = (): void => { + requireOpen(); + const entry = lstatSync(finalDatabasePath); + if (entry.isSymbolicLink() || !entry.isFile() + || `${entry.dev}:${entry.ino}` !== initialIdentity + || realpathSync.native(finalDatabasePath) !== finalDatabasePath) { + throw new CodexUserIdentityRefusal("The coordinator database path was substituted."); + } + }; + + const capability = createCapability(db, result => { lastResult = result; }); + return { + capability, + expectation() { + requireOpen(); + const state = readState(db); + return { + nativeBefore: state.nativeGeneration, + nativeAfter: state.nativeGeneration + 1, + txId: randomUUID(), + }; + }, + version() { + requireOpen(); + const state = readState(db); + return { nativeGeneration: state.nativeGeneration, currentTxId: state.currentTxId }; + }, + assertPublished(expectation) { + requireOpen(); + if (lastResult?.kind !== "updated") { + throw new CodexCoordinatorTransactionError("The coordinator transition was not published."); + } + const state = readState(db); + if (state.nativeGeneration !== expectation.nativeAfter || state.currentTxId !== expectation.txId) { + throw new CodexCoordinatorTransactionError("The coordinator published a different transition."); + } + }, + assertStablePath, + commit() { + requireOpen(); + assertStablePath(); + db.exec("COMMIT"); + transactionOpen = false; + }, + rollback() { + if (!closed && transactionOpen) { + try { db.exec("ROLLBACK"); } finally { transactionOpen = false; } + } + }, + close() { + if (closed) return; + if (transactionOpen) { + try { db.exec("ROLLBACK"); } catch { /* close still releases the lock */ } + transactionOpen = false; + } + db.close(); + closed = true; + }, + }; +} + +function currentCoordinatorDatabasePath(): string { + const canonicalCodexHome = realpathSync.native(resolveCodexHomeDir()); + return resolveCodexCoordinatorDatabasePath(resolveEffectiveUserIdentity(), canonicalCodexHome); +} + +function mapUnavailable( + error: unknown, +): Extract { + if (error instanceof CodexUserIdentityRefusal) return { kind: "unavailable", reason: "unsafe-path" }; + return { kind: "unavailable", reason: isBusy(error) ? "busy" : "database" }; +} + +function mapReadError(error: unknown): TransitionStateRead { + if (error instanceof CodexCoordinatorLegacyAmbiguousError) { + return { kind: "legacy-ambiguous", message: error.message }; + } + return mapUnavailable(error); +} + +export const readCodexTransitionState: ReadCodexTransitionState = () => { + let transaction: CodexCoordinatorTransactionController | undefined; + try { + transaction = openCodexCoordinatorTransaction(currentCoordinatorDatabasePath()); + // Initialization and validation happen while N is held. Commit that setup + // before reopening read-only; the controller never leaks its Database. + transaction.commit(); + transaction.close(); + transaction = undefined; + return readCommittedState(); + } catch (error) { + transaction?.rollback(); + return mapReadError(error); + } finally { + transaction?.close(); + } +}; + +function readCommittedState(): TransitionStateRead { + const path = currentCoordinatorDatabasePath(); + let database: Database | undefined; + try { + database = new Database(path, { readonly: true }); + database.exec("PRAGMA busy_timeout = 0"); + return { kind: "ready", state: readState(database) }; + } catch (error) { + return mapUnavailable(error); + } finally { + try { database?.close(); } catch { /* read already completed */ } + } +} + +export const beginCodexTransition: BeginCodexTransition = (expected, next) => { + let transaction: CodexCoordinatorTransactionController | undefined; + try { + transaction = openCodexCoordinatorTransaction(currentCoordinatorDatabasePath()); + const result = transaction.capability.beginTransition(expected, next); + transaction.commit(); + return result; + } catch (error) { + transaction?.rollback(); + const unavailable = mapUnavailable(error); + return { kind: "unavailable", reason: unavailable.reason }; + } finally { + transaction?.close(); + } +}; + +export const updateCodexHistoryTransition: UpdateCodexHistoryTransition = (expected, history) => { + let database: Database | undefined; + let transactionOpen = false; + try { + validateHistoryWrite(expected, history); + // `{ create: false }` ALONE is SQLITE_MISUSE on Bun 1.3.14: the flags must + // name a read mode. Without `readwrite` every history update failed before + // reaching its conditional UPDATE and returned `unavailable/database`, so no + // terminal history state could ever be recorded — and the four tests here + // still passed, because none of them called this function. + database = new Database(currentCoordinatorDatabasePath(), { readwrite: true, create: false }); + database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + transactionOpen = true; + const current = readState(database); + if (current.nativeGeneration > 0 && current.historySchedule === null) { + throw new CodexCoordinatorTransactionError("A positive transition cannot lose its direction."); + } + const result = database.query(UPDATE_HISTORY).run( + history.status, + history.reason ?? null, + history.attempts, + history.nextRetryAt, + history.txId, + history.pendingRows, + history.backupEntries, + new Date().toISOString(), + expected.nativeGeneration, + expected.currentTxId, + expected.currentTxId, + ); + const state = readState(database); + database.exec("COMMIT"); + transactionOpen = false; + return result.changes === 1 + ? { kind: "updated", state } + : { kind: "conflict", current: state }; + } catch (error) { + if (transactionOpen) { + try { database?.exec("ROLLBACK"); } catch { /* close releases N */ } + } + const unavailable = mapUnavailable(error); + return { kind: "unavailable", reason: unavailable.reason }; + } finally { + try { database?.close(); } catch { /* operation already completed */ } + } +}; diff --git a/src/codex/user-identity.ts b/src/codex/user-identity.ts new file mode 100644 index 000000000..606e15e9e --- /dev/null +++ b/src/codex/user-identity.ts @@ -0,0 +1,266 @@ +/** + * Environment-independent identity and namespace resolution for Codex writes. + * + * Bun 1.3.14 made the obvious implementation unsafe: both `os.homedir()` and + * `os.userInfo().homedir` follow HOME. A service and CLI for the same account + * could therefore coordinate through different databases. The namespace is + * keyed only by the effective uid/SID and the canonical CODEX_HOME. + * + * Design record: devlog/_plan/260804_codex_write_substrate/005_contract.md §7. + */ +import { createHash } from "node:crypto"; +import { + lstatSync, + mkdirSync, + realpathSync, + statSync, +} from "node:fs"; +import { isAbsolute, join, resolve } from "node:path"; + +import type { + ResolveCodexCoordinatorDatabasePath, + ResolveCodexCatalogSerializationDatabasePath, + ResolveCodexHistorySerializationDatabasePath, + ResolveEffectiveUserIdentity, + UserIdentity, +} from "./convergence-types"; + +const POSIX_PRIVATE_MODE = 0o700; +const POSIX_TMP_REQUIRED_MODE = 0o1003; +const POSIX_TMP_PATH = "/tmp"; +const SID_PATTERN = /^S-1-(?:\d+-)+\d+$/i; + +export class CodexUserIdentityRefusal extends Error { + readonly code = "CODEX_USER_IDENTITY_REFUSED"; + + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "CodexUserIdentityRefusal"; + } +} + +function refuse(message: string, cause?: unknown): never { + throw new CodexUserIdentityRefusal(message, cause === undefined ? undefined : { cause }); +} + +function powershellValue(expression: string): string { + let result: ReturnType; + try { + result = Bun.spawnSync([ + "powershell.exe", + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + expression, + ], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + } catch (cause) { + refuse("Windows effective-account lookup could not start.", cause); + } + if (result.exitCode !== 0) refuse("Windows effective-account lookup failed."); + const value = new TextDecoder().decode(result.stdout).trim(); + if (!value) refuse("Windows effective-account lookup returned an empty value."); + return value; +} + +function resolveWindowsSid(): string { + const sid = powershellValue( + "[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value", + ); + if (!SID_PATTERN.test(sid)) refuse("Windows effective-account lookup returned an invalid SID."); + return sid.toUpperCase(); +} + +export const resolveEffectiveUserIdentity: ResolveEffectiveUserIdentity = () => { + if (process.platform === "win32") { + return { platform: "win32", sid: resolveWindowsSid() }; + } + + const getuid = process.getuid; + if (typeof getuid !== "function") { + refuse("The runtime does not expose the effective POSIX uid."); + } + let uid: number; + try { + uid = getuid.call(process); + } catch (cause) { + refuse("The effective POSIX uid lookup failed.", cause); + } + if (!Number.isSafeInteger(uid) || uid < 0) { + refuse("The runtime returned an invalid effective POSIX uid."); + } + return { platform: "posix", uid }; +}; + +function assertPrivatePosixDirectory(path: string, uid: number): void { + let entry; + try { + entry = lstatSync(path); + } catch (cause) { + refuse("The Codex coordinator namespace cannot be inspected.", cause); + } + if (entry.isSymbolicLink() || !entry.isDirectory()) { + refuse("The Codex coordinator namespace is not a real directory."); + } + if (entry.uid !== uid || (entry.mode & 0o777) !== POSIX_PRIVATE_MODE) { + refuse("The Codex coordinator namespace has unsafe ownership or permissions."); + } +} + +function ensurePrivatePosixDirectory(path: string, uid: number): void { + try { + mkdirSync(path, { mode: POSIX_PRIVATE_MODE }); + } catch (cause) { + const code = cause && typeof cause === "object" && "code" in cause + ? String((cause as { code?: unknown }).code) + : ""; + if (code !== "EEXIST") refuse("The Codex coordinator namespace cannot be created.", cause); + } + assertPrivatePosixDirectory(path, uid); +} + +function resolvePosixRuntimeRoot(uid: number): string { + let realTmp: string; + try { + realTmp = realpathSync.native(POSIX_TMP_PATH); + const entry = statSync(realTmp); + if (!entry.isDirectory() || entry.uid !== 0) { + refuse("The system temporary directory has unsafe ownership."); + } + if ((entry.mode & POSIX_TMP_REQUIRED_MODE) !== POSIX_TMP_REQUIRED_MODE) { + refuse("The system temporary directory lacks sticky world write/search permissions."); + } + } catch (cause) { + if (cause instanceof CodexUserIdentityRefusal) throw cause; + refuse("The system temporary directory cannot be trusted.", cause); + } + + const root = join(realTmp, `opencodex-runtime-v1-${uid}`); + ensurePrivatePosixDirectory(root, uid); + return root; +} + +function resolveWindowsRuntimeRoot(identity: Extract): string { + if (!SID_PATTERN.test(identity.sid)) refuse("The coordinator identity contains an invalid SID."); + const localAppData = powershellValue( + "[Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)", + ); + if (!isAbsolute(localAppData)) refuse("Windows LocalAppData resolution returned a relative path."); + + // The SID and known-folder values come from the effective token/.NET OS APIs, + // never USERPROFILE or LOCALAPPDATA. WP11 adds descriptor/reparse/ACL checks at + // the stable-database open boundary where those checks can cover SQLite too. + const root = resolve(localAppData, "OpenCodex", "Runtime", "v1", identity.sid.toUpperCase()); + try { + mkdirSync(root, { recursive: true }); + } catch (cause) { + refuse("The Windows coordinator namespace cannot be created.", cause); + } + return root; +} + +export const resolveCodexCoordinatorDatabasePath: ResolveCodexCoordinatorDatabasePath = ( + identity, + canonicalCodexHome, +) => { + if (!isAbsolute(canonicalCodexHome)) { + refuse("The canonical CODEX_HOME must be an absolute path."); + } + const root = identity.platform === "posix" + ? resolvePosixRuntimeRoot(identity.uid) + : resolveWindowsRuntimeRoot(identity); + const locks = join(root, "native-write-locks"); + if (identity.platform === "posix") ensurePrivatePosixDirectory(locks, identity.uid); + else { + try { + mkdirSync(locks, { recursive: true }); + } catch (cause) { + refuse("The Windows coordinator lock directory cannot be created.", cause); + } + } + + const homeDigest = createHash("sha256").update(canonicalCodexHome).digest("hex"); + return join(locks, `${homeDigest}.sqlite`); +}; + +/** + * K's FINAL database path. Never the native coordinator path. + * + * Catalog serialization is a different ownership surface from the native + * coordinator N: `K -> C` is a legal order and `N -> K` nests, so sharing one + * database would make the required nesting self-contend. The two live in + * sibling directories under the same per-user runtime root — same identity + * namespace, same environment-independent parent, distinct exclusion. + * + * Consumers use the returned path verbatim and append nothing + * (`005_contract.md:1256-1330`). + */ +export const resolveCodexCatalogSerializationDatabasePath: + ResolveCodexCatalogSerializationDatabasePath = (identity, canonicalCodexHome) => { + if (!isAbsolute(canonicalCodexHome)) { + refuse("The canonical CODEX_HOME must be an absolute path."); + } + const root = identity.platform === "posix" + ? resolvePosixRuntimeRoot(identity.uid) + : resolveWindowsRuntimeRoot(identity); + const locks = join(root, "catalog-write-locks"); + if (identity.platform === "posix") ensurePrivatePosixDirectory(locks, identity.uid); + else { + try { + mkdirSync(locks, { recursive: true }); + } catch (cause) { + refuse("The Windows catalog serialization directory cannot be created.", cause); + } + } + + const homeDigest = createHash("sha256").update(canonicalCodexHome).digest("hex"); + return join(locks, `${homeDigest}.sqlite`); + }; + +/** + * H's FINAL database path. + * + * Keyed by the canonical state DB in addition to the canonical home, unlike N and + * K. One `CODEX_HOME` can name a different `state_5.sqlite` — `model_catalog_json` + * has the same shape of indirection for catalogs — and two operations against + * different history databases are not the same exclusion. Hashing only the home + * would serialize them together, and hashing the raw request path would let two + * spellings of one database take different locks. + */ +export const resolveCodexHistorySerializationDatabasePath: + ResolveCodexHistorySerializationDatabasePath = ( + identity, + canonicalCodexHome, + canonicalStateDbPath, + ) => { + if (!isAbsolute(canonicalCodexHome)) { + refuse("The canonical CODEX_HOME must be an absolute path."); + } + if (!isAbsolute(canonicalStateDbPath)) { + refuse("The canonical Codex state database must be an absolute path."); + } + const root = identity.platform === "posix" + ? resolvePosixRuntimeRoot(identity.uid) + : resolveWindowsRuntimeRoot(identity); + const locks = join(root, "history-write-locks"); + if (identity.platform === "posix") ensurePrivatePosixDirectory(locks, identity.uid); + else { + try { + mkdirSync(locks, { recursive: true }); + } catch (cause) { + refuse("The Windows history serialization directory cannot be created.", cause); + } + } + + // Both components are length-prefixed so no pair of (home, stateDb) values can + // collide by concatenation. + const digest = createHash("sha256") + .update(`${canonicalCodexHome.length}:${canonicalCodexHome}`) + .update(`${canonicalStateDbPath.length}:${canonicalStateDbPath}`) + .digest("hex"); + return join(locks, `${digest}.sqlite`); + }; diff --git a/src/codex/write-coordination.ts b/src/codex/write-coordination.ts new file mode 100644 index 000000000..77377f1d4 --- /dev/null +++ b/src/codex/write-coordination.ts @@ -0,0 +1,114 @@ +/** + * The witness a native Codex write is coordinated by. + * + * NOT an `AdmissionSnapshot`, and the difference is the whole reason this file + * exists. `admitCodexWrite` refuses before it constructs a snapshot unless + * ownership is `owned` (`admission.ts`), so every snapshot it produces carries + * `owned` — the field cannot hold `unknown` at all. Reusing that type here would + * have meant one of two untruths: either the call becomes gated on ownership, + * which refuses Codex injection on every Windows machine and on Linux without a + * reachable user bus, or a hand-built snapshot claims an admission that never + * happened. + * + * So this type authorizes THE WRITE, not the DECISION to write. Deciding whether + * a write should happen at all is admission's job and belongs to its own phase, + * behind the Windows definition-chain walk. + * + * What the id covers, and why it is the OUTPUT rather than the inputs: the bytes + * `injectCodexConfig` emits depend on the port, the resolved catalog path, the + * hostname, websocket mode, legacy mode and the managed sub-agent defaults. An + * enumeration of those was already incomplete once. Hashing the computed + * candidate bytes closes the class instead of the instance — an input that + * changes the output changes the id whether or not anyone remembered to list it. + */ +import { createHash } from "node:crypto"; + +export interface CodexWriteCandidate { + /** The exact string about to replace `config.toml`. */ + readonly configBytes: string; + /** The exact string about to replace the profile. */ + readonly profileBytes: string; + /** The RESOLVED catalog path, never the raw option. */ + readonly catalogPath: string | null; +} + +export interface CodexWriteEvidence { + /** sha256 of the `config.toml` bytes this operation read as its input. */ + readonly nativeInputIdentity: string; + /** Digest of the persisted OpenCodex config these bytes were derived from. */ + readonly persistedIdentity: string; + /** Generation as observed before the lock; `present:false` means no coordinator yet. */ + readonly generation: Readonly<{ present: boolean; value: number }>; + /** Content identity of the journal, or a stable marker for absence. */ + readonly journalIdentity: string; + readonly canonicalTargets: Readonly<{ + config: string; + profile: string; + journal: string; + }>; +} + +export interface CodexWriteCoordination { + readonly candidate: CodexWriteCandidate; + readonly evidence: CodexWriteEvidence; + /** + * Recorded context, deliberately OUTSIDE `comparisonId`. + * + * The lock compares one id and nothing else, so a field that is copied rather + * than re-observed detects no drift by being in it — it would only match + * itself. Ownership is not re-observed under the lock (that would mean running + * a service-manager subprocess while N and C are held), so it travels as + * something the operation SAW, not as something the comparison PROVES. + */ + readonly observedOwnership: "owned" | "foreign" | "unknown"; + readonly comparisonId: string; +} + +/** + * Collapse a generation to one token. + * + * Absent and present-zero are the same authority — no cooperating write has + * committed — and they cannot be observed the same way: before the lock only + * absence is visible, and inside it only the committed zero is. Without this + * they never match and every first write refuses. + */ +function generationToken(generation: CodexWriteEvidence["generation"]): string { + const { present, value } = generation; + if (!Number.isSafeInteger(value) || value < 0) { + throw new TypeError(`A config generation must be a non-negative safe integer, got ${String(value)}.`); + } + return present && value > 0 ? `gen:${value}` : "gen:0"; +} + +export function hashCodexWriteCoordination( + candidate: CodexWriteCandidate, + evidence: CodexWriteEvidence, +): string { + return createHash("sha256") + .update(JSON.stringify([ + // The output, hashed directly. Two operations that intend different bytes + // cannot share an id, however they came to differ. + createHash("sha256").update(candidate.configBytes).digest("hex"), + createHash("sha256").update(candidate.profileBytes).digest("hex"), + candidate.catalogPath, + evidence.nativeInputIdentity, + evidence.persistedIdentity, + generationToken(evidence.generation), + evidence.journalIdentity, + evidence.canonicalTargets, + ])) + .digest("hex"); +} + +export function codexWriteCoordination( + candidate: CodexWriteCandidate, + evidence: CodexWriteEvidence, + observedOwnership: CodexWriteCoordination["observedOwnership"], +): CodexWriteCoordination { + return { + candidate, + evidence, + observedOwnership, + comparisonId: hashCodexWriteCoordination(candidate, evidence), + }; +} diff --git a/src/config.ts b/src/config.ts index 3e0315141..8f9513deb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,10 +1,25 @@ import { execFileSync } from "node:child_process"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { chmodSync, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { Database } from "bun:sqlite"; import * as z from "zod/v4"; +import { + bumpConfigGenerationAtPath, + bumpCurrentConfigGeneration, + initializeConfigGeneration, + observeConfigGenerationAtPath, + readConfigGenerationAtPath, + readConfigGenerationInTransaction, + type ConfigGenerationObservation, +} from "./codex/generation"; +import type { + BumpConfigGeneration, + ConfigGeneration, + ReadConfigGeneration, + WithExpectedConfigGenerationSync, +} from "./codex/convergence-types"; import { CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR, codexAccountNamespaceForModel, @@ -959,6 +974,20 @@ const apiKeyEntrySchema = z.object({ createdAt: z.string().catch(""), }).passthrough(); +/** + * Durable per-client intent. + * + * `.passthrough()` is load-bearing: a binary that only knows `codex` must not + * erase a key a later version wrote during a field-scoped mutation. And each key + * degrades on its own — a hand edit of `{"codex": "false", "future": false}` + * drops `codex` to absent (which reads as ON) and keeps `future`, rather than + * invalidating the object or, worse, the whole config. + */ +const clientIntegrationsSchema = z.object({ + codex: z.boolean().optional().catch(undefined), + grok: z.boolean().optional().catch(undefined), +}).passthrough(); + const configSchema = z.object({ port: z.number().int().min(0).max(65535).default(10100), managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024), @@ -980,6 +1009,7 @@ const configSchema = z.object({ // Invalid hand edits must not discard an otherwise usable config. Treat them as // pre-migration so startup can safely re-run the one-time normalization. googleAntigravityStaticCatalogVersion: z.literal(1).optional().catch(undefined), + clientIntegrations: clientIntegrationsSchema.optional().catch(undefined), providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(), contextCapValue: z.number().int().positive().optional(), multiAgentGuidanceEnabled: z.boolean().optional(), @@ -1868,6 +1898,54 @@ export function readConfigDiagnostics(): ConfigDiagnostics { return readConfigFileSnapshot().diagnostics; } +/** + * The persisted config, plus a digest of the EXACT bytes it was parsed from. + * + * A union rather than a nullable digest, because `{ kind: "read" }` with no + * digest is a state that cannot occur — and a state that cannot occur should + * not be a state that can be written down. Refusing it at runtime is a check + * somebody eventually forgets; making it unrepresentable is not. + * + * Why a byte digest at all: the Codex write lock compares an authority snapshot + * taken before the lock against one taken while holding it, and its config + * component used to hash the PARSED object. Two files that differ only in + * whitespace or key order parse identically, so a non-cooperating writer could + * rewrite the file between admission and commit and the comparison would see + * nothing. Hashing what was actually read closes that. + * + * `readConfigFileSnapshot` stays private on purpose. Its `raw` carries provider + * API keys and admission tokens, and `privacy:scan` reads tracked source text, + * not runtime values — so it would not catch a caller that logged or serialized + * that string. The digest travels; the bytes do not. + */ +export type ConfigAdmissionSnapshot = + | Readonly<{ kind: "read"; diagnostics: ConfigDiagnostics; contentSha256: string }> + | Readonly<{ kind: "unreadable"; diagnostics: ConfigDiagnostics; contentSha256: null }>; + +export function readConfigAdmissionSnapshot(): ConfigAdmissionSnapshot { + let bytes: Buffer; + try { + // ONE read. Hashing the file and then reading it again to parse would leave + // a window for the two to disagree, which is the exact hazard this exists + // to detect — the check would become a second chance to be wrong. + bytes = readFileSync(getConfigPath()); + } catch (error) { + return { + kind: "unreadable", + diagnostics: isMissingPathError(error) + ? { config: getDefaultConfig(), source: "default", error: null } + : { config: getDefaultConfig(), source: "fallback", error: "invalid_json" }, + contentSha256: null, + }; + } + return { + kind: "read", + // Decoded from the same buffer that was hashed, not re-read from disk. + diagnostics: configDiagnosticsFromRaw(bytes.toString("utf-8")), + contentSha256: createHash("sha256").update(bytes).digest("hex"), + }; +} + const CONFIG_MUTATION_DB_FILENAME = "config-mutation.sqlite"; const CONFIG_MUTATION_DB_SIDECARS = ["-journal", "-wal", "-shm"] as const; let warnedConfigMutationDirectoryAcl = false; @@ -1916,6 +1994,7 @@ function configMutationDatabasePath(): string { } let configMutationLockDepth = 0; +let configMutationDatabase: Database | null = null; /** * Serialize synchronous config and Codex credential-generation commits across processes with an @@ -1942,7 +2021,11 @@ export function withConfigMutationLockSync(fn: () => T): T { try { chmodSync(path, 0o600); } catch { /* platform may ignore chmod */ } database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); transactionOpen = true; + initializeConfigGeneration(database); } catch (cause) { + if (transactionOpen) { + try { database?.exec("ROLLBACK"); } catch { /* close below still releases the OS lock */ } + } try { database?.close(); } catch { /* acquisition already failed */ } const code = cause && typeof cause === "object" && "code" in cause ? String((cause as { code?: unknown }).code) @@ -1954,6 +2037,7 @@ export function withConfigMutationLockSync(fn: () => T): T { } configMutationLockDepth = 1; + configMutationDatabase = database; try { const value = fn(); database.exec("COMMIT"); @@ -1967,19 +2051,118 @@ export function withConfigMutationLockSync(fn: () => T): T { throw error; } finally { configMutationLockDepth = 0; + configMutationDatabase = null; try { database.close(); } catch { /* the OS lock is released with the handle */ } } } -function persistConfigUnlocked(config: OcxConfig): void { +function bumpGenerationForCooperatingConfigWrite(): void { + if (!configMutationDatabase) { + throw new Error("A cooperating config write requires the config mutation transaction."); + } + bumpCurrentConfigGeneration(configMutationDatabase); +} + +export const readConfigGeneration: ReadConfigGeneration = () => { + try { + return readConfigGenerationAtPath(configMutationDatabasePath()); + } catch { + return { kind: "unavailable", reason: "database" }; + } +}; + +export function observeConfigGeneration(): ConfigGenerationObservation { + return observeConfigGenerationAtPath(join(getConfigDir(), CONFIG_MUTATION_DB_FILENAME)); +} + +/** + * Read the generation from the transaction that is open RIGHT NOW. + * + * The observer cannot do this job. On the very first acquisition the + * `BEGIN IMMEDIATE` that creates the table has not committed yet, so a separate + * read-only connection cannot read a generation from it — measured, not + * assumed. A caller that compared a pre-lock observation against an observer + * re-read would therefore refuse every first write as stale. + * + * Throwing when no transaction is open is deliberate. Being called outside the + * lock is broken plumbing, and returning a typed "unavailable" would let that + * bug arrive disguised as an environmental failure — retried forever, on a + * machine where nothing is wrong. + */ +export function readConfigGenerationInCurrentMutationTransaction(): ConfigGeneration { + if (configMutationLockDepth < 1 || !configMutationDatabase) { + throw new Error( + "readConfigGenerationInCurrentMutationTransaction requires an open config mutation transaction.", + ); + } + return readConfigGenerationInTransaction(configMutationDatabase); +} + +export const bumpConfigGeneration: BumpConfigGeneration = expected => { + try { + return bumpConfigGenerationAtPath(configMutationDatabasePath(), expected); + } catch { + return { kind: "unavailable", reason: "database" }; + } +}; + +function configGenerationFailureReason(error: unknown): "busy" | "database" { + const cause = error instanceof ConfigMutationLockError ? error.cause : error; + const code = cause && typeof cause === "object" && "code" in cause + ? String((cause as { code?: unknown }).code) + : ""; + const message = cause instanceof Error ? cause.message : ""; + return code === "SQLITE_BUSY" + || code === "SQLITE_LOCKED" + || /database (?:is|table is) locked/i.test(message) + ? "busy" + : "database"; +} + +export const withExpectedConfigGenerationSync: WithExpectedConfigGenerationSync = ( + expected, + commit, +) => { + let callbackThrew = false; + let callbackError: unknown; + try { + return withConfigMutationLockSync(() => { + const database = configMutationDatabase; + if (!database) throw new Error("Config mutation transaction database is unavailable."); + const current = readConfigGenerationInTransaction(database); + if (current.value !== expected.value) return { kind: "conflict", current }; + try { + return { kind: "matched", generation: current, value: commit() }; + } catch (error) { + callbackThrew = true; + callbackError = error; + throw error; + } + }); + } catch (error) { + if (callbackThrew && error === callbackError) throw error; + return { kind: "unavailable", reason: configGenerationFailureReason(error) }; + } +}; + +function persistConfigUnlocked(config: OcxConfig): boolean { const configPath = getConfigPath(); - atomicWriteFile(configPath, JSON.stringify(config, null, 2) + "\n"); + const bytes = JSON.stringify(config, null, 2) + "\n"; + try { + if (readFileSync(configPath, "utf8") === bytes) return false; + } catch (error) { + if (!isMissingPathError(error)) throw error; + } + atomicWriteFile(configPath, bytes); + return true; } export function saveConfig(config: OcxConfig): void { // Keep the real-home assertion ahead of even lock-directory preparation. assertNotRealHomeUnderTest(getConfigDir()); - withConfigMutationLockSync(() => persistConfigUnlocked(config)); + withConfigMutationLockSync(() => { + if (persistConfigUnlocked(config)) bumpGenerationForCooperatingConfigWrite(); + }); } export type PersistedConfigMutation = { @@ -2059,7 +2242,7 @@ export function mutatePersistedConfig( continue; } - persistConfigUnlocked(confirmedConfig); + if (persistConfigUnlocked(confirmedConfig)) bumpGenerationForCooperatingConfigWrite(); return { status: "committed", value: confirmed.value }; } return { status: "unavailable", reason: "conflict" }; @@ -2318,10 +2501,10 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void { const persistedConfig: OcxConfig = { ...config, port: persistedBinding.port }; if (persistedBinding.hostname === undefined) delete persistedConfig.hostname; else persistedConfig.hostname = persistedBinding.hostname; - persistConfigUnlocked(persistedConfig); + if (persistConfigUnlocked(persistedConfig)) bumpGenerationForCooperatingConfigWrite(); persistedLiveServerBinding.set(config, persistedBinding); } else { - persistConfigUnlocked(config); + if (persistConfigUnlocked(config)) bumpGenerationForCooperatingConfigWrite(); } if (claudeCodeBaseline.has(config)) { claudeCodeBaseline.set(config, structuredClone(config.claudeCode)); diff --git a/src/integrations/native/ownership-preflight.ts b/src/integrations/native/ownership-preflight.ts index 960c25891..c76339270 100644 --- a/src/integrations/native/ownership-preflight.ts +++ b/src/integrations/native/ownership-preflight.ts @@ -15,6 +15,17 @@ import { assertServiceEnvironmentMatchesInstall, isServiceOwnershipError, } from "../../service"; +import { + currentServiceHomes, + inspectServiceStateEvidence, + serviceHomeMatches, + type ServiceStateEvidence, +} from "../../service"; +import { + inspectServiceManagerInstallation, + type ProbeDeps, + type ServiceManagerClaim, +} from "../../service-manager-probe"; export type NativeTeardownOwnership = { ok: true } | { ok: false; message: string }; @@ -34,3 +45,121 @@ export function assertNativeTeardownOwned(): NativeTeardownOwnership { return { ok: true }; } } + +/** + * Tri-state ownership for UNATTENDED writes. + * + * Deliberately not `assertNativeTeardownOwned`. That one fails OPEN — a corrupt + * state file yields `{ok:true}` — which is right for a teardown route a human + * just invoked, and catastrophic as an authority for automatic convergence: + * "could not read" would become "belongs to me". + * + * `owned` here means NO PERSISTENT SERVICE CLAIM WAS OBSERVED. It does not mean + * this process is the only writer. Two foreground `ocx start` processes on one + * home both read `owned`, and correctly so, because neither installs a service — + * keeping them apart is the write lock's job, not this function's. + */ +export type NativeCodexOwnership = "owned" | "foreign" | "unknown"; + +export interface OwnershipInspection { + readonly ownership: NativeCodexOwnership; + /** Why, in the words a refusal message can use. */ + readonly reason: string; +} + +function claimNamesDifferentHome( + claim: ServiceManagerClaim, + current: { codexHome: string; opencodexHome: string }, +): boolean { + // A definition that OMITS a home is not a definition that disagrees about it: + // an install run without CODEX_HOME set writes no such key at all. + if (claim.homes.codexHome !== null && !serviceHomeMatches(claim.homes.codexHome, current.codexHome)) return true; + if (claim.homes.opencodexHome !== null && !serviceHomeMatches(claim.homes.opencodexHome, current.opencodexHome)) return true; + return false; +} + +export interface OwnershipDeps extends ProbeDeps { + /** + * Which state paths to consult. Injectable because the default set includes + * the DEFAULT home mirror, resolved from `homedir()` — which no test sandbox + * moves. Without this a fixture reads the developer's real installation and + * calls their own machine foreign. + */ + readonly statePaths?: readonly string[]; + readonly currentHomes?: { codexHome: string; opencodexHome: string }; +} + +export function inspectNativeCodexOwnership(deps: OwnershipDeps = {}): OwnershipInspection { + const current = deps.currentHomes ?? currentServiceHomes(); + const evidence = deps.statePaths + ? inspectServiceStateEvidence(deps.statePaths) + : inspectServiceStateEvidence(); + + const unreadable = evidence.find((e): e is Extract => e.kind === "unreadable"); + if (unreadable) { + return { ownership: "unknown", reason: `service state at ${unreadable.path} could not be read (${unreadable.reason})` }; + } + const invalid = evidence.find(e => e.kind === "invalid"); + if (invalid) { + return { ownership: "unknown", reason: `service state at ${invalid.path} is malformed` }; + } + + const valid = evidence.filter((e): e is Extract => e.kind === "valid"); + // Mirrors that disagree with each other are not a majority vote. + for (const one of valid) { + for (const other of valid) { + if (!serviceHomeMatches(one.state.codexHome, other.state.codexHome) + || !serviceHomeMatches(one.state.opencodexHome, other.state.opencodexHome)) { + return { ownership: "unknown", reason: "two service state files disagree about which homes are installed" }; + } + } + } + + const foreign = valid.find(e => + !serviceHomeMatches(e.state.codexHome, current.codexHome) + || !serviceHomeMatches(e.state.opencodexHome, current.opencodexHome)); + if (foreign) { + return { + ownership: "foreign", + reason: `a service is installed for CODEX_HOME=${foreign.state.codexHome} / OPENCODEX_HOME=${foreign.state.opencodexHome}`, + }; + } + + const manager = inspectServiceManagerInstallation(deps); + if (manager.kind === "unknown") { + return { ownership: "unknown", reason: manager.reason }; + } + if (manager.kind === "conflict") { + return { ownership: "unknown", reason: "more than one service manager holds a registration for this proxy" }; + } + if (manager.kind === "present") { + const disagreeing = manager.claims.find(claim => claimNamesDifferentHome(claim, current)); + if (disagreeing) { + /* + * The state file says this home and the definition says another. An + * interrupted reinstall looks exactly like this — installation writes the + * definition BEFORE the state file — and picking a winner unattended would + * be guessing which half of a half-finished operation to believe. + */ + return { + ownership: "unknown", + reason: `${disagreeing.backend} is installed from ${disagreeing.definitionPath}, which names different homes than the recorded service state`, + }; + } + // Definition agrees. Valid state agreeing with it is ownership; no state at + // all beside an installed definition is not, because the definition is the + // claim and nothing here recorded making it. + if (valid.length === 0) { + return { + ownership: "unknown", + reason: `${manager.claims[0]?.backend ?? "a service manager"} holds a registration that no service state file accounts for`, + }; + } + return { ownership: "owned", reason: "the installed service names these homes" }; + } + + // manager.kind === "absent" + return valid.length === 0 + ? { ownership: "owned", reason: "no service state and no service manager claim" } + : { ownership: "owned", reason: "the recorded service state names these homes" }; +} diff --git a/src/lib/windows-secret-acl.ts b/src/lib/windows-secret-acl.ts index 28a06dc08..f8cc2ec90 100644 --- a/src/lib/windows-secret-acl.ts +++ b/src/lib/windows-secret-acl.ts @@ -29,14 +29,175 @@ * hardenSecretDir — same contract for directories. */ -import { existsSync } from "node:fs"; +import { existsSync, statSync } from "node:fs"; import { env, platform } from "node:process"; -const hardenedDirectories = new Set(); -const hardenedPaths = new Set(); +const hardenedDirectories = new Map(); +const hardenedPaths = new Map(); /** Paths whose harden TIMED OUT this process: do not re-stall every loadConfig on them. */ const timedOutPaths = new Set(); +/** + * The memo value: `object:freshness` for a file a harden was actually attributed + * to. + * + * There is deliberately no null member. An observation that cannot be read is + * not stored at all — the entry is deleted — because a "recorded as unverifiable" + * value was dead code the moment attribution became a before/after comparison, + * and a branch nothing can reach is a branch no test can defend. + */ +type HardenedIdentity = string; + +/** + * What a stat can tell us about WHICH OBJECT is at a path. + * + * Two fields, deliberately separated, because conflating them shipped a bug: + * + * - `object` — `dev:ino`. Answers "is this the same file". Survives an ACL or + * permission change, which is exactly what we need across an icacls call. + * - `freshness` — `ctimeNs`. Answers "has this file's metadata moved since". It + * distinguishes an unlink/recreate that ext4 gave the same inode back for, and + * it MOVES when permissions change. + * + * The first version used `dev:ino:ctimeNs` for both jobs. Since chmod bumps ctime + * — probed, `{ctimeChangedByChmod: true}` — and icacls is a permission change, the + * before/after comparison would have rejected its own successful harden and failed + * closed on every first harden on Windows. Requiring the identity to be unchanged + * across an operation whose entire purpose is to change it is not a strict check; + * it is a broken one. + */ +interface PathObservation { + readonly object: string; + readonly freshness: string; +} + +/** + * Observe which object is at a path, and how fresh it is. + * + * `dev:ino` alone is not enough to detect a replacement, which a Linux CI run + * proved: ext4 reuses the inode of an unlinked file immediately — 100 of 100 + * unlink/recreate cycles produced the SAME `ino`, while macOS reused none in 200 + * and happily reported the earlier fix working. That is why `freshness` exists; + * `ctimeNs` differed in 100 of 100 of those same cycles. + * + * `bigint: true` is used because `ctimeNs` exists only in that variant. + * + * Test seam: `setStatForTests` replaces this reader so a test can vary `dev`, + * `ino`, and `ctimeNs` independently. Mirroring the implementation's string + * format in test setup proves nothing about which components production uses — + * an audit removed `dev` and all forty tests still passed. + * + * UNVERIFIED: the plain `ino` is reported to be 0 on NTFS while the bigint form + * carries the file index, and the zero-ino guard exists for that case. Neither + * Darwin nor Linux CI can confirm it and no pinned-Bun Windows probe has run. + * It is defensive code, not a demonstrated platform fact. + */ +type StatReader = (path: string) => { dev: bigint; ino: bigint; ctimeNs: bigint }; + +const defaultStatReader: StatReader = path => { + const s = statSync(path, { bigint: true }); + return { dev: s.dev, ino: s.ino, ctimeNs: s.ctimeNs }; +}; + +let statReader: StatReader = defaultStatReader; + +/** Test seam: drive dev / ino / ctime independently. */ +export function setStatForTests(reader: StatReader | null): void { + statReader = reader ?? defaultStatReader; +} + +function observe(targetPath: string): PathObservation | null { + try { + const s = statReader(targetPath); + if (s.ino === 0n) return null; + return { object: `${s.dev}:${s.ino}`, freshness: `${s.ctimeNs}` }; + } catch { + return null; + } +} + +function memoValue(seen: PathObservation): HardenedIdentity { + return `${seen.object}:${seen.freshness}`; +} + +/** + * True only when this exact FILE was hardened, not merely this pathname. + * + * The memo used to be a `Set` of paths. A stable destination — such as + * the coordinator database `hardenStableLockFile` hardens — can be unlinked and + * recreated at the same name, and the replacement inherited the previous file's + * hardening while never having been through icacls. Ephemeral temps escaped this + * only because atomic writers call `forgetEphemeralSecretPath` once the temp is + * gone; nothing does that for a stable path. + */ +function memoSatisfied(cache: Map, targetPath: string): boolean { + const remembered = cache.get(targetPath); + if (remembered === undefined) return false; + const current = observe(targetPath); + // Unreadable now is not "unchanged": re-harden rather than trust a value we + // cannot confirm still describes what is there. + // + // A miss RETIRES the entry rather than leaving it. Keeping it left the cache in + // a state nothing could justify: after a mismatch and a failed re-harden, the + // stale value survived, so restoring the old identity would satisfy it again + // without any ACL work. That needs exact-identity ABA to bite — outside the + // proof bound this unit claims — but "the consequence is out of scope" is not a + // reason to keep an entry we have just proven does not describe what is there. + if (current === null || memoValue(current) !== remembered) { + cache.delete(targetPath); + return false; + } + return true; +} + +/** + * Record a harden ONLY if the file we hardened is still the file at that path. + * + * Reading identity after the ACL sequence returns answers "what is there now", + * which is not the same question as "what did icacls operate on". A replacement + * landing mid-sequence — probed by swapping the file during the final + * `/remove:g` — made the memo remember the REPLACEMENT as hardened, so the next + * acquisition skipped ACL work on a file that had never seen it: + * + * {identityChangedDuringHarden: true, callsForOriginal: 3, totalCalls: 3, + * replacementWasHardened: false} + * + * So the OBJECT is captured before the sequence and compared after it. Only the + * object — `dev:ino` — because icacls changes permissions, and `ctimeNs` moves + * when permissions change (probed: `{ctimeChangedByChmod: true}`). Comparing the + * full identity across the call would have rejected every successful harden and + * failed closed on the first harden on Windows: the check would have been + * demanding that an operation not do the thing it exists to do. + * + * The memo then stores the object plus the freshness read AFTER hardening, which + * is the state a later lookup should match. + * + * A changed object, or an unreadable observation at either end, means we cannot + * say what was hardened: the memo is cleared rather than written, and required + * callers fail closed. An optional caller soft-fails, as it does for every other + * unproven ACL. + * + * Returns true when the harden may be reported successful. + */ +function recordHarden( + cache: Map, + targetPath: string, + before: PathObservation | null, +): boolean { + const after = observe(targetPath); + if (before === null || after === null || before.object !== after.object) { + // Never leave a memo behind for a file we cannot vouch for, including one + // written by an earlier successful harden of a now-replaced file. + cache.delete(targetPath); + return false; + } + cache.set(targetPath, memoValue(after)); + return true; +} + +const SUBSTITUTED_DIAGNOSTIC = + "ACL hardening could not be attributed — the file at this path changed during hardening"; + export interface HardenResult { ok: boolean; diagnostics?: string; @@ -196,6 +357,14 @@ export function hardenedSecretPathCountForTests(): number { return hardenedPaths.size; } +/** + * Directory counterpart. It had no seam, and that absence hid a real gap: a + * directory-only pathname memo passed every file-based test in this suite. + */ +export function hardenedSecretDirCountForTests(): number { + return hardenedDirectories.size; +} + function effectivePlatform(): string { return platformOverride ?? platform; } @@ -411,11 +580,13 @@ function hardenEntry( targetPath: string, directory: boolean, opts: HardenOptions, - cache: Set, + cache: Map, ): HardenResult { - if (!existsSync(targetPath)) return { ok: true }; + // Observed absence retires the memo. Leaving it would let a later file at this + // path satisfy the cache if the filesystem ever hands back a matching identity. + if (!existsSync(targetPath)) { cache.delete(targetPath); return { ok: true }; } if (effectivePlatform() !== "win32") return { ok: true }; - if (cache.has(targetPath)) return { ok: true }; + if (memoSatisfied(cache, targetPath)) return { ok: true }; const memoKey = timeoutMemoKey(targetPath, opts); if (timedOutPaths.has(memoKey)) { const diagnostics = "ACL hardening skipped — previous attempt timed out"; @@ -428,10 +599,17 @@ function hardenEntry( for (let attempt = 0; attempt < 2; attempt++) { if (attempt > 0 && deadline - nowFn() <= 0) break; // retry only while budget remains try { + // Captured BEFORE the sequence: this is the file we are about to harden. + const before = observe(targetPath); runIcacls(targetPath, directory, deadline); - cache.add(targetPath); + if (!recordHarden(cache, targetPath, before)) { + if (opts.required) throw new Error(SUBSTITUTED_DIAGNOSTIC); + return { ok: false, diagnostics: SUBSTITUTED_DIAGNOSTIC }; + } return { ok: true }; } catch (err) { + // A substitution is not a transient icacls stall; do not spend the retry on it. + if (err instanceof Error && err.message === SUBSTITUTED_DIAGNOSTIC) throw err; lastErr = err; if (!isTimeoutError(err)) break; // real failures do not retry } @@ -455,11 +633,11 @@ async function hardenEntryAsync( targetPath: string, directory: boolean, opts: HardenOptions, - cache: Set, + cache: Map, ): Promise { - if (!existsSync(targetPath)) return { ok: true }; + if (!existsSync(targetPath)) { cache.delete(targetPath); return { ok: true }; } if (effectivePlatform() !== "win32") return { ok: true }; - if (cache.has(targetPath)) return { ok: true }; + if (memoSatisfied(cache, targetPath)) return { ok: true }; const memoKey = timeoutMemoKey(targetPath, opts); if (timedOutPaths.has(memoKey)) { const diagnostics = "ACL hardening skipped — previous attempt timed out"; @@ -472,10 +650,15 @@ async function hardenEntryAsync( for (let attempt = 0; attempt < 2; attempt++) { if (attempt > 0 && deadline - nowFn() <= 0) break; try { + const before = observe(targetPath); await runIcaclsAsync(targetPath, directory, deadline); - cache.add(targetPath); + if (!recordHarden(cache, targetPath, before)) { + if (opts.required) throw new Error(SUBSTITUTED_DIAGNOSTIC); + return { ok: false, diagnostics: SUBSTITUTED_DIAGNOSTIC }; + } return { ok: true }; } catch (err) { + if (err instanceof Error && err.message === SUBSTITUTED_DIAGNOSTIC) throw err; lastErr = err; if (!isTimeoutError(err)) break; } diff --git a/src/oauth/index.ts b/src/oauth/index.ts index ef777b55e..aa2a8278f 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -4,7 +4,7 @@ import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types"; import { loadConfig, resolveEnvValue, saveConfig } from "../config"; import { maskEmail } from "../lib/privacy"; import { KiroTokenRefreshError, environmentKiroRoutingMetadata, loginKiro, refreshKiroToken, settleKiroLoginTransaction } from "./kiro"; -import { getAccountCredential, getAccountSet, removeAccount, saveAccountCredential, saveCredential, setActiveAccount, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, markOAuthRefreshIntentStaleOwner, clearOAuthRefreshIntent, OAuthMutationBusyError } from "./store"; +import { getAccountCredential, getAccountSet, removeAccount, saveAccountCredential, saveCredential, setActiveAccount, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, markOAuthRefreshIntentStaleOwner, clearOAuthRefreshIntent, normalizeAuthStoreBuffer, OAuthMutationBusyError } 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"; @@ -56,6 +56,20 @@ export interface OAuthAccessSnapshot { kiro?: Pick; } +export interface ObservedOAuthAccessSnapshot extends OAuthAccessSnapshot { + /** Allowlisted provider API origin consumed by GitHub Copilot model discovery. */ + apiBaseUrl?: string; +} + +export type OAuthActiveTokenObservation = + | { readonly kind: "available"; readonly snapshot: ObservedOAuthAccessSnapshot } + | { readonly kind: "missing" } + | { readonly kind: "malformed" } + | { readonly kind: "needs-reauth" } + | { readonly kind: "expired" } + | { readonly kind: "near-expiry" } + | { readonly kind: "unsupported" }; + const MAX_OAUTH_TOKEN_REFRESH_FLIGHTS = 32; const OAUTH_TOKEN_REFRESH_FLIGHT_STALE_MS = 120_000; interface OAuthRefreshFlightEvidence { flightId: string; dispatched: boolean } @@ -278,6 +292,38 @@ function accessSnapshot(provider: string, accountId: string, cred: OAuthCredenti }; } +/** + * Observe the active OAuth token from an auth-store buffer supplied by its filesystem owner. + * Missing, malformed, reauth-required, and expiring credentials are typed no-token outcomes; + * this path never refreshes, locks, hardens, backs up, or persists credentials. + */ +export function observeActiveOAuthAccessToken( + provider: string, + authStoreBuffer: Uint8Array | null, + now = Date.now(), +): OAuthActiveTokenObservation { + const authStore = normalizeAuthStoreBuffer(authStoreBuffer); + if (authStore.kind === "absent") return { kind: "missing" }; + if (authStore.kind === "malformed") return { kind: "malformed" }; + if (!isOAuthProvider(provider)) return { kind: "unsupported" }; + + const accountSet = authStore.store[provider]; + const account = accountSet?.accounts.find(candidate => candidate.id === accountSet.activeAccountId); + if (!account) return { kind: "missing" }; + if (account.needsReauth) return { kind: "needs-reauth" }; + if (account.credential.expires <= now) return { kind: "expired" }; + if (account.credential.expires <= now + REFRESH_SKEW_MS) return { kind: "near-expiry" }; + + const apiBaseUrl = validateCopilotApiBaseUrl(account.credential.apiBaseUrl); + return { + kind: "available", + snapshot: { + ...accessSnapshot(provider, account.id, account.credential), + ...(apiBaseUrl ? { apiBaseUrl } : {}), + }, + }; +} + async function resolveAccessSnapshotForAccount( provider: string, accountId: string, @@ -583,13 +629,25 @@ function modelDiscoveryTransportSeed(providerName: string, prov: OcxProviderConf * Everyone else uses the OpenAI-style `/models` + Bearer with a `{ data: [{ id, owned_by? }] }` * response. */ -export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | undefined, providerName = ""): { url: string; headers: Record } { +export interface ModelsRequestObservedAuth { + readonly oauthApiBaseUrl?: string; +} + +export function buildModelsRequest( + prov: OcxProviderConfig, + apiKey: string | undefined, + providerName = "", + observedAuth?: ModelsRequestObservedAuth, +): { url: string; headers: Record } { const transportSeed = modelDiscoveryTransportSeed(providerName, prov); + const copilotApiBaseUrl = observedAuth === undefined + ? (providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(providerName) : undefined) + : observedAuth.oauthApiBaseUrl; const effectiveProvider = resolveProviderTransport( providerName, transportSeed, undefined, - providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(providerName) : undefined, + copilotApiBaseUrl, ); const headers: Record = { ...(effectiveProvider.headers ?? {}) }; const discoveryUrl = (defaultUrl: string): string => resolveProviderModelDiscoveryUrl( diff --git a/src/oauth/store.ts b/src/oauth/store.ts index 084019c4a..a018de097 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -29,7 +29,14 @@ import { import { validateCopilotApiBaseUrl } from "./github-copilot"; import type { OAuthCredentialSource, OAuthCredentials, ProviderAccount, ProviderAccountSet } from "./types"; -type AuthStore = Record; +export type AuthStore = Record; + +export type AuthStoreBufferSnapshot = + | { readonly kind: "ready"; readonly store: AuthStore } + | { readonly kind: "absent" } + | { readonly kind: "malformed" }; + +const authStoreDecoder = new TextDecoder("utf-8", { fatal: true }); let lastReconciledGeneration = 0; let liveOAuthAccountKeys = new Set(); @@ -142,6 +149,27 @@ export function loadAuthStore(): AuthStore { return loadAuthStoreInternal().store; } +/** + * Pure normalization for auth-store bytes already read by another owner. + * This function performs no filesystem consultation, hardening, backup, or persistence. + */ +export function normalizeAuthStoreBuffer(buffer: Uint8Array | null): AuthStoreBufferSnapshot { + if (buffer === null) return { kind: "absent" }; + try { + const parsed: unknown = JSON.parse(authStoreDecoder.decode(buffer)); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { kind: "malformed" }; + } + const { store } = normalizeAuthStore(parsed); + if (Object.keys(parsed).length > 0 && Object.keys(store).length === 0) { + return { kind: "malformed" }; + } + return { kind: "ready", store }; + } catch { + return { kind: "malformed" }; + } +} + /** * Observe-only auth store read for diagnostics (`ocx doctor` / status). * Does not chmod paths or backup invalid JSON — corrupt files are treated as empty. @@ -149,11 +177,8 @@ export function loadAuthStore(): AuthStore { export function peekAuthStore(): AuthStore { const path = getAuthStorePath(); if (!existsSync(path)) return {}; - try { - return normalizeAuthStore(JSON.parse(readFileSync(path, "utf-8"))).store; - } catch { - return {}; - } + const snapshot = normalizeAuthStoreBuffer(readFileSync(path)); + return snapshot.kind === "ready" ? snapshot.store : {}; } function persist(store: AuthStore): void { diff --git a/src/server/index.ts b/src/server/index.ts index 450fe2b0c..0601bb04e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -19,7 +19,9 @@ import { websocketsEnabled, } from "../config"; import { reconcileOAuthProviders } from "../oauth"; -import { invalidateCodexModelsCache } from "../codex/catalog"; +import { withCatalogWriteSerialization } from "../codex/catalog-write-serialization"; +import { invalidateCodexModelsCacheWithPermit } from "../codex/catalog/sync"; +import { getCodexHome } from "../codex/paths"; import { registerCodexCooldownRecoveryProbeWorker } from "../codex/auth-api"; import { startMemoryWatchdog } from "./memory-watchdog"; import { @@ -398,7 +400,16 @@ export function startServer(port?: number, deps: StartServerDeps = {}) { if (migrated) saveConfig(config); } } - invalidateCodexModelsCache(); + // Startup cache invalidation is best-effort and must never block the server from + // serving. It now takes K so it cannot race a convergence commit, but both the + // home resolution and the acquisition can fail on a machine with no Codex home — + // `getCodexHome()` THROWS when CODEX_HOME names a missing directory, which would + // otherwise turn "no Codex installed" into "proxy will not start". + try { + const startupCodexHome = getCodexHome(); + withCatalogWriteSerialization(startupCodexHome, permit => + invalidateCodexModelsCacheWithPermit(permit, startupCodexHome)); + } catch { /* no readable Codex home: nothing to invalidate */ } // Arm the `claudeCode` hand-edit guard (devlog 260726_claude_auth_auto/040 H1) BEFORE // the server can serve a request, and AFTER the startup migrations above — those run // against a config nobody else holds and are the documented exception to the save diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 61936bd2e..9da81e6c6 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -76,6 +76,7 @@ import type { ManagementPrincipal } from "./management-auth"; export type { ManagementApiDeps } from "./management/context"; import { fetchAllModels } from "./management/shared"; import { CatalogGatherBusyError } from "../codex/catalog/provider-fetch"; +import type { CatalogDisposition, ConvergeCodex } from "../codex/convergence-types"; import { managementBodyTooLargeResponse } from "./management/body"; // installed npm version instead of a stale hardcode. @@ -87,6 +88,33 @@ export const VERSION = (() => { } })(); +function isCatalogDisposition(value: unknown): value is CatalogDisposition { + if (!value || typeof value !== "object" || !("status" in value)) return false; + const disposition = value as Record; + if (disposition.status === "committed") { + return typeof disposition.changed === "boolean" + && typeof disposition.degraded === "boolean" + && Array.isArray(disposition.notices) + && disposition.notices.every(notice => notice === "provider-auth" || notice === "provider-network" || notice === "fallback"); + } + if (disposition.status === "skipped") { + return ["not-requested", "catalog-unavailable", "busy", "stale", "refused"].includes(String(disposition.reason)) + && typeof disposition.retryable === "boolean"; + } + if (disposition.status === "failed") { + return ["provider-auth", "provider-network", "disk"].includes(String(disposition.reason)) + && (disposition.phase === "gather" || disposition.phase === "commit") + && typeof disposition.retryable === "boolean" + && typeof disposition.partialWrite === "boolean"; + } + return false; +} + +const managementConvergenceBindings = new WeakMap) => ConvergeCodex; + converge: ConvergeCodex; +}>>(); + export async function handleManagementAPI( req: Request, url: URL, @@ -105,13 +133,38 @@ export async function handleManagementAPI( return jsonResponse({ error: "request body too large" }, 413, req, config); } } - async function refreshCodexCatalogBestEffort(): Promise { - if (deps.refreshCodexCatalog) return deps.refreshCodexCatalog(); + async function convergeCodexCatalog(): Promise { + let convergenceInvoked = false; + let managementConvergeCodex: ConvergeCodex | undefined; try { - const { refreshCodexModelCatalog } = await import("../codex/refresh"); - await refreshCodexModelCatalog(config); + if (!managementConvergeCodex) { + const factory = deps.createManagementConvergeCodex + ?? (await import("../codex/management-convergence")).createManagementConvergeCodex; + if (typeof factory !== "function") throw new TypeError("Catalog convergence factory is unavailable."); + let binding = managementConvergenceBindings.get(config); + if (!binding || binding.factory !== factory) { + const created = factory(config); + if (typeof created !== "function") throw new TypeError("Catalog convergence factory returned no function."); + binding = { factory, converge: created }; + managementConvergenceBindings.set(config, binding); + } + managementConvergeCodex = binding.converge; + } + const { createCatalogConvergeRequest } = await import("../codex/catalog-admission"); + convergenceInvoked = true; + const outcome = await managementConvergeCodex(createCatalogConvergeRequest({ deadlineMs: 1_000 })); + if (!outcome || outcome.kind !== "catalog-only" || !isCatalogDisposition(outcome.catalogRefresh)) { + throw new TypeError("Catalog convergence returned an invalid outcome."); + } + return outcome.catalogRefresh; } catch { - /* catalog absent */ + return { + status: "failed", + reason: "disk", + phase: convergenceInvoked ? "commit" : "gather", + retryable: false, + partialWrite: convergenceInvoked, + }; } } @@ -136,7 +189,7 @@ export async function handleManagementAPI( } } catch { /* best-effort */ } } - const ctx: ManagementContext = { req, url, config, deps, principal, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort }; + const ctx: ManagementContext = { req, url, config, deps, principal, convergeCodexCatalog, syncClaudeAgentDefsBestEffort }; let routed: Response | null; try { routed = (await handleConfigRoutes(ctx)) @@ -171,7 +224,7 @@ export async function handleManagementAPI( if (routed) return routed; if (url.pathname === "/api/stop" && req.method === "POST") { - const { restoreNativeCodex } = await import("../codex/inject"); + const { restoreNativeCodexAsync } = await import("../codex/inject"); const { stopServiceIfInstalled, isServiceOwnershipError } = await import("../service"); try { stopServiceIfInstalled(); @@ -184,7 +237,7 @@ export async function handleManagementAPI( } throw err; } - const restore = restoreNativeCodex(); + const restore = await restoreNativeCodexAsync(); // 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. diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 3d4f1e241..d06d25630 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -125,7 +125,7 @@ export function setGrokApplyFlightTestHooks( import type { ManagementContext } from "./context"; export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise { - const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx; + const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx; /** Best-effort Desktop 3P config auto-reconcile when providers change. */ async function autoApplyDesktopBestEffort(): Promise { @@ -277,7 +277,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise if (getAgentsEnabled() === false && isMultiAgentV2Enabled()) { warnings.push("agents.enabled = false has no effect while features.multi_agent_v2 is enabled; upstream keeps V2 active."); } - await refreshCodexCatalogBestEffort(); + const catalogRefresh = await convergeCodexCatalog(); if (requestedFlag !== undefined) warnings.push("Applies to new sessions; restart the Codex app or wait out its picker cache to see the ladder change."); const enabled = isMultiAgentV2Enabled(); return jsonResponse({ @@ -291,6 +291,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise subagentDeveloperInstructions: getSubagentDeveloperInstructions(), agentsMaxDepthAppliesWhenV2Disabled: !enabled, warnings, + catalogRefresh, }); } @@ -522,10 +523,10 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise config.subagentModels = chosen; const { saveConfigPreservingClaudeCode: save } = await import("../../config"); save(config); - await refreshCodexCatalogBestEffort(); + const catalogRefresh = await convergeCodexCatalog(); await syncClaudeAgentDefsBestEffort(); await autoApplyDesktopBestEffort(); - return jsonResponse({ ok: true, applied: chosen }); + return jsonResponse({ ok: true, applied: chosen, catalogRefresh }); } // Priority-ordered subagent model fallback chain for quota-aware spawn routing. diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts index f5d7e5100..81b25d7c8 100644 --- a/src/server/management/combo-routes.ts +++ b/src/server/management/combo-routes.ts @@ -66,7 +66,7 @@ import type { ManagementContext } from "./context"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; export async function handleComboRoutes(ctx: ManagementContext): Promise { - const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx; + const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx; if (url.pathname === "/api/combos" && req.method === "GET") { const { comboPublicModelId, getCombo, listComboIds } = await import("../../combos"); @@ -195,9 +195,9 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise { - const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx; + const { req, url, config, deps, syncClaudeAgentDefsBestEffort } = ctx; if (url.pathname === "/api/config" && req.method === "GET") { return jsonResponse(safeConfigDTO(config)); } diff --git a/src/server/management/context.ts b/src/server/management/context.ts index 9da702cfb..f39690ba5 100644 --- a/src/server/management/context.ts +++ b/src/server/management/context.ts @@ -5,11 +5,12 @@ import type { ManagementPrincipal } from "../management-auth"; import type { CatalogModel } from "../../codex/catalog"; import type { injectGrokConfig } from "../../grok/inject"; import type { RuntimePortState } from "../../config"; +import type { CatalogDisposition, ConvergeCodex } from "../../codex/convergence-types"; export interface ManagementApiDeps { toggleCodexMultiAgentV2?: (enabled: boolean) => void; toggleDefaultModeRequestUserInput?: (enabled: boolean) => void; - refreshCodexCatalog?: () => Promise; + createManagementConvergeCodex?: (config: Readonly) => ConvergeCodex; /** * Persistence seam for route-level tests. Production leaves this unset and uses * `saveConfigPreservingClaudeCode`; tests that pass an in-memory fixture config @@ -65,6 +66,6 @@ export interface ManagementContext { * tests, which are treated as the untrusted `admin-token` case. */ principal?: ManagementPrincipal; - refreshCodexCatalogBestEffort: () => Promise; + convergeCodexCatalog: () => Promise; syncClaudeAgentDefsBestEffort: () => Promise; } diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index 225f3998d..6501e5636 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -121,7 +121,7 @@ function refreshedUsageSummary { - const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx; + const { req, url, config, deps, syncClaudeAgentDefsBestEffort } = ctx; if (url.pathname === "/api/logs" && req.method === "GET") { const all = getRequestLogEntries(); diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index 91f724ff4..de218f205 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -121,7 +121,7 @@ function summarizeExportedModels(client: ExportClientId, document: unknown): { m } export async function handleModelRoutes(ctx: ManagementContext): Promise { - const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx; + const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx; // A handler persists the exact config object passed in. Production defaults to // the real store; tests that pass an in-memory fixture inject a no-op/spy. Do not // bypass this seam with a dynamic config import — doing so replaced a user's @@ -211,8 +211,8 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise typeof m === "string") : []; config.disabledModels = disabled; persistConfig(config); - await refreshCodexCatalogBestEffort(); - return jsonResponse({ ok: true, disabled }); + const catalogRefresh = await convergeCodexCatalog(); + return jsonResponse({ ok: true, disabled, catalogRefresh }); } // One user-facing visibility switch spans two persisted filters: a provider allowlist and the @@ -310,8 +310,8 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise 0 ? list : undefined; persistConfig(config); - await refreshCodexCatalogBestEffort(); - return jsonResponse({ ok: true }); + const catalogRefresh = await convergeCodexCatalog(); + return jsonResponse({ ok: true, catalogRefresh }); } // Per-provider catalog allowlist (issue #52): when a provider has a non-empty selectedModels list, @@ -437,8 +437,8 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise 0) config.providers[provider].selectedModels = models; else delete config.providers[provider].selectedModels; persistConfig(config); - await refreshCodexCatalogBestEffort(); - return jsonResponse({ ok: true, provider, selected: models }); + const catalogRefresh = await convergeCodexCatalog(); + return jsonResponse({ ok: true, provider, selected: models, catalogRefresh }); } return null; } diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index 521cef2ea..ec605be0a 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -28,7 +28,7 @@ import { jsonResponse } from "../auth-cors"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import type { ManagementContext } from "./context"; -export type NativeIntegrationClientId = "claude" | "grok"; +export type NativeIntegrationClientId = "claude" | "grok" | "codex"; /** Every reason this module can decline, in one place (audit r3 #6). */ export type NativeRefusalReason = @@ -191,6 +191,108 @@ const ORPHANED_MARKER_MESSAGE = const NOT_INSTALLED_MESSAGE = "Grok home was not found, so there is nothing to change. Install Grok Build first."; +/** + * One Codex change at a time, for the same reason Grok has this: the guard + * stands BEFORE the first await, or two concurrent PUTs both pass it while their + * bodies are still parsing. + */ +let codexToggleFlight: Promise | null = null; + +/** + * Turn native Codex routing on or off. + * + * THE PROXY STAYS UP. Turning Codex off is not `ocx stop`: other clients keep + * routing through this process, `/healthz` keeps answering, and only Codex goes + * back to its own path. That is the entire point of having a per-client switch + * rather than a kill switch, and it is why this route restores rather than + * stopping anything. + * + * Two writes, in this order: + * 1. persist the desired state, so the decision survives the next `ocx start`; + * 2. converge the artifacts to match it. + * + * Intent first is deliberate. If the process dies between them, the next start + * reads the persisted intent and converges — whereas converging first and dying + * would leave artifacts the next start silently undoes. + */ +async function handleCodexToggle(ctx: ManagementContext): Promise { + const { req } = ctx; + if (codexToggleFlight) { + return refusal(409, "codex", "config_busy", + "Another Codex change is already in flight. Nothing was written — try again in a moment."); + } + codexToggleFlight = (async (): Promise => { + let body: { enabled?: unknown }; + try { + body = await readManagementJsonBody(req); + } catch (error) { + rethrowManagementBodyTooLarge(error); + return jsonResponse({ error: "invalid JSON body" }, 400); + } + if (typeof body.enabled !== "boolean") { + return jsonResponse({ error: "enabled must be a boolean" }, 400); + } + const enabled = body.enabled; + + const { setCodexIntegrationEnabled } = await import("../../codex/desired-state"); + const persisted = setCodexIntegrationEnabled(enabled); + /* + * `missing` does not block the switch — see the Grok route for the reasoning. + * A user with no config file yet still gets the artifact change; what they + * lose is durability across a restart, and the envelope says so rather than + * implying the switch failed. + */ + if (!persisted.ok && persisted.reason !== "missing") { + return refusal( + persisted.retryable ? 409 : 500, + "codex", + persisted.retryable ? "config_busy" : "write_failed", + persisted.message, + ); + } + const durable = persisted.ok; + + if (enabled) { + // The port this process actually BOUND, not what config.json last recorded. + // A stale config port would inject a base_url pointing at nothing — the + // same trap `runGrokApplyFlight` documents below. + const runtime = (ctx.deps.readRuntimePort ?? readRuntimePort)(process.pid); + const port = runtime?.port ?? ctx.config.port; + const { syncModelsToCodex } = await import("../../codex/sync"); + const applied = await syncModelsToCodex(port); + return jsonResponse({ + ok: true, clientId: "codex", changed: durable && persisted.status === "committed", + state: applied.ok ? "current" : "absent", + message: applied.ok + ? "Codex now routes through opencodex" + : `Codex intent saved, but applying it did not complete: ${applied.message}`, + ...(applied.ok + ? (durable ? {} : { reason: "not_durable" }) + : { reason: "apply_incomplete" }), + } satisfies NativeToggleEnvelope); + } + + // OFF. Restore the native path; the proxy keeps serving every other client. + const { restoreNativeCodexAsync } = await import("../../codex/inject"); + const restored = await restoreNativeCodexAsync(); + return jsonResponse({ + ok: true, clientId: "codex", changed: durable && persisted.status === "committed", + state: restored.success ? "absent" : "unsafe", + message: restored.success + ? "Codex restored to its native path; the proxy is still serving other clients" + : `Codex intent saved, but restoring the native path did not complete: ${restored.message}`, + ...(restored.success + ? (durable ? {} : { reason: "not_durable" }) + : { reason: "restore_incomplete" }), + } satisfies NativeToggleEnvelope); + })(); + try { + return await codexToggleFlight; + } finally { + codexToggleFlight = null; + } +} + async function handleGrokToggle(ctx: ManagementContext): Promise { const { req, config, deps } = ctx; /* @@ -225,6 +327,41 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { if (seen.kind === "not_installed") return refusal(404, "grok", "not_installed", NOT_INSTALLED_MESSAGE); if (seen.kind === "orphaned_marker") return refusal(409, "grok", "orphaned_marker", ORPHANED_MARKER_MESSAGE); + /* + * Persist the DECISION before touching the fence. + * + * This route shipped without it, which is the whole bug: stripping the fence + * records nothing, so the next `ocx start` calls syncGrokConfig + * unconditionally and writes it straight back. The switch worked and lasted + * exactly one restart. + * + * Intent first, artifacts second, for the same reason as the Codex route: a + * process that dies between them leaves a decision the next start can act + * on, where the other order leaves artifacts the next start undoes. + */ + const { setGrokIntegrationEnabled } = await import("../../codex/desired-state"); + const persisted = setGrokIntegrationEnabled(enabled); + /* + * `missing` does NOT block the toggle here. + * + * A config file that does not exist yet is a normal state for someone who + * has never saved settings, and refusing their switch because of it would + * make the button dead for exactly the users least able to diagnose why. + * The fence change is still worth performing and still reports honestly; + * what they lose is durability across a restart, which is what the reason + * on the envelope says. `conflict` and `invalid` are different: another + * writer won, or the file is malformed and must not be overwritten. + */ + if (!persisted.ok && persisted.reason !== "missing") { + return refusal( + persisted.retryable ? 409 : 500, + "grok", + persisted.retryable ? "config_busy" : "write_failed", + persisted.message, + ); + } + const durable = persisted.ok; + if (!enabled) { /* * Disable only: a strip is a SHARED teardown — it must not run under a @@ -442,5 +579,9 @@ export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Pro return handleGrokToggle(ctx); } + if (url.pathname === "/api/native-integrations/codex" && req.method === "PUT") { + return handleCodexToggle(ctx); + } + return null; } diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 4e982fbfd..f3db21c6a 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -112,7 +112,7 @@ function validateKeyName( } export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise { - const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx; + const { req, url, config, deps, syncClaudeAgentDefsBestEffort } = ctx; // Which providers support real OAuth login (drives the GUI's "Log in with …" buttons). if (url.pathname === "/api/oauth/providers" && req.method === "GET") { diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index cab9390a8..85c5a9400 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -233,7 +233,7 @@ function applyProviderPatchFields( } export async function handleProviderRoutes(ctx: ManagementContext): Promise { - const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx; + const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx; if (url.pathname === "/api/provider-quotas" && req.method === "GET") { const forceRefresh = url.searchParams.get("refresh") === "1" || url.searchParams.get("refresh") === "true"; @@ -308,8 +308,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise jsonResponse({ ok: true, cap: DEFAULT_PROVIDER_CONTEXT_CAP, value: globalContextCapValue(config), caps: providerContextCaps(config) }); + const respond = (catalogRefresh: Awaited>) => jsonResponse({ + ok: true, + cap: DEFAULT_PROVIDER_CONTEXT_CAP, + value: globalContextCapValue(config), + caps: providerContextCaps(config), + catalogRefresh, + }); // Branch 1: set the global cap value and re-point every enabled provider to it. if (body.value !== undefined) { @@ -603,8 +610,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { + const result = spawnSync(file, [...args], { + encoding: "utf8", + windowsHide: true, + timeout: SERVICE_PROBE_TIMEOUT_MS, + }); + return { + status: result.status, + stdout: String(result.stdout ?? ""), + stderr: String(result.stderr ?? ""), + // `signal` is SIGTERM when the timeout fired; a spawn failure sets `error`. + timedOut: result.signal !== null && result.error === undefined, + spawnFailed: result.error !== undefined, + }; +}; + +export interface ProbeDeps { + readonly run?: ProbeRunner; + readonly platform?: NodeJS.Platform; + readonly uid?: number; + readonly home?: string; +} + +const LABEL = "com.opencodex.proxy"; +const TASK = "opencodex-proxy"; + +/** + * `launchctl print` exits 113 for a service that is not there and 112 when the + * domain itself cannot be reached — measured against nonexistent targets rather + * than assumed. Only 113 is an answer; everything else is a failure to ask. + */ +const LAUNCHCTL_NO_SUCH_SERVICE = 113; +/** + * 112 is an answer about the DOMAIN, not the label. + * + * Measured on macOS 27.0: querying a domain with no service name at all still + * returns it, and a domain that does not exist cannot be running one of our + * jobs. Treating it as "could not ask" would refuse every write on a fresh + * headless Mac — no GUI domain, and no installation either. + */ +const LAUNCHCTL_NO_SUCH_DOMAIN = 112; + +/** + * Residue on disk, distinguished from a path that could not be read. + * + * `existsSync` answers "no" for a dangling symlink and for a path whose parent + * denies traversal. Both are residue, not absence — only ENOENT is absence. + */ +function artifactPresence(path: string): "present" | "absent" | "unreadable" { + try { + lstatSync(path); + return "present"; + } catch (error) { + return (error as NodeJS.ErrnoException)?.code === "ENOENT" ? "absent" : "unreadable"; + } +} + +function unknown(reason: string): ServiceManagerInstallation { + return { kind: "unknown", reason }; +} + +/** Pull `NAMEVALUE` out of a plist body. */ +function plistEnvValue(body: string, key: string): string | null { + const match = body.match( + new RegExp(`\\s*${key}\\s*\\s*([^<]*)`), + ); + return match ? match[1] : null; +} + +/** Pull `Environment="NAME=VALUE"` (quoted or bare) out of a systemd unit. */ +function unitEnvValue(body: string, key: string): string | null { + for (const line of body.split("\n")) { + const match = line.match(new RegExp(`^\\s*Environment=\\s*"?${key}=([^"\\n]*)"?\\s*$`)); + if (match) return match[1]; + } + return null; +} + +function inspectLaunchd(deps: Required>): ServiceManagerInstallation { + const definitionPath = join(deps.home, "Library", "LaunchAgents", `${LABEL}.plist`); + + /* + * BOTH domains, because they are independent and hold separate service sets. + * Measured on macOS 27.0: the shipped agent answers 0 under `gui/` and + * 113 under `user/`. Asking only one leaves the other free to hold a job + * this probe would then call absent. + */ + let registration: "present" | "absent" = "absent"; + let unreachableDomains = 0; + for (const domain of [`gui/${deps.uid}`, `user/${deps.uid}`]) { + const printed = deps.run("/bin/launchctl", ["print", `${domain}/${LABEL}`]); + if (printed.spawnFailed || printed.timedOut) { + return unknown(`launchctl could not be asked: ${printed.timedOut ? "timed out" : printed.stderr.trim()}`); + } + if (printed.status === 0) { registration = "present"; break; } + if (printed.status === LAUNCHCTL_NO_SUCH_SERVICE) continue; + if (printed.status === LAUNCHCTL_NO_SUCH_DOMAIN) { unreachableDomains += 1; continue; } + return unknown(`launchctl print exited ${String(printed.status)}: ${printed.stderr.trim()}`); + } + + const definition = artifactPresence(definitionPath); + if (definition === "absent") { + // No file. A registration without one means launchd holds a definition whose + // file is gone — real, and not something to resolve unattended. + if (registration === "present") { + return unknown("launchd has a job loaded but its plist is missing"); + } + /* + * Nothing staged, and every domain either answered "no such service" or does + * not exist. 112 is an answer ABOUT THE DOMAIN and is label-independent — + * querying a domain with no service name at all returns it — so an + * unreachable domain cannot be hiding a job of ours. Calling this `unknown` + * instead would refuse every write on a fresh headless Mac, which has no + * GUI domain and no installation either. + */ + void unreachableDomains; + return { kind: "absent" }; + } + + let body: string; + try { + body = readFileSync(definitionPath, "utf-8"); + } catch (error) { + // Present-but-unreadable cannot supply homes, and `present` without homes + // would compare equal to nothing and read as agreement. + return unknown(`the launchd plist exists but could not be read: ${String(error)}`); + } + + return { + kind: "present", + claims: [{ + backend: "launchd", + definitionPath, + homes: { + codexHome: plistEnvValue(body, "CODEX_HOME"), + opencodexHome: plistEnvValue(body, "OPENCODEX_HOME"), + }, + registration, + }], + }; +} + +function systemdProperty(out: string, key: string): string | null { + for (const line of out.split("\n")) { + const match = line.match(new RegExp(`^${key}=(.*)$`)); + if (match) return match[1].trim(); + } + return null; +} + +function inspectSystemd(deps: Required>): ServiceManagerInstallation { + const definitionPath = join(deps.home, ".config", "systemd", "user", `${TASK}.service`); + + /* + * All four properties in one call. LoadState alone is not enough — it is + * orthogonal to ActiveState — and neither says whether the LOADED bytes match + * the file. NeedDaemonReload is that signal, and this repository already + * documents it as the systemd analogue of launchd's stale plist. + */ + const shown = deps.run("systemctl", [ + "--user", "show", TASK, + "-p", "LoadState", "-p", "ActiveState", "-p", "FragmentPath", "-p", "NeedDaemonReload", + ]); + if (shown.spawnFailed || shown.timedOut) { + return unknown(`systemctl could not be asked: ${shown.timedOut ? "timed out" : shown.stderr.trim()}`); + } + if (shown.status !== 0) { + // A missing unit still exits ZERO and says not-found; a non-zero status means + // the question never reached the bus. + return unknown(`systemctl show exited ${String(shown.status)}: ${shown.stderr.trim()}`); + } + + const loadState = systemdProperty(shown.stdout, "LoadState"); + const activeState = systemdProperty(shown.stdout, "ActiveState"); + const fragmentPath = systemdProperty(shown.stdout, "FragmentPath"); + const needReload = systemdProperty(shown.stdout, "NeedDaemonReload"); + if (loadState === null || activeState === null || needReload === null) { + return unknown("systemctl show did not report the properties it was asked for"); + } + if (needReload === "yes") { + return unknown("systemd has a stale definition loaded; it needs daemon-reload"); + } + + const registration: "present" | "absent" = + loadState === "not-found" && activeState === "inactive" && !fragmentPath ? "absent" : "present"; + + if (artifactPresence(definitionPath) === "absent") { + return registration === "absent" + ? { kind: "absent" } + : unknown("systemd knows this unit but its file is missing"); + } + + let body: string; + try { + body = readFileSync(definitionPath, "utf-8"); + } catch (error) { + return unknown(`the systemd unit exists but could not be read: ${String(error)}`); + } + + return { + kind: "present", + claims: [{ + backend: "systemd", + definitionPath, + homes: { + codexHome: unitEnvValue(body, "CODEX_HOME"), + opencodexHome: unitEnvValue(body, "OPENCODEX_HOME"), + }, + registration, + }], + }; +} + +/** + * Windows is deferred to its own phase and reports `unknown` until then. + * + * Not an oversight: the definition there is a chain, not a file. The task XML + * names only the launcher, and the homes live in the batch wrapper it eventually + * runs — a probe that parsed the XML and stopped would find no homes and read + * that as agreement. Reporting `unknown` refuses unattended convergence on + * Windows, which is the safe direction while the chain walk is unwritten. + */ +function inspectWindows(): ServiceManagerInstallation { + return unknown("the Windows definition chain is not inspected yet"); +} + +export function inspectServiceManagerInstallation(deps: ProbeDeps = {}): ServiceManagerInstallation { + const platform = deps.platform ?? process.platform; + const run = deps.run ?? defaultProbeRunner; + const home = deps.home ?? homedir(); + if (platform === "darwin") return inspectLaunchd({ run, uid: deps.uid ?? process.getuid?.() ?? 0, home }); + if (platform === "linux") return inspectSystemd({ run, home }); + if (platform === "win32") return inspectWindows(); + return unknown(`no service manager probe for platform ${platform}`); +} diff --git a/src/service.ts b/src/service.ts index c397eb109..ce51dc72c 100644 --- a/src/service.ts +++ b/src/service.ts @@ -12,7 +12,7 @@ import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { expandUserPath, getConfigDir, readPid, removePid, removeRuntimePort, verifyPidIdentity } from "./config"; import { loadConfig } from "./config"; -import { restoreNativeCodex } from "./codex/inject"; +import { restoreNativeCodex, restoreNativeCodexAsync } from "./codex/inject"; import { stripGrokConfig } from "./grok/inject"; import { isWslRuntime } from "./codex/home"; import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from "./lib/bun-runtime"; @@ -174,6 +174,58 @@ function readServiceInstallState(): ServiceInstallState | null { return null; } +/** What ONE state path said. Absent, unreadable and invalid are different answers. */ +export type ServiceStateEvidence = + | { readonly path: string; readonly kind: "absent" } + | { readonly path: string; readonly kind: "unreadable"; readonly reason: string } + | { readonly path: string; readonly kind: "invalid" } + | { readonly path: string; readonly kind: "valid"; readonly state: ServiceInstallState }; + +/** + * Every state path, with what each one said. + * + * `readServiceInstallState` returns the FIRST path that parsed and discards the + * rest, so a valid mirror beside a corrupt one reads as clean. That is the right + * behavior for callers that just need the install state; it is the wrong input + * for deciding ownership, where a disagreement between mirrors is exactly the + * evidence that matters. + */ +export function inspectServiceStateEvidence( + paths: readonly string[] = serviceStatePaths(), +): readonly ServiceStateEvidence[] { + return paths.map((path): ServiceStateEvidence => { + let raw: string; + try { + raw = readFileSync(path, "utf8"); + } catch (error) { + const code = error && typeof error === "object" && "code" in error + ? String((error as { code?: unknown }).code) + : ""; + // ENOENT is an answer. EACCES, ENOTDIR and the rest are a failure to ask, + // and collapsing them into absence is how a locked-down state file would + // become permission to write. + if (code === "ENOENT") return { path, kind: "absent" }; + return { path, kind: "unreadable", reason: code || String(error) }; + } + let parsed: ServiceInstallState | null; + try { + parsed = parseServiceInstallState(JSON.parse(raw)); + } catch { + return { path, kind: "invalid" }; + } + return parsed ? { path, kind: "valid", state: parsed } : { path, kind: "invalid" }; + }); +} + +/** The homes this process is actually using, for comparison against a claim. */ +export function currentServiceHomes(): { codexHome: string; opencodexHome: string } { + return { codexHome: currentCodexHome(), opencodexHome: currentOpenCodexHome() }; +} + +export function serviceHomeMatches(a: string, b: string): boolean { + return normalizePathForCompare(a) === normalizePathForCompare(b); +} + /** Single accessor for backend-sensitive service code — v1/legacy state maps to scheduler. */ export function readServiceBackend(): ServiceBackend { return readServiceInstallState()?.backend === "native" ? "native" : "scheduler"; @@ -249,6 +301,7 @@ export function assertServiceEnvironmentMatchesInstall(): void { } } + function plistString(value: string): string { return value .replace(/&/g, "&") @@ -581,16 +634,28 @@ function sh(cmd: string): string { export function runLaunchctl( args: string[], deps: { run?: typeof spawnSync } = {}, -): { ok: boolean; stdout: string; stderr: string } { +): { ok: boolean; stdout: string; stderr: string; status: number | null } { const run = deps.run ?? spawnSync; const result = run("/bin/launchctl", args, { encoding: "utf8", windowsHide: true }); // `error` is set when the spawn itself failed (ENOENT off macOS) and `status` is // null for a signalled child; neither may be reported as success. - if (result.error) return { ok: false, stdout: "", stderr: String(result.error.message ?? "") }; + if (result.error) { + return { ok: false, stdout: "", stderr: String(result.error.message ?? ""), status: null }; + } return { ok: result.status === 0, stdout: String(result.stdout ?? "").trim(), stderr: String(result.stderr ?? "").trim(), + /* + * The NUMBER, not just its zero-ness. + * + * `launchctl print` distinguishes "that domain does not exist" (112) from + * "the domain answered and has no such service" (113), and an ownership + * probe needs that difference: the second proves absence, the first only + * proves we could not look. Collapsing both into `ok: false` forced callers + * to parse stderr, which Apple does not treat as a stable interface. + */ + status: result.status ?? null, }; } @@ -2619,7 +2684,7 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise/" ids, or routed diff --git a/tests/catalog-input-modality-enum.test.ts b/tests/catalog-input-modality-enum.test.ts index 8ea8f98f8..e13e8762d 100644 --- a/tests/catalog-input-modality-enum.test.ts +++ b/tests/catalog-input-modality-enum.test.ts @@ -87,7 +87,12 @@ describe("custom-model API rejects out-of-enum input modalities", () => { deps: { saveConfigPreservingClaudeCode: () => { persistCalls++; }, } as Parameters[0]["deps"], - refreshCodexCatalogBestEffort: async () => {}, + convergeCodexCatalog: async () => ({ + status: "committed", + changed: false, + degraded: false, + notices: [], + }), syncClaudeAgentDefsBestEffort: async () => {}, }); } diff --git a/tests/catalog-oauth-observation.test.ts b/tests/catalog-oauth-observation.test.ts new file mode 100644 index 000000000..fa1fa144e --- /dev/null +++ b/tests/catalog-oauth-observation.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + chmodSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + observeActiveOAuthAccessToken, + OAUTH_PROVIDERS, +} from "../src/oauth"; +import { + gatherRoutedModelsForCatalogGather, + type CatalogGatherProviderAuthOutcome, +} from "../src/codex/catalog/provider-fetch"; +import { clearModelCache } from "../src/codex/model-cache"; +import { getAuthRefreshIntentPath } from "../src/oauth/store"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; + +interface FileSnapshot { + readonly bytes: Buffer; + readonly inode: bigint; + readonly mode: bigint; + readonly mtimeNs: bigint; +} + +const originalHome = process.env.HOME; +const originalOpencodexHome = process.env.OPENCODEX_HOME; +const originalCodexHome = process.env.CODEX_HOME; +const originalKimiRefresh = OAUTH_PROVIDERS.kimi!.refresh; + +let root: string; +let opencodexHome: string; + +function authStoreBytes(expires: number): Buffer { + return Buffer.from(JSON.stringify({ + kimi: { + activeAccountId: "active", + accounts: [{ + id: "active", + credential: { + access: "fixture-a", + refresh: "fixture-r", + expires, + }, + }], + }, + }) + "\n"); +} + +function snapshotFile(path: string): FileSnapshot { + const stat = statSync(path, { bigint: true }); + return { + bytes: readFileSync(path), + inode: stat.ino, + mode: stat.mode, + mtimeNs: stat.mtimeNs, + }; +} + +function expectFileUnchanged(path: string, before: FileSnapshot): void { + const after = snapshotFile(path); + expect(Buffer.compare(after.bytes, before.bytes)).toBe(0); + expect({ inode: after.inode, mode: after.mode, mtimeNs: after.mtimeNs }).toEqual({ + inode: before.inode, + mode: before.mode, + mtimeNs: before.mtimeNs, + }); +} + +function liveKimiProvider(onFetch: () => void): OcxProviderConfig { + return { + ...structuredClone(OAUTH_PROVIDERS.kimi!.providerConfig), + liveModels: true, + models: ["k3"], + fetch: async () => { + onFetch(); + return new Response(JSON.stringify({ data: [{ id: "k3" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }; +} + +async function runCatalogGather( + authStoreBuffer: Uint8Array | null, + onFetch: () => void, +): Promise<{ rows: Awaited>; outcomes: CatalogGatherProviderAuthOutcome[] }> { + const config: OcxConfig = { providers: { kimi: liveKimiProvider(onFetch) } }; + const outcomes: CatalogGatherProviderAuthOutcome[] = []; + const rows = await gatherRoutedModelsForCatalogGather( + config, + { authStoreBuffer }, + { providerAuthOutcomes: outcomes }, + ); + return { rows, outcomes }; +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-catalog-auth-observe-")); + opencodexHome = join(root, "opencodex"); + mkdirSync(opencodexHome, { recursive: true, mode: 0o700 }); + process.env.HOME = join(root, "home"); + process.env.OPENCODEX_HOME = opencodexHome; + process.env.CODEX_HOME = join(root, "codex"); + clearModelCache(); +}); + +afterEach(() => { + OAUTH_PROVIDERS.kimi!.refresh = originalKimiRefresh; + clearModelCache(); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalOpencodexHome; + if (originalCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = originalCodexHome; + rmSync(root, { recursive: true, force: true }); +}); + +describe("catalog gather OAuth observation", () => { + test("expired active token stays typed and gather does not refresh or touch the auth store", async () => { + const now = Date.now(); + const authPath = join(opencodexHome, "auth.json"); + writeFileSync(authPath, authStoreBytes(now - 1), { mode: 0o600 }); + chmodSync(authPath, 0o644); + + // A pre-existing intent marker proves observe-only gather neither removes nor rewrites it. + const intentPath = getAuthRefreshIntentPath("kimi", "active"); + writeFileSync(intentPath, "{\"version\":1,\"sentinel\":true}\n", { mode: 0o600 }); + + const authBefore = snapshotFile(authPath); + const intentBefore = snapshotFile(intentPath); + const listingBefore = readdirSync(opencodexHome).sort(); + const observedBuffer = readFileSync(authPath); + let refreshCalls = 0; + let outboundCalls = 0; + OAUTH_PROVIDERS.kimi!.refresh = async () => { + refreshCalls += 1; + return { access: "replacement-a", refresh: "replacement-r", expires: now + 3_600_000 }; + }; + + expect(observeActiveOAuthAccessToken("kimi", observedBuffer, now).kind).toBe("expired"); + const { rows, outcomes } = await runCatalogGather(observedBuffer, () => { outboundCalls += 1; }); + + expect(rows.map(row => row.id)).toEqual(["k3"]); + expect(refreshCalls).toBe(0); + expect(outboundCalls).toBe(0); + expect(outcomes).toEqual([{ provider: "kimi", state: "expired" }]); + expectFileUnchanged(authPath, authBefore); + expectFileUnchanged(intentPath, intentBefore); + expect(readdirSync(opencodexHome).sort()).toEqual(listingBefore); + expect(readdirSync(opencodexHome).some(name => name.startsWith("auth.json.invalid-"))).toBe(false); + expect(existsSync(`${authPath}.pre-multiauth`)).toBe(false); + }); + + test("unparseable auth-store bytes are typed malformed and never backed up or rewritten", async () => { + const authPath = join(opencodexHome, "auth.json"); + writeFileSync(authPath, "{unparseable\n", { mode: 0o644 }); + const before = snapshotFile(authPath); + const listingBefore = readdirSync(opencodexHome).sort(); + const observedBuffer = readFileSync(authPath); + let refreshCalls = 0; + let outboundCalls = 0; + OAUTH_PROVIDERS.kimi!.refresh = async () => { + refreshCalls += 1; + return { access: "replacement-a", refresh: "replacement-r", expires: Date.now() + 3_600_000 }; + }; + + expect(observeActiveOAuthAccessToken("kimi", observedBuffer).kind).toBe("malformed"); + const { rows, outcomes } = await runCatalogGather(observedBuffer, () => { outboundCalls += 1; }); + + expect(rows.map(row => row.id)).toEqual(["k3"]); + expect(outcomes).toEqual([{ provider: "kimi", state: "malformed" }]); + expect(refreshCalls).toBe(0); + expect(outboundCalls).toBe(0); + expectFileUnchanged(authPath, before); + expect(readdirSync(opencodexHome).sort()).toEqual(listingBefore); + expect(readdirSync(opencodexHome).some(name => name.startsWith("auth.json.invalid-"))).toBe(false); + expect(existsSync(`${authPath}.pre-multiauth`)).toBe(false); + }); + + test("available observed token permits live discovery without entering refresh", async () => { + const now = Date.now(); + const authPath = join(opencodexHome, "auth.json"); + writeFileSync(authPath, authStoreBytes(now + 3_600_000), { mode: 0o644 }); + const before = snapshotFile(authPath); + const listingBefore = readdirSync(opencodexHome).sort(); + const observedBuffer = readFileSync(authPath); + let refreshCalls = 0; + let outboundCalls = 0; + OAUTH_PROVIDERS.kimi!.refresh = async () => { + refreshCalls += 1; + return { access: "replacement-a", refresh: "replacement-r", expires: now + 3_600_000 }; + }; + + expect(observeActiveOAuthAccessToken("kimi", observedBuffer, now).kind).toBe("available"); + const { rows, outcomes } = await runCatalogGather(observedBuffer, () => { outboundCalls += 1; }); + + expect(rows.map(row => row.id)).toEqual(["k3"]); + expect(outcomes).toEqual([{ provider: "kimi", state: "available" }]); + expect(refreshCalls).toBe(0); + expect(outboundCalls).toBe(1); + expectFileUnchanged(authPath, before); + expect(readdirSync(opencodexHome).sort()).toEqual(listingBefore); + }); +}); diff --git a/tests/cli-restore-back.test.ts b/tests/cli-restore-back.test.ts index 42a2fc2f6..cf72a94c6 100644 --- a/tests/cli-restore-back.test.ts +++ b/tests/cli-restore-back.test.ts @@ -22,7 +22,7 @@ describe("ocx restore back", () => { expect(restoreCase.indexOf("if (!synced.ok)")).toBeLessThan(restoreCase.indexOf("target.effectiveCodexHome")); expect(restoreCase).toContain("target.effectiveCodexHome"); // The forward switch reports incomplete marker cleanup instead of claiming native success. - expect(restoreCase).toContain("restoreNativeCodex()"); + expect(restoreCase).toContain("restoreNativeCodexAsync()"); expect(restoreCase).toContain("process.exitCode = 1"); expect(restoreCase).toContain("was not fully restored"); }); diff --git a/tests/client-export-modality-enum.test.ts b/tests/client-export-modality-enum.test.ts new file mode 100644 index 000000000..bcefd5039 --- /dev/null +++ b/tests/client-export-modality-enum.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, test } from "bun:test"; +import { + buildClientConfig, + OPENCODE_PROVIDER_ID, + type ExportContext, + type ExportModel, + type GajaeGeneratedConfig, + type PiGeneratedConfig, +} from "../src/clients/config-export"; +import type { OcxConfig } from "../src/types"; + +/** + * Sibling of catalog-input-modality-enum.test.ts, whose incident this repeats + * at a different boundary. + * + * Our internal modality vocabulary is text|image|audio. Pi and Gajae accept only + * text|image, and BOTH reject the whole config over one out-of-enum value — + * Gajae falls back to its built-in list, Pi returns an empty model config. This + * actually happened: zenmux advertises audio on meta-muse-spark-1.1, we wrote it + * through verbatim, and gjc reported + * `/providers/opencodex/models/30/input/2: Invalid option: expected one of + * "text"|"image"` while showing none of the routed models. + * + * The per-entry tests in client-config-export.test.ts all passed while that file + * was broken, which is why the whole-catalog assertion at the bottom exists. + */ + +const CONFIG: OcxConfig = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, +} as unknown as OcxConfig; + +function ctx(models: ExportModel[]): ExportContext { + return { baseUrl: "http://127.0.0.1:10100/v1", models, config: CONFIG }; +} + +function piModels(models: ExportModel[]) { + return (buildClientConfig("pi", ctx(models)) as PiGeneratedConfig) + .providers[OPENCODE_PROVIDER_ID].models; +} + +function gajaeModels(models: ExportModel[]) { + return (buildClientConfig("gajae", ctx(models)) as GajaeGeneratedConfig) + .providers[OPENCODE_PROVIDER_ID].models; +} + +/** The live failure, by its real id and real modality list. */ +const MIXED: ExportModel = { + namespaced: "zenmux/meta-muse-spark-1.1", + provider: "zenmux", + id: "meta-muse-spark-1.1", + contextWindow: 1_048_576, + inputModalities: ["text", "image", "audio"], +}; + +/** + * Reachable three ways: `ocx models add --modalities audio` (src/cli/models.ts), + * POST /api/custom-models (model-routes.ts ALLOWED_INPUT_MODALITIES), and + * provider discovery returning an audio-only list. + */ +const AUDIO_ONLY: ExportModel = { + namespaced: "p/audio-only", + provider: "p", + id: "audio-only", + inputModalities: ["audio"], +}; + +describe("exported modalities stay inside the enum each client accepts", () => { + test("audio is dropped from a mixed Gajae entry rather than written through", () => { + expect(gajaeModels([MIXED])[0]?.input).toEqual(["text", "image"]); + }); + + test("the same holds for Pi, whose exposure was latent only because its file was empty", () => { + expect(piModels([MIXED])[0]?.input).toEqual(["text", "image"]); + }); + + test("a model with NO acceptable modality is omitted, never rewritten to text", () => { + // Claiming text would advertise a capability the model does not have, and it + // would fail at call time with no explanation. Losing the row is the lesser + // cost — and unlike a fabricated modality, it is visible. + expect(gajaeModels([AUDIO_ONLY])).toEqual([]); + expect(piModels([AUDIO_ONLY])).toEqual([]); + }); + + test("an undeclared modality list still yields text — unknown is not incompatible", () => { + // Every routed model takes prompts, so text is the honest floor here. This + // branch is also what keeps the byte-exact goldens stable. + const bare: ExportModel = { namespaced: "p/bare", provider: "p", id: "bare" }; + expect(gajaeModels([bare])[0]?.input).toEqual(["text"]); + expect(piModels([bare])[0]?.input).toEqual(["text"]); + const empty: ExportModel = { ...bare, namespaced: "p/empty", id: "empty", inputModalities: [] }; + expect(gajaeModels([empty])[0]?.input).toEqual(["text"]); + }); + + test("an already-acceptable list survives untouched", () => { + const vision: ExportModel = { + namespaced: "p/vision", provider: "p", id: "vision", inputModalities: ["text", "image"], + }; + expect(gajaeModels([vision])[0]?.input).toEqual(["text", "image"]); + expect(piModels([vision])[0]?.input).toEqual(["text", "image"]); + }); + + test("order is preserved and duplicates collapse", () => { + const dup: ExportModel = { + namespaced: "p/dup", provider: "p", id: "dup", inputModalities: ["image", "text", "image"], + }; + expect(gajaeModels([dup])[0]?.input).toEqual(["image", "text"]); + }); + + test("no emitted entry in a whole catalog carries a value its client rejects", () => { + // The assertion that would have caught the live bug: every per-entry test + // above can pass while one model in a real catalog still poisons the file. + const catalog: ExportModel[] = [ + MIXED, + AUDIO_ONLY, + { namespaced: "p/bare", provider: "p", id: "bare" }, + { namespaced: "p/vision", provider: "p", id: "vision", inputModalities: ["text", "image"] }, + ]; + for (const models of [gajaeModels(catalog), piModels(catalog)]) { + expect(models.length).toBe(3); + for (const entry of models) { + for (const value of entry.input) { + expect(["text", "image"]).toContain(value); + } + } + // And the incompatible one is gone rather than silently retyped. + expect(models.map(m => m.id)).not.toContain("p/audio-only"); + } + }); +}); diff --git a/tests/codex-admission-primitives.test.ts b/tests/codex-admission-primitives.test.ts new file mode 100644 index 000000000..3e643efd2 --- /dev/null +++ b/tests/codex-admission-primitives.test.ts @@ -0,0 +1,362 @@ +/** + * The primitives the Codex write lock's authority comparison rests on. + * + * These are deliberately function-level. The refusal they enable — + * `authority_not_proven` on a competing write — cannot be proven here, because + * it needs an admitted snapshot, and admission cannot honestly admit anything + * while its ownership field is still hardcoded. Proving the refusal against a + * placeholder would be proving it against a known lie, so that claim belongs to + * the phases that make ownership real. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; + +import { + observeConfigGeneration, + readConfigAdmissionSnapshot, + readConfigGenerationInCurrentMutationTransaction, + saveConfig, + withConfigMutationLockSync, +} from "../src/config"; +import { hashAuthority } from "../src/codex/admission"; +import { captureCatalogAdmissionSnapshot } from "../src/codex/catalog-admission"; +import { JOURNAL_PATH } from "../src/codex/journal"; +import type { AdmissionSnapshot } from "../src/codex/convergence-types"; +import type { OcxConfig } from "../src/types"; + +let root = ""; +let previousOpencodexHome: string | undefined; +const cleanup: string[] = []; + +function config(port = 10100): OcxConfig { + return { + port, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + } as OcxConfig["providers"], + defaultProvider: "openai", + }; +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-admission-primitives-")); + cleanup.push(root); + previousOpencodexHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = root; +}); + +afterEach(() => { + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + while (cleanup.length) rmSync(cleanup.pop()!, { recursive: true, force: true }); +}); + +describe("the config digest is over bytes, not over meaning", () => { + /** + * The defect this closes: the digest used to hash `JSON.stringify(config)`, + * so two files that parse the same hashed the same. A non-cooperating writer + * could reformat the file between admission and commit and the comparison + * would see nothing at all. + */ + test("a whitespace-only rewrite moves the digest", () => { + const path = join(root, "config.json"); + writeFileSync(path, JSON.stringify(config(), null, 2)); + const compact = readConfigAdmissionSnapshot(); + + writeFileSync(path, JSON.stringify(config(), null, 8)); + const spacious = readConfigAdmissionSnapshot(); + + expect(compact.kind).toBe("read"); + expect(spacious.kind).toBe("read"); + // Same meaning... + expect(spacious.diagnostics.config).toEqual(compact.diagnostics.config); + // ...different bytes. + expect(spacious.contentSha256).not.toBe(compact.contentSha256); + }); + + test("and moves the authority hash with it", () => { + const path = join(root, "config.json"); + writeFileSync(path, JSON.stringify(config(), null, 2)); + const before = readConfigAdmissionSnapshot(); + writeFileSync(path, JSON.stringify(config(), null, 8)); + const after = readConfigAdmissionSnapshot(); + + expect(hashAuthority(snapshotWith({ configDigest: after.contentSha256 ?? "" }))) + .not.toBe(hashAuthority(snapshotWith({ configDigest: before.contentSha256 ?? "" }))); + }); + + test("identical bytes hash identically, or every write would refuse itself", () => { + const path = join(root, "config.json"); + const bytes = JSON.stringify(config(), null, 2); + writeFileSync(path, bytes); + const first = readConfigAdmissionSnapshot(); + writeFileSync(path, bytes); + const second = readConfigAdmissionSnapshot(); + expect(second.contentSha256).toBe(first.contentSha256); + }); + + /** + * Hashing the file and then reading it again to parse would leave a window in + * which the two disagree — turning the check into a second chance to be wrong. + * The read count is the property; the digest being correct is not enough. + */ + /** + * A spy cannot see through the module boundary here, so the read count is + * proven by CONSEQUENCE instead: if the producer read the file twice, a + * change landing between the two reads would make the digest and the parsed + * config describe different files. Swapping the contents underneath a single + * read is impossible; underneath two reads it is the whole hazard. + */ + test("the digest and the parsed config always describe the same bytes", () => { + const path = join(root, "config.json"); + for (const port of [10100, 20200, 30300]) { + const bytes = JSON.stringify(config(port), null, 2); + writeFileSync(path, bytes); + const snapshot = readConfigAdmissionSnapshot(); + expect(snapshot.kind).toBe("read"); + expect(snapshot.diagnostics.config.port).toBe(port); + // The digest of what we just wrote, computed independently. + const independent = new Bun.CryptoHasher("sha256").update(bytes).digest("hex"); + expect(snapshot.contentSha256).toBe(independent); + } + }); + + test("an unreadable config cannot carry a digest", () => { + // No config.json at all. + const snapshot = readConfigAdmissionSnapshot(); + expect(snapshot.kind).toBe("unreadable"); + expect(snapshot.contentSha256).toBeNull(); + expect(snapshot.diagnostics.source).toBe("default"); + }); +}); + +describe("absence is one state, and being unable to look is another", () => { + test("a missing coordinator database is absent", () => { + expect(observeConfigGeneration()).toEqual({ kind: "absent" }); + }); + + test("a corrupt database is not absent", () => { + writeFileSync(join(root, "config-mutation.sqlite"), "not sqlite"); + expect(observeConfigGeneration()).toEqual({ kind: "unavailable", reason: "database" }); + }); + + test("a directory where the database should be is not absent", () => { + mkdirSync(join(root, "config-mutation.sqlite")); + const observed = observeConfigGeneration(); + expect(observed.kind).toBe("unavailable"); + }); + + test("an unreadable parent directory is not absent", () => { + const nested = join(root, "locked"); + mkdirSync(nested); + writeFileSync(join(nested, "config-mutation.sqlite"), ""); + chmodSync(nested, 0o000); + process.env.OPENCODEX_HOME = nested; + try { + const observed = observeConfigGeneration(); + // Either it could not stat (unavailable) or the platform let it through; + // what must never happen is reporting ABSENT for a file that is there. + expect(observed.kind).not.toBe("absent"); + } finally { + chmodSync(nested, 0o700); + process.env.OPENCODEX_HOME = root; + } + }); + + test("an existing database reports its value", () => { + saveConfig(config()); + expect(observeConfigGeneration()).toEqual({ kind: "ready", generation: { value: 1 } }); + }); +}); + +describe("the generation read that belongs to the open transaction", () => { + /** + * The measured fact that made this necessary: on first acquisition the + * BEGIN IMMEDIATE creating the table has not committed, so a separate + * read-only connection still sees nothing. The observer cannot do this job. + */ + test("the observer cannot read the first transaction, and this can", () => { + const before = observeConfigGeneration(); + let observedInside: { kind: string } | undefined; + let transactional: unknown; + withConfigMutationLockSync(() => { + observedInside = observeConfigGeneration(); + transactional = readConfigGenerationInCurrentMutationTransaction(); + return null; + }); + const after = observeConfigGeneration(); + + // Before: nothing exists. Inside: the file exists but its creating + // transaction has not committed, so a separate connection still cannot read + // a generation — it reports unavailable, NOT the zero that is really there. + // Only after the commit does the observer agree. + expect(before).toEqual({ kind: "absent" }); + expect(observedInside?.kind).not.toBe("ready"); + expect(after).toEqual({ kind: "ready", generation: { value: 0 } }); + // The transactional read saw the truth the whole time. + expect(transactional).toEqual({ value: 0 }); + }); + + test("calling it outside a transaction throws rather than guessing", () => { + expect(() => readConfigGenerationInCurrentMutationTransaction()).toThrow( + /requires an open config mutation transaction/, + ); + }); + + test("a nested call reads the same open handle", () => { + const seen: unknown[] = []; + withConfigMutationLockSync(() => { + seen.push(readConfigGenerationInCurrentMutationTransaction()); + withConfigMutationLockSync(() => { + seen.push(readConfigGenerationInCurrentMutationTransaction()); + return null; + }); + return null; + }); + expect(seen).toEqual([{ value: 0 }, { value: 0 }]); + }); + + test("it throws again once the transaction has closed", () => { + withConfigMutationLockSync(() => readConfigGenerationInCurrentMutationTransaction()); + expect(() => readConfigGenerationInCurrentMutationTransaction()).toThrow(); + }); +}); + +describe("absent and present-zero are one authority", () => { + test("they hash identically, or no first write could ever commit", () => { + expect(hashAuthority(snapshotWith({ generation: { present: false, value: 0 } }))) + .toBe(hashAuthority(snapshotWith({ generation: { present: true, value: 0 } }))); + }); + + test("but a committed bump does not hash like either", () => { + const zero = hashAuthority(snapshotWith({ generation: { present: true, value: 0 } })); + expect(hashAuthority(snapshotWith({ generation: { present: true, value: 1 } }))).not.toBe(zero); + expect(hashAuthority(snapshotWith({ generation: { present: true, value: 2 } }))).not.toBe(zero); + expect(hashAuthority(snapshotWith({ generation: { present: true, value: 2 } }))) + .not.toBe(hashAuthority(snapshotWith({ generation: { present: true, value: 1 } }))); + }); + + /** + * Without this guard every malformed value collapses to "gen:0" — the single + * value that means "nothing has happened yet, go ahead". + */ + test.each([ + ["negative", -1], + ["NaN", Number.NaN], + ["infinity", Number.POSITIVE_INFINITY], + ["fractional", 1.5], + ["beyond safe integers", Number.MAX_SAFE_INTEGER + 1], + ])("a %s generation throws instead of becoming zero", (_label, value) => { + expect(() => hashAuthority(snapshotWith({ generation: { present: true, value } }))) + .toThrow(/non-negative safe integer/); + }); + + test("every other authority field still moves the hash", () => { + const base = snapshotWith({}); + const variants: Partial[] = [ + { configDigest: "different" }, + { intent: "off" }, + { ownership: "foreign" }, + { externalProvider: "someone" }, + { journalIdentity: "different" }, + { provenanceIdentity: "different" }, + { canonicalTargets: { ...base.canonicalTargets, config: "/elsewhere" } }, + ]; + for (const variant of variants) { + expect(hashAuthority({ ...base, ...variant })).not.toBe(hashAuthority(base)); + } + }); +}); + +describe("absence keeps the projection it always had", () => { + /* + * management-convergence.ts classifies this failure by MATCHING THE MESSAGE + * (`admissionFailure`, ~:62). A missing coordinator has always produced a + * retryable skip; a message the classifier does not recognize silently + * becomes a non-retryable failed/disk. Adding the `absent` branch broke + * exactly that, and only a full-suite run caught it — this is the local guard. + */ + test("catalog admission still reads as retryable when the coordinator is absent", () => { + let thrown: unknown; + try { + captureCatalogAdmissionSnapshot(config()); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(Error); + const message = (thrown as Error).message; + expect( + message.includes("config generation is busy") || message.includes("config generation is database"), + ).toBeTrue(); + }); +}); + +describe("the journal has one owner", () => { + /** + * Admission re-derived this path by hand and got both halves wrong, so its + * journal identity watched a file nothing writes. The fixture re-derived it + * the same wrong way and agreed. Importing the production constant is the + * point of the test. + */ + test("the exported constant is the file journal.ts actually uses", () => { + // The old hand-derived path was OPENCODEX_HOME/codex-journal.json — wrong + // directory AND wrong basename. Assert both halves. Paths are compared by + // shape rather than string equality because macOS resolves the temp root + // through /private, which is not the property under test. + expect(basename(JOURNAL_PATH)).toBe("opencodex-journal.json"); + expect(dirname(JOURNAL_PATH).endsWith(".codex")).toBeTrue(); + expect(dirname(JOURNAL_PATH).endsWith(".opencodex")).toBeFalse(); + }); + + /** + * The constant existing is not the property. ADMISSION USING IT is. + * + * The first version of this suite asserted only the constant, and a commit + * landed in which the comment said "the journal's own constant" directly + * above a hand-derived path — the exact defect, restated as its own fix, + * with the tests still green. Reading the producer's source is blunt, but it + * is the thing that was actually wrong. + */ + test("admission derives no journal path of its own", async () => { + const source = await Bun.file( + join(import.meta.dir, "..", "src", "codex", "admission.ts"), + ).text(); + expect(source).toContain("JOURNAL_PATH"); + expect(source).not.toContain("codex-journal.json"); + }); +}); + +function snapshotWith(overrides: Partial): AdmissionSnapshot { + return { + config: config(), + configDigest: "digest", + intent: "on", + generation: { present: true, value: 0 }, + ownership: "owned", + externalProvider: null, + canonicalTargets: { + codexHome: "/codex", + opencodexHome: "/opencodex", + config: "/codex/config.toml", + profile: "/codex/opencodex.config.toml", + catalog: "/codex/opencodex-catalog.json", + cache: "/codex/models_cache.json", + journal: JOURNAL_PATH, + integrationRecord: "/opencodex/integrations/codex.json", + catalogBackups: [], + historyDb: "/codex/state_5.sqlite", + historyManifest: "/codex/state_5.sqlite.ocx-backup.json", + historyRollouts: [], + }, + journalIdentity: "absent", + provenanceIdentity: "absent", + authoritySnapshotId: "", + ...overrides, + }; +} diff --git a/tests/codex-admission.test.ts b/tests/codex-admission.test.ts new file mode 100644 index 000000000..6a4506306 --- /dev/null +++ b/tests/codex-admission.test.ts @@ -0,0 +1,253 @@ +/** + * The AdmissionSnapshot producer. + * + * `AdmissionSnapshot` was a TYPE for this entire unit while nothing built one, + * so the write lock's API could only be exercised with a fabricated object. What + * these tests hold down is the two properties that make a real one worth having: + * it REFUSES rather than guessing, and it CREATES NOTHING while doing so — an + * admission that manufactures the state it is admitting cannot refuse. + */ +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 { admitCodexWrite as admitRaw, hashAuthority } from "../src/codex/admission"; + +/* + * Ownership is proven by shelling out to the platform service manager, and a + * fixture that reached the real one would assert against whatever this developer + * has installed. These cases are about the OTHER authorities, so ownership is + * pinned; the tri-state itself has its own suite. + */ +const admitCodexWrite = (): ReturnType => + admitRaw({ inspectOwnership: () => ({ ownership: "owned", reason: "pinned by fixture" }) }); +import { JOURNAL_PATH } from "../src/codex/journal"; +import type { OcxConfig } from "../src/types"; + +let root = ""; +let codexHome = ""; +let opencodexHome = ""; +let previousCodexHome: string | undefined; +let previousOpencodexHome: string | undefined; +const cleanup: string[] = []; + +function baseConfig(): OcxConfig { + return { + port: 10100, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + } as OcxConfig["providers"], + defaultProvider: "openai", + }; +} + +/** Everything the producer may read, and nothing it may create. */ +function seed(config: OcxConfig = baseConfig(), codexToml = 'model = "gpt-5"\n'): void { + writeFileSync(join(opencodexHome, "config.json"), JSON.stringify(config, null, 2)); + writeFileSync(join(codexHome, "config.toml"), codexToml); +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-admission-")); + cleanup.push(root); + codexHome = join(root, ".codex"); + opencodexHome = join(root, ".opencodex"); + mkdirSync(codexHome, { recursive: true }); + mkdirSync(opencodexHome, { recursive: true }); + /* + * An OWNED environment. `bun test` isolates CODEX_HOME to a temp dir, so the + * real service-state.json under ~/.opencodex names a different home and every + * admission refuses on service-home — the preflight working exactly as + * designed, against the wrong fixture. Same reason the Grok toggle tests write + * this file. + */ + writeFileSync(join(opencodexHome, "service-state.json"), JSON.stringify({ + version: 2, + codexHome, + opencodexHome, + backend: "scheduler", + })); + previousCodexHome = process.env.CODEX_HOME; + previousOpencodexHome = process.env.OPENCODEX_HOME; + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = opencodexHome; +}); + +afterEach(() => { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + while (cleanup.length) rmSync(cleanup.pop()!, { recursive: true, force: true }); +}); + +describe("it refuses rather than guessing", () => { + test("a missing config refuses on config authority", () => { + writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5"\n'); + const result = admitCodexWrite(); + expect(result.kind).toBe("refused"); + expect(result.kind === "refused" && result.authority).toBe("config"); + }); + + test("a malformed config refuses rather than falling back to defaults", () => { + writeFileSync(join(opencodexHome, "config.json"), "{ not json"); + writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5"\n'); + const result = admitCodexWrite(); + expect(result.kind).toBe("refused"); + expect(result.kind === "refused" && result.authority).toBe("config"); + }); + + /** + * An external owner is its own authority, separate from service-home. The two + * need different messages because they need different actions: one is "another + * install owns this home", the other is "you pointed Codex somewhere else". + */ + test("an external model_provider refuses on its own authority", () => { + seed(baseConfig(), [ + 'model_provider = "someone-else"', + "", + "[model_providers.someone-else]", + 'name = "someone-else"', + 'base_url = "https://example.invalid/v1"', + "", + ].join("\n")); + const result = admitCodexWrite(); + expect(result.kind).toBe("refused"); + expect(result.kind === "refused" && result.authority).toBe("external-provider"); + expect(result.kind === "refused" && result.message).toContain("someone-else"); + }); +}); + +describe("it creates nothing", () => { + /** + * The failure this guards is specific and has bitten this codebase: a status + * read that mkdirs its own library, so merely ASKING manufactures the state + * being asked about. + */ + test("a refusal leaves the filesystem exactly as it found it", () => { + writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5"\n'); + const before = [...readdirSync(codexHome), ...readdirSync(opencodexHome)].sort(); + + expect(admitCodexWrite().kind).toBe("refused"); + + const after = [...readdirSync(codexHome), ...readdirSync(opencodexHome)].sort(); + expect(after).toEqual(before); + }); + + test("a successful admission also creates nothing", () => { + seed(); + const before = [...readdirSync(codexHome), ...readdirSync(opencodexHome)].sort(); + + expect(admitCodexWrite().kind).toBe("admitted"); + + const after = [...readdirSync(codexHome), ...readdirSync(opencodexHome)].sort(); + expect(after).toEqual(before); + // Named explicitly, because these are the two an eager producer would make. + expect(existsSync(join(opencodexHome, "integrations"))).toBe(false); + expect(existsSync(join(codexHome, "opencodex.config.toml"))).toBe(false); + }); +}); + +describe("the snapshot describes one decision", () => { + test("intent follows the persisted switch", () => { + seed(); + const on = admitCodexWrite(); + expect(on.kind === "admitted" && on.snapshot.intent).toBe("on"); + + seed({ ...baseConfig(), clientIntegrations: { codex: false } }); + const off = admitCodexWrite(); + expect(off.kind === "admitted" && off.snapshot.intent).toBe("off"); + }); + + test("absence of the journal is evidence, not a hole", () => { + seed(); + const absent = admitCodexWrite(); + expect(absent.kind === "admitted" && absent.snapshot.journalIdentity).toBe("absent"); + + // The journal's own path, imported rather than re-derived. This fixture used + // to build OPENCODEX_HOME/codex-journal.json by hand and agreed with a + // producer that did the same — both wrong, and green because they matched. + writeFileSync(JOURNAL_PATH, "{}"); + const present = admitCodexWrite(); + expect(present.kind === "admitted" && present.snapshot.journalIdentity).not.toBe("absent"); + }); + + /** + * The lock compares authoritySnapshotId and NOTHING else, so a field left out + * of the hash is a field that can change under the lock unnoticed. + */ + test("every authority field moves the id", () => { + seed(); + const admitted = admitCodexWrite(); + expect(admitted.kind).toBe("admitted"); + if (admitted.kind !== "admitted") return; + const base = admitted.snapshot; + + const variants = [ + { ...base, configDigest: "different" }, + { ...base, intent: "off" as const }, + { ...base, generation: { present: true, value: base.generation.value + 1 } }, + { ...base, ownership: "foreign" as const }, + { ...base, externalProvider: "someone" }, + { ...base, journalIdentity: "different" }, + { ...base, provenanceIdentity: "different" }, + { ...base, canonicalTargets: { ...base.canonicalTargets, config: "/elsewhere" } }, + ]; + for (const variant of variants) { + expect(hashAuthority(variant)).not.toBe(hashAuthority(base)); + } + // And an identical snapshot hashes identically, or the comparison would + // refuse every write for no reason. + expect(hashAuthority({ ...base })).toBe(hashAuthority(base)); + }); +}); + +describe("ownership is an authority, not a formality", () => { + /* + * The mutation that exposed the gap: flipping admission's ownership guard to + * `if (false)` left every test green, because the fixtures pin ownership to + * `owned` and nothing exercised the refusal. A guard with no test is a guard + * someone can delete. + */ + test("foreign ownership refuses on the service-home authority", () => { + seed(); + const result = admitRaw({ + inspectOwnership: () => ({ ownership: "foreign", reason: "installed for /elsewhere" }), + }); + expect(result.kind).toBe("refused"); + expect(result.kind === "refused" && result.authority).toBe("service-home"); + expect(result.kind === "refused" && result.message).toContain("/elsewhere"); + }); + + test("unknown ownership refuses too, and says the proof is missing", () => { + seed(); + const result = admitRaw({ + inspectOwnership: () => ({ ownership: "unknown", reason: "the plist could not be read" }), + }); + expect(result.kind).toBe("refused"); + expect(result.kind === "refused" && result.authority).toBe("service-home"); + // The two refusals must not read alike: one is someone else's home, the + // other is a question nobody could answer, and they need different actions. + expect(result.kind === "refused" && result.message).toContain("could not be proven"); + }); + + test("neither refusal creates anything", () => { + seed(); + const before = [...readdirSync(codexHome), ...readdirSync(opencodexHome)].sort(); + for (const ownership of ["foreign", "unknown"] as const) { + expect(admitRaw({ inspectOwnership: () => ({ ownership, reason: "x" }) }).kind).toBe("refused"); + } + expect([...readdirSync(codexHome), ...readdirSync(opencodexHome)].sort()).toEqual(before); + }); + + test("the admitted snapshot carries the observed value, not a constant", () => { + seed(); + const result = admitRaw({ inspectOwnership: () => ({ ownership: "owned", reason: "probe" }) }); + expect(result.kind === "admitted" && result.snapshot.ownership).toBe("owned"); + }); +}); diff --git a/tests/codex-app-server-processes.test.ts b/tests/codex-app-server-processes.test.ts index c89ceb78b..3f3544434 100644 --- a/tests/codex-app-server-processes.test.ts +++ b/tests/codex-app-server-processes.test.ts @@ -363,12 +363,18 @@ describe("CLI /api sync wiring for stale app-servers (#476)", () => { cliSource.indexOf('case "sync-cache":'), cliSource.indexOf('case "gui":'), ); - expect(syncCacheCase).toContain("invalidateCodexModelsCache()"); - expect(syncCacheCase).toContain("if (invalidateCodexModelsCache())"); + // The cache write now happens under the catalog serialization lock K, so the + // gate reads the permitted writer's outcome instead of a bare boolean call. + // The property under test is unchanged: app-servers are touched only after a + // write actually landed, never on a refused/failed serialization attempt. + expect(syncCacheCase).toContain("withCatalogWriteSerialization"); + expect(syncCacheCase).toContain("invalidateCodexModelsCacheWithPermit(permit, owningCodexHome)"); + const gate = 'if (invalidated.kind === "completed" && invalidated.value)'; + expect(syncCacheCase).toContain(gate); expect(syncCacheCase).toContain("afterCatalogWriteHandleAppServers"); - expect(syncCacheCase.indexOf("if (invalidateCodexModelsCache())")) + expect(syncCacheCase.indexOf(gate)) .toBeLessThan(syncCacheCase.indexOf("afterCatalogWriteHandleAppServers")); - const gatedBlock = syncCacheCase.slice(syncCacheCase.indexOf("if (invalidateCodexModelsCache())")); + const gatedBlock = syncCacheCase.slice(syncCacheCase.indexOf(gate)); expect(gatedBlock).toContain("afterCatalogWriteHandleAppServers"); expect(syncCacheCase.replace(gatedBlock, "")).not.toContain("afterCatalogWriteHandleAppServers"); }); diff --git a/tests/codex-catalog-admission.test.ts b/tests/codex-catalog-admission.test.ts new file mode 100644 index 000000000..19c729f5a --- /dev/null +++ b/tests/codex-catalog-admission.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { captureCatalogAdmissionSnapshot } from "../src/codex/catalog-admission"; +import type { + CatalogConditionalSourceObservations, + CatalogConditionalSourceRole, + CatalogRequiredSourceObservations, + CatalogRequiredSourceRole, + CatalogSourceEvidence, +} from "../src/codex/convergence-types"; +import { saveConfig } from "../src/config"; +import type { OcxConfig } from "../src/types"; + +const CONDITIONAL_SOURCE_ROLES = [ + "active-catalog-merge", + "bundled-catalog-template", + "hashed-backup-fallback", + "legacy-backup-fallback", + "models-cache-fallback", + "native-catalog-selection", + "provider-auth-selection", + "runtime-selection", +] as const satisfies readonly CatalogConditionalSourceRole[]; + +type IsAssignable = [From] extends [To] ? true : false; +type MissingRequiredEvidence = Omit & Readonly<{ + required: Omit; +}>; +type MissingConditionalEvidence = Omit & Readonly<{ + conditional: Omit; +}>; +type MissingHomeEvidence = Omit; + +const STRUCTURALLY_INVALID_EVIDENCE_ASSIGNABILITY: readonly [ + IsAssignable, + IsAssignable, + IsAssignable, +] = [false, false, false]; + +let testRoot = ""; +let codexHome = ""; +let opencodexHome = ""; +let previousCodexHome: string | undefined; +let previousOpencodexHome: string | undefined; + +function config(port = 10100): OcxConfig { + return { port, providers: {}, defaultProvider: "openai" }; +} + +beforeEach(() => { + previousCodexHome = process.env.CODEX_HOME; + previousOpencodexHome = process.env.OPENCODEX_HOME; + testRoot = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-catalog-admission-"))); + codexHome = join(testRoot, "codex-home"); + opencodexHome = join(testRoot, "opencodex-home"); + mkdirSync(codexHome, { recursive: true }); + mkdirSync(opencodexHome, { recursive: true }); + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = opencodexHome; +}); + +afterEach(() => { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + rmSync(testRoot, { recursive: true, force: true }); +}); + +test("captures the given config reference, generation, and catalog target identities", () => { + saveConfig(config(20200)); + const residentConfig = config(30300); + writeFileSync(join(codexHome, "opencodex-catalog.json"), "{}\n"); + writeFileSync(join(codexHome, "models_cache.json"), "{}\n"); + + const snapshot = captureCatalogAdmissionSnapshot(residentConfig); + + expect(snapshot.config).toBe(residentConfig); + expect(snapshot.config.port).toBe(30300); + expect(snapshot.generation).toEqual({ value: 1 }); + expect(snapshot.configIdentity).toEqual({ + referenceIdentity: expect.any(String), + generation: { value: 1 }, + snapshotIdentity: expect.any(String), + }); + expect(JSON.parse(snapshot.targets.catalog)).toMatchObject({ + path: join(codexHome, "opencodex-catalog.json"), + canonicalParent: codexHome, + parentIdentity: { device: expect.any(String), inode: expect.any(String) }, + fileIdentity: { device: expect.any(String), inode: expect.any(String) }, + }); + expect(JSON.parse(snapshot.targets.cache)).toMatchObject({ + path: join(codexHome, "models_cache.json"), + canonicalParent: codexHome, + fileIdentity: { device: expect.any(String), inode: expect.any(String) }, + }); + expect(snapshot.targets.catalogBackups).toHaveLength(2); + + expect(snapshot.sourceEvidence.required["catalog-target-selection"]).toEqual({ + state: "absent", + role: "catalog-target-selection", + logicalPath: join(codexHome, "config.toml"), + canonicalPath: join(codexHome, "config.toml"), + parentIdentity: { + canonicalPath: codexHome, + volume: expect.any(String), + fileId: expect.any(String), + }, + fileIdentity: null, + }); + expect(snapshot.sourceEvidence.homeSelection).toEqual({ + selector: { kind: "environment", raw: codexHome }, + canonicalCodexHome: codexHome, + rootIdentity: { volume: expect.any(String), fileId: expect.any(String) }, + }); + expect(Object.keys(snapshot.sourceEvidence.conditional).sort()).toEqual(CONDITIONAL_SOURCE_ROLES); + for (const observations of Object.values(snapshot.sourceEvidence.conditional)) { + expect(observations).toEqual([]); + } +}); + +test("captures PRESENT catalog target-selection evidence from the exact config bytes", () => { + saveConfig(config()); + const selectedCatalog = join(codexHome, "selected-catalog.json"); + const configBytes = Buffer.from( + `model_catalog_json = ${JSON.stringify(selectedCatalog)}\n`, + "utf8", + ); + writeFileSync(join(codexHome, "config.toml"), configBytes); + + const snapshot = captureCatalogAdmissionSnapshot(config()); + const observation = snapshot.sourceEvidence.required["catalog-target-selection"]; + + expect(JSON.parse(snapshot.targets.catalog)).toMatchObject({ path: selectedCatalog }); + if (observation.state !== "present") { + throw new Error(`Expected PRESENT target-selection evidence, received ${observation.state}.`); + } + expect(observation).toEqual({ + state: "present", + role: "catalog-target-selection", + logicalPath: join(codexHome, "config.toml"), + canonicalPath: join(codexHome, "config.toml"), + parentIdentity: { + canonicalPath: codexHome, + volume: expect.any(String), + fileId: expect.any(String), + }, + fileIdentity: { + volume: expect.any(String), + fileId: expect.any(String), + }, + sha256: createHash("sha256").update(configBytes).digest("hex"), + }); +}); + +test("binds opaque config identity to the exact reference, generation, and snapshot", () => { + saveConfig(config()); + const firstConfig = config(20200); + const equalButDistinctConfig = config(20200); + + const first = captureCatalogAdmissionSnapshot(firstConfig).configIdentity; + const sameReference = captureCatalogAdmissionSnapshot(firstConfig).configIdentity; + const distinctReference = captureCatalogAdmissionSnapshot(equalButDistinctConfig).configIdentity; + firstConfig.port = 30300; + const mutated = captureCatalogAdmissionSnapshot(firstConfig).configIdentity; + + expect(sameReference).toEqual(first); + expect(distinctReference.referenceIdentity).not.toBe(first.referenceIdentity); + expect(distinctReference.snapshotIdentity).toBe(first.snapshotIdentity); + expect(mutated.referenceIdentity).toBe(first.referenceIdentity); + expect(mutated.snapshotIdentity).not.toBe(first.snapshotIdentity); + expect(mutated.generation).toEqual(first.generation); +}); + +test("rejects missing home, required, and conditional evidence keys structurally", () => { + expect(STRUCTURALLY_INVALID_EVIDENCE_ASSIGNABILITY).toEqual([false, false, false]); +}); + +test("changes target identity when a parent symlink retargets without changing the path", () => { + saveConfig(config()); + const parentA = join(testRoot, "catalog-parent-a"); + const parentB = join(testRoot, "catalog-parent-b"); + const linkedParent = join(testRoot, "catalog-parent"); + mkdirSync(parentA); + mkdirSync(parentB); + symlinkSync(parentA, linkedParent, process.platform === "win32" ? "junction" : "dir"); + const textualCatalogPath = join(linkedParent, "catalog.json"); + writeFileSync( + join(codexHome, "config.toml"), + `model_catalog_json = ${JSON.stringify(textualCatalogPath)}\n`, + ); + + const before = JSON.parse(captureCatalogAdmissionSnapshot(config()).targets.catalog); + if (process.platform === "win32") rmSync(linkedParent, { recursive: true, force: true }); + else unlinkSync(linkedParent); + symlinkSync(parentB, linkedParent, process.platform === "win32" ? "junction" : "dir"); + const after = JSON.parse(captureCatalogAdmissionSnapshot(config()).targets.catalog); + + expect(before.path).toBe(textualCatalogPath); + expect(after.path).toBe(textualCatalogPath); + expect(before.canonicalParent).not.toBe(after.canonicalParent); + expect(before.parentIdentity).not.toEqual(after.parentIdentity); +}); diff --git a/tests/codex-catalog-write-serialization.test.ts b/tests/codex-catalog-write-serialization.test.ts new file mode 100644 index 000000000..1453c8338 --- /dev/null +++ b/tests/codex-catalog-write-serialization.test.ts @@ -0,0 +1,198 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, realpathSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + assertCatalogWritePermit, + isCatalogWritePermitLive, + withCatalogWriteSerialization, + type CatalogWritePermit, +} from "../src/codex/catalog-write-serialization"; +import { + resolveCodexCatalogSerializationDatabasePath, + resolveCodexCoordinatorDatabasePath, + resolveEffectiveUserIdentity, +} from "../src/codex/user-identity"; + +let codexHome = ""; +let otherHome = ""; +let previousCodexHome: string | undefined; + +beforeEach(() => { + previousCodexHome = process.env.CODEX_HOME; + codexHome = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-k-home-"))); + otherHome = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-k-other-"))); + process.env.CODEX_HOME = codexHome; +}); + +afterEach(() => { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + const identity = resolveEffectiveUserIdentity(); + for (const home of [codexHome, otherHome]) { + const path = resolveCodexCatalogSerializationDatabasePath(identity, home); + for (const suffix of ["", "-journal", "-wal", "-shm"]) { + rmSync(`${path}${suffix}`, { force: true }); + } + } + rmSync(codexHome, { recursive: true, force: true }); + rmSync(otherHome, { recursive: true, force: true }); +}); + +/** + * Sharing one database with the native coordinator would make `N -> K` nest + * onto itself and deadlock the very transition it is meant to serialize. + */ +test("K's database is never the native coordinator's database", () => { + const identity = resolveEffectiveUserIdentity(); + const kPath = resolveCodexCatalogSerializationDatabasePath(identity, codexHome); + const nPath = resolveCodexCoordinatorDatabasePath(identity, codexHome); + + expect(kPath).not.toBe(nPath); + // Same identity namespace, different exclusion surface. + expect(kPath.endsWith(".sqlite")).toBe(true); + expect(kPath).toContain("catalog-write-locks"); + expect(nPath).toContain("native-write-locks"); +}); + +test("two homes take two different K databases", () => { + const identity = resolveEffectiveUserIdentity(); + expect(resolveCodexCatalogSerializationDatabasePath(identity, codexHome)) + .not.toBe(resolveCodexCatalogSerializationDatabasePath(identity, otherHome)); +}); + +test("a callback holding a live permit may write for its own home", () => { + const outcome = withCatalogWriteSerialization(codexHome, (permit) => { + assertCatalogWritePermit(permit, codexHome); + return "published"; + }); + + expect(outcome).toEqual({ kind: "completed", value: "published" }); +}); + +/** + * The defect this registry exists for: an opaque type proves a permit-bearing + * call path exists, never that the callback still holds K. A leaked permit + * type-checks perfectly, so only a runtime lookup can refuse it. + */ +test("a permit leaked out of its callback is refused afterwards", () => { + let leaked: CatalogWritePermit | undefined; + const outcome = withCatalogWriteSerialization(codexHome, (permit) => { + leaked = permit; + expect(isCatalogWritePermitLive(permit)).toBe(true); + return "done"; + }); + expect(outcome.kind).toBe("completed"); + + expect(isCatalogWritePermitLive(leaked!)).toBe(false); + expect(() => assertCatalogWritePermit(leaked!, codexHome)) + .toThrow("was not minted by the serialization owner"); +}); + +test("a permit is revoked even when its callback throws", () => { + let leaked: CatalogWritePermit | undefined; + expect(() => withCatalogWriteSerialization(codexHome, (permit) => { + leaked = permit; + throw new Error("callback exploded"); + })).toThrow("callback exploded"); + + expect(isCatalogWritePermitLive(leaked!)).toBe(false); + expect(() => assertCatalogWritePermit(leaked!, codexHome)).toThrow(); +}); + +/** + * A later acquisition must not resurrect an earlier permit, even for the same + * home: one live permit authorizes the mutations of ITS OWN callback only. + */ +test("a permit cannot be reused by a later acquisition of the same home", () => { + let first: CatalogWritePermit | undefined; + withCatalogWriteSerialization(codexHome, (permit) => { first = permit; }); + + const outcome = withCatalogWriteSerialization(codexHome, (second) => { + expect(second).not.toBe(first); + expect(() => assertCatalogWritePermit(first!, codexHome)).toThrow(); + assertCatalogWritePermit(second, codexHome); + return "second-only"; + }); + expect(outcome).toEqual({ kind: "completed", value: "second-only" }); +}); + +/** + * The owning home is supplied by the caller, not inferred from the target's + * parent, because a configured catalog target may legitimately be absolute and + * outside CODEX_HOME. That makes cross-home confusion possible, so it is + * refused explicitly. + */ +test("a live permit for one home is refused by a writer for another home", () => { + const outcome = withCatalogWriteSerialization(codexHome, (permit) => { + assertCatalogWritePermit(permit, codexHome); + expect(() => assertCatalogWritePermit(permit, otherHome)) + .toThrow("authorizes a different CODEX_HOME"); + return "home-bound"; + }); + expect(outcome).toEqual({ kind: "completed", value: "home-bound" }); +}); + +test("a forged permit shaped like the real one is refused", () => { + withCatalogWriteSerialization(codexHome, (real) => { + const forgedByCast = {} as CatalogWritePermit; + const forgedByPrototype = Object.create( + Object.getPrototypeOf(real) ?? Object.prototype, + ) as CatalogWritePermit; + const forgedBySymbolCopy = { ...(real as object) } as CatalogWritePermit; + + for (const forged of [forgedByCast, forgedByPrototype, forgedBySymbolCopy]) { + expect(() => assertCatalogWritePermit(forged, codexHome)) + .toThrow("was not minted by the serialization owner"); + } + return null; + }); +}); + +/** + * `busy_timeout = 0` is deliberate: contention must be a typed outcome the + * caller can decide about, not an unbounded wait while K is held. + */ +test("a second acquisition during a live callback is typed busy, not blocked", () => { + const outcome = withCatalogWriteSerialization(codexHome, () => { + const nested = withCatalogWriteSerialization(codexHome, () => "should-not-run"); + expect(nested).toEqual({ kind: "unavailable", reason: "busy" }); + return "outer-kept-k"; + }); + + expect(outcome).toEqual({ kind: "completed", value: "outer-kept-k" }); +}); + +test("a different home is not excluded by a live acquisition", () => { + const outcome = withCatalogWriteSerialization(codexHome, () => { + const other = withCatalogWriteSerialization(otherHome, () => "other-home-ran"); + expect(other).toEqual({ kind: "completed", value: "other-home-ran" }); + return "independent"; + }); + + expect(outcome).toEqual({ kind: "completed", value: "independent" }); +}); + +/** + * A callback failure is the caller's error, not a lock outcome. Reporting it as + * `unavailable` would invite a retry of something that fails identically. + */ +test("a callback error propagates rather than becoming a lock outcome", () => { + expect(() => withCatalogWriteSerialization(codexHome, () => { + throw new TypeError("derivation failed"); + })).toThrow(TypeError); + + // K must be released, so the next acquisition succeeds rather than hanging. + expect(withCatalogWriteSerialization(codexHome, () => "recovered")) + .toEqual({ kind: "completed", value: "recovered" }); +}); + +test("K creates its database private to the effective user", () => { + withCatalogWriteSerialization(codexHome, () => null); + const path = resolveCodexCatalogSerializationDatabasePath( + resolveEffectiveUserIdentity(), + codexHome, + ); + expect(existsSync(path)).toBe(true); +}); diff --git a/tests/codex-catalog-writer.test.ts b/tests/codex-catalog-writer.test.ts new file mode 100644 index 000000000..261fd1942 --- /dev/null +++ b/tests/codex-catalog-writer.test.ts @@ -0,0 +1,264 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + chmodSync, + existsSync, + linkSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + renameSync, + rmSync, + statSync, + truncateSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { AtomicWriteIO } from "../src/config"; +import { + type CatalogWritePermit, + CatalogWritePermitRefusal, + withCatalogWriteSerialization, +} from "../src/codex/catalog-write-serialization"; +import { + resolveCodexCatalogSerializationDatabasePath, + resolveEffectiveUserIdentity, +} from "../src/codex/user-identity"; +import { + type CatalogBackupWriteIO, + type PreparedCatalogFileWrite, + publishHashedCodexCatalogBackup, + publishLegacyCodexCatalogBackup, + replaceActiveCodexCatalog, + replaceCodexModelsCache, +} from "../src/codex/internal/catalog-writer"; + +interface MutatorCase { + readonly name: string; + readonly invoke: ( + permit: CatalogWritePermit, + owningCodexHome: string, + prepared: PreparedCatalogFileWrite, + effects: string[], + ) => unknown; +} + +let testRoot = ""; +let codexHome = ""; +let otherCodexHome = ""; +let targetDir = ""; +let previousCodexHome: string | undefined; +let previousOpenCodexHome: string | undefined; + +function atomicIo(effects: string[]): AtomicWriteIO { + return { + write(path, content) { + effects.push(`temp:${path}`); + writeFileSync(path, content, { encoding: "utf8", mode: 0o600 }); + }, + harden(path) { + effects.push(`harden:${path}`); + chmodSync(path, 0o600); + }, + rename(source, destination) { + effects.push(`rename:${source}->${destination}`); + expect(existsSync(source)).toBe(true); + renameSync(source, destination); + }, + truncate(path) { + effects.push(`truncate:${path}`); + truncateSync(path, 0); + }, + unlink(path) { + effects.push(`unlink:${path}`); + unlinkSync(path); + }, + }; +} + +function backupIo(effects: string[]): CatalogBackupWriteIO { + return { + resolveTarget: path => path, + write(path, content) { + effects.push(`temp:${path}`); + writeFileSync(path, content, { encoding: "utf8", mode: 0o600 }); + }, + harden(path) { + effects.push(`harden:${path}`); + chmodSync(path, 0o600); + }, + publishNoReplace(source, destination) { + effects.push(`publish:${source}->${destination}`); + expect(existsSync(source)).toBe(true); + linkSync(source, destination); + }, + truncate(path) { + effects.push(`truncate:${path}`); + truncateSync(path, 0); + }, + unlink(path) { + effects.push(`unlink:${path}`); + unlinkSync(path); + }, + }; +} + +const mutators: readonly MutatorCase[] = [ + { + name: "active catalog replacement", + invoke: (permit, home, prepared, effects) => + replaceActiveCodexCatalog(permit, home, prepared, atomicIo(effects)), + }, + { + name: "hashed backup publication", + invoke: (permit, home, prepared, effects) => + publishHashedCodexCatalogBackup(permit, home, prepared, backupIo(effects)), + }, + { + name: "legacy backup publication", + invoke: (permit, home, prepared, effects) => + publishLegacyCodexCatalogBackup(permit, home, prepared, backupIo(effects)), + }, + { + name: "models cache replacement", + invoke: (permit, home, prepared, effects) => + replaceCodexModelsCache(permit, home, prepared, atomicIo(effects)), + }, +] as const; + +function directorySnapshot(): string[] { + return readdirSync(targetDir).sort(); +} + +function expectRefusedBeforeFilesystemEffect( + mutator: MutatorCase, + permit: CatalogWritePermit, + owningHome = codexHome, +): void { + const before = directorySnapshot(); + const effects: string[] = []; + const prepared = { + path: join(targetDir, `${mutator.name.replaceAll(" ", "-")}.json`), + content: "new bytes\n", + }; + + expect(() => mutator.invoke(permit, owningHome, prepared, effects)) + .toThrow(CatalogWritePermitRefusal); + expect(effects).toEqual([]); + expect(directorySnapshot()).toEqual(before); +} + +function withLivePermit(callback: (permit: CatalogWritePermit) => T): T { + const outcome = withCatalogWriteSerialization(codexHome, callback); + expect(outcome.kind).toBe("completed"); + if (outcome.kind !== "completed") throw new Error(`K unavailable: ${outcome.reason}`); + return outcome.value; +} + +beforeEach(() => { + previousCodexHome = process.env.CODEX_HOME; + previousOpenCodexHome = process.env.OPENCODEX_HOME; + testRoot = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-catalog-writer-"))); + codexHome = join(testRoot, "codex-home"); + otherCodexHome = join(testRoot, "other-codex-home"); + targetDir = join(testRoot, "external-catalog-targets"); + for (const path of [codexHome, otherCodexHome, targetDir, join(testRoot, "opencodex-home")]) { + mkdirSync(path, { recursive: true }); + } + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = join(testRoot, "opencodex-home"); +}); + +afterEach(() => { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (previousOpenCodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpenCodexHome; + + const identity = resolveEffectiveUserIdentity(); + for (const home of [codexHome, otherCodexHome]) { + const databasePath = resolveCodexCatalogSerializationDatabasePath(identity, home); + for (const suffix of ["", "-journal", "-wal", "-shm"]) { + rmSync(`${databasePath}${suffix}`, { force: true }); + } + } + rmSync(testRoot, { recursive: true, force: true }); +}); + +test("every mutator refuses a missing or forged permit before temp creation", () => { + for (const mutator of mutators) { + expectRefusedBeforeFilesystemEffect( + mutator, + undefined as unknown as CatalogWritePermit, + ); + expectRefusedBeforeFilesystemEffect(mutator, {} as CatalogWritePermit); + } +}); + +test("every mutator refuses leaked and revoked permits before temp creation", () => { + let leaked: CatalogWritePermit | undefined; + withLivePermit((permit) => { + leaked = permit; + }); + + let revoked: CatalogWritePermit | undefined; + expect(() => withCatalogWriteSerialization(codexHome, (permit) => { + revoked = permit; + throw new Error("revoke this acquisition"); + })).toThrow("revoke this acquisition"); + + for (const mutator of mutators) { + expectRefusedBeforeFilesystemEffect(mutator, leaked!); + expectRefusedBeforeFilesystemEffect(mutator, revoked!); + } +}); + +test("every mutator refuses a live permit bound to a different home before temp creation", () => { + withLivePermit((permit) => { + for (const mutator of mutators) { + expectRefusedBeforeFilesystemEffect(mutator, permit, otherCodexHome); + } + }); +}); + +for (const mutator of mutators) { + test(`${mutator.name} writes prepared bytes atomically with the right live permit`, () => { + const path = join(targetDir, `${mutator.name.replaceAll(" ", "-")}.json`); + const isBackup = mutator.name.includes("backup"); + if (!isBackup) writeFileSync(path, "old bytes\n", { mode: 0o600 }); + const effects: string[] = []; + + const result = withLivePermit((permit) => + mutator.invoke(permit, codexHome, { path, content: "new bytes\n" }, effects) + ); + + expect(readFileSync(path, "utf8")).toBe("new bytes\n"); + expect(statSync(path).mode & 0o777).toBe(0o600); + expect(readdirSync(targetDir).filter(name => name.endsWith(".tmp"))).toEqual([]); + expect(effects.some(effect => effect.startsWith("temp:"))).toBe(true); + expect(effects.some(effect => effect.startsWith(isBackup ? "publish:" : "rename:"))).toBe(true); + if (isBackup) expect(result).toBe("written"); + }); +} + +for (const [name, publish] of [ + ["hashed", publishHashedCodexCatalogBackup], + ["legacy", publishLegacyCodexCatalogBackup], +] as const) { + test(`${name} create-once backup preserves an existing winner byte-for-byte`, () => { + const path = join(targetDir, `${name}.backup.json`); + writeFileSync(path, "first winner\n", { mode: 0o600 }); + + const result = withLivePermit((permit) => + publish(permit, codexHome, { path, content: "late contender\n" }) + ); + + expect(result).toBe("preserved"); + expect(readFileSync(path, "utf8")).toBe("first winner\n"); + expect(readdirSync(targetDir).filter(entry => entry.endsWith(".tmp"))).toEqual([]); + }); +} diff --git a/tests/codex-config-generation.test.ts b/tests/codex-config-generation.test.ts new file mode 100644 index 000000000..a2a085b0d --- /dev/null +++ b/tests/codex-config-generation.test.ts @@ -0,0 +1,335 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { Database } from "bun:sqlite"; + +import { + bumpConfigGeneration, + mutatePersistedConfig, + observeConfigGeneration, + readConfigGeneration, + saveConfig, + saveConfigPreservingClaudeCode, + withExpectedConfigGenerationSync, +} from "../src/config"; +import type { OcxConfig } from "../src/types"; + +const CHILD_TIMEOUT_MS = 10_000; +const configModuleUrl = pathToFileURL(join(import.meta.dir, "../src/config.ts")).href; +const generationGuardRaceScript = ` + import { existsSync, writeFileSync } from "node:fs"; + import { withExpectedConfigGenerationSync } from ${JSON.stringify(configModuleUrl)}; + + const payload = JSON.parse(process.env.OCX_TEST_PAYLOAD); + const waitFor = path => { + const deadline = Date.now() + ${CHILD_TIMEOUT_MS}; + while (!existsSync(path)) { + if (Date.now() >= deadline) throw new Error(\`timed out waiting for \${path}\`); + Bun.sleepSync(5); + } + }; + writeFileSync(payload.readyPath, "ready"); + waitFor(payload.releasePath); + + const result = withExpectedConfigGenerationSync({ value: 0 }, () => { + writeFileSync(payload.callbackPath, payload.id); + waitFor(payload.peerOutcomePath); + return payload.id; + }); + writeFileSync(payload.outcomePath, JSON.stringify(result)); + process.stdout.write(JSON.stringify(result)); +`; + +let testRoot = ""; +let previousCodexHome: string | undefined; +let previousOpencodexHome: string | undefined; + +function config(port = 10100): OcxConfig { + return { port, providers: {}, defaultProvider: "openai" }; +} + +async function waitForPaths(paths: readonly string[]): Promise { + const deadline = Date.now() + CHILD_TIMEOUT_MS; + while (!paths.every(existsSync)) { + if (Date.now() >= deadline) throw new Error(`timed out waiting for ${paths.join(", ")}`); + await Bun.sleep(5); + } +} + +async function collectGuardRaceChild( + child: ReturnType, +): Promise> { + const timeout = setTimeout(() => child.kill(), CHILD_TIMEOUT_MS); + try { + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + expect(exitCode, stderr).toBe(0); + return JSON.parse(stdout) as Record; + } finally { + clearTimeout(timeout); + } +} + +beforeEach(() => { + previousCodexHome = process.env.CODEX_HOME; + previousOpencodexHome = process.env.OPENCODEX_HOME; + testRoot = mkdtempSync(join(import.meta.dir, ".tmp-codex-config-generation-")); + process.env.CODEX_HOME = testRoot; + process.env.OPENCODEX_HOME = testRoot; +}); + +afterEach(() => { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + rmSync(testRoot, { recursive: true, force: true }); +}); + +test("observe-only generation reports a missing database without creating or chmodding paths", () => { + const absentParent = join(testRoot, "missing-parent"); + const absentHome = join(absentParent, "opencodex-home"); + const rootBefore = statSync(testRoot, { bigint: true }); + process.env.CODEX_HOME = absentHome; + process.env.OPENCODEX_HOME = absentHome; + + const absentObservation = observeConfigGeneration(); + expect(existsSync(join(absentHome, "config-mutation.sqlite"))).toBeFalse(); + expect(existsSync(absentHome)).toBeFalse(); + expect(existsSync(absentParent)).toBeFalse(); + // Missing storage is now its own state. The original concern behind folding it + // into `unavailable` — that a look-only caller must not receive something it + // could mistake for a known-good zero — is unchanged and still honored: + // `absent` authorizes nothing by itself. Only a caller holding the config + // transaction may promote it, and only by reading a real zero in there. + // Refusing outright was not neutral either: it meant refusing every Codex + // write on any home whose config predates this database. + expect(absentObservation).toEqual({ kind: "absent" }); + const rootAfter = statSync(testRoot, { bigint: true }); + expect(rootAfter.mode).toBe(rootBefore.mode); + + const existingHome = join(testRoot, "existing-home"); + mkdirSync(existingHome, { mode: 0o751 }); + chmodSync(existingHome, 0o751); + const existingMode = statSync(existingHome).mode & 0o777; + process.env.CODEX_HOME = existingHome; + process.env.OPENCODEX_HOME = existingHome; + + const existingObservation = observeConfigGeneration(); + expect(existsSync(join(existingHome, "config-mutation.sqlite"))).toBeFalse(); + expect(statSync(existingHome).mode & 0o777).toBe(existingMode); + expect(existingObservation).toEqual({ kind: "absent" }); +}); + +test("observe-only generation reads an existing value without modifying its database", () => { + saveConfig(config()); + saveConfig(config(20200)); + const databasePath = join(testRoot, "config-mutation.sqlite"); + const before = statSync(databasePath, { bigint: true }); + + expect(observeConfigGeneration()).toEqual({ + kind: "ready", + generation: { value: 2 }, + }); + + const after = statSync(databasePath, { bigint: true }); + expect({ inode: after.ino, mtime: after.mtimeNs, size: after.size }).toEqual({ + inode: before.ino, + mtime: before.mtimeNs, + size: before.size, + }); +}); + +test("observe-only generation returns typed outcomes for malformed and unreadable databases", () => { + const databasePath = join(testRoot, "config-mutation.sqlite"); + writeFileSync(databasePath, "not sqlite", "utf8"); + + expect(observeConfigGeneration()).toEqual({ kind: "unavailable", reason: "database" }); + expect(observeConfigGeneration()).not.toEqual({ kind: "ready", generation: { value: 0 } }); + + rmSync(databasePath); + mkdirSync(databasePath); + expect(observeConfigGeneration()).toEqual({ kind: "unavailable", reason: "database" }); +}); + +test("an initial read creates the singleton generation at zero", () => { + expect(readConfigGeneration()).toEqual({ + kind: "ready", + generation: { value: 0 }, + }); +}); + +test("a cooperating save bumps once and an unchanged save does not bump", () => { + saveConfig(config()); + expect(readConfigGeneration()).toEqual({ + kind: "ready", + generation: { value: 1 }, + }); + + saveConfig(config()); + expect(readConfigGeneration()).toEqual({ + kind: "ready", + generation: { value: 1 }, + }); + + saveConfig(config(20200)); + expect(readConfigGeneration()).toEqual({ + kind: "ready", + generation: { value: 2 }, + }); +}); + +test("every cooperating writer bumps only when its committed bytes change", () => { + saveConfig(config()); + + expect(mutatePersistedConfig(persisted => { + persisted.port = 20200; + return { changed: true, value: persisted.port }; + })).toEqual({ status: "committed", value: 20200 }); + expect(readConfigGeneration()).toMatchObject({ generation: { value: 2 } }); + + expect(mutatePersistedConfig(persisted => ( + { changed: false, value: persisted.port } + ))).toEqual({ status: "unchanged", value: 20200 }); + expect(readConfigGeneration()).toMatchObject({ generation: { value: 2 } }); + + saveConfigPreservingClaudeCode(config(30300)); + expect(readConfigGeneration()).toMatchObject({ generation: { value: 3 } }); + saveConfigPreservingClaudeCode(config(30300)); + expect(readConfigGeneration()).toMatchObject({ generation: { value: 3 } }); +}); + +test("a stale expected value conflicts without changing the winner", () => { + const admitted = readConfigGeneration(); + expect(admitted.kind).toBe("ready"); + if (admitted.kind !== "ready") throw new Error("generation unavailable"); + + saveConfig(config()); + expect(bumpConfigGeneration(admitted.generation)).toEqual({ + kind: "conflict", + current: { value: 1 }, + }); + expect(readConfigGeneration()).toEqual({ + kind: "ready", + generation: { value: 1 }, + }); +}); + +test("the generation guard joins the existing mutation transaction", () => { + const result = withExpectedConfigGenerationSync({ value: 0 }, () => { + saveConfig(config()); + return "committed-with-nested-writer"; + }); + + expect(result).toEqual({ + kind: "matched", + generation: { value: 0 }, + value: "committed-with-nested-writer", + }); + expect(readConfigGeneration()).toEqual({ + kind: "ready", + generation: { value: 1 }, + }); +}); + +test("the generation guard never invokes its callback on a mismatch", () => { + saveConfig(config()); + let callbackRuns = 0; + + expect(withExpectedConfigGenerationSync({ value: 0 }, () => { + callbackRuns += 1; + return "must-not-run"; + })).toEqual({ kind: "conflict", current: { value: 1 } }); + expect(callbackRuns).toBe(0); +}); + +test("two real processes racing one generation run exactly one callback", async () => { + const releasePath = join(testRoot, "race-release"); + const ids = ["a", "b"] as const; + const children = ids.map((id, index) => { + const peerId = ids[1 - index]!; + const payload = { + id, + readyPath: join(testRoot, `${id}.ready`), + releasePath, + callbackPath: join(testRoot, `${id}.callback`), + outcomePath: join(testRoot, `${id}.outcome`), + peerOutcomePath: join(testRoot, `${peerId}.outcome`), + }; + return Bun.spawn([process.execPath, "--eval", generationGuardRaceScript], { + cwd: join(import.meta.dir, ".."), + env: { + ...process.env, + HOME: testRoot, + OPENCODEX_HOME: testRoot, + OCX_TEST_PAYLOAD: JSON.stringify(payload), + }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + }); + + await waitForPaths([join(testRoot, "a.ready"), join(testRoot, "b.ready")]); + writeFileSync(releasePath, "go"); + const results = await Promise.all(children.map(collectGuardRaceChild)); + + expect(results.filter(result => result.kind === "matched")).toHaveLength(1); + expect(results.filter(result => ( + result.kind === "conflict" + || (result.kind === "unavailable" && result.reason === "busy") + ))).toHaveLength(1); + expect(["a", "b"].filter(id => existsSync(join(testRoot, `${id}.callback`)))).toHaveLength(1); + expect(readConfigGeneration()).toEqual({ + kind: "ready", + generation: { value: 0 }, + }); +}); + +test("a throwing guard callback rolls back and releases the transaction", () => { + expect(() => withExpectedConfigGenerationSync({ value: 0 }, () => { + throw new Error("guard callback failed"); + })).toThrow("guard callback failed"); + + expect(() => saveConfig(config())).not.toThrow(); + expect(readConfigGeneration()).toEqual({ + kind: "ready", + generation: { value: 1 }, + }); +}); + +test("busy and unavailable databases return typed outcomes instead of throwing", () => { + expect(readConfigGeneration().kind).toBe("ready"); + const databasePath = join(testRoot, "config-mutation.sqlite"); + const holder = new Database(databasePath, { readwrite: true, create: false }); + holder.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + try { + expect(readConfigGeneration()).toEqual({ kind: "unavailable", reason: "busy" }); + expect(bumpConfigGeneration({ value: 0 })).toEqual({ kind: "unavailable", reason: "busy" }); + expect(withExpectedConfigGenerationSync({ value: 0 }, () => "must-not-run")) + .toEqual({ kind: "unavailable", reason: "busy" }); + } finally { + holder.exec("ROLLBACK"); + holder.close(); + } + + rmSync(testRoot, { recursive: true, force: true }); + writeFileSync(testRoot, "not a directory", "utf8"); + expect(readConfigGeneration()).toEqual({ kind: "unavailable", reason: "database" }); + expect(bumpConfigGeneration({ value: 0 })).toEqual({ kind: "unavailable", reason: "database" }); + expect(withExpectedConfigGenerationSync({ value: 0 }, () => "must-not-run")) + .toEqual({ kind: "unavailable", reason: "database" }); +}); diff --git a/tests/codex-convergence-contract.test.ts b/tests/codex-convergence-contract.test.ts new file mode 100644 index 000000000..74af96030 --- /dev/null +++ b/tests/codex-convergence-contract.test.ts @@ -0,0 +1,251 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; + +import { captureCatalogAdmissionSnapshot } from "../src/codex/catalog-admission"; +import { + commitCodexCatalogCandidate, + gatherCodexCatalogCandidate, + type CodexCatalogCandidate, +} from "../src/codex/convergence"; +import { resetCatalogRuntimeStateForTests } from "../src/codex/catalog"; +import { + resetCodexRuntimeResolveCacheForTests, + setCodexRuntimeResolveCacheForTests, +} from "../src/codex/runtime"; +import { + invalidateBundledCatalogCache, + setBundledCatalogCacheForTests, +} from "../src/codex/catalog/bundled"; +import { + resolveCodexCatalogSerializationDatabasePath, + resolveCodexCoordinatorDatabasePath, + resolveEffectiveUserIdentity, +} from "../src/codex/user-identity"; +import { saveConfig } from "../src/config"; +import { handleManagementAPI } from "../src/server/management-api"; +import type { OcxConfig } from "../src/types"; +import { ManagementRequest } from "./helpers/management-auth"; + +let root = ""; +let codexHome = ""; +let opencodexHome = ""; +let previousCodexHome: string | undefined; +let previousOpencodexHome: string | undefined; + +function config(port = 10100): OcxConfig { + return { port, providers: {}, defaultProvider: "openai" }; +} + +function sourceCatalog(marker = "original"): string { + return `${JSON.stringify({ + marker, + models: [{ + slug: "gpt-5.6-sol", + display_name: "GPT-5.6-Sol", + description: "Native", + priority: 1, + visibility: "list", + base_instructions: "You are Codex.", + supported_reasoning_levels: [{ effort: "medium", description: "Medium" }], + }], + }, null, 2)}\n`; +} + +function manifest(base: string): string[] { + const out: string[] = []; + const visit = (directory: string) => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + const stat = lstatSync(path, { bigint: true }); + const name = relative(base, path); + if (entry.isDirectory()) { + out.push(`${name}|dir|${stat.mode}|${stat.mtimeNs}`); + visit(path); + } else { + const bytes = readFileSync(path); + out.push(`${name}|file|${stat.mode}|${stat.mtimeNs}|${bytes.length}|${createHash("sha256").update(bytes).digest("hex")}`); + } + } + }; + visit(base); + return out.sort(); +} + +async function candidate(): Promise { + const gathered = await gatherCodexCatalogCandidate(captureCatalogAdmissionSnapshot(config())); + expect(gathered.kind).toBe("candidate"); + return (gathered as Extract).candidate; +} + +beforeEach(() => { + previousCodexHome = process.env.CODEX_HOME; + previousOpencodexHome = process.env.OPENCODEX_HOME; + root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-convergence-"))); + codexHome = join(root, "codex"); + opencodexHome = join(root, "opencodex"); + mkdirSync(codexHome); + mkdirSync(opencodexHome); + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = opencodexHome; + resetCatalogRuntimeStateForTests(); + resetCodexRuntimeResolveCacheForTests(); + saveConfig(config()); + writeFileSync(join(codexHome, "opencodex-catalog.json"), sourceCatalog()); +}); + +afterEach(() => { + const identity = resolveEffectiveUserIdentity(); + const kPath = resolveCodexCatalogSerializationDatabasePath(identity, codexHome); + for (const suffix of ["", "-journal", "-wal", "-shm"]) rmSync(`${kPath}${suffix}`, { force: true }); + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + rmSync(root, { recursive: true, force: true }); +}); + +test("T1 gather performs no filesystem write and does not materialize a runtime probe home", async () => { + process.env.CODEX_CLI_PATH = join(root, "must-not-execute"); + const before = manifest(root); + const gathered = await gatherCodexCatalogCandidate(captureCatalogAdmissionSnapshot(config())); + expect(gathered.kind).toBe("candidate"); + expect(manifest(root)).toEqual(before); + expect(existsSync(join(root, "probe-home"))).toBe(false); + delete process.env.CODEX_CLI_PATH; +}); + +test("commit is fixed-order, receipt-exact, and a consumed candidate cannot be replayed", async () => { + const gathered = await candidate(); + const first = await commitCodexCatalogCandidate(gathered, 1_000); + expect(first).toEqual({ + kind: "committed", + changed: true, + writes: { keyedBackup: "written", legacyBackup: "written", catalog: "written", cache: "written" }, + }); + const after = manifest(root); + expect(await commitCodexCatalogCandidate(gathered, 1_000)).toEqual({ + kind: "stale", + reason: "candidate-consumed", + }); + expect(manifest(root)).toEqual(after); +}); + +test("generation drift rejects before every catalog target write", async () => { + const gathered = await candidate(); + const before = manifest(codexHome); + saveConfig(config(20200)); + expect(await commitCodexCatalogCandidate(gathered, 1_000)).toEqual({ kind: "stale", reason: "generation" }); + expect(manifest(codexHome)).toEqual(before); +}); + +test("home-selection drift rejects before every catalog target write", async () => { + const gathered = await candidate(); + const other = join(root, "other-codex"); + mkdirSync(other); + process.env.CODEX_HOME = other; + expect(await commitCodexCatalogCandidate(gathered, 1_000)).toEqual({ kind: "stale", reason: "home-selection" }); + expect(readdirSync(other)).toEqual([]); +}); + +test("same-inode source drift rejects before every catalog target write", async () => { + const gathered = await candidate(); + const path = join(codexHome, "opencodex-catalog.json"); + const inode = lstatSync(path).ino; + writeFileSync(path, sourceCatalog("drifted")); + expect(lstatSync(path).ino).toBe(inode); + const drifted = readFileSync(path, "utf8"); + expect(await commitCodexCatalogCandidate(gathered, 1_000)).toEqual({ kind: "stale", reason: "source-observation" }); + expect(readFileSync(path, "utf8")).toBe(drifted); + expect(existsSync(join(codexHome, "models_cache.json"))).toBe(false); +}); + +test("target identity drift wins before source comparison and writes nothing", async () => { + const gathered = await candidate(); + const path = join(codexHome, "opencodex-catalog.json"); + const moved = join(codexHome, "moved.json"); + renameSync(path, moved); + writeFileSync(path, readFileSync(moved)); + const before = readFileSync(path, "utf8"); + expect(await commitCodexCatalogCandidate(gathered, 1_000)).toEqual({ kind: "stale", reason: "target-identity" }); + expect(readFileSync(path, "utf8")).toBe(before); + expect(existsSync(join(codexHome, "models_cache.json"))).toBe(false); +}); + +test("used process-local authority drift rejects before every catalog target write", async () => { + const runtime = { command: "/tmp/codex", version: "0.146.0", source: "environment" as const }; + setCodexRuntimeResolveCacheForTests({ runtime, failures: [] }); + setBundledCatalogCacheForTests(runtime, JSON.parse(sourceCatalog("bundled")) as never); + const gathered = await candidate(); + invalidateBundledCatalogCache(); + expect(await commitCodexCatalogCandidate(gathered, 1_000)).toEqual({ kind: "stale", reason: "process-local" }); + expect(existsSync(join(codexHome, "models_cache.json"))).toBe(false); +}); + +test("catalog-only commit never creates the native pair or routing/history artifacts", async () => { + const gathered = await candidate(); + expect((await commitCodexCatalogCandidate(gathered, 1_000)).kind).toBe("committed"); + const nativeDb = resolveCodexCoordinatorDatabasePath(resolveEffectiveUserIdentity(), codexHome); + expect(existsSync(nativeDb)).toBe(false); + expect(existsSync(join(codexHome, "config.toml"))).toBe(false); + expect(manifest(root).join("\n")).not.toContain("journal"); + expect(manifest(root).join("\n")).not.toContain("history"); +}); + +test("the total lazy adapter preserves a persisted-success route when factory construction fails", async () => { + const live = config(); + const request = new ManagementRequest("http://localhost/api/disabled-models", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ models: ["gpt-5.6-sol"] }), + }); + const response = await handleManagementAPI(request, new URL(request.url), live, { + saveConfigPreservingClaudeCode: () => {}, + createManagementConvergeCodex: () => { throw new Error("factory exploded"); }, + }); + expect(response?.status).toBe(200); + expect(await response?.json()).toMatchObject({ + ok: true, + disabled: ["gpt-5.6-sol"], + catalogRefresh: { + status: "failed", + reason: "disk", + phase: "gather", + partialWrite: false, + }, + }); +}); + +test("the route inventory contains exactly the specified 6 + 6 + 2 + 2 convergence calls", () => { + const counts = Object.fromEntries([ + ["provider-routes.ts", 6], + ["model-routes.ts", 6], + ["combo-routes.ts", 2], + ["agent-settings-routes.ts", 2], + ].map(([file, expected]) => { + const source = readFileSync(join(import.meta.dir, "..", "src", "server", "management", file as string), "utf8"); + const count = source.match(/await convergeCodexCatalog\(\)/g)?.length ?? 0; + expect(count).toBe(expected); + expect(source).not.toContain("refreshCodexCatalogBestEffort"); + return [file, count]; + })); + expect(counts).toEqual({ + "provider-routes.ts": 6, + "model-routes.ts": 6, + "combo-routes.ts": 2, + "agent-settings-routes.ts": 2, + }); +}); diff --git a/tests/codex-desired-state.test.ts b/tests/codex-desired-state.test.ts new file mode 100644 index 000000000..642c06c51 --- /dev/null +++ b/tests/codex-desired-state.test.ts @@ -0,0 +1,278 @@ +/** + * Durable desired state for the native Codex integration. + * + * The switch was never the hard part — `ocx restore` already unroutes Codex + * without stopping the proxy. What was missing is that the decision did not + * survive a restart, because `ocx start` force-synced unconditionally. These + * tests pin the two halves of the fix: absence means ON, and only an explicit + * `false` gates the startup sync. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { loadConfig, saveConfig } from "../src/config"; +import { + codexIntegrationEnabled, + codexIntegrationEnabledNow, + setCodexIntegrationEnabled, + setGrokIntegrationEnabled, + grokIntegrationEnabled, + shouldSyncGrokOnStart, + syncCodexOnStartIfEnabled, +} from "../src/codex/desired-state"; +import type { OcxConfig } from "../src/types"; + +let testRoot = ""; +let previousOpencodexHome: string | undefined; + +function baseConfig(): OcxConfig { + return { port: 10100, providers: {}, defaultProvider: "openai" }; +} + +beforeEach(() => { + previousOpencodexHome = process.env.OPENCODEX_HOME; + testRoot = mkdtempSync(join(tmpdir(), "ocx-desired-state-")); + process.env.OPENCODEX_HOME = testRoot; +}); + +afterEach(() => { + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + rmSync(testRoot, { recursive: true, force: true }); +}); + +describe("absence means ON", () => { + /** + * Three different configs, one meaning. A user who never touched a switch, a + * config written by a binary that predates this field, and an explicit `true` + * are the same state — and none of them may read as "turned off". Only an + * explicit `false` is OFF. + */ + const onCases: { name: string; config: OcxConfig }[] = [ + { name: "no clientIntegrations at all", config: baseConfig() }, + { name: "an empty clientIntegrations object", config: { ...baseConfig(), clientIntegrations: {} } }, + { name: "an explicit true", config: { ...baseConfig(), clientIntegrations: { codex: true } } }, + ]; + + for (const { name, config } of onCases) { + test(`${name} reads as enabled`, () => { + expect(codexIntegrationEnabled(config)).toBe(true); + }); + } + + test("only an explicit false is off", () => { + expect(codexIntegrationEnabled({ ...baseConfig(), clientIntegrations: { codex: false } })).toBe(false); + }); + + /** + * A hand edit of the wrong type must not be read as OFF. `"false"` is a string, + * and treating any non-true value as OFF would silently unroute a user who + * fat-fingered their config — the schema drops the bad key, and absence is ON. + */ + test("a malformed value degrades to ON rather than OFF, and keeps unknown keys", () => { + writeFileSync( + join(testRoot, "config.json"), + JSON.stringify({ + ...baseConfig(), + clientIntegrations: { codex: "false", "future-client": false }, + }, null, 2), + ); + const loaded = loadConfig(); + expect(codexIntegrationEnabled(loaded)).toBe(true); + // The key this binary does not understand survives the parse. + expect((loaded.clientIntegrations as Record | undefined)?.["future-client"]).toBe(false); + }); +}); + +describe("persisting the decision", () => { + test("turning it off is written, and survives a fresh read", () => { + saveConfig(baseConfig()); + expect(codexIntegrationEnabledNow()).toBe(true); + + const result = setCodexIntegrationEnabled(false); + expect(result).toMatchObject({ ok: true, status: "committed", enabled: false }); + expect(codexIntegrationEnabledNow()).toBe(false); + + // And it is on disk, not merely in a cache. + const raw = JSON.parse(readFileSync(join(testRoot, "config.json"), "utf8")) as Record; + expect((raw.clientIntegrations as Record).codex).toBe(false); + }); + + /** + * Re-enabling REMOVES the key rather than storing `true`. Otherwise an + * untouched config and a re-enabled one differ in bytes while meaning the same + * thing, and every later reader has to treat them identically anyway. + */ + test("turning it back on removes the key instead of storing true", () => { + saveConfig(baseConfig()); + setCodexIntegrationEnabled(false); + expect(setCodexIntegrationEnabled(true)).toMatchObject({ ok: true, status: "committed", enabled: true }); + + const raw = JSON.parse(readFileSync(join(testRoot, "config.json"), "utf8")) as Record; + expect(raw.clientIntegrations).toBeUndefined(); + expect(codexIntegrationEnabledNow()).toBe(true); + }); + + test("setting the state it is already in is unchanged, not a write", () => { + saveConfig(baseConfig()); + expect(setCodexIntegrationEnabled(true)).toMatchObject({ ok: true, status: "unchanged" }); + setCodexIntegrationEnabled(false); + expect(setCodexIntegrationEnabled(false)).toMatchObject({ ok: true, status: "unchanged" }); + }); + + /** + * The mutation is field-scoped. A whole-config write built from a stale read + * would clobber a provider edit that landed in between; this asserts the + * neighbouring field is still there afterwards. + */ + test("writing the switch preserves unrelated config the caller never saw", () => { + saveConfig({ + ...baseConfig(), + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + } as OcxConfig["providers"], + }); + setCodexIntegrationEnabled(false); + + const after = loadConfig(); + expect(Object.keys(after.providers)).toEqual(["openai"]); + expect(codexIntegrationEnabled(after)).toBe(false); + }); + + test("an unknown future key in the object is preserved across a write", () => { + writeFileSync( + join(testRoot, "config.json"), + JSON.stringify({ ...baseConfig(), clientIntegrations: { "future-client": false } }, null, 2), + ); + setCodexIntegrationEnabled(false); + + const raw = JSON.parse(readFileSync(join(testRoot, "config.json"), "utf8")) as Record; + const integrations = raw.clientIntegrations as Record; + expect(integrations.codex).toBe(false); + expect(integrations["future-client"]).toBe(false); + }); + + test("a missing config refuses rather than creating one", () => { + const result = setCodexIntegrationEnabled(false); + expect(result).toMatchObject({ ok: false, reason: "missing", retryable: false }); + }); +}); + +describe("the startup gate", () => { + /** + * This is the defect the whole phase exists for. `ocx start` called + * `syncModelsToCodex(port).catch(() => {})` unconditionally, so an OFF lasted + * exactly until the next start: restore unrouted Codex, and start put the + * routing straight back. + * + * It lived inline in a 600-line startup function that binds sockets and + * installs services, which is why nothing tested it — and an untestable gate is + * how the unconditional version survived. It is a function now. + */ + test("an explicit OFF skips the startup sync entirely", async () => { + let calls = 0; + const ran = await syncCodexOnStartIfEnabled( + 10100, + { clientIntegrations: { codex: false } }, + async () => { calls += 1; }, + ); + expect(ran).toBe(false); + expect(calls).toBe(0); + }); + + test("absence, an empty object, and an explicit true all still sync", async () => { + for (const clientIntegrations of [undefined, {}, { codex: true }]) { + let calls = 0; + const ran = await syncCodexOnStartIfEnabled( + 10100, + { clientIntegrations }, + async () => { calls += 1; }, + ); + expect(ran).toBe(true); + expect(calls).toBe(1); + } + }); + + test("the port reaches the sync", async () => { + const ports: number[] = []; + await syncCodexOnStartIfEnabled(43210, {}, async port => { ports.push(port); }); + expect(ports).toEqual([43210]); + }); + + /** + * The swallow stays, and is asserted rather than assumed. A provider fetch + * failing at startup must not stop the proxy from coming up — swallowing a + * failure to APPLY is tolerable, swallowing the user's DECISION was not. + */ + test("a sync failure is swallowed so startup continues, and still reports that it ran", async () => { + const ran = await syncCodexOnStartIfEnabled( + 10100, + {}, + async () => { throw new Error("provider unreachable"); }, + ); + expect(ran).toBe(true); + }); +}); + +describe("Grok has the same durability, because it shipped without it", () => { + /** + * Grok's toggle already existed and already worked — and lasted exactly one + * restart. It strips the fence from `~/.grok/config.toml` and recorded + * nothing, so `ocx start` called `syncGrokConfig` unconditionally and wrote + * the fence straight back. Identical defect to Codex, different file. + */ + test("absence, empty, and explicit true all read as enabled; only false is off", () => { + expect(grokIntegrationEnabled(baseConfig())).toBe(true); + expect(grokIntegrationEnabled({ ...baseConfig(), clientIntegrations: {} })).toBe(true); + expect(grokIntegrationEnabled({ ...baseConfig(), clientIntegrations: { grok: true } })).toBe(true); + expect(grokIntegrationEnabled({ ...baseConfig(), clientIntegrations: { grok: false } })).toBe(false); + }); + + test("the startup sync is skipped only for an explicit off", () => { + expect(shouldSyncGrokOnStart(baseConfig())).toBe(true); + expect(shouldSyncGrokOnStart({ ...baseConfig(), clientIntegrations: { grok: true } })).toBe(true); + expect(shouldSyncGrokOnStart({ ...baseConfig(), clientIntegrations: { grok: false } })).toBe(false); + }); + + /** + * The two switches are independent. Turning Codex off must not take Grok with + * it — a shared key or a shared helper reading the wrong field would, and the + * ten-key union this design rejected is exactly how that happens. + */ + test("the two clients do not affect each other", () => { + saveConfig(baseConfig()); + setCodexIntegrationEnabled(false); + + const after = loadConfig(); + expect(codexIntegrationEnabled(after)).toBe(false); + expect(grokIntegrationEnabled(after)).toBe(true); + + setGrokIntegrationEnabled(false); + const both = loadConfig(); + expect(codexIntegrationEnabled(both)).toBe(false); + expect(grokIntegrationEnabled(both)).toBe(false); + + // And re-enabling one leaves the other alone. + setCodexIntegrationEnabled(true); + const one = loadConfig(); + expect(codexIntegrationEnabled(one)).toBe(true); + expect(grokIntegrationEnabled(one)).toBe(false); + }); + + test("the last client re-enabled removes the whole object, not an empty husk", () => { + saveConfig(baseConfig()); + setCodexIntegrationEnabled(false); + setGrokIntegrationEnabled(false); + setCodexIntegrationEnabled(true); + setGrokIntegrationEnabled(true); + + const raw = JSON.parse(readFileSync(join(testRoot, "config.json"), "utf8")) as Record; + expect(raw.clientIntegrations).toBeUndefined(); + }); +}); diff --git a/tests/codex-filesystem-evidence.test.ts b/tests/codex-filesystem-evidence.test.ts new file mode 100644 index 000000000..e36f71c4d --- /dev/null +++ b/tests/codex-filesystem-evidence.test.ts @@ -0,0 +1,242 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + readlinkSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { captureCatalogAdmissionSnapshot } from "../src/codex/catalog-admission"; +import { + resetBundledCatalogCacheForTests, + resolveCatalogSourceForGather, +} from "../src/codex/catalog/bundled"; +import { + acceptCatalogGatherSourcePath, + captureAndSealCatalogHomeSelection, + captureCatalogGatherTargetIdentity, + createCatalogGatherEvidenceSession, + readCatalogGatherSource, + sealCatalogGatherEvidenceSession, + type CatalogGatherProviderAuthEvidence, +} from "../src/codex/catalog/filesystem-evidence"; +import type { CatalogSourceEvidence } from "../src/codex/convergence-types"; +import { saveConfig } from "../src/config"; +import type { OcxConfig } from "../src/types"; + +interface ManifestEntry { + readonly path: string; + readonly kind: "directory" | "file" | "symlink" | "other"; + readonly mode: number; + readonly mtimeNs: string; + readonly inode: string; + readonly device: string; + readonly size: string; + readonly sha256?: string; + readonly linkTarget?: string; +} + +let testRoot = ""; +let codexHome = ""; +let opencodexHome = ""; +let previousCodexHome: string | undefined; +let previousOpencodexHome: string | undefined; + +function config(): OcxConfig { + return { port: 10100, providers: {}, defaultProvider: "openai" }; +} + +function recursiveManifest(root: string): readonly ManifestEntry[] { + const entries: ManifestEntry[] = []; + const visit = (path: string, relativePath: string): void => { + const stat = lstatSync(path, { bigint: true }); + const kind = stat.isDirectory() + ? "directory" + : stat.isFile() + ? "file" + : stat.isSymbolicLink() + ? "symlink" + : "other"; + entries.push({ + path: relativePath, + kind, + mode: Number(stat.mode), + mtimeNs: String(stat.mtimeNs), + inode: String(stat.ino), + device: String(stat.dev), + size: String(stat.size), + ...(kind === "file" + ? { sha256: createHash("sha256").update(readFileSync(path)).digest("hex") } + : {}), + ...(kind === "symlink" ? { linkTarget: readlinkSync(path) } : {}), + }); + if (kind !== "directory") return; + for (const child of readdirSync(path).sort()) { + visit(join(path, child), relativePath === "." ? child : join(relativePath, child)); + } + }; + visit(root, "."); + return entries; +} + +beforeEach(() => { + previousCodexHome = process.env.CODEX_HOME; + previousOpencodexHome = process.env.OPENCODEX_HOME; + testRoot = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-filesystem-evidence-"))); + codexHome = join(testRoot, "codex-home"); + opencodexHome = join(testRoot, "opencodex-home"); + mkdirSync(codexHome, { recursive: true }); + mkdirSync(opencodexHome, { recursive: true }); + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = opencodexHome; +}); + +afterEach(() => { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + rmSync(testRoot, { recursive: true, force: true }); +}); + +test("source reads record PRESENT and ABSENT before returning their result", () => { + const presentPath = join(codexHome, "present.json"); + const absentPath = join(codexHome, "absent.json"); + const presentBytes = Buffer.from("present bytes\n"); + writeFileSync(presentPath, presentBytes); + + const session = createCatalogGatherEvidenceSession(); + captureAndSealCatalogHomeSelection(session); + acceptCatalogGatherSourcePath(session, "catalog-target-selection", join(codexHome, "config.toml")); + expect(readCatalogGatherSource(session, "catalog-target-selection")).toBeNull(); + acceptCatalogGatherSourcePath(session, "active-catalog-merge", presentPath); + expect(readCatalogGatherSource(session, "active-catalog-merge")).toEqual(Uint8Array.from(presentBytes)); + acceptCatalogGatherSourcePath(session, "models-cache-fallback", absentPath); + expect(readCatalogGatherSource(session, "models-cache-fallback")).toBeNull(); + + const evidence = sealCatalogGatherEvidenceSession(session); + expect(evidence.conditional["active-catalog-merge"]).toEqual([{ + state: "present", + role: "active-catalog-merge", + logicalPath: presentPath, + canonicalPath: presentPath, + parentIdentity: { + canonicalPath: codexHome, + volume: expect.any(String), + fileId: expect.any(String), + }, + fileIdentity: { volume: expect.any(String), fileId: expect.any(String) }, + sha256: createHash("sha256").update(presentBytes).digest("hex"), + }]); + expect(evidence.conditional["models-cache-fallback"]).toEqual([{ + state: "absent", + role: "models-cache-fallback", + logicalPath: absentPath, + canonicalPath: absentPath, + parentIdentity: { + canonicalPath: codexHome, + volume: expect.any(String), + fileId: expect.any(String), + }, + fileIdentity: null, + }]); +}); + +test("refuses sealing without the required catalog-target-selection observation", () => { + const session = createCatalogGatherEvidenceSession(); + captureAndSealCatalogHomeSelection(session); + expect(() => sealCatalogGatherEvidenceSession(session)).toThrow("catalog-target-selection"); +}); + +test("sealed evidence is detached and recursively immutable", () => { + const session = createCatalogGatherEvidenceSession(); + captureAndSealCatalogHomeSelection(session); + acceptCatalogGatherSourcePath(session, "catalog-target-selection", join(codexHome, "config.toml")); + readCatalogGatherSource(session, "catalog-target-selection"); + acceptCatalogGatherSourcePath(session, "active-catalog-merge", join(codexHome, "missing.json")); + readCatalogGatherSource(session, "active-catalog-merge"); + const evidence = sealCatalogGatherEvidenceSession(session); + + expect(Object.isFrozen(evidence)).toBe(true); + expect(Object.isFrozen(evidence.conditional)).toBe(true); + expect(Object.isFrozen(evidence.conditional["active-catalog-merge"])).toBe(true); + expect(() => { + (evidence.conditional["active-catalog-merge"] as CatalogSourceEvidence["conditional"]["active-catalog-merge"] & unknown[]) + .push(evidence.conditional["active-catalog-merge"][0]!); + }).toThrow(); + expect(() => { + (evidence.required["catalog-target-selection"].parentIdentity as { canonicalPath: string }).canonicalPath = "mutated"; + }).toThrow(); + expect(evidence.conditional["active-catalog-merge"]).toHaveLength(1); + expect(evidence.required["catalog-target-selection"].parentIdentity.canonicalPath).toBe(codexHome); +}); + +test("refuses a derived path before catalog-home selection is sealed", () => { + const session = createCatalogGatherEvidenceSession(); + expect(() => acceptCatalogGatherSourcePath( + session, + "catalog-target-selection", + join(codexHome, "config.toml"), + )).toThrow("before accepting a derived path"); + expect(() => captureCatalogGatherTargetIdentity(session, join(codexHome, "catalog.json"))) + .toThrow("before accepting a derived path"); +}); + +test("admission observation does not create missing generation storage", () => { + const before = recursiveManifest(testRoot); + expect(() => captureCatalogAdmissionSnapshot(config())).toThrow("generation is database"); + expect(recursiveManifest(testRoot)).toEqual(before); +}); + +test("populated and scratch gather sessions perform zero filesystem writes", () => { + saveConfig(config()); + const configPath = join(codexHome, "config.toml"); + const activePath = join(codexHome, "active.json"); + const authPath = join(opencodexHome, "oauth.json"); + writeFileSync(configPath, "model_catalog_json = \"active.json\"\n"); + writeFileSync(activePath, JSON.stringify({ + models: [{ slug: "gpt-5.5", base_instructions: "observed" }], + })); + writeFileSync(authPath, "{}\n"); + const beforePopulated = recursiveManifest(testRoot); + + resetBundledCatalogCacheForTests(); + const populated = createCatalogGatherEvidenceSession(); + captureAndSealCatalogHomeSelection(populated); + acceptCatalogGatherSourcePath(populated, "catalog-target-selection", configPath); + readCatalogGatherSource(populated, "catalog-target-selection"); + acceptCatalogGatherSourcePath(populated, "active-catalog-merge", activePath); + expect(resolveCatalogSourceForGather(populated).kind).toBe("available"); + acceptCatalogGatherSourcePath(populated, "models-cache-fallback", join(codexHome, "missing-cache.json")); + populated.readSource("models-cache-fallback"); + acceptCatalogGatherSourcePath(populated, "provider-auth-selection", authPath); + const providerEvidence: CatalogGatherProviderAuthEvidence = populated; + expect(providerEvidence.authStoreBuffer).toEqual(Uint8Array.from(readFileSync(authPath))); + captureCatalogGatherTargetIdentity(populated, activePath); + sealCatalogGatherEvidenceSession(populated); + expect(recursiveManifest(testRoot)).toEqual(beforePopulated); + + const scratchHome = join(testRoot, "scratch-home"); + mkdirSync(scratchHome); + process.env.CODEX_HOME = scratchHome; + const beforeScratch = recursiveManifest(testRoot); + const scratch = createCatalogGatherEvidenceSession(); + captureAndSealCatalogHomeSelection(scratch); + acceptCatalogGatherSourcePath(scratch, "catalog-target-selection", join(scratchHome, "config.toml")); + readCatalogGatherSource(scratch, "catalog-target-selection"); + sealCatalogGatherEvidenceSession(scratch); + expect(recursiveManifest(testRoot)).toEqual(beforeScratch); + + process.env.CODEX_HOME = join(testRoot, "absent-home"); + const beforeAbsent = recursiveManifest(testRoot); + expect(() => captureAndSealCatalogHomeSelection(createCatalogGatherEvidenceSession())).toThrow(); + expect(recursiveManifest(testRoot)).toEqual(beforeAbsent); +}); diff --git a/tests/codex-gather-authority.test.ts b/tests/codex-gather-authority.test.ts new file mode 100644 index 000000000..124244f16 --- /dev/null +++ b/tests/codex-gather-authority.test.ts @@ -0,0 +1,298 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { createHash } from "node:crypto"; + +import { + clearGatherRoutedModelsInflight, + gatherRoutedModels, +} from "../src/codex/catalog"; +import { clearModelCache } from "../src/codex/model-cache"; +import { PROVIDER_REGISTRY, type ProviderModelDiscoverySpec } from "../src/providers/registry"; +import type { OcxConfig } from "../src/types"; +import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch"; +import { withRegistryDiscovery } from "./helpers/provider-registry-discovery"; + +const originalFetch = globalThis.fetch; + +function deferred(): { readonly promise: Promise; readonly resolve: () => void } { + let resolve!: () => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +function togetherConfig(apiKey = "together-authority-secret"): OcxConfig { + return withStubbedProviderFetch({ + port: 10100, + defaultProvider: "together", + modelCacheTtlMs: 0, + providers: { + together: { + adapter: "openai-chat", + baseUrl: "https://api.together.xyz/v1", + authMode: "key", + apiKey, + models: ["safe-fallback"], + }, + }, + }); +} + +const strictDiscovery: ProviderModelDiscoverySpec = { + maxModels: 2, + filter: { + allOf: [{ path: ["type"], equalsAny: ["chat"] }], + }, +}; + +afterEach(() => { + globalThis.fetch = originalFetch; + clearModelCache(); + clearGatherRoutedModelsInflight(); +}); + +describe("catalog gather discovery-policy authority", () => { + test("different live registry policies cannot share a flight", async () => { + const config = togetherConfig(); + const body = { + data: [ + { id: "chat-one", type: "chat" }, + { id: "chat-two", type: "chat" }, + { id: "embedding-three", type: "embedding" }, + ], + }; + const warning = spyOn(console, "warn").mockImplementation(() => {}); + try { + globalThis.fetch = (async () => Response.json(body)) as typeof fetch; + const control = await withRegistryDiscovery( + "together", + strictDiscovery, + () => gatherRoutedModels(config), + { preserveCustomDestination: true }, + ); + expect(control.filter(model => model.provider === "together").map(model => model.id)) + .toEqual(["safe-fallback"]); + expect(warning.mock.calls.flat().join(" ")).toContain("2-row model limit"); + + clearModelCache("together"); + clearGatherRoutedModelsInflight(); + warning.mockClear(); + const firstResponse = deferred(); + let fetchCount = 0; + globalThis.fetch = (async () => { + fetchCount += 1; + if (fetchCount === 1) await firstResponse.promise; + return Response.json(body); + }) as typeof fetch; + try { + let first!: Promise>>; + await withRegistryDiscovery( + "together", + { maxModels: 3 }, + () => { first = gatherRoutedModels(config); }, + { preserveCustomDestination: true }, + ); + expect(fetchCount).toBe(1); + + let second!: Promise>>; + await withRegistryDiscovery( + "together", + strictDiscovery, + () => { second = gatherRoutedModels(config); }, + { preserveCustomDestination: true }, + ); + expect(fetchCount).toBe(2); + + firstResponse.resolve(); + const [permissive, strict] = await Promise.all([first, second]); + expect(fetchCount).toBe(2); + expect(permissive.filter(model => model.provider === "together").map(model => model.id)) + .toEqual(["chat-one", "chat-two", "embedding-three"]); + expect(strict.filter(model => model.provider === "together").map(model => model.id)) + .toEqual(["safe-fallback"]); + expect(warning.mock.calls.flat().join(" ")).toContain("2-row model limit"); + } finally { + firstResponse.resolve(); + } + } finally { + warning.mockRestore(); + } + }); + + test("a flight uses its captured transport non-match after the registry override is restored", async () => { + const config = withStubbedProviderFetch({ + port: 10100, + defaultProvider: "openai-apikey", + modelCacheTtlMs: 0, + providers: { + "openai-apikey": { + adapter: "openai-responses", + baseUrl: "https://custom-openai.example/v1", + authMode: "key", + apiKey: "custom-openai-secret", + }, + }, + }); + const responseGate = deferred(); + let fetchCount = 0; + globalThis.fetch = (async () => { + fetchCount += 1; + await responseGate.promise; + return Response.json({ data: [{ id: "custom-only" }] }); + }) as typeof fetch; + + let pending!: Promise>>; + await withRegistryDiscovery( + "openai-apikey", + {}, + () => { pending = gatherRoutedModels(config); }, + { preserveCustomDestination: true }, + ); + expect(fetchCount).toBe(1); + + const originalFind = PROVIDER_REGISTRY.find; + PROVIDER_REGISTRY.find = function forbiddenPostLookupRegistryRead() { + throw new Error("post-lookup provider registry read"); + } as typeof PROVIDER_REGISTRY.find; + try { + responseGate.resolve(); + const models = await pending; + expect(models.filter(model => model.provider === "openai-apikey").map(model => model.id)) + .toEqual(["custom-only"]); + } finally { + PROVIDER_REGISTRY.find = originalFind; + responseGate.resolve(); + } + }); + + test("credentials and private authority identities stay out of logs and serialized results", async () => { + const credential = "catalog-authority-privacy-secret"; + const plainDigest = createHash("sha256").update(credential).digest("hex"); + const config = togetherConfig(credential); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + const error = spyOn(console, "error").mockImplementation(() => {}); + const log = spyOn(console, "log").mockImplementation(() => {}); + globalThis.fetch = (async () => Response.json({ data: [{ id: "privacy-model" }] })) as typeof fetch; + try { + const models = await withRegistryDiscovery( + "together", + { filter: { allOf: [{ path: ["id"], equalsAny: ["privacy-model"] }] } }, + () => gatherRoutedModels(config), + { preserveCustomDestination: true }, + ); + const observable = JSON.stringify({ + models, + logs: [warn, error, log].map(spy => spy.mock.calls), + }); + expect(observable).not.toContain(credential); + expect(observable).not.toContain(plainDigest); + expect(observable).not.toMatch(/[a-f0-9]{64}/i); + } finally { + warn.mockRestore(); + error.mockRestore(); + log.mockRestore(); + } + }); + + /** + * Two admissions that differ ONLY in credential must not share a flight. + * + * The flight key's fingerprint carries endpoints and model lists but no + * `authMode`, key or headers, and discovery policy does not carry them either. + * So a key rotated through `/api/providers/keys` mid-flight left the second + * admission joining the first, receiving rows the OLD key had fetched, and + * reporting `committed` — the catalog ended up holding the old key's models + * under the new key's admission. + * + * Removing `authIdentity` from the join comparison collapses the two fetches + * back into one and turns this red. + */ + test("a rotated credential cannot join the flight it did not authorize", async () => { + clearModelCache("together"); + clearGatherRoutedModelsInflight(); + + const firstResponse = deferred(); + const seenKeys: string[] = []; + let fetchCount = 0; + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + fetchCount += 1; + const headers = new Headers((init?.headers ?? {}) as HeadersInit); + seenKeys.push(headers.get("authorization") ?? headers.get("x-api-key") ?? "none"); + if (fetchCount === 1) await firstResponse.promise; + return Response.json({ data: [{ id: `model-for-call-${fetchCount}` }] }); + }) as unknown as typeof fetch; + + try { + const oldKey = gatherRoutedModels(togetherConfig("old-key")); + // The flight claims its slot synchronously, but the request itself starts a + // few microtasks later; yield until it is actually in flight. + await Bun.sleep(20); + expect(fetchCount).toBe(1); + + // The rotation: same provider, same endpoint, same discovery policy — only + // the credential moved. + const newKey = gatherRoutedModels(togetherConfig("new-key")); + await Bun.sleep(20); + expect(fetchCount).toBe(2); + + firstResponse.resolve(); + await Promise.all([oldKey, newKey]); + + // Each admission fetched under its own credential; neither borrowed the other's. + expect(seenKeys.some(value => value.includes("old-key"))).toBe(true); + expect(seenKeys.some(value => value.includes("new-key"))).toBe(true); + } finally { + clearGatherRoutedModelsInflight(); + clearModelCache("together"); + } + }); + + /** + * The general form of the same defect, found after credentials were fixed. + * + * `providerCatalogFingerprint` is an allow-list, so every provider field it + * does not name was treated as equivalence. Credentials leaked a flight until + * `authIdentity` landed; `reasoningEfforts` leaked one after that, and it + * changes catalog rows. Enumerating fields cannot converge — the next field + * added to a provider row inherits the defect — so the join now compares the + * whole admitted provider graph. + * + * This test uses `reasoningEfforts` because that is what the verifier + * reproduced against real routes, but it is really a test of the general rule: + * dropping `providerGraphIdentity` from the comparison turns it red. + */ + test("a provider field outside the legacy fingerprint cannot join another admission's flight", async () => { + clearModelCache("together"); + clearGatherRoutedModelsInflight(); + + const firstResponse = deferred(); + let fetchCount = 0; + globalThis.fetch = (async () => { + fetchCount += 1; + if (fetchCount === 1) await firstResponse.promise; + return Response.json({ data: [{ id: `model-${fetchCount}` }] }); + }) as typeof fetch; + + const withEfforts = (efforts: readonly string[]): OcxConfig => { + const config = togetherConfig(); + (config.providers.together as Record).reasoningEfforts = [...efforts]; + return config; + }; + + try { + const first = gatherRoutedModels(withEfforts(["low"])); + await Bun.sleep(20); + expect(fetchCount).toBe(1); + + // Same provider, same endpoint, same credential, same discovery policy — + // only a field the legacy fingerprint never listed has moved. + const second = gatherRoutedModels(withEfforts(["low", "high"])); + await Bun.sleep(20); + expect(fetchCount).toBe(2); + + firstResponse.resolve(); + await Promise.all([first, second]); + } finally { + clearGatherRoutedModelsInflight(); + clearModelCache("together"); + } + }); +}); diff --git a/tests/codex-history-job.test.ts b/tests/codex-history-job.test.ts new file mode 100644 index 000000000..0c9846198 --- /dev/null +++ b/tests/codex-history-job.test.ts @@ -0,0 +1,161 @@ +import { afterEach, expect, test } from "bun:test"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Database } from "bun:sqlite"; + +import { + deriveCodexHistoryOperation, + runCodexHistoryJob, +} from "../src/codex/history-job"; + +const sandboxes: string[] = []; +let previousCodexHome: string | undefined; + +afterEach(() => { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + previousCodexHome = undefined; + for (const root of sandboxes.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +interface Fixture { + readonly canonicalCodexHome: string; + readonly canonicalStateDbPath: string; + readonly canonicalBackupPath: string; +} + +function makeFixture(prefix: string): Fixture { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), prefix))); + sandboxes.push(root); + const codexHome = join(root, "codex-home"); + mkdirSync(codexHome, { recursive: true }); + chmodSync(codexHome, 0o700); + + const stateDb = join(codexHome, "state_5.sqlite"); + const rollout = join(codexHome, "rollout.jsonl"); + writeFileSync(rollout, `${JSON.stringify({ + type: "session_meta", + payload: { id: "thread-1", model_provider: "opencodex", source: "exec" }, + })}\n`); + + const db = new Database(stateDb, { create: true }); + db.exec(`CREATE TABLE threads ( + id TEXT PRIMARY KEY, rollout_path TEXT, model_provider TEXT, + source TEXT, has_user_event INTEGER, first_user_message TEXT + )`); + db.run("INSERT INTO threads VALUES ('thread-1', ?, 'opencodex', 'exec', 1, 'hi')", [rollout]); + db.close(); + + previousCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = codexHome; + + return { + canonicalCodexHome: codexHome, + canonicalStateDbPath: stateDb, + canonicalBackupPath: join(codexHome, "history-backup.json"), + }; +} + +/** + * The opt-out outranks the direction. An apply that migrated history anyway + * would be `syncResumeHistory: false` failing silently, which is worse than + * failing loudly. + */ +test("the operation is derived from admitted intent, not chosen by a caller", () => { + expect(deriveCodexHistoryOperation({ direction: "apply", resumeHistory: false, legacyMode: false })) + .toBe("skip"); + expect(deriveCodexHistoryOperation({ direction: "restore", resumeHistory: false, legacyMode: true })) + .toBe("skip"); + + // Legacy mode is the only case that routes history TO opencodex; the ordinary + // apply migrates to native so a later restore has nothing to undo. + expect(deriveCodexHistoryOperation({ direction: "apply", resumeHistory: true, legacyMode: true })) + .toBe("apply-opencodex"); + expect(deriveCodexHistoryOperation({ direction: "apply", resumeHistory: true, legacyMode: false })) + .toBe("migrate-openai"); + expect(deriveCodexHistoryOperation({ direction: "restore", resumeHistory: true, legacyMode: false })) + .toBe("restore-openai"); +}); + +test("skip resolves without spawning a thread and writes nothing", async () => { + const fixture = makeFixture("ocx-history-job-skip-"); + + const outcome = await runCodexHistoryJob({ ...fixture, operation: "skip" }); + expect(outcome).toEqual({ kind: "skipped" }); + + const db = new Database(fixture.canonicalStateDbPath, { readonly: true }); + const row = db.query<{ model_provider: string }, []>( + "SELECT model_provider FROM threads WHERE id = 'thread-1'", + ).get(); + db.close(); + expect(row?.model_provider).toBe("opencodex"); +}); + +/** + * The real round trip: a Worker thread runs the unit and the parent joins it + * before returning, so the caller never observes a half-applied transition. + */ +test("a real Worker performs the transition and the parent joins it", async () => { + const fixture = makeFixture("ocx-history-job-run-"); + + const outcome = await runCodexHistoryJob({ ...fixture, operation: "recover-legacy-openai" }); + expect(outcome.kind).toBe("converged"); + + // Already committed by the time the promise settles — that is what joining buys. + const db = new Database(fixture.canonicalStateDbPath, { readonly: true }); + const row = db.query<{ model_provider: string }, []>( + "SELECT model_provider FROM threads WHERE id = 'thread-1'", + ).get(); + db.close(); + expect(row?.model_provider).toBe("openai"); +}, 30_000); + +/** + * A Worker that overruns must not become the caller's stall. The caller here is + * a route that has already persisted its own mutation; an exception crossing + * back would turn a successful change into a 500. + */ +test("an overrun Worker returns a typed timeout rather than hanging", async () => { + const fixture = makeFixture("ocx-history-job-timeout-"); + + const started = Date.now(); + const outcome = await runCodexHistoryJob( + { ...fixture, operation: "recover-legacy-openai" }, + { timeoutMs: 1 }, + ); + + // Either the unit beat the 1ms watchdog or the watchdog fired; both are typed, + // and neither throws. + expect(["converged", "failed"]).toContain(outcome.kind); + if (outcome.kind === "failed") expect(outcome.reason).toBe("timeout"); + expect(Date.now() - started).toBeLessThan(20_000); +}, 30_000); + +/** + * The async restore wrapper owns history; the synchronous body must not also do + * it, or every restore would run the transition twice — once unserialized on the + * caller thread, which is the path this phase exists to remove. + * + * Asserted against the SOURCE rather than by running it. The synchronous body + * resolves its state database from a module-load constant + * (`history-provider.ts:16`), so a test that moves `CODEX_HOME` cannot observe + * which database it would have touched — a behavioural version of this passed + * with `skipHistory` ignored entirely, which is worse than no test. Removing the + * guard changes this text, and that is something a check can actually see. + */ +test("the synchronous restore body is gated on skipHistory", () => { + const source = readFileSync(join(import.meta.dir, "..", "src", "codex", "inject.ts"), "utf8"); + const body = source.slice(source.indexOf("export function restoreNativeCodex(")); + const historyCall = body.indexOf("syncCodexHistoryProvider(\"openai\""); + expect(historyCall).toBeGreaterThan(-1); + + // The inline call is reachable only through the gate. + const gate = body.indexOf("options.skipHistory"); + expect(gate).toBeGreaterThan(-1); + expect(gate).toBeLessThan(historyCall); + + // And the async wrapper is the thing that sets it. + expect(source).toContain("restoreNativeCodex({ skipHistory: true })"); +}); diff --git a/tests/codex-history-lock.test.ts b/tests/codex-history-lock.test.ts new file mode 100644 index 000000000..c6b950bc7 --- /dev/null +++ b/tests/codex-history-lock.test.ts @@ -0,0 +1,165 @@ +import { afterEach, expect, test } from "bun:test"; +import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { + assertHistoryWritePermit, + isHistoryWritePermitLive, + withHistoryWriteSerialization, + type HistoryWritePermit, +} from "../src/codex/history-lock"; + +const repoRoot = resolve(import.meta.dir, ".."); +const sandboxes: string[] = []; + +interface Sandbox { + readonly root: string; + readonly codexHome: string; + readonly stateDb: string; + readonly env: Record; +} + +function makeSandbox(prefix: string): Sandbox { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), prefix))); + sandboxes.push(root); + const codexHome = join(root, "codex-home"); + const home = join(root, "user-home"); + const runtime = join(root, "runtime"); + for (const path of [codexHome, home, runtime]) { + mkdirSync(path, { recursive: true }); + chmodSync(path, 0o700); + } + const stateDb = join(codexHome, "state_5.sqlite"); + writeFileSync(stateDb, ""); + return { + root, + codexHome, + stateDb, + env: { + ...Object.fromEntries(Object.entries(process.env) + .filter((entry): entry is [string, string] => entry[1] !== undefined)), + CODEX_HOME: codexHome, + HOME: home, + USERPROFILE: home, + TMPDIR: runtime, + TEMP: runtime, + TMP: runtime, + XDG_RUNTIME_DIR: runtime, + }, + }; +} + +afterEach(() => { + for (const root of sandboxes.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +async function waitForPath(path: string, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!existsSync(path)) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${path}`); + await Bun.sleep(5); + } +} + +test("H excludes a second process across the whole history unit", async () => { + const sandbox = makeSandbox("ocx-history-lock-"); + const ready = join(sandbox.root, "held"); + const release = join(sandbox.root, "release"); + + // A real second process holds H and parks inside the callback, which is where + // the DB, manifest and rollout writes all happen. + const holder = Bun.spawn([process.execPath, "--eval", ` + import { existsSync, writeFileSync } from "node:fs"; + const { withHistoryWriteSerialization } = await import("./src/codex/history-lock.ts"); + const outcome = withHistoryWriteSerialization( + ${JSON.stringify(sandbox.codexHome)}, + ${JSON.stringify(sandbox.stateDb)}, + () => { + writeFileSync(${JSON.stringify(ready)}, "held"); + const waiter = new Int32Array(new SharedArrayBuffer(4)); + while (!existsSync(${JSON.stringify(release)})) Atomics.wait(waiter, 0, 0, 10); + }, + ); + if (outcome.kind !== "completed") throw new Error(JSON.stringify(outcome)); + `], { cwd: repoRoot, env: sandbox.env, stdout: "pipe", stderr: "pipe" }); + + try { + await waitForPath(ready); + + // Contention is fail-fast and typed, never a block: holding H across an + // unbounded wait is the stall this phase exists to remove. + let ran = false; + const contended = withHistoryWriteSerialization( + sandbox.codexHome, + sandbox.stateDb, + () => { ran = true; }, + ); + expect(contended).toEqual({ kind: "unavailable", reason: "busy" }); + expect(ran).toBe(false); + } finally { + writeFileSync(release, "release"); + expect(await holder.exited).toBe(0); + } + + // Once the holder is gone the lock is available again. + const after = withHistoryWriteSerialization(sandbox.codexHome, sandbox.stateDb, () => "ok"); + expect(after).toEqual({ kind: "completed", value: "ok" }); +}, 30_000); + +test("a permit is refused once its acquisition released, and for a foreign state database", () => { + const sandbox = makeSandbox("ocx-history-permit-"); + const other = join(sandbox.codexHome, "other_state.sqlite"); + + let leaked!: HistoryWritePermit; + const outcome = withHistoryWriteSerialization(sandbox.codexHome, sandbox.stateDb, permit => { + leaked = permit; + // Live and correct inside the acquisition. + expect(isHistoryWritePermitLive(permit)).toBe(true); + assertHistoryWritePermit(permit, sandbox.stateDb); + // Right permit, wrong database: authority is per state DB, not per process. + expect(() => assertHistoryWritePermit(permit, other)).toThrow(/different Codex state database/); + return "done"; + }); + expect(outcome).toEqual({ kind: "completed", value: "done" }); + + // The reason the permit is a registry entry rather than a type: a leaked + // permit type-checks perfectly, so only a runtime check can refuse it. + expect(isHistoryWritePermitLive(leaked)).toBe(false); + expect(() => assertHistoryWritePermit(leaked, sandbox.stateDb)).toThrow(/released acquisition/); + + // A forged value of the right type is refused too. + expect(() => assertHistoryWritePermit({} as HistoryWritePermit, sandbox.stateDb)) + .toThrow(/not minted by the serialization owner/); +}); + +test("a permit is revoked even when the callback throws", () => { + const sandbox = makeSandbox("ocx-history-throw-"); + let leaked!: HistoryWritePermit; + + expect(() => withHistoryWriteSerialization(sandbox.codexHome, sandbox.stateDb, permit => { + leaked = permit; + throw new Error("callback failed"); + })).toThrow("callback failed"); + + // Revocation happens in a `finally`, so the throwing path cannot leave a live + // permit behind after the lock is gone. + expect(isHistoryWritePermitLive(leaked)).toBe(false); + + // And the lock itself was released rather than wedged. + expect(withHistoryWriteSerialization(sandbox.codexHome, sandbox.stateDb, () => "free")) + .toEqual({ kind: "completed", value: "free" }); +}); + +test("two different state databases under one home do not exclude each other", () => { + const sandbox = makeSandbox("ocx-history-sibling-"); + const second = join(sandbox.codexHome, "second_state.sqlite"); + writeFileSync(second, ""); + + // H is keyed by the state database, so work against a different history + // database proceeds while this acquisition is held. + const outcome = withHistoryWriteSerialization(sandbox.codexHome, sandbox.stateDb, () => { + return withHistoryWriteSerialization(sandbox.codexHome, second, () => "sibling"); + }); + expect(outcome).toEqual({ kind: "completed", value: { kind: "completed", value: "sibling" } }); +}); diff --git a/tests/codex-history-reachability.test.ts b/tests/codex-history-reachability.test.ts new file mode 100644 index 000000000..8f61c5173 --- /dev/null +++ b/tests/codex-history-reachability.test.ts @@ -0,0 +1,127 @@ +import { expect, test } from "bun:test"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; + +/** + * Who may reach a history writer, checked by walking imports rather than counting + * strings. + * + * The previous guard in this unit was a regex over a handful of files. It could + * not see a wrapper, an alias, a re-export, or a dynamic import, so a new bypass + * would have landed green — and this phase exists precisely because a lock one + * caller can skip is not a lock. + * + * The inventory is deliberately small and named. `internal/history-writer.ts` + * mutates the database, the manifest and the rollouts; only `history-worker.ts` + * runs inside H, so only it may reach those symbols. + */ +const SRC = resolve(import.meta.dir, "..", "src"); + +/** Modules whose exports mutate Codex history. */ +const HISTORY_WRITER = "codex/internal/history-writer.ts"; + +/** The only production module allowed to import them. */ +const PERMITTED_ROOTS = new Set(["codex/history-worker.ts"]); + +/** + * Modules that legitimately still contain inline history calls. + * + * `history-provider.ts` owns the implementations. `inject.ts` keeps a + * synchronous body for the process-shutdown path, where awaiting a Worker the + * process may not outlive trades a correct restore for a faster one; it is gated + * on `skipHistory` so the async wrapper is the one that runs under H. + */ +const INLINE_ALLOWED = new Set([ + "codex/history-provider.ts", + "codex/inject.ts", + "codex/internal/history-writer.ts", +]); + +/** Direct mutators — reaching these outside the inventory is the bypass. */ +const MUTATORS = [ + "syncCodexHistoryProvider", + "restoreLegacyOpenaiHistory", + "migrateHistoryToOpenai", +]; + +function sourceFiles(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) sourceFiles(full, out); + else if (entry.endsWith(".ts")) out.push(full); + } + return out; +} + +/** + * Every import specifier in a file: static, `export ... from`, and dynamic. + * + * Dynamic imports matter most — they are how a caller reaches a module without + * appearing in the import block, and the old regex guard was blind to them. + */ +function importSpecifiers(source: string): string[] { + const specifiers: string[] = []; + const patterns = [ + /(?:^|\n)\s*import\s[^;]*?from\s*["']([^"']+)["']/g, + /(?:^|\n)\s*export\s[^;]*?from\s*["']([^"']+)["']/g, + /import\s*\(\s*["']([^"']+)["']\s*\)/g, + ]; + for (const pattern of patterns) { + for (const match of source.matchAll(pattern)) { + if (match[1]) specifiers.push(match[1]); + } + } + return specifiers; +} + +function resolveSpecifier(fromFile: string, specifier: string): string | null { + if (!specifier.startsWith(".")) return null; + const base = resolve(join(fromFile, ".."), specifier); + for (const candidate of [base, `${base}.ts`, join(base, "index.ts")]) { + try { + if (statSync(candidate).isFile()) return relative(SRC, candidate); + } catch { /* not this shape */ } + } + return null; +} + +test("only the history Worker can reach a history writer", () => { + const offenders: string[] = []; + for (const file of sourceFiles(SRC)) { + const rel = relative(SRC, file); + if (rel === HISTORY_WRITER) continue; + const resolved = importSpecifiers(readFileSync(file, "utf8")) + .map(specifier => resolveSpecifier(file, specifier)); + if (resolved.includes(HISTORY_WRITER) && !PERMITTED_ROOTS.has(rel)) { + offenders.push(rel); + } + } + expect(offenders).toEqual([]); +}); + +test("no production module outside the inventory calls a history mutator inline", () => { + const offenders: Array<{ file: string; symbol: string }> = []; + for (const file of sourceFiles(SRC)) { + const rel = relative(SRC, file); + if (INLINE_ALLOWED.has(rel)) continue; + const source = readFileSync(file, "utf8"); + for (const symbol of MUTATORS) { + // A call, not a type reference or a re-export of the name. + if (new RegExp(`\\b${symbol}\\s*\\(`).test(source)) { + offenders.push({ file: rel, symbol }); + } + } + } + expect(offenders).toEqual([]); +}); + +/** + * The inventory is only meaningful if it is currently satisfied by real modules. + * A guard whose permitted root does not exist would pass forever by vacuity. + */ +test("the permitted root exists and does import the writer", () => { + const worker = readFileSync(join(SRC, "codex", "history-worker.ts"), "utf8"); + const resolved = importSpecifiers(worker) + .map(specifier => resolveSpecifier(join(SRC, "codex", "history-worker.ts"), specifier)); + expect(resolved).toContain(HISTORY_WRITER); +}); diff --git a/tests/codex-history-worker-boundary.test.ts b/tests/codex-history-worker-boundary.test.ts new file mode 100644 index 000000000..b86c18f5b --- /dev/null +++ b/tests/codex-history-worker-boundary.test.ts @@ -0,0 +1,91 @@ +/** + * The parent side of the history Worker boundary. + * + * Three gaps an audit found, each a different way a dead or malformed Worker + * surfaced as something it was not. The one to watch is the last: the Worker + * ALWAYS closes after posting its result, so a close handler that overturned a + * valid success would report every completed job as a death. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { runCodexHistoryJob } from "../src/codex/history-job"; + +let root = ""; +let previousCodexHome: string | undefined; +let previousOpencodexHome: string | undefined; +const cleanup: string[] = []; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-hist-boundary-")); + cleanup.push(root); + const codexHome = join(root, ".codex"); + const opencodexHome = join(root, ".opencodex"); + mkdirSync(codexHome, { recursive: true }); + mkdirSync(opencodexHome, { recursive: true }); + previousCodexHome = process.env.CODEX_HOME; + previousOpencodexHome = process.env.OPENCODEX_HOME; + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = opencodexHome; +}); + +afterEach(() => { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + while (cleanup.length) rmSync(cleanup.pop()!, { recursive: true, force: true }); +}); + +describe("a dead worker is not a slow one", () => { + /** + * The skip path never spawns a Worker at all, so it is the control: the job + * completes without the boundary being exercised. The boundary cases below + * need a Worker that actually runs, and a real history target is a heavy + * fixture — so these hold down the classification contract through the + * in-process seams the module already exposes, and the live two-process race + * exercises the real Worker end to end. + */ + test("skip completes without a worker", async () => { + const outcome = await runCodexHistoryJob({ + operation: "skip", + canonicalCodexHome: process.env.CODEX_HOME!, + canonicalStateDbPath: join(root, ".codex", "state_5.sqlite"), + canonicalBackupPath: join(root, ".codex", "state_5.sqlite.ocx-backup.json"), + }); + expect(outcome.kind).toBe("skipped"); + }); +}); + +describe("the result validator", () => { + test("a recognized type with no payload is not a success", async () => { + // {requestId, type:"done"} with nothing else used to read as converged with + // undefined rows. The module-level validator is the contract, exercised + // through the job's own classification seam rather than a fabricated cast. + const { isPlausibleWorkerResultForTests } = await import("../src/codex/history-job"); + expect(isPlausibleWorkerResultForTests( + { requestId: "r", type: "done" }, "r", "j", + )).toBe(false); + expect(isPlausibleWorkerResultForTests( + { requestId: "r", jobId: "j", type: "done", outcome: "converged", rows: 3, files: 1 }, "r", "j", + )).toBe(true); + // A reply for a different job is not this job's answer. + expect(isPlausibleWorkerResultForTests( + { requestId: "r", jobId: "OTHER", type: "done", outcome: "converged", rows: 3, files: 1 }, "r", "j", + )).toBe(false); + }); + + test("blocked carries exactly its three reasons", async () => { + const { isPlausibleWorkerResultForTests } = await import("../src/codex/history-job"); + for (const reason of ["busy", "database", "unsafe-path"] as const) { + expect(isPlausibleWorkerResultForTests( + { requestId: "r", jobId: "j", type: "blocked", reason }, "r", "j", + )).toBe(true); + } + expect(isPlausibleWorkerResultForTests( + { requestId: "r", jobId: "j", type: "blocked", reason: "invented" }, "r", "j", + )).toBe(false); + }); +}); diff --git a/tests/codex-history-worker.test.ts b/tests/codex-history-worker.test.ts new file mode 100644 index 000000000..eb76fa1cd --- /dev/null +++ b/tests/codex-history-worker.test.ts @@ -0,0 +1,193 @@ +import { afterEach, expect, test } from "bun:test"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { Database } from "bun:sqlite"; + +import { + isHistoryWorkerRunMessage, + runHistoryUnitUnderLock, + type HistoryWorkerRunMessage, +} from "../src/codex/history-worker"; + +const repoRoot = resolve(import.meta.dir, ".."); +const sandboxes: string[] = []; + +afterEach(() => { + for (const root of sandboxes.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +interface Fixture { + readonly codexHome: string; + readonly stateDb: string; + readonly backup: string; + readonly rollout: string; + readonly env: Record; +} + +function makeFixture(prefix: string): Fixture { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), prefix))); + sandboxes.push(root); + const codexHome = join(root, "codex-home"); + const home = join(root, "user-home"); + const runtime = join(root, "runtime"); + for (const path of [codexHome, home, runtime]) { + mkdirSync(path, { recursive: true }); + chmodSync(path, 0o700); + } + + const stateDb = join(codexHome, "state_5.sqlite"); + const rollout = join(codexHome, "rollout.jsonl"); + writeFileSync(rollout, `${JSON.stringify({ + type: "session_meta", + payload: { id: "thread-1", model_provider: "opencodex", source: "exec" }, + })}\n`); + + const db = new Database(stateDb, { create: true }); + db.exec(`CREATE TABLE threads ( + id TEXT PRIMARY KEY, rollout_path TEXT, model_provider TEXT, + source TEXT, has_user_event INTEGER, first_user_message TEXT + )`); + db.run( + "INSERT INTO threads VALUES ('thread-1', ?, 'opencodex', 'exec', 1, 'hi')", + [rollout], + ); + db.close(); + + return { + codexHome, + stateDb, + backup: join(codexHome, "history-backup.json"), + rollout, + env: { + ...Object.fromEntries(Object.entries(process.env) + .filter((entry): entry is [string, string] => entry[1] !== undefined)), + CODEX_HOME: codexHome, + HOME: home, + USERPROFILE: home, + TMPDIR: runtime, + TEMP: runtime, + TMP: runtime, + XDG_RUNTIME_DIR: runtime, + }, + }; +} + +function runMessage(fixture: Fixture, overrides: Partial = {}): HistoryWorkerRunMessage { + return { + type: "run", + requestId: "req-1", + jobId: "job-1", + operation: "recover-legacy-openai", + canonicalCodexHome: fixture.codexHome, + canonicalStateDbPath: fixture.stateDb, + canonicalBackupPath: fixture.backup, + ...overrides, + } as HistoryWorkerRunMessage; +} + +/** + * The message must survive structured clone, which is what "Worker boundary" is + * really asserting: no function, no class instance, no handle, no path that + * resolves differently in another process. + */ +test("the run message is structured-clone safe and fully explicit", () => { + const fixture = makeFixture("ocx-history-worker-clone-"); + const message = runMessage(fixture); + + const cloned = structuredClone(message); + expect(cloned).toEqual(message); + expect(isHistoryWorkerRunMessage(cloned)).toBe(true); + + // Every path is carried, because a Worker does not inherit the module-load + // CODEX_HOME that history-provider resolves at import time. + for (const field of ["canonicalCodexHome", "canonicalStateDbPath", "canonicalBackupPath"] as const) { + const { [field]: _omitted, ...without } = message; + expect(isHistoryWorkerRunMessage(without)).toBe(false); + expect(isHistoryWorkerRunMessage({ ...message, [field]: " " })).toBe(false); + } + + // A direction is not part of the protocol at all: the operation is the only + // thing that decides which way history moves. + expect(Object.keys(message)).not.toContain("targetProvider"); + expect(Object.keys(message)).not.toContain("direction"); + + // An unknown operation is refused rather than coerced. + expect(isHistoryWorkerRunMessage({ ...message, operation: "delete-everything" })).toBe(false); +}); + +test("skip is a recorded outcome, not an absence, and writes nothing", () => { + const fixture = makeFixture("ocx-history-worker-skip-"); + const before = readFileSync(fixture.rollout, "utf8"); + + const result = runHistoryUnitUnderLock(runMessage(fixture, { operation: "skip" })); + + expect(result).toMatchObject({ type: "done", outcome: "skipped", rows: 0, files: 0 }); + expect(readFileSync(fixture.rollout, "utf8")).toBe(before); + expect(existsSync(fixture.backup)).toBe(false); +}); + +test("the unit runs the real transition under H", () => { + const fixture = makeFixture("ocx-history-worker-run-"); + + const result = runHistoryUnitUnderLock(runMessage(fixture)); + expect(result).toMatchObject({ type: "done", outcome: "converged" }); + + const db = new Database(fixture.stateDb, { readonly: true }); + const row = db.query<{ model_provider: string }, []>( + "SELECT model_provider FROM threads WHERE id = 'thread-1'", + ).get(); + db.close(); + expect(row?.model_provider).toBe("openai"); +}); + +/** + * The reason the unit lives in a Worker at all: while another process holds H, + * this one reports a typed block instead of stalling its own thread. + */ +test("a second holder of H makes the unit report blocked rather than wait", async () => { + const fixture = makeFixture("ocx-history-worker-busy-"); + const ready = join(fixture.codexHome, "..", "held"); + const release = join(fixture.codexHome, "..", "release"); + + const holder = Bun.spawn([process.execPath, "--eval", ` + import { existsSync, writeFileSync } from "node:fs"; + const { withHistoryWriteSerialization } = await import("./src/codex/history-lock.ts"); + const outcome = withHistoryWriteSerialization( + ${JSON.stringify(fixture.codexHome)}, + ${JSON.stringify(fixture.stateDb)}, + () => { + writeFileSync(${JSON.stringify(ready)}, "held"); + const waiter = new Int32Array(new SharedArrayBuffer(4)); + while (!existsSync(${JSON.stringify(release)})) Atomics.wait(waiter, 0, 0, 10); + }, + ); + if (outcome.kind !== "completed") throw new Error(JSON.stringify(outcome)); + `], { cwd: repoRoot, env: fixture.env, stdout: "pipe", stderr: "pipe" }); + + try { + const deadline = Date.now() + 10_000; + while (!existsSync(ready)) { + if (Date.now() > deadline) throw new Error("holder never acquired H"); + await Bun.sleep(5); + } + + const started = Date.now(); + const result = runHistoryUnitUnderLock(runMessage(fixture)); + expect(result).toMatchObject({ type: "blocked", reason: "busy" }); + // Fail-fast, not a stall: blocking here is the freeze this phase removes. + expect(Date.now() - started).toBeLessThan(2_000); + + // Nothing was written while the other process held the lock. + const db = new Database(fixture.stateDb, { readonly: true }); + const row = db.query<{ model_provider: string }, []>( + "SELECT model_provider FROM threads WHERE id = 'thread-1'", + ).get(); + db.close(); + expect(row?.model_provider).toBe("opencodex"); + } finally { + writeFileSync(release, "release"); + expect(await holder.exited).toBe(0); + } +}, 30_000); diff --git a/tests/codex-history-writer.test.ts b/tests/codex-history-writer.test.ts new file mode 100644 index 000000000..b15bb550a --- /dev/null +++ b/tests/codex-history-writer.test.ts @@ -0,0 +1,124 @@ +import { afterEach, expect, test } from "bun:test"; +import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Database } from "bun:sqlite"; + +import { + withHistoryWriteSerialization, + type HistoryWritePermit, +} from "../src/codex/history-lock"; +import { + writeHistoryProviderTransition, + writeLegacyOpenaiHistoryRecovery, + type HistoryWriteTarget, +} from "../src/codex/internal/history-writer"; + +const sandboxes: string[] = []; + +afterEach(() => { + for (const root of sandboxes.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function makeTarget(prefix: string): { codexHome: string; target: HistoryWriteTarget } { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), prefix))); + sandboxes.push(root); + const codexHome = join(root, "codex-home"); + mkdirSync(codexHome, { recursive: true }); + chmodSync(codexHome, 0o700); + + const canonicalStateDbPath = join(codexHome, "state_5.sqlite"); + const db = new Database(canonicalStateDbPath, { create: true }); + db.exec(`CREATE TABLE threads ( + id TEXT PRIMARY KEY, rollout_path TEXT, model_provider TEXT, + source TEXT, has_user_event INTEGER, first_user_message TEXT + )`); + db.close(); + + return { + codexHome, + target: { + canonicalStateDbPath, + canonicalBackupPath: join(codexHome, "history-backup.json"), + }, + }; +} + +/** + * The whole point of the permit argument: a writer reached without H fails + * closed instead of racing. A type alone cannot enforce this, because a permit + * leaked past its callback still type-checks. + */ +test("every history writer refuses a permit that is not live", () => { + const { codexHome, target } = makeTarget("ocx-history-writer-dead-"); + + let leaked!: HistoryWritePermit; + withHistoryWriteSerialization(codexHome, target.canonicalStateDbPath, permit => { + leaked = permit; + }); + + expect(() => writeHistoryProviderTransition(leaked, target, "openai")) + .toThrow(/released acquisition/); + expect(() => writeLegacyOpenaiHistoryRecovery(leaked, target)) + .toThrow(/released acquisition/); + + // A forged value of the right type is refused for the same reason. + const forged = {} as HistoryWritePermit; + expect(() => writeHistoryProviderTransition(forged, target, "openai")) + .toThrow(/not minted by the serialization owner/); + expect(() => writeLegacyOpenaiHistoryRecovery(forged, target)) + .toThrow(/not minted by the serialization owner/); +}); + +test("a live permit for another state database cannot write this one", () => { + const first = makeTarget("ocx-history-writer-a-"); + const second = makeTarget("ocx-history-writer-b-"); + + const outcome = withHistoryWriteSerialization( + first.codexHome, + first.target.canonicalStateDbPath, + permit => { + // Live, but authorized for a different history database. + expect(() => writeHistoryProviderTransition(permit, second.target, "openai")) + .toThrow(/different Codex state database/); + return writeHistoryProviderTransition(permit, first.target, "openai"); + }, + ); + expect(outcome.kind).toBe("completed"); +}); + +test("a writer holding a live permit performs the real transition", () => { + const { codexHome, target } = makeTarget("ocx-history-writer-live-"); + const rollout = join(codexHome, "rollout.jsonl"); + writeFileSync(rollout, `${JSON.stringify({ + type: "session_meta", + payload: { id: "thread-1", model_provider: "opencodex", source: "exec" }, + })}\n`); + + const db = new Database(target.canonicalStateDbPath); + db.run( + "INSERT INTO threads (id, rollout_path, model_provider, source, has_user_event, first_user_message) VALUES (?, ?, 'opencodex', 'exec', 1, 'hi')", + ["thread-1", rollout], + ); + db.close(); + + const outcome = withHistoryWriteSerialization( + codexHome, + target.canonicalStateDbPath, + permit => writeLegacyOpenaiHistoryRecovery(permit, target), + ); + + expect(outcome.kind).toBe("completed"); + if (outcome.kind !== "completed") return; + // Manifest-independent recovery patches rollout metadata as well as rows, which + // is why calling it DB-only would be wrong. + expect(outcome.value.rows).toBeGreaterThan(0); + + const after = new Database(target.canonicalStateDbPath, { readonly: true }); + const row = after.query<{ model_provider: string }, []>( + "SELECT model_provider FROM threads WHERE id = 'thread-1'", + ).get(); + after.close(); + expect(row?.model_provider).toBe("openai"); +}); diff --git a/tests/codex-inject-write-lock.test.ts b/tests/codex-inject-write-lock.test.ts new file mode 100644 index 000000000..1ce4f92dc --- /dev/null +++ b/tests/codex-inject-write-lock.test.ts @@ -0,0 +1,224 @@ +/** + * The production call edge, proven by contention rather than by a spy. + * + * `withCodexWriteLock` shipped with zero production callers, and every test it + * had exercised it with a fabricated snapshot. The property that matters is not + * "the lock function was invoked" — a pass-through mock satisfies that — but + * that two real processes running the real injection cannot both write. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const repoRoot = join(import.meta.dir, ".."); +const CHILD = join(repoRoot, "tests", "helpers", "codex-inject-race-child.ts"); +const LOCK_CHILD = join(repoRoot, "tests", "helpers", "codex-write-lock-child.ts"); + +let root = ""; +let codexHome = ""; +let opencodexHome = ""; +const cleanup: string[] = []; + +function seedNative(): void { + writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5"\n'); +} + +function runInject(port: number, lockTimeoutMs = 0): { success: boolean; retryable: boolean; message: string } { + const result = spawnSync(process.execPath, [CHILD], { + cwd: repoRoot, + encoding: "utf8", + env: { + ...process.env, + CODEX_HOME: codexHome, + OPENCODEX_HOME: opencodexHome, + OCX_INJECT_RACE_PAYLOAD: JSON.stringify({ port, lockTimeoutMs }), + }, + }); + const line = (result.stdout ?? "").trim().split("\n").filter(Boolean).pop() ?? "{}"; + return JSON.parse(line) as { success: boolean; retryable: boolean; message: string }; +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-inject-race-")); + cleanup.push(root); + codexHome = join(root, ".codex"); + opencodexHome = join(root, ".opencodex"); + mkdirSync(codexHome, { recursive: true }); + mkdirSync(opencodexHome, { recursive: true }); +}); + +afterEach(() => { + while (cleanup.length) rmSync(cleanup.pop()!, { recursive: true, force: true }); +}); + +describe("the lock is on the production path", () => { + test("a clean first apply coordinates and records a transition", () => { + seedNative(); + const result = runInject(10100); + expect(result.success).toBeTrue(); + + // The row is the proof that the lock ran, not that the function was called. + const state = spawnSync(process.execPath, ["--eval", ` + const { readCodexTransitionState } = require("./src/codex/transition-state"); + console.log(JSON.stringify(readCodexTransitionState())); + `], { + cwd: repoRoot, + encoding: "utf8", + env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: opencodexHome }, + }); + const row = JSON.parse((state.stdout ?? "{}").trim().split("\n").pop() ?? "{}") as { + kind?: string; + state?: { nativeGeneration?: number; currentTxId?: string | null }; + }; + expect(row.kind).toBe("ready"); + expect(row.state?.nativeGeneration).toBeGreaterThan(0); + // Guessing null passes on a fresh machine and fails on a real one, so the + // id being present is part of the claim. + expect(typeof row.state?.currentTxId).toBe("string"); + }); + + /** + * The contention proof. A real second process holds N through the production + * lock module while a real injection runs; the injection must report busy and + * must not have written its candidate bytes. + */ + test("a held lock makes real injection report busy and write nothing", () => { + seedNative(); + // Establish the coordinator first: a clean home has no row, and the holder + // needs one to contend over. + expect(runInject(10100).success).toBeTrue(); + const afterFirst = readFileSync(join(codexHome, "config.toml"), "utf-8"); + + const holdMarker = join(root, "held"); + const releaseMarker = join(root, "release"); + const holder = Bun.spawn([process.execPath, LOCK_CHILD], { + cwd: repoRoot, + env: { + ...process.env, + CODEX_HOME: codexHome, + OPENCODEX_HOME: opencodexHome, + OCX_LOCK_CHILD_PAYLOAD: JSON.stringify({ timeoutMs: 5_000, holdMarker, releaseMarker }), + }, + stdout: "pipe", + stderr: "pipe", + }); + + const deadline = Date.now() + 10_000; + while (!existsSync(holdMarker) && Date.now() < deadline) { + spawnSync(process.execPath, ["--eval", "Bun.sleepSync(20)"], { encoding: "utf8" }); + } + expect(existsSync(holdMarker)).toBeTrue(); + + // PROCESS-UNIQUE bytes: a different port means different candidate bytes, so + // the loser's work is identifiable rather than assumed. + const contender = runInject(20200); + + writeFileSync(releaseMarker, "go"); + holder.exited.then(() => undefined); + + expect(contender.success).toBeFalse(); + expect(contender.retryable).toBeTrue(); + // Its bytes are absent: the file still names the first winner's port. + const finalConfig = readFileSync(join(codexHome, "config.toml"), "utf-8"); + expect(finalConfig).not.toContain("20200"); + expect(finalConfig).toBe(afterFirst); + }, 30_000); +}); + +describe("homes the coordinator cannot adopt keep working", () => { + /** + * Every install predating this substrate is routed with no coordinator row, + * and that row cannot be created over routed bytes. Gating on the lock there + * would have broken re-injection for the entire installed base. + */ + test("a pre-substrate routed home still injects, without a coordinator", () => { + writeFileSync(join(codexHome, "config.toml"), [ + 'model_provider = "opencodex"', + 'model = "gpt-5.5"', + "", + "[model_providers.opencodex]", + 'name = "OpenCodex Proxy"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'wire_api = "responses"', + "", + ].join("\n")); + + const result = runInject(10100); + expect(result.success).toBeTrue(); + expect(readFileSync(join(codexHome, "config.toml"), "utf-8")).toContain("openai_base_url"); + }); +}); + +describe("the transition is resolved, not left pending", () => { + /** + * `updateCodexHistoryTransition` had no production caller, so every completed + * or skipped job left the row permanently `pending` — a transition published + * and never resolved. The row must now show what the job actually did. + */ + test("a completed apply leaves a converged row, not a pending one", () => { + seedNative(); + expect(runInject(10100).success).toBeTrue(); + + const state = spawnSync(process.execPath, ["--eval", ` + const { readCodexTransitionState } = require("./src/codex/transition-state"); + console.log(JSON.stringify(readCodexTransitionState())); + `], { + cwd: repoRoot, + encoding: "utf8", + env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: opencodexHome }, + }); + const row = JSON.parse((state.stdout ?? "{}").trim().split("\n").pop() ?? "{}") as { + kind?: string; + state?: { history?: { status?: string } }; + }; + expect(row.kind).toBe("ready"); + expect(row.state?.history?.status).not.toBe("pending"); + }); + + test("an opted-out apply records the opt-out as converged, not blocked", () => { + seedNative(); + writeFileSync(join(opencodexHome, "config.json"), JSON.stringify({ + port: 10100, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + defaultProvider: "openai", + syncResumeHistory: false, + }, null, 2)); + + const result = spawnSync(process.execPath, [CHILD], { + cwd: repoRoot, + encoding: "utf8", + env: { + ...process.env, + CODEX_HOME: codexHome, + OPENCODEX_HOME: opencodexHome, + OCX_INJECT_RACE_PAYLOAD: JSON.stringify({ port: 10100, lockTimeoutMs: 0 }), + }, + }); + expect(result.status).toBe(0); + + const state = spawnSync(process.execPath, ["--eval", ` + const { readCodexTransitionState } = require("./src/codex/transition-state"); + console.log(JSON.stringify(readCodexTransitionState())); + `], { + cwd: repoRoot, + encoding: "utf8", + env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: opencodexHome }, + }); + const row = JSON.parse((state.stdout ?? "{}").trim().split("\n").pop() ?? "{}") as { + kind?: string; + state?: { history?: { status?: string; attempts?: number } }; + }; + expect(row.kind).toBe("ready"); + // Opt-out is a completed decision, not a failure: converged, never blocked, + // and never left pending for a job that chose to do nothing. + expect(row.state?.history?.status).toBe("converged"); + }); +}); diff --git a/tests/codex-integration-record.test.ts b/tests/codex-integration-record.test.ts new file mode 100644 index 000000000..57ad5f970 --- /dev/null +++ b/tests/codex-integration-record.test.ts @@ -0,0 +1,188 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + readIntegrationRecord, + updateIntegrationRecord, +} from "../src/codex/integration-record"; +import type { + CodexArtifactId, + CodexIntegrationRecord, + CodexProvenanceEntry, +} from "../src/codex/convergence-types"; + +let opencodexHome = ""; +let previousOpencodexHome: string | undefined; + +function integrationRecordPath(): string { + return join(opencodexHome, "integrations", "codex.json"); +} + +function writeRecord(value: unknown): void { + mkdirSync(join(opencodexHome, "integrations"), { recursive: true }); + writeFileSync(integrationRecordPath(), JSON.stringify(value, null, 2)); +} + +function persistedRecord(): Record { + return JSON.parse(readFileSync(integrationRecordPath(), "utf8")) as Record; +} + +function knownArtifactFields(artifact: CodexArtifactId): CodexArtifactId { + switch (artifact.kind) { + case "config": + case "generated-profile": + case "models-cache": + case "injection-journal": + return { kind: artifact.kind }; + case "active-catalog": + return { kind: artifact.kind, canonicalPath: artifact.canonicalPath }; + case "catalog-backup": + return { kind: artifact.kind, form: artifact.form, canonicalPath: artifact.canonicalPath }; + case "history-row": + case "history-manifest-entry": + return { kind: artifact.kind, stateDbId: artifact.stateDbId, threadId: artifact.threadId }; + case "history-manifest": + case "history-rollout": + return { kind: artifact.kind, stateDbId: artifact.stateDbId, canonicalPath: artifact.canonicalPath }; + } +} + +beforeEach(() => { + previousOpencodexHome = process.env.OPENCODEX_HOME; + opencodexHome = mkdtempSync(join(tmpdir(), "ocx-integration-record-")); + process.env.OPENCODEX_HOME = opencodexHome; +}); + +afterEach(() => { + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + rmSync(opencodexHome, { recursive: true, force: true }); +}); + +describe("Codex integration record", () => { + test("accepts a v1 record written before provenance existed", () => { + writeRecord({ version: 1 }); + + expect(readIntegrationRecord()).toEqual({ + kind: "ready", + record: { version: 1 }, + }); + }); + + test("preserves future keys at record, ledger, entry, artifact, and both baseline levels", () => { + const artifacts: CodexArtifactId[] = [ + { kind: "config", futureArtifact: { owner: "future-config" } }, + { kind: "generated-profile", futureArtifact: { owner: "future-profile" } }, + { kind: "active-catalog", canonicalPath: "/catalog", futureArtifact: { owner: "future-catalog" } }, + { kind: "catalog-backup", form: "hashed", canonicalPath: "/backup", futureArtifact: { owner: "future-backup" } }, + { kind: "models-cache", futureArtifact: { owner: "future-cache" } }, + { kind: "injection-journal", futureArtifact: { owner: "future-journal" } }, + { kind: "history-row", stateDbId: "db-row", threadId: "thread-row", futureArtifact: { owner: "future-row" } }, + { kind: "history-manifest", stateDbId: "db-manifest", canonicalPath: "/manifest", futureArtifact: { owner: "future-manifest" } }, + { kind: "history-manifest-entry", stateDbId: "db-entry", threadId: "thread-entry", futureArtifact: { owner: "future-entry" } }, + { kind: "history-rollout", stateDbId: "db-rollout", canonicalPath: "/rollout", futureArtifact: { owner: "future-rollout" } }, + ]; + writeRecord({ + version: 1, + futureRecord: { mode: "newer" }, + provenance: { + futureLedger: ["keep"], + entries: artifacts.map((artifact, index): CodexProvenanceEntry => ({ + artifact, + baseline: index % 2 === 0 + ? { kind: "absent", futureAbsentBaseline: index } + : { + kind: "present", + sha256: `baseline-sha-${index}`, + bytesBase64: "YmFzZWxpbmU=", + futurePresentBaseline: { codec: index }, + }, + postImage: `old-post-image-${index}`, + txId: `tx-${index}`, + at: `2026-08-04T00:00:${String(index).padStart(2, "0")}.000Z`, + futureEntry: { evidence: index }, + })), + }, + }); + + const result = updateIntegrationRecord(record => ({ + version: 1, + provenance: { + entries: record.provenance!.entries.map((entry, index) => ({ + artifact: knownArtifactFields(entry.artifact), + baseline: entry.baseline.kind === "absent" + ? { kind: "absent" } + : { + kind: "present", + sha256: entry.baseline.sha256, + bytesBase64: entry.baseline.bytesBase64, + }, + postImage: `new-post-image-${index}`, + txId: entry.txId, + at: entry.at, + })), + }, + })); + + expect(result.kind).toBe("updated"); + const saved = persistedRecord(); + expect(saved.futureRecord).toEqual({ mode: "newer" }); + const ledger = saved.provenance as Record; + expect(ledger.futureLedger).toEqual(["keep"]); + const entries = ledger.entries as Array>; + expect(entries.map(entry => entry.futureEntry)).toEqual( + artifacts.map((_, index) => ({ evidence: index })), + ); + expect(entries.map(entry => (entry.artifact as Record).futureArtifact)).toEqual( + artifacts.map(artifact => artifact.futureArtifact), + ); + expect((entries[0]!.baseline as Record).futureAbsentBaseline).toBe(0); + expect((entries[1]!.baseline as Record).futurePresentBaseline) + .toEqual({ codec: 1 }); + expect(entries.map(entry => entry.postImage)).toEqual( + artifacts.map((_, index) => `new-post-image-${index}`), + ); + }); + + test("fails closed on unparseable bytes without invoking the mutator or resetting the file", () => { + mkdirSync(join(opencodexHome, "integrations"), { recursive: true }); + writeFileSync(integrationRecordPath(), "{ definitely-not-json", "utf8"); + let invoked = false; + + const result = updateIntegrationRecord((record): CodexIntegrationRecord => { + invoked = true; + return record; + }); + + expect(result).toEqual({ + kind: "invalid", + message: "Codex integration record contains invalid JSON", + }); + expect(invoked).toBe(false); + expect(readFileSync(integrationRecordPath(), "utf8")).toBe("{ definitely-not-json"); + }); + + test("creates the minimal v1 record on the first provenance write", () => { + const firstEntry: CodexProvenanceEntry = { + artifact: { kind: "config" }, + baseline: { kind: "absent" }, + postImage: "first-post-image", + txId: "tx-first", + at: "2026-08-04T00:00:00.000Z", + }; + const result = updateIntegrationRecord(record => ({ + ...record, + provenance: { entries: [firstEntry] }, + })); + + expect(result).toEqual({ + kind: "updated", + record: { version: 1, provenance: { entries: [firstEntry] } }, + }); + expect(persistedRecord()).toEqual({ + version: 1, + provenance: { entries: [firstEntry] }, + }); + }); +}); diff --git a/tests/codex-management-convergence.test.ts b/tests/codex-management-convergence.test.ts new file mode 100644 index 000000000..6f9df06da --- /dev/null +++ b/tests/codex-management-convergence.test.ts @@ -0,0 +1,198 @@ +import { expect, test } from "bun:test"; + +import { createCatalogConvergeRequest } from "../src/codex/catalog-admission"; +import { + createManagementConvergeCodex, + projectCatalogOnlyOutcome, +} from "../src/codex/management-convergence"; +import type { CatalogDisposition } from "../src/codex/convergence-types"; +import type { OcxConfig } from "../src/types"; + +function config(): OcxConfig { + return { port: 10100, providers: {}, defaultProvider: "openai" }; +} + +test("projects unavailable generation admission as retryable busy", async () => { + const convergeCodex = createManagementConvergeCodex(config()); + + const outcome = await convergeCodex(createCatalogConvergeRequest({ deadlineMs: 1_000 })); + + expect(outcome).toEqual({ + kind: "catalog-only", + changed: false, + catalogRefresh: { status: "skipped", reason: "busy", retryable: true }, + history: { + status: "not-evaluated", + attempts: 0, + nextRetryAt: null, + txId: null, + pendingRows: null, + backupEntries: null, + }, + observed: { + aggregate: "not-evaluated", + isApplied: null, + desired: "unknown", + converged: null, + authority: { service: "unknown", externalProvider: null }, + surfaces: { + config: "not-evaluated", + profile: "not-evaluated", + catalog: "not-evaluated", + cache: "not-evaluated", + journal: "not-evaluated", + history: { + state: { + status: "not-evaluated", + attempts: 0, + nextRetryAt: null, + txId: null, + pendingRows: null, + backupEntries: null, + }, + database: "not-evaluated", + manifest: "not-evaluated", + rollouts: "not-evaluated", + }, + provenance: { + state: "not-evaluated", + nativeGeneration: null, + currentTxId: null, + }, + }, + }, + }); +}); + +test("refuses a non-catalog request through the total projection", async () => { + const convergeCodex = createManagementConvergeCodex(config()); + + const outcome = await convergeCodex({ + action: "observe", + scope: "full", + reason: "cli", + mode: "explicit", + deadlineMs: 1_000, + }); + expect(outcome.kind).toBe("catalog-only"); + expect(outcome.catalogRefresh).toEqual({ + status: "failed", + reason: "disk", + phase: "gather", + retryable: false, + partialWrite: false, + }); +}); + +test("constructs the fixed catalog request and ignores caller attempts to choose direction", () => { + const malformedInput = { + action: "remove", + scope: "full", + reason: "cli", + mode: "explicit", + deadlineMs: 1_000, + } as unknown as Parameters[0]; + const request = createCatalogConvergeRequest(malformedInput); + + expect(request).toEqual({ + action: "converge", + scope: "catalog", + reason: "management-mutation", + mode: "automatic", + deadlineMs: 1_000, + }); +}); + +test("rejects malformed catalog request deadlines", () => { + for (const deadlineMs of [ + 0, + -1, + 1.5, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER + 1, + ]) { + expect(() => createCatalogConvergeRequest({ deadlineMs })).toThrow( + "Catalog convergence deadlineMs must be a positive safe integer.", + ); + } +}); + +const booleans = [false, true] as const; +const catalogRefreshVariants: readonly CatalogDisposition[] = [ + ...booleans.flatMap(changed => booleans.map(degraded => ({ + status: "committed" as const, + changed, + degraded, + notices: ["provider-auth", "provider-network", "fallback"] as const, + }))), + ...(["not-requested", "catalog-unavailable", "busy", "stale", "refused"] as const) + .flatMap(reason => booleans.map(retryable => ({ + status: "skipped" as const, + reason, + retryable, + }))), + ...(["provider-auth", "provider-network", "disk"] as const).flatMap(reason => + (["gather", "commit"] as const).flatMap(phase => + booleans.flatMap(retryable => booleans.map(partialWrite => ({ + status: "failed" as const, + reason, + phase, + retryable, + partialWrite, + }))), + ), + ), +]; + +for (const changed of booleans) { + for (const [variantIndex, catalogRefresh] of catalogRefreshVariants.entries()) { + test(`projects changed=${changed} with catalog variant ${variantIndex} exactly`, () => { + expect(projectCatalogOnlyOutcome({ changed, catalogRefresh })).toEqual({ + kind: "catalog-only", + changed, + catalogRefresh, + history: { + status: "not-evaluated", + attempts: 0, + nextRetryAt: null, + txId: null, + pendingRows: null, + backupEntries: null, + }, + observed: { + aggregate: "not-evaluated", + isApplied: null, + desired: "unknown", + converged: null, + authority: { service: "unknown", externalProvider: null }, + surfaces: { + config: "not-evaluated", + profile: "not-evaluated", + catalog: "not-evaluated", + cache: "not-evaluated", + journal: "not-evaluated", + history: { + state: { + status: "not-evaluated", + attempts: 0, + nextRetryAt: null, + txId: null, + pendingRows: null, + backupEntries: null, + }, + database: "not-evaluated", + manifest: "not-evaluated", + rollouts: "not-evaluated", + }, + provenance: { + state: "not-evaluated", + nativeGeneration: null, + currentTxId: null, + }, + }, + }, + }); + }); + } +} diff --git a/tests/codex-native-residue.test.ts b/tests/codex-native-residue.test.ts new file mode 100644 index 000000000..b086e3d5f --- /dev/null +++ b/tests/codex-native-residue.test.ts @@ -0,0 +1,846 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { createHash, randomUUID } from "node:crypto"; +import { + chmodSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; + +import { Database } from "bun:sqlite"; + +import { + buildCatalogEntries, + readCodexCatalogPath, + syncCatalogModels, +} from "../src/codex/catalog"; +import { buildProfileFile } from "../src/codex/inject"; +import { classifyNativeRoutedResidue } from "../src/codex/native-residue"; +import { readCodexTransitionState } from "../src/codex/transition-state"; +import { + resolveCodexCoordinatorDatabasePath, + resolveEffectiveUserIdentity, +} from "../src/codex/user-identity"; +import type { OcxConfig } from "../src/types"; + +let codexHome = ""; +let opencodexHome = ""; +let coordinatorPath = ""; +let previousCodexHome: string | undefined; +let previousOpencodexHome: string | undefined; + +beforeEach(() => { + previousCodexHome = process.env.CODEX_HOME; + previousOpencodexHome = process.env.OPENCODEX_HOME; + codexHome = mkdtempSync(join(tmpdir(), "ocx-native-residue-codex-")); + opencodexHome = mkdtempSync(join(tmpdir(), "ocx-native-residue-opencodex-")); + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = opencodexHome; + coordinatorPath = resolveCodexCoordinatorDatabasePath( + resolveEffectiveUserIdentity(), + realpathSync.native(codexHome), + ); +}); + +afterEach(() => { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + for (const suffix of ["", "-journal", "-wal", "-shm"]) { + rmSync(`${coordinatorPath}${suffix}`, { force: true }); + } + rmSync(codexHome, { recursive: true, force: true }); + rmSync(opencodexHome, { recursive: true, force: true }); +}); + +function pathInCodexHome(name: string): string { + return join(codexHome, name); +} + +function canonicalPathInCodexHome(name: string): string { + return join(realpathSync.native(codexHome), name); +} + +function routedCatalog(): string { + const models = buildCatalogEntries( + null, + [], + [{ provider: "fixture-provider", id: "fixture-model" }], + ); + return JSON.stringify({ models }, null, 2) + "\n"; +} + +function sessionMeta(id: string, modelProvider: string): string { + return JSON.stringify({ + timestamp: "2026-08-04T00:00:00.000Z", + type: "session_meta", + payload: { id, model_provider: modelProvider, source: "cli" }, + }); +} + +function createHistoryDatabase( + modelProvider: "openai" | "opencodex", + rolloutProviders: string[] = [modelProvider], +): void { + writeFileSync( + pathInCodexHome("rollout.jsonl"), + rolloutProviders.map(provider => sessionMeta("thread-1", provider)).join("\n") + "\n", + ); + const database = new Database(pathInCodexHome("state_5.sqlite")); + database.exec(` + CREATE TABLE threads ( + id TEXT PRIMARY KEY, + rollout_path TEXT NOT NULL, + model_provider TEXT NOT NULL, + source TEXT NOT NULL, + first_user_message TEXT NOT NULL, + has_user_event INTEGER NOT NULL DEFAULT 0 + ) + `); + database.query(` + INSERT INTO threads ( + id, rollout_path, model_provider, source, first_user_message, has_user_event + ) VALUES (?, ?, ?, 'cli', 'routed history', 1) + `).run("thread-1", pathInCodexHome("rollout.jsonl"), modelProvider); + database.close(); +} + +function historyBackupPath(): string { + const databasePath = join(realpathSync.native(codexHome), "state_5.sqlite"); + const normalized = process.platform === "win32" + ? resolve(databasePath).toLowerCase() + : resolve(databasePath); + const id = createHash("sha256").update(normalized).digest("hex").slice(0, 16); + return join(opencodexHome, `codex-history-backup-${id}.json`); +} + +const residueFixtures: Array<{ + name: string; + surface: string; + arrange: () => void; +}> = [ + { + name: "injected config.toml", + surface: "config", + arrange: () => writeFileSync(pathInCodexHome("config.toml"), [ + "# Auto-injected by opencodex", + 'openai_base_url = "http://127.0.0.1:10100/v1"', + "", + ].join("\n")), + }, + { + name: "generated profile", + surface: "profile", + arrange: () => writeFileSync( + pathInCodexHome("opencodex.config.toml"), + buildProfileFile(10100, null), + ), + }, + { + name: "routed catalog", + surface: "catalog", + arrange: () => writeFileSync(pathInCodexHome("opencodex-catalog.json"), routedCatalog()), + }, + { + name: "routed models cache", + surface: "models-cache", + arrange: () => writeFileSync(pathInCodexHome("models_cache.json"), routedCatalog()), + }, + { + name: "restore journal", + surface: "journal", + arrange: () => writeFileSync(pathInCodexHome("opencodex-journal.json"), JSON.stringify({ + version: 1, + originalConfig: Buffer.from('model = "gpt-5.5"\n').toString("base64"), + originalProfile: null, + pid: 12345, + timestamp: "2026-08-04T00:00:00.000Z", + })), + }, + { + name: "history database row", + surface: "history", + arrange: () => createHistoryDatabase("opencodex"), + }, + { + name: "history backup entry", + surface: "history-backup", + arrange: () => { + writeFileSync(pathInCodexHome("rollout.jsonl"), sessionMeta("thread-1", "openai") + "\n"); + writeFileSync(historyBackupPath(), JSON.stringify({ + version: 1, + stateDbPath: join(realpathSync.native(codexHome), "state_5.sqlite"), + entries: { + "thread-1": { + id: "thread-1", + rolloutPath: pathInCodexHome("rollout.jsonl"), + modelProvider: "openai", + source: "cli", + hasUserEvent: 1, + }, + }, + })); + }, + }, +]; + +for (const fixture of residueFixtures) { + test(`${fixture.name} is structurally provable routed residue`, () => { + fixture.arrange(); + expect(classifyNativeRoutedResidue()).toMatchObject({ + kind: "residue", + surface: fixture.surface, + }); + }); +} + +test("an OpenCodex atomic-write artifact is indeterminate", () => { + writeFileSync(pathInCodexHome("config.toml.ocx.123.1.tmp"), "partial"); + expect(classifyNativeRoutedResidue()).toMatchObject({ + kind: "indeterminate", + surface: "partial-write", + }); +}); + +test("a routed catalog at the configured nested path refuses coordinator initialization", async () => { + const catalogPath = canonicalPathInCodexHome("nested/custom-catalog.json"); + mkdirSync(pathInCodexHome("nested")); + writeFileSync(pathInCodexHome("config.toml"), 'model_catalog_json = "nested/custom-catalog.json"\n'); + writeFileSync(catalogPath, JSON.stringify({ models: [] })); + const config: OcxConfig = { + port: 10100, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-chat", + baseUrl: "https://fixture.invalid/v1", + liveModels: false, + models: ["fixture-model"], + }, + }, + }; + + const sync = await syncCatalogModels(config); + + expect(sync).toMatchObject({ path: catalogPath, catalogWritten: true }); + expect(classifyNativeRoutedResidue()).toMatchObject({ + kind: "residue", + surface: "catalog", + path: catalogPath, + }); + expect(readCodexTransitionState()).toEqual({ + kind: "legacy-ambiguous", + message: "A missing coordinator row cannot be initialized while native Codex routing residue exists.", + }); +}); + +test("a BOM-prefixed configured catalog refuses coordinator initialization", () => { + writeFileSync( + pathInCodexHome("config.toml"), + '\uFEFFmodel_catalog_json = "bom-catalog.json"\n', + ); + const productionTarget = readCodexCatalogPath(); + mkdirSync(dirname(productionTarget), { recursive: true }); + writeFileSync(productionTarget, routedCatalog()); + + expect(classifyNativeRoutedResidue()).toEqual({ + kind: "residue", + surface: "catalog", + path: productionTarget, + }); + expect(readCodexTransitionState()).toEqual({ + kind: "legacy-ambiguous", + message: "A missing coordinator row cannot be initialized while native Codex routing residue exists.", + }); +}); + +const catalogConfigShapes: Array<{ + name: string; + content: string; + strictOnlyTargets?: string[]; +}> = [ + { + name: "quoted key", + content: '"model_catalog_json" = "quoted.json"\n', + strictOnlyTargets: ["quoted.json"], + }, + { name: "single-quoted value", content: "model_catalog_json = 'single.json'\n" }, + { name: "BOM prefix", content: '\uFEFFmodel_catalog_json = "bom.json"\n' }, + { name: "CRLF", content: 'model_catalog_json = "crlf.json"\r\n' }, + { name: "leading whitespace", content: ' model_catalog_json = "ws.json"\n' }, + { name: "after table header", content: '[tools]\nmodel_catalog_json = "nested.json"\n' }, + { name: "trailing comment", content: 'model_catalog_json = "cmt.json" # comment\n' }, +]; + +for (const shape of catalogConfigShapes) { + test(`catalog candidate coverage matches production for ${shape.name}`, () => { + writeFileSync(pathInCodexHome("config.toml"), shape.content); + for (const target of shape.strictOnlyTargets ?? []) { + writeFileSync(pathInCodexHome(target), JSON.stringify({ models: [] })); + } + const productionTarget = readCodexCatalogPath(); + mkdirSync(dirname(productionTarget), { recursive: true }); + writeFileSync(productionTarget, routedCatalog()); + + expect(classifyNativeRoutedResidue()).toEqual({ + kind: "residue", + surface: "catalog", + path: productionTarget, + }); + }); +} + +const catalogPathShapes: Array<{ + name: string; + configuredPath: (outsideRoot: string, leaf: string) => string; +}> = [ + { name: "root-relative", configuredPath: (_outsideRoot, leaf) => leaf }, + { name: "nested-relative", configuredPath: (_outsideRoot, leaf) => `nested/${leaf}` }, + { + name: "absolute inside CODEX_HOME", + configuredPath: (_outsideRoot, leaf) => canonicalPathInCodexHome(leaf), + }, + { + name: "absolute outside CODEX_HOME", + configuredPath: (outsideRoot, leaf) => join(outsideRoot, leaf), + }, + { + name: "parent-escaping relative", + configuredPath: (_outsideRoot, leaf) => `../${basename(codexHome)}-${leaf}`, + }, +]; + +for (const shape of catalogPathShapes) { + test(`configured catalog classification follows the ${shape.name} path`, () => { + const outsideRoot = mkdtempSync(join(tmpdir(), "ocx-native-residue-catalog-outside-")); + const configuredPath = shape.configuredPath(outsideRoot, randomUUID()); + const targetPath = resolve(realpathSync.native(codexHome), configuredPath); + try { + mkdirSync(dirname(targetPath), { recursive: true }); + writeFileSync( + pathInCodexHome("config.toml"), + `model_catalog_json = ${JSON.stringify(configuredPath)}\n`, + ); + + expect(classifyNativeRoutedResidue(), "configured absence must fail closed").toMatchObject({ + kind: "indeterminate", + surface: "catalog", + path: targetPath, + }); + + writeFileSync(targetPath, routedCatalog()); + expect(classifyNativeRoutedResidue(), "routed target must be detected").toEqual({ + kind: "residue", + surface: "catalog", + path: targetPath, + }); + } finally { + rmSync(targetPath, { force: true }); + rmSync(outsideRoot, { recursive: true, force: true }); + } + }); +} + +const productionCatalogLeafShapes = [ + { name: "extensionless", suffix: "" }, + { name: ".txt", suffix: ".txt" }, + { name: ".json5", suffix: ".json5" }, + { name: "trailing dot", suffix: "." }, + { name: "uppercase .JSON", suffix: ".JSON" }, +] as const; + +for (const shape of productionCatalogLeafShapes) { + const configuredLeaf = `${randomUUID()}${shape.suffix}`; + test(`production writer routes the ${shape.name} configured catalog ${configuredLeaf}`, async () => { + const catalogPath = canonicalPathInCodexHome(`nested/${configuredLeaf}`); + mkdirSync(dirname(catalogPath), { recursive: true }); + writeFileSync( + pathInCodexHome("config.toml"), + `model_catalog_json = ${JSON.stringify(`nested/${configuredLeaf}`)}\n`, + ); + writeFileSync(catalogPath, JSON.stringify({ models: [] })); + const config: OcxConfig = { + port: 10100, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-chat", + baseUrl: "https://fixture.invalid/v1", + liveModels: false, + models: ["fixture-model"], + }, + }, + }; + + const sync = await syncCatalogModels(config); + const catalog = JSON.parse(readFileSync(catalogPath, "utf8")) as { + models: Array>; + }; + const routedRows = catalog.models.filter(model => + typeof model.description === "string" + && model.description.startsWith("Routed via opencodex → ") + ); + + expect(sync).toMatchObject({ path: catalogPath, catalogWritten: true }); + expect(routedRows).toHaveLength(1); + expect(classifyNativeRoutedResidue()).toEqual({ + kind: "residue", + surface: "catalog", + path: catalogPath, + }); + }); +} + +test("an atomic-write artifact beside the configured catalog is indeterminate", () => { + const catalogPath = canonicalPathInCodexHome("nested/custom-catalog.json"); + const artifactPath = `${catalogPath}.ocx.42.7.tmp`; + mkdirSync(pathInCodexHome("nested")); + writeFileSync(pathInCodexHome("config.toml"), 'model_catalog_json = "nested/custom-catalog.json"\n'); + writeFileSync(catalogPath, JSON.stringify({ models: [] })); + writeFileSync(artifactPath, "partial"); + + expect(classifyNativeRoutedResidue()).toMatchObject({ + kind: "indeterminate", + surface: "partial-write", + path: artifactPath, + }); +}); + +test("an atomic-write artifact is found before its configured target exists", () => { + const catalogPath = canonicalPathInCodexHome("nested/pending.json"); + const artifactPath = `${catalogPath}.ocx.42.7.tmp`; + mkdirSync(dirname(catalogPath), { recursive: true }); + writeFileSync(pathInCodexHome("config.toml"), 'model_catalog_json = "nested/pending.json"\n'); + writeFileSync(artifactPath, "partial"); + + expect(lstatSync(catalogPath, { throwIfNoEntry: false })).toBeUndefined(); + expect(classifyNativeRoutedResidue()).toMatchObject({ + kind: "indeterminate", + surface: "partial-write", + path: artifactPath, + }); +}); + +test("a configured catalog target that is not a readable regular file is indeterminate", () => { + const catalogPath = canonicalPathInCodexHome("nested/custom-catalog.json"); + mkdirSync(catalogPath, { recursive: true }); + writeFileSync(pathInCodexHome("config.toml"), 'model_catalog_json = "nested/custom-catalog.json"\n'); + + expect(classifyNativeRoutedResidue()).toMatchObject({ + kind: "indeterminate", + surface: "catalog", + path: catalogPath, + }); +}); + +const permissionTest = process.platform === "win32" ? test.skip : test; +permissionTest("EACCES on a configured regular catalog file is indeterminate", () => { + const catalogPath = canonicalPathInCodexHome("permission-target.json"); + writeFileSync(pathInCodexHome("config.toml"), 'model_catalog_json = "permission-target.json"\n'); + writeFileSync(catalogPath, routedCatalog()); + expect(lstatSync(catalogPath).isFile()).toBe(true); + chmodSync(catalogPath, 0o000); + try { + expect(classifyNativeRoutedResidue()).toMatchObject({ + kind: "indeterminate", + surface: "catalog", + path: catalogPath, + }); + } finally { + chmodSync(catalogPath, 0o600); + } +}); + +test("an absent configured catalog target is indeterminate", () => { + const catalogPath = canonicalPathInCodexHome("nested/missing-catalog.json"); + mkdirSync(pathInCodexHome("nested")); + writeFileSync(pathInCodexHome("config.toml"), 'model_catalog_json = "nested/missing-catalog.json"\n'); + + expect(classifyNativeRoutedResidue()).toMatchObject({ + kind: "indeterminate", + surface: "catalog", + path: catalogPath, + }); +}); + +test("the default catalog is still inspected when a custom catalog is configured", () => { + const defaultCatalogPath = canonicalPathInCodexHome("opencodex-catalog.json"); + mkdirSync(pathInCodexHome("nested")); + writeFileSync(pathInCodexHome("config.toml"), 'model_catalog_json = "nested/custom-catalog.json"\n'); + writeFileSync(pathInCodexHome("nested/custom-catalog.json"), JSON.stringify({ models: [] })); + writeFileSync(defaultCatalogPath, routedCatalog()); + + expect(classifyNativeRoutedResidue()).toMatchObject({ + kind: "residue", + surface: "catalog", + path: defaultCatalogPath, + }); +}); + +for (const location of ["inside", "outside"] as const) { + test(`the default catalog remains inspected with an absolute ${location} configured path`, () => { + const outsideRoot = mkdtempSync(join(tmpdir(), "ocx-native-residue-default-outside-")); + const configuredPath = location === "inside" + ? canonicalPathInCodexHome("absolute-custom.json") + : join(outsideRoot, "absolute-custom.json"); + const defaultCatalogPath = canonicalPathInCodexHome("opencodex-catalog.json"); + try { + writeFileSync( + pathInCodexHome("config.toml"), + `model_catalog_json = ${JSON.stringify(configuredPath)}\n`, + ); + writeFileSync(configuredPath, JSON.stringify({ models: [] })); + writeFileSync(defaultCatalogPath, routedCatalog()); + + expect(classifyNativeRoutedResidue()).toMatchObject({ + kind: "residue", + surface: "catalog", + path: defaultCatalogPath, + }); + } finally { + rmSync(configuredPath, { force: true }); + rmSync(outsideRoot, { recursive: true, force: true }); + } + }); +} + +test("every non-string TOML type for model_catalog_json is indeterminate", () => { + const nonStringTomlValues = [ + ["number", "42"], + ["boolean", "true"], + ["array", '["custom.json"]'], + ["inline table", '{ path = "custom.json" }'], + ["datetime", "1979-05-27T07:32:00Z"], + ] as const; + + for (const [type, value] of nonStringTomlValues) { + writeFileSync(pathInCodexHome("config.toml"), `model_catalog_json = ${value}\n`); + expect(classifyNativeRoutedResidue(), type).toMatchObject({ + kind: "indeterminate", + surface: "config", + path: canonicalPathInCodexHome("config.toml"), + }); + } +}); + +test("duplicate configured catalog paths are indeterminate", () => { + writeFileSync(pathInCodexHome("config.toml"), [ + 'model_catalog_json = "first.json"', + 'model_catalog_json = "second.json"', + "", + ].join("\n")); + + expect(classifyNativeRoutedResidue()).toMatchObject({ + kind: "indeterminate", + surface: "config", + path: canonicalPathInCodexHome("config.toml"), + }); +}); + +const arbitraryComboAlias = randomUUID(); + +test(`production-generated arbitrary bare combo alias ${arbitraryComboAlias} is routed residue`, async () => { + const catalogPath = canonicalPathInCodexHome("opencodex-catalog.json"); + writeFileSync(catalogPath, JSON.stringify({ models: [] })); + const config: OcxConfig = { + port: 10100, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-chat", + baseUrl: "https://fixture.invalid/v1", + liveModels: false, + models: ["combo-member"], + modelContextWindows: { "combo-member": 128_000 }, + }, + }, + disabledModels: ["fixture/combo-member"], + combos: { + edge: { + alias: arbitraryComboAlias, + targets: [{ provider: "fixture", model: "combo-member" }], + }, + }, + }; + + const sync = await syncCatalogModels(config); + const catalog = JSON.parse(readFileSync(catalogPath, "utf8")) as { + models: Array>; + }; + const routedRows = catalog.models.filter(model => + typeof model.description === "string" + && model.description.startsWith("Routed via opencodex → ") + ); + + expect(sync).toMatchObject({ path: catalogPath, catalogWritten: true }); + expect(arbitraryComboAlias).not.toContain("/"); + expect(routedRows).toEqual([ + expect.objectContaining({ + slug: arbitraryComboAlias, + description: "Routed via opencodex → combo (combo).", + owned_by: "combo", + }), + ]); + expect(classifyNativeRoutedResidue()).toMatchObject({ + kind: "residue", + surface: "catalog", + }); + expect(readCodexTransitionState()).toEqual({ + kind: "legacy-ambiguous", + message: "A missing coordinator row cannot be initialized while native Codex routing residue exists.", + }); +}); + +const arbitraryForeignSlug = `${randomUUID()}/${randomUUID()}`; +const arbitraryForeignDescription = randomUUID(); + +test(`arbitrary foreign row ${arbitraryForeignSlug} described as ${arbitraryForeignDescription} is indeterminate`, () => { + writeFileSync(pathInCodexHome("opencodex-catalog.json"), JSON.stringify({ + models: [{ slug: arbitraryForeignSlug, description: arbitraryForeignDescription }], + })); + + expect(classifyNativeRoutedResidue()).toMatchObject({ + kind: "indeterminate", + surface: "catalog", + }); +}); + +test("a native-tagged history row with routed latest rollout metadata refuses coordinator initialization", () => { + createHistoryDatabase("openai", ["openai", "opencodex"]); + + expect(classifyNativeRoutedResidue()).toMatchObject({ + kind: "residue", + surface: "history", + path: pathInCodexHome("rollout.jsonl"), + }); + expect(readCodexTransitionState()).toEqual({ + kind: "legacy-ambiguous", + message: "A missing coordinator row cannot be initialized while native Codex routing residue exists.", + }); +}); + +test("routed first rollout metadata is residue even when the latest metadata is native", () => { + createHistoryDatabase("openai", ["opencodex", "openai"]); + + expect(classifyNativeRoutedResidue()).toMatchObject({ + kind: "residue", + surface: "history", + path: pathInCodexHome("rollout.jsonl"), + }); +}); + +test("a referenced rollout with native first and latest metadata is clean", () => { + createHistoryDatabase("openai", ["openai", "openai"]); + + expect(classifyNativeRoutedResidue()).toEqual({ kind: "clean" }); +}); + +for (const fixture of [ + { + name: "missing", + arrange: () => { + createHistoryDatabase("openai"); + rmSync(pathInCodexHome("rollout.jsonl")); + }, + }, + { + name: "malformed", + arrange: () => { + createHistoryDatabase("openai"); + writeFileSync(pathInCodexHome("rollout.jsonl"), "{not-json\n"); + }, + }, + { + name: "non-file", + arrange: () => { + createHistoryDatabase("openai"); + rmSync(pathInCodexHome("rollout.jsonl")); + mkdirSync(pathInCodexHome("rollout.jsonl")); + }, + }, +]) { + test(`a ${fixture.name} rollout referenced by a live history row is indeterminate`, () => { + fixture.arrange(); + + expect(classifyNativeRoutedResidue()).toMatchObject({ + kind: "indeterminate", + surface: "history", + path: pathInCodexHome("rollout.jsonl"), + }); + expect(readCodexTransitionState()).toEqual({ + kind: "legacy-ambiguous", + message: "A missing coordinator row cannot be initialized while native Codex routing residue exists.", + }); + }); +} + +test("a manifest-referenced routed rollout is residue", () => { + writeFileSync(pathInCodexHome("rollout.jsonl"), sessionMeta("thread-1", "opencodex") + "\n"); + writeFileSync(historyBackupPath(), JSON.stringify({ + version: 1, + stateDbPath: join(realpathSync.native(codexHome), "state_5.sqlite"), + entries: { + "thread-1": { + id: "thread-1", + rolloutPath: pathInCodexHome("rollout.jsonl"), + modelProvider: "openai", + source: "cli", + hasUserEvent: 1, + }, + }, + })); + + expect(classifyNativeRoutedResidue()).toMatchObject({ + kind: "residue", + surface: "history-backup", + path: pathInCodexHome("rollout.jsonl"), + }); +}); + +test("a missing manifest-referenced rollout is indeterminate", () => { + writeFileSync(historyBackupPath(), JSON.stringify({ + version: 1, + stateDbPath: join(realpathSync.native(codexHome), "state_5.sqlite"), + entries: { + "thread-1": { + id: "thread-1", + rolloutPath: pathInCodexHome("missing-rollout.jsonl"), + modelProvider: "openai", + source: "cli", + hasUserEvent: 1, + }, + }, + })); + + expect(classifyNativeRoutedResidue()).toMatchObject({ + kind: "indeterminate", + surface: "history-backup", + path: pathInCodexHome("missing-rollout.jsonl"), + }); +}); + +const indeterminateFixtures: Array<{ + name: string; + surface: string; + arrange: () => void; +}> = [ + { + name: "malformed config TOML", + surface: "config", + arrange: () => writeFileSync(pathInCodexHome("config.toml"), 'model = "unterminated\n'), + }, + { + name: "malformed profile TOML", + surface: "profile", + arrange: () => writeFileSync(pathInCodexHome("opencodex.config.toml"), "[features\n"), + }, + { + name: "malformed catalog JSON", + surface: "catalog", + arrange: () => writeFileSync(pathInCodexHome("opencodex-catalog.json"), "{not-json"), + }, + { + name: "unreadable models cache shape", + surface: "models-cache", + arrange: () => mkdirSync(pathInCodexHome("models_cache.json")), + }, + { + name: "malformed journal JSON", + surface: "journal", + arrange: () => writeFileSync(pathInCodexHome("opencodex-journal.json"), "{not-json"), + }, + { + name: "partial write", + surface: "partial-write", + arrange: () => writeFileSync(pathInCodexHome("opencodex-catalog.json.ocx.42.7.tmp"), ""), + }, + { + name: "malformed history database", + surface: "history", + arrange: () => writeFileSync(pathInCodexHome("state_5.sqlite"), "not sqlite"), + }, + { + name: "malformed history backup", + surface: "history-backup", + arrange: () => writeFileSync(historyBackupPath(), "{not-json"), + }, +]; + +for (const fixture of indeterminateFixtures) { + test(`${fixture.name} is indeterminate and refuses coordinator initialization`, () => { + fixture.arrange(); + expect(classifyNativeRoutedResidue()).toMatchObject({ + kind: "indeterminate", + surface: fixture.surface, + }); + expect(readCodexTransitionState()).toEqual({ + kind: "legacy-ambiguous", + message: "A missing coordinator row cannot be initialized while native Codex routing residue exists.", + }); + }); +} + +const symlinkTest = process.platform === "win32" ? test.skip : test; +symlinkTest("an unresolvable surface symlink is indeterminate", () => { + symlinkSync(pathInCodexHome("missing-config"), pathInCodexHome("config.toml")); + expect(classifyNativeRoutedResidue()).toMatchObject({ + kind: "indeterminate", + surface: "config", + }); +}); + +test("an empty CODEX_HOME is clean and coordinator initialization succeeds", () => { + expect(classifyNativeRoutedResidue()).toEqual({ kind: "clean" }); + expect(readCodexTransitionState()).toMatchObject({ + kind: "ready", + state: { nativeGeneration: 0, currentTxId: null }, + }); +}); + +test("user-owned non-OpenCodex content is clean and coordinator initialization succeeds", () => { + writeFileSync(pathInCodexHome("config.toml"), 'model = "gpt-5.5"\n'); + writeFileSync(pathInCodexHome("notes.txt"), "user content\n"); + writeFileSync(pathInCodexHome("opencodex-catalog.json"), JSON.stringify({ + models: [{ slug: "gpt-5.5", description: "Native GPT model" }], + })); + writeFileSync(pathInCodexHome("models_cache.json"), JSON.stringify({ + models: [{ slug: "gpt-5.5", description: "Native GPT model" }], + })); + createHistoryDatabase("openai"); + + expect(classifyNativeRoutedResidue()).toEqual({ kind: "clean" }); + expect(readCodexTransitionState()).toMatchObject({ + kind: "ready", + state: { nativeGeneration: 0, currentTxId: null }, + }); +}); + +test("CODEX_HOME is resolved at call time", () => { + const secondHome = mkdtempSync(join(tmpdir(), "ocx-native-residue-second-codex-")); + try { + writeFileSync(join(secondHome, "opencodex.config.toml"), buildProfileFile(10100, null)); + expect(classifyNativeRoutedResidue()).toEqual({ kind: "clean" }); + process.env.CODEX_HOME = secondHome; + expect(classifyNativeRoutedResidue()).toMatchObject({ kind: "residue", surface: "profile" }); + } finally { + process.env.CODEX_HOME = codexHome; + rmSync(secondHome, { recursive: true, force: true }); + } +}); + +test("a missing coordinator with only the generated profile refuses initialization", () => { + writeFileSync(join(codexHome, "opencodex.config.toml"), buildProfileFile(10100, null)); + + expect(readCodexTransitionState()).toEqual({ + kind: "legacy-ambiguous", + message: "A missing coordinator row cannot be initialized while native Codex routing residue exists.", + }); +}); diff --git a/tests/codex-retained-root-serialization.test.ts b/tests/codex-retained-root-serialization.test.ts new file mode 100644 index 000000000..36c7fdb85 --- /dev/null +++ b/tests/codex-retained-root-serialization.test.ts @@ -0,0 +1,526 @@ +import { afterEach, expect, test } from "bun:test"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { + resolveCodexCatalogSerializationDatabasePath, + resolveEffectiveUserIdentity, +} from "../src/codex/user-identity"; + +const repoRoot = resolve(import.meta.dir, ".."); +const sandboxes: Sandbox[] = []; + +interface Sandbox { + readonly root: string; + readonly codexHome: string; + readonly opencodexHome: string; + readonly env: Record; +} + +function nativeEntry(slug: string, visibility = "list"): Record { + return { + slug, + display_name: slug, + description: "native", + priority: 9, + visibility, + supported_in_api: true, + shell_type: "shell_command", + base_instructions: "You are Codex, a coding agent based on GPT-5.", + supported_reasoning_levels: [{ effort: "medium", description: "medium" }], + }; +} + +function catalogBytes(visibility = "list", routed = false): string { + return `${JSON.stringify({ + retained_marker: visibility, + models: [ + nativeEntry("gpt-5.5", visibility), + ...(routed ? [{ + ...nativeEntry("vendor/old-model"), + description: "Routed via opencodex → vendor.", + }] : []), + ], + }, null, 2)}\n`; +} + +function makeSandbox(prefix: string): Sandbox { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), prefix))); + const codexHome = join(root, "codex-home"); + const opencodexHome = join(root, "opencodex-home"); + const home = join(root, "user-home"); + const runtime = join(root, "runtime"); + for (const path of [codexHome, opencodexHome, home, runtime]) { + mkdirSync(path, { recursive: true }); + chmodSync(path, 0o700); + } + const sandbox = { + root, + codexHome, + opencodexHome, + env: { + ...Object.fromEntries(Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined)), + CODEX_HOME: codexHome, + OPENCODEX_HOME: opencodexHome, + HOME: home, + USERPROFILE: home, + TMPDIR: runtime, + TEMP: runtime, + TMP: runtime, + XDG_RUNTIME_DIR: runtime, + LOCALAPPDATA: join(home, "LocalAppData"), + }, + }; + sandboxes.push(sandbox); + return sandbox; +} + +async function waitForPath(path: string, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!existsSync(path)) { + if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${path}`); + await Bun.sleep(5); + } +} + +async function runChild( + sandbox: Sandbox, + script: string, +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + const child = Bun.spawn([process.execPath, "--eval", script], { + cwd: repoRoot, + env: sandbox.env, + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + return { exitCode, stdout, stderr }; +} + +async function holdCatalogLock(sandbox: Sandbox): Promise<{ + release(): void; + child: ReturnType; +}> { + const ready = join(sandbox.root, "lock-ready"); + const release = join(sandbox.root, "lock-release"); + const script = ` + import { existsSync, writeFileSync } from "node:fs"; + import { withCatalogWriteSerialization } from "./src/codex/catalog-write-serialization.ts"; + const home = process.env.CODEX_HOME; + const outcome = withCatalogWriteSerialization(home, () => { + writeFileSync(${JSON.stringify(ready)}, "ready"); + const waiter = new Int32Array(new SharedArrayBuffer(4)); + while (!existsSync(${JSON.stringify(release)})) Atomics.wait(waiter, 0, 0, 10); + }); + if (outcome.kind !== "completed") throw new Error(JSON.stringify(outcome)); + `; + const child = Bun.spawn([process.execPath, "--eval", script], { + cwd: repoRoot, + env: sandbox.env, + stdout: "pipe", + stderr: "pipe", + }); + await waitForPath(ready); + return { release: () => writeFileSync(release, "release"), child }; +} + +function seedCatalog(sandbox: Sandbox, bytes = catalogBytes()): string { + const path = join(sandbox.codexHome, "catalog.json"); + writeFileSync(join(sandbox.codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n'); + writeFileSync(path, bytes); + return path; +} + +afterEach(() => { + const identity = resolveEffectiveUserIdentity(); + for (const sandbox of sandboxes.splice(0)) { + const database = resolveCodexCatalogSerializationDatabasePath(identity, sandbox.codexHome); + for (const suffix of ["", "-journal", "-wal", "-shm"]) rmSync(`${database}${suffix}`, { force: true }); + rmSync(sandbox.root, { recursive: true, force: true }); + } +}); + +test("startup and CLI sync-cache cannot write models_cache while another process owns K", async () => { + const sandbox = makeSandbox("ocx-retained-cache-"); + seedCatalog(sandbox); + const cachePath = join(sandbox.codexHome, "models_cache.json"); + const holder = await holdCatalogLock(sandbox); + try { + const startupProbe = await runChild(sandbox, ` + const sentinel = new Error("TEST_LISTENER_INTERCEPTED"); + Bun.serve = () => { throw sentinel; }; + const { startServer } = await import("./src/server/index.ts"); + try { + startServer(0); + throw new Error("startServer unexpectedly reached a listener"); + } catch (error) { + if (error !== sentinel) throw error; + } + `); + expect(startupProbe.exitCode).toBe(0); + expect(existsSync(cachePath)).toBe(false); + + const cli = Bun.spawnSync([process.execPath, "run", "src/cli/index.ts", "sync-cache"], { + cwd: repoRoot, + env: sandbox.env, + stdout: "pipe", + stderr: "pipe", + }); + expect(cli.exitCode).toBe(0); + expect(existsSync(cachePath)).toBe(false); + const cliSource = readFileSync(join(repoRoot, "src/cli/index.ts"), "utf8"); + const cliStart = cliSource.indexOf('case "sync-cache"'); + const cliRoot = cliSource.slice(cliStart, cliSource.indexOf('case "gui"', cliStart)); + expect(cliRoot).toContain("withCatalogWriteSerialization(owningCodexHome"); + expect(cliRoot).toContain("invalidateCodexModelsCacheWithPermit"); + + const startup = readFileSync(join(repoRoot, "src/server/index.ts"), "utf8"); + const startupStart = startup.indexOf("const startupCodexHome"); + const startupRoot = startup.slice(startupStart, startup.indexOf("armClaudeCodeBaseline", startupStart)); + expect(startupRoot).toContain("withCatalogWriteSerialization(startupCodexHome"); + expect(startupRoot).toContain("invalidateCodexModelsCacheWithPermit"); + } finally { + holder.release(); + expect(await holder.child.exited).toBe(0); + } +}); + +test("native restore cannot read-transform-write the catalog while another process owns K", async () => { + const sandbox = makeSandbox("ocx-retained-restore-"); + const catalogPath = seedCatalog(sandbox, catalogBytes("list", true)); + writeFileSync(join(sandbox.opencodexHome, "catalog-backup.json"), catalogBytes("list", false)); + const before = readFileSync(catalogPath, "utf8"); + const holder = await holdCatalogLock(sandbox); + try { + const restored = await runChild(sandbox, ` + const { restoreNativeCodex } = await import("./src/codex/inject.ts"); + console.log(JSON.stringify(restoreNativeCodex())); + `); + expect(restored.exitCode).toBe(0); + expect(readFileSync(catalogPath, "utf8")).toBe(before); + const source = readFileSync(join(repoRoot, "src/codex/inject.ts"), "utf8"); + const restoreRoot = source.slice(source.indexOf("const owningCodexHome"), source.indexOf("// Design B", source.indexOf("const owningCodexHome"))); + expect(restoreRoot).toContain("withCatalogWriteSerialization(owningCodexHome"); + expect(restoreRoot).toContain("restoreCodexCatalogWithPermit"); + } finally { + holder.release(); + expect(await holder.child.exited).toBe(0); + } +}); + +async function runPublisher( + sandbox: Sandbox, + kind: "convergence" | "retained", +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + const config = { port: 10100, defaultProvider: "openai", providers: {}, disabledModels: ["gpt-5.5"] }; + if (kind === "retained") { + return runChild(sandbox, ` + const { handleManagementAPI } = await import("./src/server/management-api.ts"); + const config = ${JSON.stringify(config)}; + const req = new Request("http://localhost/api/sync", { method: "POST", headers: { Host: "localhost" } }); + const response = await handleManagementAPI(req, new URL(req.url), config); + console.log(JSON.stringify({ status: response.status, body: await response.json() })); + `); + } + return runChild(sandbox, ` + const { withConfigMutationLockSync } = await import("./src/config.ts"); + const { captureCatalogAdmissionSnapshot, createCatalogConvergeRequest } = await import("./src/codex/catalog-admission.ts"); + const { convergeCodexCatalog } = await import("./src/codex/convergence.ts"); + const config = ${JSON.stringify(config)}; + withConfigMutationLockSync(() => undefined); + const snapshot = captureCatalogAdmissionSnapshot(config); + const result = await convergeCodexCatalog(snapshot, createCatalogConvergeRequest({ deadlineMs: 2000 })); + console.log(JSON.stringify(result)); + `); +} + +for (const publisher of ["convergence", "retained"] as const) { + test(`POST /api/sync gathered first and acquired K second does not clobber a newer ${publisher} catalog`, async () => { + const sandbox = makeSandbox(`ocx-retained-race-${publisher}-`); + const catalogPath = seedCatalog(sandbox); + const initial = readFileSync(catalogPath, "utf8"); + const requested = join(sandbox.root, "provider-requested"); + const release = join(sandbox.root, "provider-release"); + const config = { + port: 10100, + defaultProvider: "together", + providers: { + together: { + adapter: "openai-chat", + baseUrl: "https://api.together.xyz/v1", + apiKey: "race-key", + models: ["fallback-model"], + }, + }, + }; + const sync = Bun.spawn([process.execPath, "--eval", ` + import { existsSync, writeFileSync } from "node:fs"; + const config = ${JSON.stringify(config)}; + config.providers.together.fetch = async () => { + writeFileSync(${JSON.stringify(requested)}, "requested"); + while (!existsSync(${JSON.stringify(release)})) await Bun.sleep(5); + return Response.json({ data: [{ id: "race-model" }] }); + }; + const { handleManagementAPI } = await import("./src/server/management-api.ts"); + const req = new Request("http://localhost/api/sync", { method: "POST", headers: { Host: "localhost" } }); + const response = await handleManagementAPI(req, new URL(req.url), config); + console.log(JSON.stringify({ status: response.status, body: await response.json() })); + `], { cwd: repoRoot, env: sandbox.env, stdout: "pipe", stderr: "pipe" }); + + await Promise.race([ + waitForPath(requested), + sync.exited.then(async exitCode => { + const stdout = await new Response(sync.stdout).text(); + const stderr = await new Response(sync.stderr).text(); + throw new Error(`sync exited before provider barrier (${exitCode})\nstdout=${stdout}\nstderr=${stderr}`); + }), + ]); + const published = await runPublisher(sandbox, publisher); + if (published.exitCode !== 0) { + throw new Error(`${publisher} publisher failed\nstdout=${published.stdout}\nstderr=${published.stderr}`); + } + const newer = readFileSync(catalogPath, "utf8"); + expect(newer).not.toBe(initial); + + writeFileSync(release, "release"); + const [exitCode, stdout, stderr] = await Promise.all([ + sync.exited, + new Response(sync.stdout).text(), + new Response(sync.stderr).text(), + ]); + expect({ exitCode, stdout, stderr }).toMatchObject({ exitCode: 0 }); + expect(readFileSync(catalogPath, "utf8")).toBe(newer); + }, 20_000); +} + +/** + * Runtime authority can move without touching the catalog at all. + * + * The verifier's R1→R2 case: a retained sync prepares from one Codex runtime, + * and while it is awaiting its provider another process rewrites the persisted + * runtime selection. Every catalog byte is untouched, so a freshness check built + * only from catalog/backup/cache bytes sees nothing and commits a candidate that + * was derived under a runtime that is no longer selected. + * + * `codex-runtime.json` is therefore part of the pre-await filesystem evidence, + * PRESENT or ABSENT. Removing it from `retainedCatalogSyncEvidence` turns this + * test red while every other retained-root test stays green — which is exactly + * why it exists: nothing else in the suite covered that component. + */ +test("a persisted runtime selection moved by another process during the await blocks the write", async () => { + const sandbox = makeSandbox("ocx-retained-runtime-move-"); + const catalogPath = seedCatalog(sandbox); + const initial = readFileSync(catalogPath, "utf8"); + const requested = join(sandbox.root, "provider-requested"); + const release = join(sandbox.root, "provider-release"); + const runtimeStatePath = join(sandbox.opencodexHome, "codex-runtime.json"); + writeFileSync(runtimeStatePath, `${JSON.stringify({ + version: 1, + command: "/usr/local/bin/codex-r1", + source: "configured", + selectedVersion: "1.0.0", + updatedAt: new Date(0).toISOString(), + }, null, 2)}\n`); + + const config = { + port: 10100, + defaultProvider: "together", + providers: { + together: { + adapter: "openai-chat", + baseUrl: "https://api.together.xyz/v1", + apiKey: "runtime-move-key", + models: ["fallback-model"], + }, + }, + }; + + const sync = Bun.spawn([process.execPath, "--eval", ` + import { existsSync, writeFileSync } from "node:fs"; + const config = ${JSON.stringify(config)}; + config.providers.together.fetch = async () => { + writeFileSync(${JSON.stringify(requested)}, "requested"); + while (!existsSync(${JSON.stringify(release)})) await Bun.sleep(5); + return Response.json({ data: [{ id: "runtime-move-model" }] }); + }; + const { syncCatalogModels } = await import("./src/codex/catalog/sync.ts"); + console.log(JSON.stringify(await syncCatalogModels(config))); + `], { cwd: repoRoot, env: sandbox.env, stdout: "pipe", stderr: "pipe" }); + + await Promise.race([ + waitForPath(requested), + sync.exited.then(async exitCode => { + const stdout = await new Response(sync.stdout).text(); + const stderr = await new Response(sync.stderr).text(); + throw new Error(`sync exited before provider barrier (${exitCode})\nstdout=${stdout}\nstderr=${stderr}`); + }), + ]); + + // Another process selects a different Codex runtime. No catalog byte changes. + writeFileSync(runtimeStatePath, `${JSON.stringify({ + version: 1, + command: "/usr/local/bin/codex-r2", + source: "configured", + selectedVersion: "2.0.0", + updatedAt: new Date(1).toISOString(), + }, null, 2)}\n`); + + writeFileSync(release, "release"); + const [exitCode, stdout, stderr] = await Promise.all([ + sync.exited, + new Response(sync.stdout).text(), + new Response(sync.stderr).text(), + ]); + expect({ exitCode, stderr }).toMatchObject({ exitCode: 0 }); + expect(JSON.parse(stdout.trim())).toMatchObject({ catalogWritten: false }); + expect(readFileSync(catalogPath, "utf8")).toBe(initial); +}, 20_000); + +/** + * The post-approval seam, raced by two real processes through a real route. + * + * Every case above drives `/api/sync`, which is a retained root. This one drives + * `PATCH /api/providers` — one of the sixteen management mutations that used to + * reach a catalog write through `refreshCodexCatalogBestEffort`, whose entire + * error policy was `catch {}`. The interesting window is AFTER the route has + * already persisted its own mutation and approved the refresh: two processes + * arriving there together must serialize, and neither may report `committed` + * for bytes the other replaced. + * + * Both processes go through `handleManagementAPI`, so this exercises the bound + * factory, the total adapter, and K in the shape production actually uses. + * + * The edit itself is a `note` update: a recognized field that persists a real + * config mutation without changing routing, so what is under test is the refresh + * that follows approval rather than the edit. + */ +test("two processes at the post-approval management seam serialize instead of interleaving", async () => { + const sandbox = makeSandbox("ocx-post-approval-race-"); + const catalogPath = seedCatalog(sandbox); + const seeded = readFileSync(catalogPath, "utf8"); + const barrier = join(sandbox.root, "seam-barrier"); + + // Warm the config ownership + mutation database in a single process first. + // Two cold processes otherwise race to create `.opencodex-owner.json` and both + // die with EEXIST before approval, which would make this test vacuous. + const warm = Bun.spawn([process.execPath, "--eval", ` + const { withConfigMutationLockSync } = await import("./src/config.ts"); + withConfigMutationLockSync(() => undefined); + `], { cwd: repoRoot, env: sandbox.env, stdout: "pipe", stderr: "pipe" }); + expect(await warm.exited).toBe(0); + + const routeScript = (marker: string) => ` + import { existsSync, writeFileSync } from "node:fs"; + // The stub lives on globalThis, NOT on the provider row. Catalog admission + // encodes the config to derive its identity and refuses a function member, so + // a per-provider \`fetch\` makes the seam throw before it can converge — which + // looked exactly like a production defect until the encoder said so. + globalThis.fetch = async () => { + writeFileSync(${JSON.stringify(barrier)} + "-" + ${JSON.stringify(marker)}, "here"); + const deadline = Date.now() + 8000; + while (Date.now() < deadline) { + if (existsSync(${JSON.stringify(barrier)} + "-a") && existsSync(${JSON.stringify(barrier)} + "-b")) break; + await Bun.sleep(5); + } + return Response.json({ data: [{ id: "seam-model-" + ${JSON.stringify(marker)} }] }); + }; + const config = { + port: 10100, + defaultProvider: "together", + providers: { + together: { + adapter: "openai-chat", + baseUrl: "https://api.together.xyz/v1", + apiKey: "seam-key", + models: ["fallback-model"], + }, + }, + }; + const { handleManagementAPI } = await import("./src/server/management-api.ts"); + const url = new URL("http://localhost/api/providers?name=together"); + const req = new Request(url, { + method: "PATCH", + headers: { Host: "localhost", "content-type": "application/json" }, + body: JSON.stringify({ note: "seam-" + ${JSON.stringify(marker)} }), + }); + const response = await handleManagementAPI(req, url, config); + const body = await response.json(); + console.log(JSON.stringify({ status: response.status, catalogRefresh: body.catalogRefresh })); + `; + + const children = (["a", "b"] as const).map(marker => Bun.spawn( + [process.execPath, "--eval", routeScript(marker)], + { cwd: repoRoot, env: sandbox.env, stdout: "pipe", stderr: "pipe" }, + )); + + const results = await Promise.all(children.map(async child => { + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + return { exitCode, stdout, stderr }; + })); + + for (const result of results) { + // A process can lose a race BEFORE approval and never reach the seam at all. + // Both known cases come from `saveConfigPreservingClaudeCode`: the config + // mutation lock is already held, or two cold processes create the ownership + // file at once. Neither says anything about catalog convergence, so they are + // excluded here — but only these two, so a genuine seam failure still fails. + if (result.exitCode !== 0) { + const preApproval = result.stderr.includes("CONFIG_MUTATION_LOCK_UNAVAILABLE") + || (result.stderr.includes("EEXIST") && result.stderr.includes("createOwnership")); + expect({ preApproval, stderr: result.stderr }).toMatchObject({ preApproval: true }); + continue; + } + const parsed = JSON.parse(result.stdout.trim()) as { + status: number; + catalogRefresh: { status: string }; + }; + // The route persisted its mutation, so it must answer 2xx no matter what the + // catalog attempt decided. A throw here would be the old `catch {}` failure + // inverted: a persisted change reported as a 500. + expect(parsed.status).toBeGreaterThanOrEqual(200); + expect(parsed.status).toBeLessThan(300); + // Whatever happened, it is REPORTED — never swallowed into silence. + expect(["committed", "skipped", "failed"]).toContain(parsed.catalogRefresh.status); + } + + // At least one process must have gotten through to the seam, or this test would + // be vacuous — two config-lock losers prove nothing about catalog serialization. + expect(results.some(r => r.exitCode === 0)).toBe(true); + + // At least one process must reach a real commit, or the race proves nothing: + // the adapter is total, so a seam that only ever failed would still answer 2xx + // with a typed disposition and satisfy every assertion above. + const dispositions = results + .filter(r => r.exitCode === 0) + .map(r => (JSON.parse(r.stdout.trim()) as { catalogRefresh: { status: string } }).catalogRefresh.status); + expect(dispositions).toContain("committed"); + + // A commit means the catalog really moved. + expect(readFileSync(catalogPath, "utf8")).not.toBe(seeded); + + // The surviving catalog is one process's complete output, never a blend of both. + const finalBytes = readFileSync(catalogPath, "utf8"); + const parsedCatalog = JSON.parse(finalBytes) as { models: Array<{ slug?: unknown }> }; + const slugs = parsedCatalog.models.flatMap(m => typeof m.slug === "string" ? [m.slug] : []); + const fromA = slugs.some(s => s.includes("seam-model-a")); + const fromB = slugs.some(s => s.includes("seam-model-b")); + expect(fromA && fromB).toBe(false); +}, 30_000); diff --git a/tests/codex-runtime.test.ts b/tests/codex-runtime.test.ts index bf48f1977..4c37be2d8 100644 --- a/tests/codex-runtime.test.ts +++ b/tests/codex-runtime.test.ts @@ -1,26 +1,59 @@ import { describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { + clearCodexRuntimeResolveCache, compareCodexVersions, displayCodexRuntimePath, effortClampAppliesToRuntime, loadLastEffortClamp, loadPersistedCodexRuntime, parseCodexVersionOutput, + peekCodexRuntimeProcessCache, persistCodexRuntime, persistEffortClamp, resolveAndPersistCodexRuntime, resolveCodexRuntime, resetCodexRuntimeResolveCacheForTests, + setCodexRuntimeResolveCacheForTests, type RuntimeExecFile, } from "../src/codex/runtime"; +import { + bundledCatalogCacheState, + invalidateBundledCatalogCache, + peekCodexRuntimeForCatalogGather, + resetBundledCatalogCacheForTests, + resolveCatalogSourceForGather, + setBundledCatalogCacheForTests, + type CatalogGatherEvidenceSession, +} from "../src/codex/catalog/bundled"; function tempConfigDir(): string { return mkdtempSync(join(tmpdir(), "ocx-runtime-")); } +function persistedRuntimeBytes( + command: string, + version: string | null = "0.145.0", +): Uint8Array { + return Buffer.from(JSON.stringify({ + version: 1, + command, + source: "environment", + selectedVersion: version, + updatedAt: "2026-08-04T00:00:00.000Z", + })); +} + +function gatherEvidence( + sources: Partial[0], Uint8Array>> = {}, +): CatalogGatherEvidenceSession { + return { + readSource: role => sources[role] ?? null, + }; +} + describe("parseCodexVersionOutput / compareCodexVersions", () => { test("parses dotted and prerelease versions", () => { expect(parseCodexVersionOutput("codex-cli 0.133.0")).toBe("0.133.0"); @@ -36,6 +69,216 @@ describe("parseCodexVersionOutput / compareCodexVersions", () => { }); }); +describe("observe-only Codex catalog gather caches", () => { + test("cold gather returns typed misses without spawning or probing an executable", () => { + const home = tempConfigDir(); + const launcher = process.platform === "win32" + ? join(home, "codex.cmd") + : join(home, "codex"); + const spawnLog = join(home, "spawn.log"); + if (process.platform === "win32") { + writeFileSync(launcher, [ + "@echo off", + `echo spawn>>"${spawnLog}"`, + "echo codex-cli 0.145.0", + "", + ].join("\r\n")); + } else { + writeFileSync(launcher, [ + "#!/bin/sh", + `printf '%s\\n' spawn >> '${spawnLog}'`, + "printf '%s\\n' 'codex-cli 0.145.0'", + "", + ].join("\n")); + chmodSync(launcher, 0o755); + } + + const previousHome = process.env.OPENCODEX_HOME; + const previousCli = process.env.CODEX_CLI_PATH; + const previousPath = process.env.PATH; + process.env.OPENCODEX_HOME = home; + process.env.CODEX_CLI_PATH = launcher; + process.env.PATH = ""; + resetCodexRuntimeResolveCacheForTests(); + resetBundledCatalogCacheForTests(); + + try { + const evidence = gatherEvidence(); + expect(peekCodexRuntimeForCatalogGather(evidence)).toEqual({ + kind: "runtime-unavailable", + processLocal: { state: "unused" }, + }); + expect(resolveCatalogSourceForGather(evidence)).toEqual({ + kind: "catalog-unavailable", + processLocal: { + runtime: { state: "unused" }, + bundledCatalog: { state: "unused" }, + }, + }); + expect(existsSync(spawnLog) ? readFileSync(spawnLog, "utf8").trim().split("\n").length : 0).toBe(0); + } finally { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousCli === undefined) delete process.env.CODEX_CLI_PATH; + else process.env.CODEX_CLI_PATH = previousCli; + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + resetCodexRuntimeResolveCacheForTests(); + resetBundledCatalogCacheForTests(); + } + }); + + test("runtime cache reads are detached and recursively frozen", () => { + const deps = { env: { PATH: "" }, discoverAlternatives: false }; + resetCodexRuntimeResolveCacheForTests(); + setCodexRuntimeResolveCacheForTests({ + runtime: { command: "codex", version: null, source: "fallback" }, + failures: [{ command: "missing", source: "path", reason: "not found" }], + }, deps); + + const first = resolveCodexRuntime(deps); + const second = resolveCodexRuntime(deps); + expect(first).not.toBe(second); + expect(Object.isFrozen(first)).toBe(true); + expect(Object.isFrozen(first.runtime)).toBe(true); + expect(Object.isFrozen(first.failures)).toBe(true); + expect(Object.isFrozen(first.failures[0])).toBe(true); + expect(() => { + (first.failures as Array<{ reason: string }>)[0]!.reason = "mutated"; + }).toThrow(); + expect(resolveCodexRuntime(deps).failures[0]?.reason).toBe("not found"); + }); + + test("bundled gather reads are detached and recursively frozen", () => { + const runtime = { command: "/tmp/codex", version: "0.145.0", source: "environment" as const }; + resetBundledCatalogCacheForTests(); + setBundledCatalogCacheForTests(runtime, { + models: [{ + slug: "gpt-5.5", + base_instructions: "private", + supported_reasoning_levels: [{ effort: "medium", description: "medium" }], + }], + }); + const evidence = gatherEvidence({ + "runtime-selection": persistedRuntimeBytes(runtime.command, runtime.version), + }); + + const first = resolveCatalogSourceForGather(evidence); + const second = resolveCatalogSourceForGather(evidence); + expect(first.kind).toBe("available"); + expect(second.kind).toBe("available"); + if (first.kind !== "available" || second.kind !== "available") return; + expect(first.catalog).not.toBe(second.catalog); + expect(Object.isFrozen(first.catalog)).toBe(true); + expect(Object.isFrozen(first.catalog.models)).toBe(true); + expect(Object.isFrozen(first.catalog.models?.[0])).toBe(true); + expect(Object.isFrozen(first.catalog.models?.[0]?.supported_reasoning_levels)).toBe(true); + expect(() => { + (first.catalog.models as Array>)[0]!.base_instructions = "mutated"; + }).toThrow(); + expect(() => { + (first.catalog.models?.[0]?.supported_reasoning_levels as unknown[]).push({ effort: "high" }); + }).toThrow(); + expect(second.catalog.models?.[0]?.base_instructions).toBe("private"); + expect(second.catalog.models?.[0]?.supported_reasoning_levels).toHaveLength(1); + }); + + test("gather consumes a persisted runtime observation and observed disk fallback", () => { + resetCodexRuntimeResolveCacheForTests(); + resetBundledCatalogCacheForTests(); + const evidence = gatherEvidence({ + "runtime-selection": persistedRuntimeBytes("/tmp/persisted-codex"), + "active-catalog-merge": Buffer.from(JSON.stringify({ + models: [{ slug: "gpt-5.5", base_instructions: "observed" }], + })), + }); + + const runtime = peekCodexRuntimeForCatalogGather(evidence); + expect(runtime.kind).toBe("available"); + if (runtime.kind === "available") { + expect(runtime.origin).toBe("persisted"); + expect(runtime.runtime.command).toBe("/tmp/persisted-codex"); + expect(runtime.processLocal).toEqual({ state: "unused" }); + } + + const source = resolveCatalogSourceForGather(evidence); + expect(source.kind).toBe("available"); + if (source.kind === "available") { + expect(source.source).toBe("active-catalog-merge"); + expect(source.catalog.models?.[0]?.base_instructions).toBe("observed"); + expect(source.processLocal).toEqual({ + runtime: { state: "unused" }, + bundledCatalog: { state: "unused" }, + }); + } + }); + + test("runtime epoch advances on population and replacement", () => { + const deps = { env: { PATH: "" }, discoverAlternatives: false }; + resetCodexRuntimeResolveCacheForTests(); + const beforePopulation = peekCodexRuntimeProcessCache().epoch; + setCodexRuntimeResolveCacheForTests({ + runtime: { command: "codex-a", version: "1.0.0", source: "path" }, + failures: [], + }, deps); + const afterPopulation = peekCodexRuntimeProcessCache().epoch; + expect(afterPopulation).toBeGreaterThan(beforePopulation); + + setCodexRuntimeResolveCacheForTests({ + runtime: { command: "codex-b", version: "2.0.0", source: "path" }, + failures: [], + }, deps); + expect(peekCodexRuntimeProcessCache().epoch).toBeGreaterThan(afterPopulation); + }); + + test("bundled epoch advances on population, replacement, and negative-cache publication", () => { + const runtime = { command: "/tmp/codex", version: "0.145.0", source: "environment" as const }; + resetBundledCatalogCacheForTests(); + const beforePopulation = bundledCatalogCacheState().epoch; + setBundledCatalogCacheForTests(runtime, { models: [{ slug: "first" }] }); + const afterPopulation = bundledCatalogCacheState().epoch; + expect(afterPopulation).toBeGreaterThan(beforePopulation); + + setBundledCatalogCacheForTests(runtime, { models: [{ slug: "replacement" }] }); + const afterReplacement = bundledCatalogCacheState().epoch; + expect(afterReplacement).toBeGreaterThan(afterPopulation); + + setBundledCatalogCacheForTests(runtime, null); + expect(bundledCatalogCacheState().epoch).toBeGreaterThan(afterReplacement); + }); + + test("runtime epoch advances on clear", () => { + const before = peekCodexRuntimeProcessCache().epoch; + clearCodexRuntimeResolveCache(); + expect(peekCodexRuntimeProcessCache().epoch).toBeGreaterThan(before); + }); + + test("bundled epoch advances on invalidation", () => { + const before = bundledCatalogCacheState().epoch; + invalidateBundledCatalogCache(); + expect(bundledCatalogCacheState().epoch).toBeGreaterThan(before); + }); + + test("runtime epoch advances on persisted-runtime write", () => { + const before = peekCodexRuntimeProcessCache().epoch; + persistCodexRuntime({ + command: "/tmp/codex", + version: "0.145.0", + source: "configured", + }, { configDir: tempConfigDir() }); + expect(peekCodexRuntimeProcessCache().epoch).toBeGreaterThan(before); + }); + + test("both epochs advance on test reset", () => { + const runtimeBefore = peekCodexRuntimeProcessCache().epoch; + const bundledBefore = bundledCatalogCacheState().epoch; + resetCodexRuntimeResolveCacheForTests(); + resetBundledCatalogCacheForTests(); + expect(peekCodexRuntimeProcessCache().epoch).toBeGreaterThan(runtimeBefore); + expect(bundledCatalogCacheState().epoch).toBeGreaterThan(bundledBefore); + }); +}); + describe("resolveCodexRuntime", () => { test("CODEX_CLI_PATH overrides all other sources when valid", () => { const configDir = tempConfigDir(); @@ -427,8 +670,10 @@ describe("resolveCodexRuntime", () => { try { const cached = resolveCodexRuntime(deps); expect(cached.runtime.source).toBe("fallback"); - cached.runtime.source = "environment"; - (cached.runtime as { version?: string | null }).version = undefined; + setCodexRuntimeResolveCacheForTests({ + runtime: { command: "codex", version: null, source: "environment" }, + failures: cached.failures, + }, deps); const before = readFileSync(statePath, "utf8"); resolveAndPersistCodexRuntime(deps); diff --git a/tests/codex-service-manager-probe.test.ts b/tests/codex-service-manager-probe.test.ts new file mode 100644 index 000000000..006bba52e --- /dev/null +++ b/tests/codex-service-manager-probe.test.ts @@ -0,0 +1,404 @@ +/** + * The service-manager probe, and the ownership it feeds. + * + * Three ways this could pass while broken, each named by an audit and each + * answered here rather than by care: + * - inspect only the disk definition and miss a stale LOADED one + * - report `present` for a definition whose homes could not be parsed + * - mutation-test the fixture's argv instead of the argv production emits + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + inspectServiceManagerInstallation, + type ProbeRunner, +} from "../src/service-manager-probe"; +import { inspectNativeCodexOwnership } from "../src/integrations/native/ownership-preflight"; + +let home = ""; +const cleanup: string[] = []; +let previousCodexHome: string | undefined; +let previousOpencodexHome: string | undefined; + +/** Records exactly what production asked for, so the allowlist is observed. */ +function recorder(reply: (file: string, args: readonly string[]) => Partial>) { + const calls: { file: string; args: readonly string[] }[] = []; + const run: ProbeRunner = (file, args) => { + calls.push({ file, args }); + return { status: 0, stdout: "", stderr: "", timedOut: false, spawnFailed: false, ...reply(file, args) }; + }; + return { run, calls }; +} + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-probe-")); + cleanup.push(home); + previousCodexHome = process.env.CODEX_HOME; + previousOpencodexHome = process.env.OPENCODEX_HOME; +}); +afterEach(() => { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + while (cleanup.length) rmSync(cleanup.pop()!, { recursive: true, force: true }); +}); + +function writePlist(codexHome: string | null, opencodexHome: string | null): string { + const dir = join(home, "Library", "LaunchAgents"); + mkdirSync(dir, { recursive: true }); + const path = join(dir, "com.opencodex.proxy.plist"); + writeFileSync(path, [ + "EnvironmentVariables", + codexHome ? `CODEX_HOME${codexHome}` : "", + opencodexHome ? `OPENCODEX_HOME${opencodexHome}` : "", + "", + ].join("\n")); + return path; +} + +function writeUnit(codexHome: string, opencodexHome: string): string { + const dir = join(home, ".config", "systemd", "user"); + mkdirSync(dir, { recursive: true }); + const path = join(dir, "opencodex-proxy.service"); + writeFileSync(path, [ + "[Service]", + `Environment="CODEX_HOME=${codexHome}"`, + `Environment="OPENCODEX_HOME=${opencodexHome}"`, + ].join("\n")); + return path; +} + +describe("the probe only ever asks", () => { + /** + * The user's proxy is live under a service manager while this runs. A probe + * that could start, stop or reload anything is not a probe — and asserting + * that from the source text is not enough, because this unit has already + * shipped a fix that was only a comment. + */ + /** + * Two calls, not one: `gui/` and `user/` are independent launchd + * domains carrying separate service sets. Measured on macOS 27.0, the shipped + * agent answers 0 under `gui` and 113 under `user` — so asking only one leaves + * the other free to hold a job this probe would then report as absent. + * + * What the test is really pinning is that every call only ASKS. + */ + test("macOS asks launchctl only with print, in both domains", () => { + const { run, calls } = recorder(() => ({ status: 113 })); + inspectServiceManagerInstallation({ run, platform: "darwin", uid: 501, home }); + + expect(calls).toHaveLength(2); + expect(calls.map(c => c.args[1])).toEqual([ + "gui/501/com.opencodex.proxy", + "user/501/com.opencodex.proxy", + ]); + for (const call of calls) { + expect(call.file).toBe("/bin/launchctl"); + expect(call.args[0]).toBe("print"); + for (const verb of ["load", "unload", "bootstrap", "bootout", "kickstart", "start", "stop", "enable", "disable"]) { + expect(call.args).not.toContain(verb); + } + } + }); + + test("a live job in the user domain is found even though gui answered first", () => { + const { run } = recorder((_file, args) => ( + String(args[1]).startsWith("user/") ? { status: 0 } : { status: 113 } + )); + const result = inspectServiceManagerInstallation({ run, platform: "darwin", uid: 501, home }); + // The plist is absent in this fixture, so a loaded job with no file is the + // interrupted-uninstall case rather than a clean absence. + expect(result.kind).toBe("unknown"); + }); + + test("Linux asks systemctl exactly once, with show", () => { + const { run, calls } = recorder(() => ({ + stdout: "LoadState=not-found\nActiveState=inactive\nFragmentPath=\nNeedDaemonReload=no\n", + })); + inspectServiceManagerInstallation({ run, platform: "linux", home }); + + expect(calls).toHaveLength(1); + expect(calls[0].file).toBe("systemctl"); + expect(calls[0].args).toContain("show"); + expect(calls[0].args).toContain("--user"); + for (const verb of ["start", "stop", "restart", "reload", "daemon-reload", "enable", "disable", "kill"]) { + expect(calls[0].args).not.toContain(verb); + } + }); + + test("and it asks systemd for the stale-definition signal, not just the load state", () => { + const { run, calls } = recorder(() => ({ + stdout: "LoadState=not-found\nActiveState=inactive\nFragmentPath=\nNeedDaemonReload=no\n", + })); + inspectServiceManagerInstallation({ run, platform: "linux", home }); + // LoadState alone cannot say whether the LOADED bytes match the file. + expect(calls[0].args).toContain("NeedDaemonReload"); + }); +}); + +describe("absence has to be proven twice", () => { + test("no registration and no definition is absent", () => { + const { run } = recorder(() => ({ status: 113 })); + expect(inspectServiceManagerInstallation({ run, platform: "darwin", uid: 501, home })) + .toEqual({ kind: "absent" }); + }); + + /** + * The case a registration-only probe gets wrong: a logged-out macOS user has + * the plist on disk with no GUI domain, so nothing is loaded while a foreign + * definition sits right there. + */ + test("no registration but a definition on disk is NOT absent", () => { + const path = writePlist("/somewhere/.codex", "/somewhere/.opencodex"); + const { run } = recorder(() => ({ status: 113 })); + const result = inspectServiceManagerInstallation({ run, platform: "darwin", uid: 501, home }); + + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + expect(result.claims[0].registration).toBe("absent"); + expect(result.claims[0].definitionPath).toBe(path); + expect(result.claims[0].homes).toEqual({ codexHome: "/somewhere/.codex", opencodexHome: "/somewhere/.opencodex" }); + }); + + test("a registration with no definition file is unknown, not present", () => { + const { run } = recorder(() => ({ status: 0, stdout: "state = running" })); + const result = inspectServiceManagerInstallation({ run, platform: "darwin", uid: 501, home }); + expect(result.kind).toBe("unknown"); + }); +}); + +describe("could not ask is not an answer", () => { + /** + * Measured against real nonexistent targets: 113 is "no such service", 112 is + * "no such domain". Only the first is an answer. + */ + /** + * 112 with nothing staged on disk is ABSENT, and this test asserted the + * opposite until a review round showed what that costs: a fresh headless Mac + * has no GUI domain and no installation either, so treating 112 as "could not + * ask" refused every Codex write on it. + * + * The measurement that settles it (macOS 27.0): 112 is an answer about the + * DOMAIN and is label-independent — `launchctl print gui/999999` with no + * service name at all returns it — so an unreachable domain cannot be hiding a + * job of ours. 113 stays service-scoped within a domain that answered. + */ + test("exit 112 with no plist is absent — an unreachable domain holds nothing", () => { + const { run } = recorder(() => ({ status: 112, stderr: "Could not find domain for user" })); + const result = inspectServiceManagerInstallation({ run, platform: "darwin", uid: 501, home }); + expect(result.kind).toBe("absent"); + }); + + test("exit 112 WITH a plist staged is unknown", () => { + mkdirSync(join(home, "Library", "LaunchAgents"), { recursive: true }); + writeFileSync(join(home, "Library", "LaunchAgents", "com.opencodex.proxy.plist"), ""); + const { run } = recorder(() => ({ status: 112, stderr: "Could not find domain for user" })); + const result = inspectServiceManagerInstallation({ run, platform: "darwin", uid: 501, home }); + // Something is staged to load and we could not see whether it did. + expect(result.kind).not.toBe("absent"); + }); + + /** + * A dangling symlink at the plist path must not read as a clean machine. + * + * Worth stating plainly: this test does NOT distinguish `lstat` from + * `existsSync`, and a mutation check proved it. Either way the probe ends at + * `unknown` — with `lstat` because the entry exists and cannot be read, with + * `existsSync` because the later `readFileSync` throws. The refusal is what + * the caller depends on and the refusal is what is pinned here; which of the + * two produced it is not observable from outside, so claiming to test it + * would be claiming more than this proves. + */ + test("a dangling plist symlink does not read as a clean machine", () => { + const agents = join(home, "Library", "LaunchAgents"); + mkdirSync(agents, { recursive: true }); + symlinkSync(join(home, "nothing-here.plist"), join(agents, "com.opencodex.proxy.plist")); + expect(existsSync(join(agents, "com.opencodex.proxy.plist"))).toBeFalse(); + + const { run } = recorder(() => ({ status: 113 })); + const result = inspectServiceManagerInstallation({ run, platform: "darwin", uid: 501, home }); + expect(result.kind).toBe("unknown"); + }); + + test("a launchctl timeout is unknown", () => { + const { run } = recorder(() => ({ status: null, timedOut: true })); + expect(inspectServiceManagerInstallation({ run, platform: "darwin", uid: 501, home }).kind).toBe("unknown"); + }); + + /** + * systemd does NOT signal absence through the exit code — a missing unit + * prints not-found and exits ZERO. A non-zero status means the question never + * reached the bus, which is the opposite conclusion. + */ + test("a non-zero systemctl status is unknown even though a missing unit exits zero", () => { + const { run } = recorder(() => ({ status: 1, stderr: "Failed to connect to bus" })); + expect(inspectServiceManagerInstallation({ run, platform: "linux", home }).kind).toBe("unknown"); + }); + + test("NeedDaemonReload=yes is unknown — systemd is running something else", () => { + writeUnit("/x/.codex", "/x/.opencodex"); + const { run } = recorder(() => ({ + stdout: "LoadState=loaded\nActiveState=active\nFragmentPath=/x/unit\nNeedDaemonReload=yes\n", + })); + const result = inspectServiceManagerInstallation({ run, platform: "linux", home }); + expect(result.kind).toBe("unknown"); + expect(result.kind === "unknown" && result.reason).toContain("daemon-reload"); + }); + + test("Windows refuses rather than guessing at a chain it does not walk", () => { + // The task XML names only the launcher; the homes are in the batch wrapper. + // Parsing the XML and stopping would find no homes and read that as + // agreement, so until the chain walk exists the honest answer is unknown. + expect(inspectServiceManagerInstallation({ platform: "win32", home }).kind).toBe("unknown"); + }); +}); + +describe("a definition that cannot supply homes is not present", () => { + test("an unreadable plist is unknown", () => { + const dir = join(home, "Library", "LaunchAgents"); + mkdirSync(dir, { recursive: true }); + // A directory where the plist should be: exists, cannot be read as a file. + mkdirSync(join(dir, "com.opencodex.proxy.plist")); + const { run } = recorder(() => ({ status: 113 })); + expect(inspectServiceManagerInstallation({ run, platform: "darwin", uid: 501, home }).kind).toBe("unknown"); + }); + + /** + * An omitted key is not a disagreement: an install run without CODEX_HOME set + * writes no such key at all, and `null` has to survive to the caller so the + * comparison can skip it rather than compare against "". + */ + test("an omitted home is null, not an empty string", () => { + writePlist(null, "/somewhere/.opencodex"); + const { run } = recorder(() => ({ status: 113 })); + const result = inspectServiceManagerInstallation({ run, platform: "darwin", uid: 501, home }); + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + expect(result.claims[0].homes.codexHome).toBeNull(); + expect(result.claims[0].homes.opencodexHome).toBe("/somewhere/.opencodex"); + }); +}); + +describe("ownership refuses what it cannot prove", () => { + /* + * The default state paths include the DEFAULT home mirror, resolved from + * homedir(), which no test sandbox moves. Left alone, these fixtures would + * read the developer's real installation and call their own machine foreign. + */ + function own(extra: { run: ProbeRunner }) { + const codexHome = join(home, ".codex"); + const opencodexHome = join(home, ".opencodex"); + return { + ...extra, + platform: "darwin" as const, + uid: 501, + home, + statePaths: [join(opencodexHome, "service-state.json")], + currentHomes: { codexHome, opencodexHome }, + }; + } + + function useHomes(): { codexHome: string; opencodexHome: string } { + const codexHome = join(home, ".codex"); + const opencodexHome = join(home, ".opencodex"); + mkdirSync(codexHome, { recursive: true }); + mkdirSync(opencodexHome, { recursive: true }); + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = opencodexHome; + return { codexHome, opencodexHome }; + } + + function writeState(dir: string, codexHome: string, opencodexHome: string): void { + writeFileSync(join(dir, "service-state.json"), JSON.stringify({ + version: 2, codexHome, opencodexHome, backend: "scheduler", + })); + } + + test("a fresh home with no state and no service is owned", () => { + useHomes(); + const { run } = recorder(() => ({ status: 113 })); + expect(inspectNativeCodexOwnership(own({ run })).ownership).toBe("owned"); + }); + + test("state naming another home is foreign", () => { + const { opencodexHome } = useHomes(); + writeState(opencodexHome, "/elsewhere/.codex", "/elsewhere/.opencodex"); + const { run } = recorder(() => ({ status: 113 })); + expect(inspectNativeCodexOwnership(own({ run })).ownership).toBe("foreign"); + }); + + /* + * THE interrupted reinstall. Installation writes the definition BEFORE the + * state file, so a valid state for this home can sit beside a plist naming + * another. Picking a winner unattended means guessing which half of a + * half-finished operation to believe. + */ + test("state says here, definition says elsewhere — unknown, not owned", () => { + const { codexHome, opencodexHome } = useHomes(); + writeState(opencodexHome, codexHome, opencodexHome); + writePlist("/elsewhere/.codex", "/elsewhere/.opencodex"); + const { run } = recorder(() => ({ status: 113 })); + + const result = inspectNativeCodexOwnership(own({ run })); + expect(result.ownership).toBe("unknown"); + expect(result.reason).toContain("different homes"); + }); + + test("state and definition agreeing is owned", () => { + const { codexHome, opencodexHome } = useHomes(); + writeState(opencodexHome, codexHome, opencodexHome); + writePlist(codexHome, opencodexHome); + const { run } = recorder(() => ({ status: 113 })); + expect(inspectNativeCodexOwnership(own({ run })).ownership).toBe("owned"); + }); + + test("an installed definition that no state file accounts for is unknown", () => { + const { codexHome, opencodexHome } = useHomes(); + writePlist(codexHome, opencodexHome); + const { run } = recorder(() => ({ status: 0, stdout: "state = running" })); + expect(inspectNativeCodexOwnership(own({ run })).ownership).toBe("unknown"); + }); + + /* + * The fail-open helper this replaces returns {ok:true} here, which is right + * for a teardown route a human just invoked and wrong as authority for an + * unattended write. + */ + test("a malformed state file is unknown, where the teardown helper says fine", () => { + const { opencodexHome } = useHomes(); + writeFileSync(join(opencodexHome, "service-state.json"), "{ not json"); + const { run } = recorder(() => ({ status: 113 })); + const result = inspectNativeCodexOwnership(own({ run })); + expect(result.ownership).toBe("unknown"); + expect(result.reason).toContain("malformed"); + }); + + /** + * The property is "silence is not absence", and it needs a case that is + * genuinely silent. Exit 112 no longer qualifies: it is an answer about the + * domain, and with nothing staged on disk it proves absence — that is what + * keeps a fresh headless Mac usable. A launchctl that will not run at all is + * the real unaskable case, and it still refuses. + */ + test("an unaskable service manager is unknown even with clean state", () => { + const { codexHome, opencodexHome } = useHomes(); + writeState(opencodexHome, codexHome, opencodexHome); + const { run } = recorder(() => ({ status: null, spawnFailed: true, stderr: "spawn EACCES" })); + expect(inspectNativeCodexOwnership(own({ run })).ownership).toBe("unknown"); + }); + + /** + * The counterpart, and the one a refuse-everything design gets wrong: no state + * file, no plist, and a domain that does not exist is a FRESH MACHINE. It has + * to be usable. + */ + test("a fresh headless machine is owned, not refused", () => { + useHomes(); + const { run } = recorder(() => ({ status: 112, stderr: "Could not find domain for user" })); + expect(inspectNativeCodexOwnership(own({ run })).ownership).toBe("owned"); + }); +}); diff --git a/tests/codex-sync-response.test.ts b/tests/codex-sync-response.test.ts new file mode 100644 index 000000000..e7b4c2968 --- /dev/null +++ b/tests/codex-sync-response.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, test } from "bun:test"; +import type { + CatalogDisposition, + CodexHistoryState, + CodexObservedState, + ConvergeOutcome, +} from "../src/codex/convergence-types"; +import { toSyncResponse } from "../src/server/management/sync-response"; + +const history: CodexHistoryState = { + status: "converged", + attempts: 1, + nextRetryAt: null, + txId: "tx-1", + pendingRows: 0, + backupEntries: 0, +}; + +const catalogRefresh: CatalogDisposition = { + status: "committed", + changed: true, + degraded: false, + notices: [], +}; + +const observed: CodexObservedState = { + aggregate: "applied", + isApplied: true, + desired: "on", + converged: true, + authority: { service: "owned", externalProvider: null }, + surfaces: { + config: "applied", + profile: "applied", + catalog: "applied", + cache: "applied", + journal: "absent", + history: { + state: history, + database: "applied", + manifest: "applied", + rollouts: "applied", + }, + provenance: { + state: "verified", + nativeGeneration: 4, + currentTxId: "tx-1", + }, + }, +}; + +async function projection(outcome: ConvergeOutcome): Promise<{ + status: number; + body: unknown; + retryAfter: string | null; +}> { + const response = toSyncResponse(outcome); + return { + status: response.status, + body: await response.json(), + retryAfter: response.headers.get("Retry-After"), + }; +} + +describe("toSyncResponse", () => { + test("maps catalog-only", async () => { + expect(await projection({ + kind: "catalog-only", + changed: true, + observed, + catalogRefresh, + history, + })).toEqual({ + status: 200, + body: { ok: true, changed: true, observed, catalogRefresh, history }, + retryAfter: null, + }); + }); + + test("maps converged, including desired OFF removal, without a separate desired-off row", async () => { + expect(await projection({ + kind: "converged", + direction: "removed", + changed: true, + observed, + nativeGeneration: 4, + currentTxId: "tx-1", + catalogRefresh, + history, + })).toEqual({ + status: 200, + body: { ok: true, changed: true, observed, catalogRefresh, history }, + retryAfter: null, + }); + }); + + test("maps skipped", async () => { + expect(await projection({ + kind: "skipped", + reason: "already-converged", + observed, + catalogRefresh, + history, + })).toEqual({ + status: 200, + body: { ok: true, changed: false, observed, catalogRefresh, history }, + retryAfter: null, + }); + }); + + test("maps refused", async () => { + expect(await projection({ + kind: "refused", + authority: "provenance", + message: "provenance does not authorize restore", + observed, + })).toEqual({ + status: 409, + body: { + ok: false, + authority: "provenance", + message: "provenance does not authorize restore", + observed, + }, + retryAfter: null, + }); + }); + + test("maps busy to 503 with Retry-After seconds", async () => { + expect(await projection({ + kind: "busy", + surface: "lock", + retryAfterMs: 1_250, + })).toEqual({ + status: 503, + body: { ok: false, surface: "lock", retryAfterMs: 1_250 }, + retryAfter: "2", + }); + }); + + test("maps deferred", async () => { + expect(await projection({ + kind: "deferred", + direction: "applied", + changed: true, + unresolved: ["history", "catalog"], + nativeGeneration: 4, + currentTxId: "tx-1", + observed, + catalogRefresh, + history, + })).toEqual({ + status: 200, + body: { + ok: true, + changed: true, + unresolved: ["history", "catalog"], + observed, + catalogRefresh, + history, + }, + retryAfter: null, + }); + }); + + test("maps failed", async () => { + expect(await projection({ + kind: "failed", + surface: "provenance", + message: "record write failed", + })).toEqual({ + status: 500, + body: { error: "record write failed", surface: "provenance" }, + retryAfter: null, + }); + }); +}); diff --git a/tests/codex-transition-state-race.test.ts b/tests/codex-transition-state-race.test.ts new file mode 100644 index 000000000..b07aec950 --- /dev/null +++ b/tests/codex-transition-state-race.test.ts @@ -0,0 +1,322 @@ +import { expect, test } from "bun:test"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { pathToFileURL } from "node:url"; + +import { Database } from "bun:sqlite"; + +import { openCodexCoordinatorTransaction } from "../src/codex/transition-state"; +import { + resolveCodexCoordinatorDatabasePath, + resolveEffectiveUserIdentity, +} from "../src/codex/user-identity"; + +const CHILD_TIMEOUT_MS = 10_000; +const transitionStateModuleUrl = pathToFileURL( + join(import.meta.dir, "..", "src", "codex", "transition-state.ts"), +).href; +const userIdentityModuleUrl = pathToFileURL( + join(import.meta.dir, "..", "src", "codex", "user-identity.ts"), +).href; + +const transitionProbe = ` + import { + beginCodexTransition, + readCodexTransitionState, + } from ${JSON.stringify(transitionStateModuleUrl)}; + import { + resolveCodexCoordinatorDatabasePath, + resolveEffectiveUserIdentity, + } from ${JSON.stringify(userIdentityModuleUrl)}; + import { existsSync, realpathSync, writeFileSync } from "node:fs"; + + const payload = JSON.parse(process.env.OCX_TEST_PAYLOAD); + const canonicalCodexHome = realpathSync.native(payload.codexHome); + const databasePath = resolveCodexCoordinatorDatabasePath( + resolveEffectiveUserIdentity(), + canonicalCodexHome, + ); + const next = txId => ({ + txId, + direction: "apply", + authoritySnapshotId: \`authority-\${txId}\`, + nextRetryAt: "2026-08-04T12:00:00.000Z", + }); + const waitFor = async path => { + const deadline = Date.now() + ${CHILD_TIMEOUT_MS}; + while (!existsSync(path)) { + if (Date.now() >= deadline) throw new Error(\`timed out waiting for \${path}\`); + await Bun.sleep(5); + } + }; + + let result; + if (payload.action === "race") { + writeFileSync(payload.readyPath, "ready"); + await waitFor(payload.releasePath); + const first = beginCodexTransition( + { nativeGeneration: 0, currentTxId: null }, + next(payload.txId), + ); + writeFileSync(payload.outcomePath, JSON.stringify(first)); + await waitFor(payload.retryPath); + const final = first.kind === "unavailable" && first.reason === "busy" + ? beginCodexTransition( + { nativeGeneration: 0, currentTxId: null }, + next(payload.txId), + ) + : first; + result = { id: payload.id, databasePath, first, final }; + } else if (payload.action === "begin") { + result = { + databasePath, + outcome: beginCodexTransition(payload.expected, next(payload.txId)), + }; + } else { + result = { databasePath, outcome: readCodexTransitionState() }; + } + process.stdout.write(JSON.stringify(result)); +`; + +interface ProbeResult { + id?: string; + databasePath: string; + first?: TransitionOutcome; + final?: TransitionOutcome; + outcome?: TransitionOutcome; +} + +interface TransitionOutcome { + kind: string; + reason?: string; + state?: { nativeGeneration: number; currentTxId: string | null }; + current?: { nativeGeneration: number; currentTxId: string | null }; +} + +interface Sandbox { + root: string; + codexHome: string; + opencodexHomes: [string, string]; + coordinatorPath: string; +} + +function createSandbox(label: string): Sandbox { + const root = mkdtempSync(join(tmpdir(), `ocx-transition-race-${label}-`)); + const codexHome = join(root, "codex"); + const opencodexHomes: [string, string] = [join(root, "ocx-a"), join(root, "ocx-b")]; + mkdirSync(codexHome); + for (const path of opencodexHomes) mkdirSync(path); + const coordinatorPath = resolveCodexCoordinatorDatabasePath( + resolveEffectiveUserIdentity(), + realpathSync.native(codexHome), + ); + return { root, codexHome, opencodexHomes, coordinatorPath }; +} + +function cleanupSandbox(sandbox: Sandbox): void { + for (const suffix of ["", "-journal", "-wal", "-shm"]) { + rmSync(`${sandbox.coordinatorPath}${suffix}`, { recursive: true, force: true }); + } + rmSync(sandbox.root, { recursive: true, force: true }); +} + +function spawnProbe(sandbox: Sandbox, opencodexHome: string, payload: Record) { + return Bun.spawn([process.execPath, "--eval", transitionProbe], { + env: { + ...process.env, + CODEX_HOME: sandbox.codexHome, + OPENCODEX_HOME: opencodexHome, + OCX_TEST_PAYLOAD: JSON.stringify({ ...payload, codexHome: sandbox.codexHome }), + }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); +} + +async function collectProbe(child: ReturnType): Promise { + const timeout = setTimeout(() => child.kill(), CHILD_TIMEOUT_MS); + try { + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + expect(exitCode, stderr).toBe(0); + expect(stdout.trim().split("\n"), stderr).toHaveLength(1); + return JSON.parse(stdout) as ProbeResult; + } finally { + clearTimeout(timeout); + } +} + +async function waitForFiles(paths: readonly string[]): Promise { + const deadline = Date.now() + CHILD_TIMEOUT_MS; + while (!paths.every(existsSync)) { + if (Date.now() >= deadline) throw new Error(`timed out waiting for ${paths.join(", ")}`); + await Bun.sleep(5); + } +} + +test("two real processes racing first use publish exactly one initial transition", async () => { + const sandbox = createSandbox("initialization"); + const barrier = join(sandbox.root, "barrier"); + mkdirSync(barrier); + const releasePath = join(barrier, "release"); + const retryPath = join(barrier, "retry"); + const children = ["a", "b"].map((id, index) => spawnProbe( + sandbox, + sandbox.opencodexHomes[index]!, + { + action: "race", + id, + txId: `tx-${id}`, + readyPath: join(barrier, `${id}.ready`), + outcomePath: join(barrier, `${id}.outcome`), + releasePath, + retryPath, + }, + )); + + try { + await waitForFiles([join(barrier, "a.ready"), join(barrier, "b.ready")]); + writeFileSync(releasePath, "go"); + await waitForFiles([join(barrier, "a.outcome"), join(barrier, "b.outcome")]); + writeFileSync(retryPath, "retry-busy-loser"); + const results = await Promise.all(children.map(collectProbe)); + + const firstKinds = results.map(result => result.first?.kind); + // AT MOST one first-attempt winner, not exactly one. The coordinator uses + // `busy_timeout = 0` deliberately, so under load both contenders can lose + // their first attempt to SQLITE_BUSY and resolve it on the retry below. + // Demanding a first-round winner asserts scheduler luck; the invariant that + // actually matters — never two winners — is the filter being <= 1, and the + // terminal state is pinned exactly by `finalKinds`. + expect(firstKinds.filter(kind => kind === "updated").length).toBeLessThanOrEqual(1); + for (const result of results) { + const acceptable = result.first?.kind === "updated" + || result.first?.kind === "conflict" + || (result.first?.kind === "unavailable" && result.first.reason === "busy"); + // Report the actual value on failure; `toBe(true)` alone says only that + // something unexpected happened, which is the least useful thing a race + // test can tell you. + expect({ acceptable, first: result.first }).toMatchObject({ acceptable: true }); + } + + const finalKinds = results.map(result => result.final?.kind).sort(); + expect(finalKinds).toEqual(["conflict", "updated"]); + const winner = results.find(result => result.final?.kind === "updated")!; + const loser = results.find(result => result.final?.kind === "conflict")!; + expect(loser.final?.current?.currentTxId).toBe(winner.final?.state?.currentTxId); + expect(results.map(result => result.databasePath)).toEqual([ + sandbox.coordinatorPath, + sandbox.coordinatorPath, + ]); + + const database = new Database(sandbox.coordinatorPath, { readonly: true }); + try { + expect(database.query<{ count: number }, []>( + "SELECT count(*) AS count FROM codex_transition_state WHERE singleton = 1", + ).get()?.count).toBe(1); + expect(database.query<{ native_generation: number; current_tx_id: string }, []>( + "SELECT native_generation, current_tx_id FROM codex_transition_state WHERE singleton = 1", + ).get()).toEqual({ + native_generation: 1, + current_tx_id: winner.final?.state?.currentTxId, + }); + } finally { + database.close(); + } + } finally { + for (const child of children) child.kill(); + cleanupSandbox(sandbox); + } +}, { timeout: 30_000 }); + +test("different OPENCODEX_HOME claimants advance the row under one CODEX_HOME", async () => { + const sandbox = createSandbox("shared-codex-home"); + try { + const first = await collectProbe(spawnProbe(sandbox, sandbox.opencodexHomes[0], { + action: "begin", + expected: { nativeGeneration: 0, currentTxId: null }, + txId: "tx-home-a", + })); + expect(first.outcome?.kind).toBe("updated"); + + const integrations = join(sandbox.opencodexHomes[1], "integrations"); + mkdirSync(integrations); + writeFileSync(join(integrations, "codex.json"), JSON.stringify({ + version: 1, + nativeGeneration: 91, + currentTxId: "opencodex-home-local-claimant", + history: { status: "pending", txId: "opencodex-home-local-claimant" }, + })); + + const second = await collectProbe(spawnProbe(sandbox, sandbox.opencodexHomes[1], { + action: "begin", + expected: { nativeGeneration: 1, currentTxId: "tx-home-a" }, + txId: "tx-home-b", + })); + expect(second.databasePath).toBe(first.databasePath); + expect(second.outcome).toMatchObject({ + kind: "updated", + state: { nativeGeneration: 2, currentTxId: "tx-home-b" }, + }); + + const observed = await collectProbe(spawnProbe(sandbox, sandbox.opencodexHomes[0], { + action: "read", + })); + expect(observed.databasePath).toBe(first.databasePath); + expect(observed.outcome).toMatchObject({ + kind: "ready", + state: { nativeGeneration: 2, currentTxId: "tx-home-b" }, + }); + } finally { + cleanupSandbox(sandbox); + } +}, { timeout: 30_000 }); + +test("a locked coordinator returns the exact typed busy outcome", async () => { + const sandbox = createSandbox("busy"); + let controller: ReturnType | undefined; + try { + const initialized = await collectProbe(spawnProbe(sandbox, sandbox.opencodexHomes[0], { + action: "read", + })); + expect(initialized.outcome?.kind).toBe("ready"); + + controller = openCodexCoordinatorTransaction(sandbox.coordinatorPath); + const blocked = await collectProbe(spawnProbe(sandbox, sandbox.opencodexHomes[1], { + action: "read", + })); + expect(blocked.outcome).toEqual({ kind: "unavailable", reason: "busy" }); + } finally { + controller?.close(); + cleanupSandbox(sandbox); + } +}, { timeout: 20_000 }); + +test("an unsafe coordinator path returns the exact typed unsafe-path outcome", async () => { + const sandbox = createSandbox("unsafe-path"); + try { + mkdirSync(sandbox.coordinatorPath); + const refused = await collectProbe(spawnProbe(sandbox, sandbox.opencodexHomes[0], { + action: "read", + })); + expect(refused.outcome).toEqual({ kind: "unavailable", reason: "unsafe-path" }); + } finally { + if (process.platform !== "win32" && existsSync(sandbox.coordinatorPath)) { + chmodSync(sandbox.coordinatorPath, 0o700); + } + cleanupSandbox(sandbox); + } +}, { timeout: 20_000 }); diff --git a/tests/codex-transition-state.test.ts b/tests/codex-transition-state.test.ts new file mode 100644 index 000000000..fea549e20 --- /dev/null +++ b/tests/codex-transition-state.test.ts @@ -0,0 +1,577 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { Database } from "bun:sqlite"; + +import { + beginCodexTransition, + openCodexCoordinatorTransaction, + readCodexTransitionState, + updateCodexHistoryTransition, +} from "../src/codex/transition-state"; +import { + resolveCodexCoordinatorDatabasePath, + resolveEffectiveUserIdentity, +} from "../src/codex/user-identity"; + +let codexHome = ""; +let opencodexHome = ""; +let coordinatorPath = ""; +let previousCodexHome: string | undefined; +let previousOpencodexHome: string | undefined; + +beforeEach(() => { + previousCodexHome = process.env.CODEX_HOME; + previousOpencodexHome = process.env.OPENCODEX_HOME; + codexHome = mkdtempSync(join(tmpdir(), "ocx-transition-state-codex-home-")); + opencodexHome = mkdtempSync(join(tmpdir(), "ocx-transition-state-opencodex-home-")); + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = opencodexHome; + coordinatorPath = resolveCodexCoordinatorDatabasePath( + resolveEffectiveUserIdentity(), + realpathSync.native(codexHome), + ); +}); + +afterEach(() => { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + for (const suffix of ["", "-journal", "-wal", "-shm"]) { + rmSync(`${coordinatorPath}${suffix}`, { force: true }); + } + rmSync(codexHome, { recursive: true, force: true }); + rmSync(opencodexHome, { recursive: true, force: true }); +}); + +function transition(txId: string) { + return { + txId, + direction: "apply" as const, + authoritySnapshotId: `authority-${txId}`, + nextRetryAt: "2026-08-04T12:00:00.000Z", + }; +} + +test("a missing database initializes only from clean integration and native state", () => { + expect(readCodexTransitionState()).toEqual({ + kind: "ready", + state: { + nativeGeneration: 0, + currentTxId: null, + history: { + status: "unknown", + attempts: 0, + nextRetryAt: null, + txId: null, + pendingRows: null, + backupEntries: null, + }, + historySchedule: null, + }, + }); + + const result = beginCodexTransition( + { nativeGeneration: 0, currentTxId: null }, + transition("tx-winner"), + ); + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.state.nativeGeneration).toBe(1); + expect(result.state.currentTxId).toBe("tx-winner"); + expect(result.state.historySchedule?.direction).toBe("apply"); + } +}); + +/** + * The missing-row review fixture carried the old JSON pair and history. Before + * this regression, initialization silently replaced that evidence with + * `{0,null}`, making an interrupted legacy transition look clean. + */ +test("a missing database with legacy JSON transition fields is legacy-ambiguous", () => { + const integrations = join(opencodexHome, "integrations"); + mkdirSync(integrations, { recursive: true }); + writeFileSync(join(integrations, "codex.json"), JSON.stringify({ + version: 1, + nativeGeneration: 7, + currentTxId: "legacy", + history: { status: "pending", txId: "legacy" }, + })); + + expect(readCodexTransitionState()).toEqual({ + kind: "legacy-ambiguous", + message: "A missing coordinator row cannot be initialized over legacy or invalid Codex integration state.", + }); +}); + +/** + * Absence of the coordinator file also said nothing about native bytes. The + * exact marker-owned routing grammar is authoritative residue and must prevent + * a fresh zero row from claiming no transition ever happened. + */ +test("a missing database with native routed residue is legacy-ambiguous", () => { + writeFileSync(join(codexHome, "config.toml"), [ + "# Auto-injected by opencodex", + 'openai_base_url = "http://127.0.0.1:10100/v1"', + "", + ].join("\n")); + + expect(readCodexTransitionState()).toEqual({ + kind: "legacy-ambiguous", + message: "A missing coordinator row cannot be initialized while native Codex routing residue exists.", + }); +}); + +test("an existing database without the singleton row is legacy-ambiguous", () => { + const database = new Database(coordinatorPath, { create: true }); + database.exec("PRAGMA user_version = 1"); + database.close(); + if (process.platform !== "win32") chmodSync(coordinatorPath, 0o600); + + expect(readCodexTransitionState()).toEqual({ + kind: "legacy-ambiguous", + message: "The existing coordinator database has no authoritative transition row.", + }); +}); + +test("a zero-row conditional update reports conflict and preserves the winner", () => { + const winner = beginCodexTransition( + { nativeGeneration: 0, currentTxId: null }, + transition("tx-newer"), + ); + expect(winner.kind).toBe("updated"); + + const stale = beginCodexTransition( + { nativeGeneration: 0, currentTxId: null }, + transition("tx-stale"), + ); + expect(stale.kind).toBe("conflict"); + if (stale.kind === "conflict") { + expect(stale.current.currentTxId).toBe("tx-newer"); + expect(stale.current.historySchedule?.authoritySnapshotId).toBe("authority-tx-newer"); + } + expect(readCodexTransitionState()).toMatchObject({ + kind: "ready", + state: { nativeGeneration: 1, currentTxId: "tx-newer" }, + }); +}); + +/** + * The C-phase review found the conflict tests PARTIAL: every stale caller they + * exercised disagreed on BOTH halves of the expected pair, so dropping either + * `native_generation = ?` or `current_tx_id IS ?` from the CAS predicate left + * them green. A CAS on a two-part version has to be proven one part at a time. + */ +test("a native CAS with a matching generation but the wrong txId still conflicts", () => { + expect(beginCodexTransition( + { nativeGeneration: 0, currentTxId: null }, + transition("tx-a"), + ).kind).toBe("updated"); + expect(beginCodexTransition( + { nativeGeneration: 1, currentTxId: "tx-a" }, + transition("tx-b"), + ).kind).toBe("updated"); + + // Generation 2 is current, so only the txId half disagrees. Removing the + // `current_tx_id IS ?` predicate makes this succeed. + const wrongTxId = beginCodexTransition( + { nativeGeneration: 2, currentTxId: "tx-a" }, + transition("tx-forged"), + ); + expect(wrongTxId.kind).toBe("conflict"); + + expect(readCodexTransitionState()).toMatchObject({ + kind: "ready", + state: { nativeGeneration: 2, currentTxId: "tx-b" }, + }); +}); + +for (const generation of [1, 2, 4]) { + test(`a native CAS at generation ${generation} never treats a null txId as a wildcard`, () => { + let currentTxId: string | null = null; + for (let nextGeneration = 1; nextGeneration <= generation; nextGeneration++) { + const nextTxId = `tx-current-${nextGeneration}`; + expect(beginCodexTransition( + { nativeGeneration: nextGeneration - 1, currentTxId }, + transition(nextTxId), + ).kind).toBe("updated"); + currentTxId = nextTxId; + } + + const nullTxId = beginCodexTransition( + { nativeGeneration: generation, currentTxId: null }, + transition("tx-forged"), + ); + expect(nullTxId.kind).toBe("conflict"); + + expect(readCodexTransitionState()).toMatchObject({ + kind: "ready", + state: { nativeGeneration: generation, currentTxId }, + }); + }); +} + +test("a native CAS with a matching txId but the wrong generation still conflicts", () => { + expect(beginCodexTransition( + { nativeGeneration: 0, currentTxId: null }, + transition("tx-a"), + ).kind).toBe("updated"); + expect(beginCodexTransition( + { nativeGeneration: 1, currentTxId: "tx-a" }, + transition("tx-b"), + ).kind).toBe("updated"); + + // `tx-b` really is the current txId, so only the generation half disagrees. + // Removing the `native_generation = ?` predicate makes this succeed. + const wrongGeneration = beginCodexTransition( + { nativeGeneration: 1, currentTxId: "tx-b" }, + transition("tx-forged"), + ); + expect(wrongGeneration.kind).toBe("conflict"); + + expect(readCodexTransitionState()).toMatchObject({ + kind: "ready", + state: { nativeGeneration: 2, currentTxId: "tx-b" }, + }); +}); + +test("a positive generation cannot carry a null direction", () => { + expect(beginCodexTransition( + { nativeGeneration: 0, currentTxId: null }, + transition("tx-direction"), + ).kind).toBe("updated"); + + const database = new Database(coordinatorPath); + try { + expect(() => database.run( + "UPDATE codex_transition_state SET history_direction = NULL WHERE singleton = 1", + )).toThrow(); + expect(database.query<{ history_direction: string }, []>( + "SELECT history_direction FROM codex_transition_state WHERE singleton = 1", + ).get()?.history_direction).toBe("apply"); + } finally { + database.close(); + } +}); + +/** + * The test above proves the SQL CHECK constraint and nothing else. The row + * validator in `rowToState` is a SECOND, independent gate that exists because a + * database written by another build, an older schema, or a hand-edit can hold a + * row the current CHECKs would have rejected at write time. Dropping the + * validator left the suite green, so it is proven here directly: the row is + * corrupted with the constraints switched off, and the reader must still refuse. + */ +test("the row validator refuses a malformed row the CHECK constraints never saw", () => { + expect(beginCodexTransition( + { nativeGeneration: 0, currentTxId: null }, + transition("tx-validator"), + ).kind).toBe("updated"); + + const database = new Database(coordinatorPath); + try { + // `ignore_check_constraints` lets a write land that the schema forbids, + // which is exactly the state a foreign writer can leave behind. + database.exec("PRAGMA ignore_check_constraints = ON"); + database.run( + "UPDATE codex_transition_state SET history_direction = NULL WHERE singleton = 1", + ); + expect(database.query<{ history_direction: string | null }, []>( + "SELECT history_direction FROM codex_transition_state WHERE singleton = 1", + ).get()?.history_direction).toBeNull(); + } finally { + database.close(); + } + + // The CHECK did not stop it; the validator must. + expect(readCodexTransitionState()).toEqual({ kind: "unavailable", reason: "database" }); +}); + +for (const direction of ["sideways", "reverse", "forward", "APPLY", ""] as const) { + test(`the row validator refuses unknown history direction ${JSON.stringify(direction)}`, () => { + expect(beginCodexTransition( + { nativeGeneration: 0, currentTxId: null }, + transition("tx-unknown-direction"), + ).kind).toBe("updated"); + + const database = new Database(coordinatorPath); + try { + database.exec("PRAGMA ignore_check_constraints = ON"); + database.query( + "UPDATE codex_transition_state SET history_direction = ? WHERE singleton = 1", + ).run(direction); + expect(database.query<{ history_direction: string }, []>( + "SELECT history_direction FROM codex_transition_state WHERE singleton = 1", + ).get()?.history_direction).toBe(direction); + } finally { + database.close(); + } + + expect(readCodexTransitionState()).toEqual({ kind: "unavailable", reason: "database" }); + }); +} + +test("the row validator refuses every whitespace-only txId", () => { + expect(beginCodexTransition( + { nativeGeneration: 0, currentTxId: null }, + transition("tx-blank"), + ).kind).toBe("updated"); + + const trimRemovedCodePoints: Array<[string, string]> = []; + for (let codePoint = 0; codePoint <= 0x10ffff; codePoint++) { + const character = String.fromCodePoint(codePoint); + if (character.trim() === "") { + trimRemovedCodePoints.push([ + `U+${codePoint.toString(16).toUpperCase().padStart(4, "0")}`, + character, + ]); + } + } + expect(trimRemovedCodePoints.some(([, value]) => value === "\r")).toBe(true); + + for (const [label, txId] of trimRemovedCodePoints) { + const database = new Database(coordinatorPath); + try { + database.exec("PRAGMA ignore_check_constraints = ON"); + database.query( + "UPDATE codex_transition_state SET current_tx_id = ?, history_tx_id = ? WHERE singleton = 1", + ).run(txId, txId); + } finally { + database.close(); + } + + expect(readCodexTransitionState(), label).toEqual({ kind: "unavailable", reason: "database" }); + } +}); + +/** + * A capability backed by a nominal transaction is not opaque if its caller can + * simply open another connection. The C-phase review found the old test only + * checked a boolean in one object and never exercised SQLite exclusion. + */ +test("the opaque coordinator capability cannot reach a second connection", () => { + expect(readCodexTransitionState().kind).toBe("ready"); + const controller = openCodexCoordinatorTransaction(coordinatorPath); + try { + expect(() => { + const second = openCodexCoordinatorTransaction(coordinatorPath); + second.close(); + }).toThrow(); + } finally { + controller.close(); + } +}); + +/** + * SQLite exclusion is only half of "opaque". The other half is that the + * capability object itself must not hand its caller a usable handle on the open + * connection: a caller who can reach the `Database` can write the native pair + * behind the CAS, on the very transaction that is supposed to serialize it. + * The previous test passed while the connection was reachable. + */ +test("the opaque capability never exposes a reachable database handle", () => { + expect(readCodexTransitionState().kind).toBe("ready"); + const controller = openCodexCoordinatorTransaction(coordinatorPath); + try { + const ownKeys = Reflect.ownKeys(controller.capability); + const stringKeys = ownKeys.filter((key): key is string => typeof key === "string"); + const symbolKeys = ownKeys.filter((key): key is symbol => typeof key === "symbol"); + + expect(stringKeys).toEqual(["beginTransition"]); + expect(symbolKeys).toHaveLength(1); + expect(symbolKeys[0]?.description).toBe("CodexCoordinatorTransaction"); + + for (const key of ownKeys) { + const descriptor = Reflect.getOwnPropertyDescriptor(controller.capability, key); + expect(descriptor).toBeDefined(); + expect("get" in descriptor!).toBe(false); + expect("set" in descriptor!).toBe(false); + } + expect(Reflect.getOwnPropertyDescriptor(controller.capability, "beginTransition")).toEqual({ + value: expect.any(Function), + writable: true, + enumerable: true, + configurable: true, + }); + expect(Reflect.getOwnPropertyDescriptor(controller.capability, symbolKeys[0]!)).toEqual({ + value: true, + writable: true, + enumerable: true, + configurable: true, + }); + + let prototype: object | null = controller.capability; + while (prototype !== null) { + for (const key of Reflect.ownKeys(prototype)) { + const descriptor = Reflect.getOwnPropertyDescriptor(prototype, key)!; + const intrinsicLegacyProtoAccessor = prototype === Object.prototype && key === "__proto__"; + if (!intrinsicLegacyProtoAccessor) { + expect(descriptor.get, `getter ${String(key)} on capability prototype chain`).toBeUndefined(); + expect(descriptor.set, `setter ${String(key)} on capability prototype chain`).toBeUndefined(); + } + } + prototype = Reflect.getPrototypeOf(prototype); + } + + const reachable = new Set(); + const walk = (value: unknown, depth: number): void => { + if (depth > 4 || value === null || reachable.has(value)) return; + const kind = typeof value; + if (kind !== "object" && kind !== "function") return; + reachable.add(value); + for (const key of Reflect.ownKeys(value as object)) { + const descriptor = Reflect.getOwnPropertyDescriptor(value as object, key); + if (descriptor && "value" in descriptor) walk(descriptor.value, depth + 1); + } + walk(Reflect.getPrototypeOf(value as object), depth + 1); + }; + walk(controller.capability, 0); + + for (const value of reachable) { + expect(value).not.toBeInstanceOf(Database); + } + } finally { + controller.close(); + } +}); + +test("the opaque coordinator capability is one-shot", () => { + const controller = openCodexCoordinatorTransaction(coordinatorPath); + try { + const expectation = controller.expectation(); + const expected = { nativeGeneration: expectation.nativeBefore, currentTxId: null }; + const next = transition(expectation.txId); + expect(controller.capability.beginTransition(expected, next).kind).toBe("updated"); + expect(() => controller.capability.beginTransition(expected, next)) + .toThrow("already been consumed"); + controller.assertPublished(expectation); + controller.commit(); + } finally { + controller.close(); + } +}); + +/** + * The C-phase reviewer found this by running the code rather than the suite: + * `new Database(path, { create: false })` is SQLITE_MISUSE on Bun 1.3.14 + * because the flags name no read mode. Every history update therefore failed + * before reaching its conditional UPDATE and returned `unavailable/database`, + * so no terminal history state could ever be recorded. + * + * Eighteen tests were green while that was true, because none of them called + * `updateCodexHistoryTransition` at all. That is the gap this file closes. + */ +test("a history transition update reaches its conditional UPDATE and records terminal state", () => { + const started = beginCodexTransition( + { nativeGeneration: 0, currentTxId: null }, + transition("tx-history"), + ); + expect(started.kind).toBe("updated"); + + const updated = updateCodexHistoryTransition( + { nativeGeneration: 1, currentTxId: "tx-history" }, + { + status: "converged", + attempts: 1, + nextRetryAt: null, + txId: "tx-history", + pendingRows: 0, + backupEntries: 0, + }, + ); + + // Before the fix this was `unavailable` with reason `database`, every time. + expect(updated.kind).toBe("updated"); + + const after = readCodexTransitionState(); + expect(after.kind).toBe("ready"); + if (after.kind === "ready") { + expect(after.state.history.status).toBe("converged"); + expect(after.state.history.txId).toBe("tx-history"); + expect(after.state.history.pendingRows).toBe(0); + } +}); + +/** + * The overtaking case the substrate exists for: a stale Worker finishing after + * a newer transition committed must NOT publish its terminal state over the + * winner's schedule. It must report conflict. + */ +test("a stale history update conflicts and leaves the newer transition's schedule intact", () => { + beginCodexTransition({ nativeGeneration: 0, currentTxId: null }, transition("tx-a")); + const newer = beginCodexTransition( + { nativeGeneration: 1, currentTxId: "tx-a" }, + { ...transition("tx-b"), direction: "remove" as const }, + ); + expect(newer.kind).toBe("updated"); + + const stale = updateCodexHistoryTransition( + { nativeGeneration: 1, currentTxId: "tx-a" }, + { + status: "converged", + attempts: 1, + nextRetryAt: null, + txId: "tx-a", + pendingRows: 0, + backupEntries: 0, + }, + ); + expect(stale.kind).toBe("conflict"); + + const after = readCodexTransitionState(); + expect(after.kind).toBe("ready"); + if (after.kind === "ready") { + expect(after.state.currentTxId).toBe("tx-b"); + expect(after.state.historySchedule?.direction).toBe("remove"); + // The stale worker must not have published its own terminal status. + expect(after.state.history.status).not.toBe("converged"); + } +}); + +/** + * Reviewer finding: the happy-path update test still passed with the + * conditional WHERE removed, so it did not prove the update is conditional. + * This one fails the moment the guard stops matching on BOTH columns. + */ +test("a begin whose txId matches but whose generation does not is rejected", () => { + beginCodexTransition({ nativeGeneration: 0, currentTxId: null }, transition("tx-one")); + + const wrongGeneration = beginCodexTransition( + { nativeGeneration: 7, currentTxId: "tx-one" }, + transition("tx-two"), + ); + expect(wrongGeneration.kind).toBe("conflict"); + + const after = readCodexTransitionState(); + expect(after.kind).toBe("ready"); + if (after.kind === "ready") expect(after.state.currentTxId).toBe("tx-one"); +}); + +/** + * A permissive coordinator is never LEFT permissive. + * + * The flake fix relaxed WHEN mode is judged, not whether. Two processes reaching + * first use together both see ENOENT, and the loser can lstat the winner's file + * before its chmod lands; refusing there reported `unsafe-path` for what was only + * a schedule (1-in-12 on a 16-core box). Ownership is still decided before the + * open — a file owned by somebody else is not a race and waiting cannot make it + * ours — while mode is narrowed once below the open and judged on the settled + * state. + * + * This asserts the outcome that matters and can actually be observed: after a + * read, the file is owner-only again. Removing the narrowing leaves it 0644 and + * turns this red. + */ +test("a coordinator found group-readable is narrowed back to owner-only", () => { + expect(readCodexTransitionState().kind).toBe("ready"); + + chmodSync(coordinatorPath, 0o644); + expect(statSync(coordinatorPath).mode & 0o777).toBe(0o644); + + const read = readCodexTransitionState(); + expect(read.kind).toBe("ready"); + expect(statSync(coordinatorPath).mode & 0o777).toBe(0o600); +}); diff --git a/tests/codex-user-identity.test.ts b/tests/codex-user-identity.test.ts new file mode 100644 index 000000000..fb7f10031 --- /dev/null +++ b/tests/codex-user-identity.test.ts @@ -0,0 +1,210 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { join, parse } from "node:path"; +import { tmpdir } from "node:os"; +import { pathToFileURL } from "node:url"; + +import { + resolveCodexCoordinatorDatabasePath, + resolveCodexCatalogSerializationDatabasePath, + resolveCodexHistorySerializationDatabasePath, + resolveEffectiveUserIdentity, +} from "../src/codex/user-identity"; + +let codexHome = ""; +let previousHome: string | undefined; + +const CHILD_TIMEOUT_MS = 10_000; +const userIdentityModuleUrl = pathToFileURL( + join(import.meta.dir, "..", "src", "codex", "user-identity.ts"), +).href; +const identityProbe = ` + import { + resolveCodexCoordinatorDatabasePath, + resolveEffectiveUserIdentity, + } from ${JSON.stringify(userIdentityModuleUrl)}; + import { realpathSync } from "node:fs"; + + const canonicalCodexHome = realpathSync.native(process.env.OCX_TEST_CANONICAL_CODEX_HOME); + const identity = resolveEffectiveUserIdentity(); + const databasePath = resolveCodexCoordinatorDatabasePath(identity, canonicalCodexHome); + process.stdout.write(JSON.stringify({ identity, databasePath })); +`; + +interface IdentityProbeResult { + identity: ReturnType; + databasePath: string; +} + +async function runIdentityProbe( + env: Record, + cwd: string, +): Promise { + const child = Bun.spawn([process.execPath, "--eval", identityProbe], { + cwd, + env: { ...process.env, ...env }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const timeout = setTimeout(() => child.kill(), CHILD_TIMEOUT_MS); + try { + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + expect(exitCode, stderr).toBe(0); + expect(stdout.trim().split("\n"), stderr).toHaveLength(1); + return JSON.parse(stdout) as IdentityProbeResult; + } finally { + clearTimeout(timeout); + } +} + +beforeEach(() => { + previousHome = process.env.HOME; + codexHome = mkdtempSync(join(tmpdir(), "ocx-user-identity-codex-home-")); +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + rmSync(codexHome, { recursive: true, force: true }); +}); + +test("the effective identity is uid/SID and does not follow HOME", () => { + const before = resolveEffectiveUserIdentity(); + process.env.HOME = join(tmpdir(), "fake-home-that-must-not-key-coordination"); + const after = resolveEffectiveUserIdentity(); + + expect(after).toEqual(before); + if (process.platform === "win32") { + expect(after.platform).toBe("win32"); + expect("sid" in after && after.sid).toMatch(/^S-1-/); + } else { + expect(after).toEqual({ platform: "posix", uid: process.getuid!() }); + } + expect(JSON.stringify(after)).not.toContain(process.env.HOME); +}); + +test("the coordinator resolver returns the final database path", () => { + const canonicalHome = realpathSync.native(codexHome); + const finalPath = resolveCodexCoordinatorDatabasePath( + resolveEffectiveUserIdentity(), + canonicalHome, + ); + + expect(parse(finalPath).ext).toBe(".sqlite"); + expect(parse(finalPath).base).toMatch(/^[a-f0-9]{64}\.sqlite$/); + expect(parse(parse(finalPath).dir).base).toBe("native-write-locks"); + expect(finalPath).toBe(resolveCodexCoordinatorDatabasePath( + resolveEffectiveUserIdentity(), + canonicalHome, + )); +}); + +test("real processes resolve one identity and coordinator path across every home/runtime environment", async () => { + const canonicalHome = realpathSync.native(codexHome); + const environmentRoots = ["a", "b"].map(label => { + const root = mkdtempSync(join(tmpdir(), `ocx-user-identity-env-${label}-`)); + const paths = { + home: join(root, "home"), + userProfile: join(root, "profile"), + homeDrive: join(root, "drive"), + homePath: join(root, "path"), + xdgRuntime: join(root, "runtime"), + temp: join(root, "temp"), + codexHome: join(root, "ambient-codex"), + opencodexHome: join(root, "ambient-opencodex"), + workingDirectory: join(root, "working-directory"), + }; + for (const path of Object.values(paths)) mkdirSync(path, { recursive: true }); + return { root, paths }; + }); + + try { + const probes = await Promise.all(environmentRoots.map(({ paths }, index) => { + const accountEnvironment = process.platform === "win32" + ? { + USERNAME: `fake-username-${index}`, + USERDOMAIN: `fake-domain-${index}`, + USERDOMAIN_ROAMINGPROFILE: `fake-roaming-domain-${index}`, + USERDNSDOMAIN: `fake-dns-domain-${index}`, + } + : { + UID: String(900_000 + index), + EUID: String(910_000 + index), + USER: `fake-user-${index}`, + LOGNAME: `fake-logname-${index}`, + }; + return runIdentityProbe({ + HOME: paths.home, + USERPROFILE: paths.userProfile, + HOMEDRIVE: paths.homeDrive, + HOMEPATH: paths.homePath, + XDG_RUNTIME_DIR: paths.xdgRuntime, + TMPDIR: paths.temp, + TEMP: paths.temp, + TMP: paths.temp, + LOCALAPPDATA: paths.temp, + CODEX_HOME: paths.codexHome, + OPENCODEX_HOME: paths.opencodexHome, + OCX_TEST_CANONICAL_CODEX_HOME: canonicalHome, + ...accountEnvironment, + }, paths.workingDirectory); + })); + + const osIdentity = resolveEffectiveUserIdentity(); + const osDatabasePath = resolveCodexCoordinatorDatabasePath(osIdentity, canonicalHome); + for (const probe of probes) { + expect(probe.identity).toEqual(osIdentity); + expect(probe.databasePath).toBe(osDatabasePath); + } + expect(probes[1]?.identity).toEqual(probes[0]?.identity); + expect(probes[1]?.databasePath).toBe(probes[0]?.databasePath); + } finally { + for (const { root } of environmentRoots) rmSync(root, { recursive: true, force: true }); + } +}, { timeout: 20_000 }); + +/** + * H is keyed by the canonical state database as well as the canonical home. + * + * N and K key on the home alone, which fully determines the routing and catalog + * bytes they guard. History does not work that way: one `CODEX_HOME` can name a + * different `state_5.sqlite`, and two operations against different history + * databases are not the same exclusion. Hashing only the home would serialize + * them together; hashing a raw request path would let two spellings of one + * database take different locks. + */ +test("H is keyed by state database identity and is never N's or K's path", () => { + const identity = resolveEffectiveUserIdentity(); + const canonicalHome = realpathSync.native(codexHome); + const stateDbA = join(canonicalHome, "state_5.sqlite"); + const stateDbB = join(canonicalHome, "other", "state_5.sqlite"); + + const nativePath = resolveCodexCoordinatorDatabasePath(identity, canonicalHome); + const catalogPath = resolveCodexCatalogSerializationDatabasePath(identity, canonicalHome); + const historyA = resolveCodexHistorySerializationDatabasePath(identity, canonicalHome, stateDbA); + const historyB = resolveCodexHistorySerializationDatabasePath(identity, canonicalHome, stateDbB); + + // Three distinct exclusions, never sharing a database. + expect(new Set([nativePath, catalogPath, historyA]).size).toBe(3); + + // A second state database under the SAME home is a different H, while N and K + // are unchanged — the property that keying H on the home alone would destroy. + expect(historyB).not.toBe(historyA); + expect(resolveCodexCoordinatorDatabasePath(identity, canonicalHome)).toBe(nativePath); + expect(resolveCodexCatalogSerializationDatabasePath(identity, canonicalHome)).toBe(catalogPath); + + // Stable across calls, and living in its own directory rather than N's or K's. + expect(resolveCodexHistorySerializationDatabasePath(identity, canonicalHome, stateDbA)) + .toBe(historyA); + expect(parse(historyA).dir).not.toBe(parse(nativePath).dir); + expect(parse(historyA).dir).not.toBe(parse(catalogPath).dir); + + // A relative state database is refused rather than silently keyed on its text. + expect(() => resolveCodexHistorySerializationDatabasePath(identity, canonicalHome, "state_5.sqlite")) + .toThrow(); +}); diff --git a/tests/codex-v2-gate.test.ts b/tests/codex-v2-gate.test.ts index 0a8a86c0b..830c0d3d0 100644 --- a/tests/codex-v2-gate.test.ts +++ b/tests/codex-v2-gate.test.ts @@ -31,6 +31,7 @@ import { } from "../src/codex/features"; import { cmdV2, codexFeaturesInvocation, v2StatusLine, multiAgentModeLine } from "../src/cli/v2"; import { handleManagementAPI } from "../src/server/management-api"; +import { catalogConvergenceFactory } from "./helpers/catalog-convergence"; function template(): Record { return { @@ -646,7 +647,7 @@ describe("management API logical v1/v2 switching", () => { const content = readFileSync(path, "utf8"); writeFileSync(path, content.replace(/^enabled\s*=\s*(?:true|false)$/m, `enabled = ${enabled}`)); }; - const deps = { toggleCodexMultiAgentV2: toggle, refreshCodexCatalog: async () => {} }; + const deps = { toggleCodexMultiAgentV2: toggle, createManagementConvergeCodex: catalogConvergenceFactory() }; try { const toV2 = new Request("http://localhost/api/v2", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ multiAgentMode: "v2" }), @@ -711,7 +712,7 @@ describe("management API logical v1/v2 switching", () => { body: JSON.stringify({ multiAgentMode: "v2", enabled: false }), }); const response = await handleManagementAPI(req, new URL(req.url), { providers: [] } as never, { - toggleCodexMultiAgentV2: () => { toggles++; }, refreshCodexCatalog: async () => {}, + toggleCodexMultiAgentV2: () => { toggles++; }, createManagementConvergeCodex: catalogConvergenceFactory(), }); expect(response?.status).toBe(400); expect(toggles).toBe(0); @@ -722,7 +723,7 @@ describe("management API logical v1/v2 switching", () => { body: JSON.stringify({ multiAgentMode: "v1", enabled: true }), }); expect((await handleManagementAPI(opposite, new URL(opposite.url), { providers: [] } as never, { - toggleCodexMultiAgentV2: () => { toggles++; }, refreshCodexCatalog: async () => {}, + toggleCodexMultiAgentV2: () => { toggles++; }, createManagementConvergeCodex: catalogConvergenceFactory(), }))?.status).toBe(400); expect(toggles).toBe(0); } finally { @@ -732,7 +733,10 @@ describe("management API logical v1/v2 switching", () => { }); describe("management API parity surface for the WP2 keys", () => { - const withConfig = (content: string, run: (path: string, deps: { toggleCodexMultiAgentV2: (enabled: boolean) => void; refreshCodexCatalog: () => Promise }) => Promise) => { + const withConfig = (content: string, run: (path: string, deps: { + toggleCodexMultiAgentV2: (enabled: boolean) => void; + createManagementConvergeCodex: ReturnType; + }) => Promise) => { const path = fixtureConfig(content); const oldCodexHome = process.env.CODEX_HOME; const oldOcxHome = process.env.OPENCODEX_HOME; @@ -742,7 +746,7 @@ describe("management API parity surface for the WP2 keys", () => { const current = readFileSync(path, "utf8"); writeFileSync(path, current.replace(/^enabled\s*=\s*(?:true|false)$/m, `enabled = ${enabled}`)); }; - return run(path, { toggleCodexMultiAgentV2: toggle, refreshCodexCatalog: async () => {} }) + return run(path, { toggleCodexMultiAgentV2: toggle, createManagementConvergeCodex: catalogConvergenceFactory() }) .finally(() => { if (oldCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = oldCodexHome; if (oldOcxHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = oldOcxHome; @@ -854,7 +858,7 @@ describe("management API default_mode_request_user_input toggle", () => { new Request("http://localhost/api/codex-auth/features/default-mode-request-user-input"), new URL("http://localhost/api/codex-auth/features/default-mode-request-user-input"), { providers: [] } as never, - { refreshCodexCatalog: async () => {} }, + { createManagementConvergeCodex: catalogConvergenceFactory() }, ); expect(response?.status).toBe(200); expect(await response?.json()).toEqual({ enabled: false, key: "default_mode_request_user_input" }); @@ -872,7 +876,7 @@ describe("management API default_mode_request_user_input toggle", () => { : `${content}\n[features]\n${line}\n`; writeFileSync(path, next); }; - const deps = { toggleDefaultModeRequestUserInput: toggle, refreshCodexCatalog: async () => {} }; + const deps = { toggleDefaultModeRequestUserInput: toggle, createManagementConvergeCodex: catalogConvergenceFactory() }; const url = new URL("http://localhost/api/codex-auth/features/default-mode-request-user-input"); const on = await handleManagementAPI(putRequest(true), url, { providers: [] } as never, deps); @@ -894,7 +898,7 @@ describe("management API default_mode_request_user_input toggle", () => { putRequest("yes"), new URL("http://localhost/api/codex-auth/features/default-mode-request-user-input"), { providers: [] } as never, - { toggleDefaultModeRequestUserInput: () => { toggles++; }, refreshCodexCatalog: async () => {} }, + { toggleDefaultModeRequestUserInput: () => { toggles++; }, createManagementConvergeCodex: catalogConvergenceFactory() }, ); expect(response?.status).toBe(400); expect(toggles).toBe(0); @@ -911,7 +915,7 @@ describe("management API default_mode_request_user_input toggle", () => { }), url, { providers: [] } as never, - { toggleDefaultModeRequestUserInput: () => { throw new Error("must not toggle"); }, refreshCodexCatalog: async () => {} }, + { toggleDefaultModeRequestUserInput: () => { throw new Error("must not toggle"); }, createManagementConvergeCodex: catalogConvergenceFactory() }, ); expect(response?.status).toBe(400); } @@ -934,7 +938,7 @@ describe("management API default_mode_request_user_input toggle", () => { }), new URL("http://localhost/api/codex-auth/features/default-mode-request-user-input"), { providers: [] } as never, - { toggleDefaultModeRequestUserInput: () => { throw new Error("must not toggle"); }, refreshCodexCatalog: async () => {} }, + { toggleDefaultModeRequestUserInput: () => { throw new Error("must not toggle"); }, createManagementConvergeCodex: catalogConvergenceFactory() }, ); expect(response?.status).toBe(413); }); @@ -951,7 +955,7 @@ describe("management API default_mode_request_user_input toggle", () => { putRequest(true), new URL("http://localhost/api/codex-auth/features/default-mode-request-user-input"), { providers: [] } as never, - { toggleDefaultModeRequestUserInput: toggle, refreshCodexCatalog: async () => {} }, + { toggleDefaultModeRequestUserInput: toggle, createManagementConvergeCodex: catalogConvergenceFactory() }, ); expect(response?.status).toBe(502); const body = await response?.json(); @@ -965,7 +969,7 @@ describe("management API default_mode_request_user_input toggle", () => { putRequest(true), new URL("http://localhost/api/codex-auth/features/default-mode-request-user-input"), { providers: [] } as never, - { toggleDefaultModeRequestUserInput: () => {}, refreshCodexCatalog: async () => {} }, + { toggleDefaultModeRequestUserInput: () => {}, createManagementConvergeCodex: catalogConvergenceFactory() }, ); expect(response?.status).toBe(502); expect(await response?.json()).toMatchObject({ error: expect.stringContaining("default_mode_request_user_input toggle failed") }); diff --git a/tests/codex-write-lock.test.ts b/tests/codex-write-lock.test.ts new file mode 100644 index 000000000..9344328c2 --- /dev/null +++ b/tests/codex-write-lock.test.ts @@ -0,0 +1,450 @@ +/** + * N — one bounded writer per canonical CODEX_HOME. + * + * What matters here is not that a mutex excludes. It is that the WRONG things + * cannot happen: two spellings of one home must not take two locks, a home the + * ambient environment would not have safety-checked must be refused rather than + * locked, and a permanent refusal must never be reported as contention — a + * caller told to retry something that will fail identically forever is how a UI + * spins on a problem only the user can fix. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { + resolveCodexCoordinatorDatabasePath, + resolveEffectiveUserIdentity, +} from "../src/codex/user-identity"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + CODEX_WRITE_LOCK_MAX_TIMEOUT_MS, + canonicalizeCodexHome, + withCodexWriteLock, +} from "../src/codex/codex-write-lock"; +import type { AdmissionSnapshot } from "../src/codex/convergence-types"; + +let root = ""; +let codexHome = ""; +let previousCodexHome: string | undefined; +const cleanup: string[] = []; + +/** + * The lock compares `authoritySnapshotId` and nothing else, so the rest is + * deliberately minimal — a fixture that mirrored every field would suggest the + * lock reads them. + */ +function admission(authoritySnapshotId = "authority-1"): AdmissionSnapshot { + return { authoritySnapshotId } as AdmissionSnapshot; +} + +function options(overrides: Partial[0]> = {}) { + const admitted = overrides.admitted ?? admission(); + return { + timeoutMs: 0, + admitted, + readAdmissionUnderLock: () => admitted, + ...overrides, + } as Parameters[0]; +} + +/** + * A commit that actually publishes a transition. + * + * The lock verifies the row before it will commit, so a callback that writes + * nothing is not a valid commit — and that is the point: a caller cannot take N, + * do something else, and have the coordinator record a transition it never made. + */ +function publishing(value: T) { + return (ctx: Parameters[1]>[0]): T => { + ctx.coordinator.beginTransition( + // The expected pair comes from the row the lock just read, not from an + // assumed zero: another process may have already published a transition, + // and hardcoding {0, null} would make this test pass only when it runs + // first. + { nativeGeneration: ctx.expectation.nativeBefore, currentTxId: ctx.currentTxId }, + { + txId: ctx.expectation.txId, + direction: "apply", + authoritySnapshotId: ctx.admission.authoritySnapshotId, + nextRetryAt: new Date().toISOString(), + }, + ); + return value; + }; +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-write-lock-")); + cleanup.push(root); + codexHome = join(root, ".codex"); + mkdirSync(codexHome, { recursive: true }); + // A clean home: the coordinator refuses to initialize over routing residue. + writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5"\n'); + previousCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = codexHome; +}); + +afterEach(() => { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + while (cleanup.length) rmSync(cleanup.pop()!, { recursive: true, force: true }); +}); + +describe("canonical home identity", () => { + /** + * Every spelling of one directory must land on one lock id, or two processes + * that mean the same home take different locks and exclude nothing. This is + * the split the whole design exists to prevent. + */ + test("symlinked, trailing-slash and relative spellings share one lock id", () => { + const link = join(root, "linked-home"); + symlinkSync(codexHome, link); + + const direct = canonicalizeCodexHome(codexHome); + const viaLink = canonicalizeCodexHome(link); + const trailing = canonicalizeCodexHome(`${codexHome}/`); + const dotted = canonicalizeCodexHome(join(codexHome, ".", "")); + + expect(direct.ok && viaLink.ok && trailing.ok && dotted.ok).toBe(true); + const ids = [direct, viaLink, trailing, dotted].map(r => (r.ok ? r.home.lockId : "x")); + expect(new Set(ids).size).toBe(1); + }); + + test("two genuinely different homes do not share a lock id", () => { + const other = join(root, "other-home"); + mkdirSync(other, { recursive: true }); + const a = canonicalizeCodexHome(codexHome); + const b = canonicalizeCodexHome(other); + expect(a.ok && b.ok).toBe(true); + expect(a.ok && b.ok && a.home.lockId === b.home.lockId).toBe(false); + }); + + /** + * A missing home is REFUSED rather than resolved optimistically. Keeping an + * unresolved suffix would split one future home on a case-insensitive + * filesystem, or alias two on a case-sensitive one. + */ + test("a missing home refuses instead of inventing an identity", () => { + const result = canonicalizeCodexHome(join(root, "never-created")); + expect(result.ok).toBe(false); + expect(result.ok === false && result.reason).toBe("codex_home_missing"); + }); + + test("a home that is a file refuses", () => { + const file = join(root, "not-a-dir"); + writeFileSync(file, "x"); + const result = canonicalizeCodexHome(file); + expect(result.ok).toBe(false); + expect(result.ok === false && result.reason).toBe("codex_home_unsafe"); + }); +}); + +describe("refusals are not contention", () => { + test("a timeout outside the cap is refused, not clamped", async () => { + for (const timeoutMs of [-1, 1.5, CODEX_WRITE_LOCK_MAX_TIMEOUT_MS + 1]) { + const result = await withCodexWriteLock(options({ timeoutMs }), () => "never"); + expect(result.status).toBe("refused"); + expect(result.status === "refused" && result.retryable).toBe(false); + } + }); + + /** + * The residue guard that decides whether the coordinator row may be created + * resolves its home from the AMBIENT environment, while the lock path keys on + * the home we were handed. Locking one directory while a different one was + * safety-checked is the hazard; until that guard takes the target as a + * parameter, a mismatch must refuse. + */ + test("an explicit home that is not the ambient one is refused", async () => { + const other = join(root, "elsewhere"); + mkdirSync(other, { recursive: true }); + const result = await withCodexWriteLock(options({ codexHome: other }), () => "never"); + expect(result.status).toBe("refused"); + expect(result.status === "refused" && result.reason).toBe("authority_not_proven"); + }); + + test("a blank explicit home is refused as a programmer error", async () => { + const result = await withCodexWriteLock(options({ codexHome: " " }), () => "never"); + expect(result.status).toBe("refused"); + expect(result.status === "refused" && result.reason).toBe("codex_home_unsafe"); + }); + + /** + * A permanent failure must NOT enter the retry loop. + * + * This is the defect this unit produced repeatedly at other layers: a denial + * classified as contention becomes an endless retry wearing the costume of a + * busy lock. Here the coordinator database is replaced by a directory, so + * every open fails identically forever — and a deadline long enough to notice + * proves the difference. A retrying implementation burns the whole budget and + * reports `busy`; a correct one refuses immediately. + */ + test("an unopenable coordinator refuses immediately instead of retrying to the deadline", async () => { + const identity = resolveEffectiveUserIdentity(); + const dbPath = resolveCodexCoordinatorDatabasePath(identity, realpathSync.native(codexHome)); + rmSync(dbPath, { force: true }); + // A directory where the database belongs: openable never, busy never. + mkdirSync(dbPath, { recursive: true }); + + const started = performance.now(); + const result = await withCodexWriteLock(options({ timeoutMs: 2_000 }), () => "never"); + const elapsed = performance.now() - started; + + expect(result.status).toBe("refused"); + expect(result.status === "refused" && result.retryable).toBe(false); + // It did not spend the deadline discovering that a permanent failure is + // permanent. + expect(elapsed).toBeLessThan(1_000); + }); + + test("an explicit home equal to the ambient one is accepted", async () => { + const result = await withCodexWriteLock(options({ codexHome }), publishing("ok")); + expect(result.status).toBe("acquired"); + }); +}); + +describe("holding the lock", () => { + test("the callback runs under the lock and its value comes back", async () => { + const result = await withCodexWriteLock(options(), ctx => { + expect(ctx.canonicalCodexHome).toBe(realpathSync.native(codexHome)); + expect(ctx.expectation.nativeAfter).toBe(ctx.expectation.nativeBefore + 1); + return publishing(42)(ctx); + }); + expect(result.status).toBe("acquired"); + expect(result.status === "acquired" && result.value).toBe(42); + }); + + /** + * Reentrancy is a DIAGNOSIS, not the exclusion mechanism — SQLite already + * refuses the second open. The value is that the caller is told "you already + * hold this" instead of being sent into a retry loop that cannot succeed. + */ + test("re-entering from inside the callback is refused as reentrant, not busy", async () => { + let nested: Promise>> | undefined; + const outer = await withCodexWriteLock(options(), ctx => { + // The nested call STARTS synchronously — far enough to read the reentrancy + // store and refuse — and its promise is settled after the callback returns. + // Returning it would trip the thenable guard, so it is captured instead. + nested = withCodexWriteLock(options(), publishing("nested")); + return publishing(1)(ctx); + }); + expect(outer.status).toBe("acquired"); + const inner = await nested!; + expect(inner.status).toBe("refused"); + expect(inner.status === "refused" && inner.reason).toBe("reentrant"); + }); + + /** + * A callback failure is the CALLER's error, not a lock outcome. Converting it + * into `busy` would tell them to retry something that will fail identically, + * and into `refused` would hide their own exception. + */ + test("a callback throw propagates rather than becoming busy or refused", async () => { + await expect(withCodexWriteLock(options(), () => { throw new Error("caller blew up"); })) + .rejects.toThrow("caller blew up"); + }); + + test("an admission that changed under the lock refuses without committing", async () => { + let calls = 0; + const result = await withCodexWriteLock( + options({ readAdmissionUnderLock: () => admission("authority-2") }), + () => { calls += 1; return "never"; }, + ); + expect(result.status).toBe("refused"); + expect(result.status === "refused" && result.reason).toBe("authority_not_proven"); + // The commit never ran: a stale snapshot must not reach the callback at all. + expect(calls).toBe(0); + }); + + /** + * An `async` callback is rejected at typecheck, but a cast one is not. C is + * synchronous, so awaiting it is impossible and the only safe answer is to + * reject rather than let a promise escape the held section. + */ + test("a cast async callback is rejected instead of escaping the held section", async () => { + await expect(withCodexWriteLock( + options(), + (async () => "sneaky") as never, + )).rejects.toThrow(/synchronous/); + }); +}); + +describe("two real processes contend for one lock", () => { + /** + * The evidence this phase actually owes. A second async task in this isolate + * shares the connection cache and the reentrancy store, so it proves neither + * exclusion nor its absence — this unit has already shipped a test that looked + * like a race and was not one. So: a real child process, calling the + * production module. + */ + const childPath = join(import.meta.dir, "helpers", "codex-write-lock-child.ts"); + + function spawnChild(payload: Record) { + return Bun.spawn(["bun", childPath], { + env: { ...process.env, CODEX_HOME: codexHome, OCX_LOCK_CHILD_PAYLOAD: JSON.stringify(payload) }, + stdout: "pipe", + stderr: "pipe", + }); + } + + /** Same child, but with the home-shaped environment variables under test. */ + function spawnChildWithEnv(payload: Record, env: Record) { + return Bun.spawn(["bun", childPath], { + env: { + ...process.env, + CODEX_HOME: codexHome, + ...env, + OCX_LOCK_CHILD_PAYLOAD: JSON.stringify(payload), + }, + stdout: "pipe", + stderr: "pipe", + }); + } + + async function childResult(child: ReturnType) { + const [stdout] = await Promise.all([new Response(child.stdout).text(), child.exited]); + const line = stdout.trim().split("\n").filter(Boolean).at(-1) ?? "{}"; + return JSON.parse(line) as { status: string; reason?: string; value?: string; lockId?: string }; + } + + async function waitFor(path: string, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (Bun.file(path).size > 0) return; + await Bun.sleep(10); + } + throw new Error(`timed out waiting for ${path}`); + } + + test("a second process is excluded while the first holds, and succeeds after it releases", async () => { + const holdMarker = join(root, "held"); + const releaseMarker = join(root, "release"); + + const holder = spawnChild({ holdMarker, releaseMarker, timeoutMs: 0 }); + await waitFor(holdMarker); + + // The lock is genuinely held by another process right now. + const blocked = await withCodexWriteLock(options({ timeoutMs: 0 }), publishing("parent")); + expect(blocked.status).toBe("busy"); + expect(blocked.status === "busy" && blocked.reason).toBe("deadline"); + + writeFileSync(releaseMarker, "go"); + const held = await childResult(holder); + expect(held.status).toBe("acquired"); + + // And once it is released the same call succeeds — proving the earlier busy + // was contention rather than a permanent refusal wearing its label. + const after = await withCodexWriteLock(options({ timeoutMs: 5_000 }), publishing("parent")); + expect(after.status).toBe("acquired"); + }, 30_000); + + test("both processes resolve the same lock id for one home", async () => { + const first = await childResult(spawnChild({ timeoutMs: 5_000 })); + expect(first.status).toBe("acquired"); + const local = canonicalizeCodexHome(codexHome); + expect(local.ok && first.lockId).toBe(local.ok ? local.home.lockId : "x"); + }, 30_000); + + /** + * A waiting contender must actually wait rather than fail fast — and must + * still come back typed rather than hanging. The holder releases partway + * through, so a deadline longer than the hold succeeds. + */ + test("a contender with a deadline waits for the holder instead of failing immediately", async () => { + const holdMarker = join(root, "held-2"); + const releaseMarker = join(root, "release-2"); + const holder = spawnChild({ holdMarker, releaseMarker, timeoutMs: 0 }); + await waitFor(holdMarker); + + const waiter = withCodexWriteLock(options({ timeoutMs: 5_000 }), publishing("waited")); + await Bun.sleep(150); + writeFileSync(releaseMarker, "go"); + + const [waited, holderResult] = await Promise.all([waiter, childResult(holder)]); + expect(holderResult.status).toBe("acquired"); + expect(waited.status).toBe("acquired"); + expect(waited.status === "acquired" && waited.waitedMs).toBeGreaterThan(0); + }, 30_000); + + /** + * C7/C18 — the namespace keys on the OS user, not on any home accessor. + * + * This is defect #7 of this unit, and it had no test until now: the lock module + * shipped with real two-process contention proven, while the property that makes + * that contention MEAN anything was unasserted. Bun 1.3.14 returns an + * environment-controlled home from both `os.homedir()` and + * `os.userInfo().homedir`, so a namespace derived from either splits one OS user + * across two lock files whenever a service and a CLI see different HOME values — + * and two processes that should exclude each other quietly stop doing so. + * + * The plan is explicit that setting HOME and USERPROFILE to the SAME fake value + * in both children is insufficient, because that cannot catch the original split. + * So each case varies one variable while holding the other equal, and the last + * varies both in opposite directions at once. + */ + /* + * Built INSIDE each test, not at module scope. + * + * `root` is assigned in beforeEach, so evaluating these paths while the module + * loads produced `join("", "fake-home-a")` — a RELATIVE path — and the children + * dutifully created `fake-home-a/` and friends in the repository root. The test + * still "passed" its exclusion assertion, which is the tell: a fixture that + * silently writes to the wrong place looks identical to one that works. + */ + const homeEnvironments = () => [ + { + name: "HOME differs, USERPROFILE shared", + a: { HOME: join(root, "fake-home-a"), USERPROFILE: join(root, "fake-common") }, + b: { HOME: join(root, "fake-home-b"), USERPROFILE: join(root, "fake-common") }, + }, + { + name: "USERPROFILE differs, HOME shared", + a: { HOME: join(root, "fake-common"), USERPROFILE: join(root, "fake-profile-a") }, + b: { HOME: join(root, "fake-common"), USERPROFILE: join(root, "fake-profile-b") }, + }, + { + name: "both differ, in opposite directions", + a: { HOME: join(root, "fake-home-a"), USERPROFILE: join(root, "fake-profile-b") }, + b: { HOME: join(root, "fake-home-b"), USERPROFILE: join(root, "fake-profile-a") }, + }, + ] as const; + + for (const index of [0, 1, 2] as const) { + test(`one OS user and one home take ONE lock, case ${index}`, async () => { + const { name, a, b } = homeEnvironments()[index]; + // Same OS user, same CODEX_HOME, different home-shaped environment. They + // must exclude each other, which can only happen if they resolved the same + // namespace. + // + // Every fake home is created under the per-test temp root first, so a + // child that resolves one still writes inside the fixture. + for (const dir of [a.HOME, a.USERPROFILE, b.HOME, b.USERPROFILE]) { + mkdirSync(dir, { recursive: true }); + } + const holdMarker = join(root, `held-env-${name.replace(/[^a-z]+/gi, "-")}`); + const releaseMarker = join(root, `release-env-${name.replace(/[^a-z]+/gi, "-")}`); + + const holder = spawnChildWithEnv({ holdMarker, releaseMarker, timeoutMs: 0 }, { ...a }); + await waitFor(holdMarker); + + // Fail-fast: if the two environments produced different lock files this + // would acquire instead of reporting contention. + const contender = await childResult( + spawnChildWithEnv({ timeoutMs: 0 }, { ...b }), + ); + expect(contender.status).toBe("busy"); + + writeFileSync(releaseMarker, "go"); + const held = await childResult(holder); + expect(held.status).toBe("acquired"); + + // And the identity is literally the same value, not merely a shared outcome. + const after = await childResult( + spawnChildWithEnv({ timeoutMs: 5_000 }, { ...b }), + ); + expect(after.status).toBe("acquired"); + expect(after.lockId).toBe(held.lockId); + }, 30_000); + } +}); diff --git a/tests/combo-management-api.test.ts b/tests/combo-management-api.test.ts index 3ac3fac4e..fd4701b90 100644 --- a/tests/combo-management-api.test.ts +++ b/tests/combo-management-api.test.ts @@ -40,6 +40,7 @@ import { handleResponses } from "../src/server/responses"; import type { OcxConfig } from "../src/types"; import { syncCatalogModels } from "../src/codex/catalog"; import { injectClaudeAgentDefs } from "../src/claude/agents-inject"; +import { catalogConvergenceFactory } from "./helpers/catalog-convergence"; const VALID_COMBO = { targets: [{ provider: "a", model: "m1" }] }; @@ -127,7 +128,7 @@ async function comboApi( body: body === undefined ? undefined : JSON.stringify(body), }); return handleManagementAPI(req, new URL(req.url), config, { - refreshCodexCatalog, + createManagementConvergeCodex: catalogConvergenceFactory(refreshCodexCatalog), }); } @@ -138,7 +139,7 @@ async function comboApiRaw(config: OcxConfig, method: string, path: string, body body, }); return handleManagementAPI(req, new URL(req.url), config, { - refreshCodexCatalog: async () => {}, + createManagementConvergeCodex: catalogConvergenceFactory(), }); } diff --git a/tests/combos.test.ts b/tests/combos.test.ts index ae8c4643f..f2705d643 100644 --- a/tests/combos.test.ts +++ b/tests/combos.test.ts @@ -42,6 +42,7 @@ import type { OcxConfig } from "../src/types"; import { syncCatalogModels } from "../src/codex/catalog"; import { injectClaudeAgentDefs } from "../src/claude/agents-inject"; import { reconcileComboRotationState } from "../src/combos/resolve"; +import { catalogConvergenceFactory } from "./helpers/catalog-convergence"; const VALID_COMBO = { targets: [{ provider: "a", model: "m1" }] }; @@ -129,7 +130,7 @@ async function comboApi( body: body === undefined ? undefined : JSON.stringify(body), }); return handleManagementAPI(req, new URL(req.url), config, { - refreshCodexCatalog, + createManagementConvergeCodex: catalogConvergenceFactory(refreshCodexCatalog), }); } @@ -140,7 +141,7 @@ async function comboApiRaw(config: OcxConfig, method: string, path: string, body body, }); return handleManagementAPI(req, new URL(req.url), config, { - refreshCodexCatalog: async () => {}, + createManagementConvergeCodex: catalogConvergenceFactory(), }); } diff --git a/tests/grok-lifecycle.test.ts b/tests/grok-lifecycle.test.ts index 331ca1112..e69368231 100644 --- a/tests/grok-lifecycle.test.ts +++ b/tests/grok-lifecycle.test.ts @@ -49,7 +49,7 @@ describe("Grok fence lifecycle wiring", () => { const gateAt = stopFn.indexOf("if (!ownershipBlocked)"); const stripAt = stopFn.indexOf("stripGrokConfig()"); - const restoreAt = stopFn.indexOf("restoreNativeCodex()"); + const restoreAt = stopFn.indexOf("restoreNativeCodexAsync()"); const revertAt = stopFn.indexOf("revertSystemEnv()"); expect(gateAt).toBeGreaterThan(-1); diff --git a/tests/helpers/catalog-convergence.ts b/tests/helpers/catalog-convergence.ts new file mode 100644 index 000000000..c75d53ec7 --- /dev/null +++ b/tests/helpers/catalog-convergence.ts @@ -0,0 +1,15 @@ +import { projectCatalogOnlyOutcome } from "../../src/codex/management-convergence"; +import type { ConvergeCodex } from "../../src/codex/convergence-types"; +import type { OcxConfig } from "../../src/types"; + +export function catalogConvergenceFactory( + run: () => Promise | void = () => {}, +): (config: Readonly) => ConvergeCodex { + return () => async () => { + await run(); + return projectCatalogOnlyOutcome({ + changed: false, + catalogRefresh: { status: "committed", changed: false, degraded: false, notices: [] }, + }); + }; +} diff --git a/tests/helpers/codex-inject-race-child.ts b/tests/helpers/codex-inject-race-child.ts new file mode 100644 index 000000000..9da07838a --- /dev/null +++ b/tests/helpers/codex-inject-race-child.ts @@ -0,0 +1,33 @@ +/** + * A real second process that reaches the lock THROUGH `injectCodexConfig`. + * + * The existing lock child calls `withCodexWriteLock` directly with a fabricated + * admission, which proves N and nothing about production. This one runs the + * actual injection, so what it proves is that the production edge is the thing + * contending. + * + * Prints exactly one JSON line so the parent asserts on a typed result rather + * than scraping logs. + */ +import { injectCodexConfig } from "../../src/codex/inject"; +import { readConfigDiagnostics } from "../../src/config"; + +const payload = JSON.parse(process.env.OCX_INJECT_RACE_PAYLOAD ?? "{}") as { + port?: number; + lockTimeoutMs?: number; +}; + +// The real caller passes the persisted config; a child that invented `{}` could +// not exercise settings the parent seeded — syncResumeHistory among them. +const persisted = readConfigDiagnostics(); +const config = persisted.source === "file" ? persisted.config : {}; + +const result = await injectCodexConfig(payload.port ?? 10100, config, { + lockTimeoutMs: payload.lockTimeoutMs ?? 0, +}); + +console.log(JSON.stringify({ + success: result.success, + retryable: (result as { retryable?: boolean }).retryable ?? false, + message: result.message.slice(0, 200), +})); diff --git a/tests/helpers/codex-write-lock-child.ts b/tests/helpers/codex-write-lock-child.ts new file mode 100644 index 000000000..fdecc73a2 --- /dev/null +++ b/tests/helpers/codex-write-lock-child.ts @@ -0,0 +1,67 @@ +/** + * A real second process for the N contention tests. + * + * Two processes are the only way to prove cross-process exclusion. A second + * async task in one isolate shares the SQLite connection cache and the + * reentrancy store, so it proves neither — and this unit has already shipped a + * test that looked like a race and was not one. + * + * It calls the PRODUCTION module, never a copy, and prints exactly one JSON + * line so the parent can assert on a typed result rather than on log scraping. + */ +import { withCodexWriteLock } from "../../src/codex/codex-write-lock"; +import type { AdmissionSnapshot } from "../../src/codex/convergence-types"; + +const payload = JSON.parse(process.env.OCX_LOCK_CHILD_PAYLOAD ?? "{}") as { + timeoutMs?: number; + holdMarker?: string; + releaseMarker?: string; +}; + +const admitted = { authoritySnapshotId: "authority-child" } as AdmissionSnapshot; + +const result = await withCodexWriteLock( + { + timeoutMs: payload.timeoutMs ?? 0, + admitted, + readAdmissionUnderLock: () => admitted, + }, + ctx => { + if (payload.holdMarker) { + // Tell the parent the lock is HELD, then block this thread so it stays + // held. The callback is synchronous by contract, so a sleep here is a busy + // wait on purpose: awaiting would release nothing and violate the contract. + Bun.write(payload.holdMarker, "held").catch(() => {}); + const until = Date.now() + 3_000; + while (Date.now() < until) { + if (payload.releaseMarker && Bun.file(payload.releaseMarker).size > 0) break; + } + } + // ALWAYS publishes. The lock verifies the row before it will commit, so a + // callback that writes nothing is not a valid commit — a caller cannot take + // N, do something else, and have the coordinator record a transition it + // never made. An earlier version of this helper had a `publish: false` + // option, and every child that used it failed with "the coordinator + // transition was not published"; the option was describing a state the + // contract does not have. + { + ctx.coordinator.beginTransition( + { nativeGeneration: ctx.expectation.nativeBefore, currentTxId: ctx.currentTxId }, + { + txId: ctx.expectation.txId, + direction: "apply", + authoritySnapshotId: ctx.admission.authoritySnapshotId, + nextRetryAt: new Date().toISOString(), + }, + ); + } + return "child-committed"; + }, +); + +console.log(JSON.stringify({ + status: result.status, + ...(result.status === "acquired" ? { value: result.value, lockId: result.lockId } : {}), + ...(result.status === "busy" ? { reason: result.reason } : {}), + ...(result.status === "refused" ? { reason: result.reason } : {}), +})); diff --git a/tests/history-migration-guardian.test.ts b/tests/history-migration-guardian.test.ts index 8f6610264..c5930e5ca 100644 --- a/tests/history-migration-guardian.test.ts +++ b/tests/history-migration-guardian.test.ts @@ -1,18 +1,25 @@ import { describe, expect, test } from "bun:test"; import { startHistoryMigrationGuardian } from "../src/codex/history-migration-guardian"; -/** Manual scheduler: collects scheduled callbacks so tests drive ticks deterministically. */ +/** + * Manual scheduler: collects scheduled callbacks so tests drive ticks + * deterministically. + * + * `runNext` awaits the callback, because a tick now runs its migration through + * the history job and is therefore async. Dropping that promise would let a test + * assert on state the tick had not finished producing. + */ function manualScheduler() { - const queue: Array<() => void> = []; + const queue: Array<() => void | Promise> = []; return { - scheduleFn: (fn: () => void) => { + scheduleFn: (fn: () => void | Promise) => { queue.push(fn); return { cancel: () => { const i = queue.indexOf(fn); if (i !== -1) queue.splice(i, 1); } }; }, - runNext(): boolean { + async runNext(): Promise { const fn = queue.shift(); if (!fn) return false; - fn(); + await fn(); return true; }, get size() { return queue.length; }, @@ -22,7 +29,7 @@ function manualScheduler() { const silent = { log: () => {} }; describe("history migration guardian", () => { - test("stops silently when nothing is pending", () => { + test("stops silently when nothing is pending", async () => { const sched = manualScheduler(); let migrations = 0; startHistoryMigrationGuardian({ @@ -32,12 +39,12 @@ describe("history migration guardian", () => { scheduleFn: sched.scheduleFn, }); - expect(sched.runNext()).toBe(true); + expect(await sched.runNext()).toBe(true); expect(migrations).toBe(0); // no pending work — never touches the migrate path expect(sched.size).toBe(0); // and never reschedules }); - test("retries while the DB stays locked, then logs and stops on success", () => { + test("retries while the DB stays locked, then logs and stops on success", async () => { const sched = manualScheduler(); const logs: string[] = []; let attempts = 0; @@ -53,15 +60,15 @@ describe("history migration guardian", () => { scheduleFn: sched.scheduleFn, }); - expect(sched.runNext()).toBe(true); // tick 1: locked - expect(sched.runNext()).toBe(true); // tick 2: locked - expect(sched.runNext()).toBe(true); // tick 3: success + expect(await sched.runNext()).toBe(true); // tick 1: locked + expect(await sched.runNext()).toBe(true); // tick 2: locked + expect(await sched.runNext()).toBe(true); // tick 3: success expect(attempts).toBe(3); expect(logs.some(l => l.includes("3 legacy opencodex thread(s) migrated"))).toBe(true); expect(sched.size).toBe(0); // stopped after success }); - test("gives up with a warning after maxTicks", () => { + test("gives up with a warning after maxTicks", async () => { const sched = manualScheduler(); const logs: string[] = []; startHistoryMigrationGuardian({ @@ -72,13 +79,13 @@ describe("history migration guardian", () => { maxTicks: 2, }); - expect(sched.runNext()).toBe(true); - expect(sched.runNext()).toBe(true); + expect(await sched.runNext()).toBe(true); + expect(await sched.runNext()).toBe(true); expect(sched.size).toBe(0); // budget exhausted — no reschedule expect(logs.some(l => l.includes("stayed locked"))).toBe(true); }); - test("stop() cancels the pending tick", () => { + test("stop() cancels the pending tick", async () => { const sched = manualScheduler(); let migrations = 0; const handle = startHistoryMigrationGuardian({ @@ -89,11 +96,11 @@ describe("history migration guardian", () => { }); handle.stop(); - expect(sched.runNext()).toBe(false); // cancelled before firing + expect(await sched.runNext()).toBe(false); // cancelled before firing expect(migrations).toBe(0); }); - test("a locked count probe still attempts migration and keeps ticking until a clean re-count", () => { + test("a locked count probe still attempts migration and keeps ticking until a clean re-count", async () => { const sched = manualScheduler(); let migrations = 0; let counts = 0; @@ -110,12 +117,12 @@ describe("history migration guardian", () => { scheduleFn: sched.scheduleFn, }); - expect(sched.runNext()).toBe(true); + expect(await sched.runNext()).toBe(true); expect(migrations).toBe(1); expect(sched.size).toBe(0); // migration succeeded and re-count is clean → stop }); - test("does not stop on a zero-row 'success' while backup entries remain (missing-DB race)", () => { + test("does not stop on a zero-row 'success' while backup entries remain (missing-DB race)", async () => { const sched = manualScheduler(); let migrations = 0; // DB missing: count sees only the backup manifest; migrate 'succeeds' with 0 rows. @@ -127,11 +134,11 @@ describe("history migration guardian", () => { maxTicks: 3, }); - expect(sched.runNext()).toBe(true); + expect(await sched.runNext()).toBe(true); expect(migrations).toBe(1); expect(sched.size).toBe(1); // NOT stopped — backup work is still pending - expect(sched.runNext()).toBe(true); - expect(sched.runNext()).toBe(true); // budget exhausted on tick 3 + expect(await sched.runNext()).toBe(true); + expect(await sched.runNext()).toBe(true); // budget exhausted on tick 3 expect(sched.size).toBe(0); }); }); diff --git a/tests/management-client-config-route.test.ts b/tests/management-client-config-route.test.ts index a6d296688..c5fa46912 100644 --- a/tests/management-client-config-route.test.ts +++ b/tests/management-client-config-route.test.ts @@ -14,6 +14,7 @@ import { type PiGeneratedConfig, } from "../src/clients/config-export"; import type { OcxConfig } from "../src/types"; +import { catalogConvergenceFactory } from "./helpers/catalog-convergence"; /** * A key that looks exactly like a real one. Every assertion about `ocx_` absence is @@ -81,7 +82,7 @@ async function clientConfigApi(config: OcxConfig, query: string): Promise {}, refreshCodexCatalog: async () => {} }, + { saveConfigPreservingClaudeCode: () => {}, createManagementConvergeCodex: catalogConvergenceFactory() }, ); expect(response).not.toBeNull(); return response!; @@ -93,7 +94,7 @@ async function modelRows(config: OcxConfig): Promise { new Request(url, { headers: { Host: url.host } }), url, config, - { saveConfigPreservingClaudeCode: () => {}, refreshCodexCatalog: async () => {} }, + { saveConfigPreservingClaudeCode: () => {}, createManagementConvergeCodex: catalogConvergenceFactory() }, ); return await response!.json() as ModelRow[]; } @@ -243,7 +244,7 @@ describe("GET /api/client-config", () => { new Request(url, { headers: { Host: url.host, Origin: "https://evil.example" } }), url, baseConfig(), - { saveConfigPreservingClaudeCode: () => {}, refreshCodexCatalog: async () => {} }, + { saveConfigPreservingClaudeCode: () => {}, createManagementConvergeCodex: catalogConvergenceFactory() }, ); expect(response?.status).toBe(403); }, 15_000); diff --git a/tests/management-integration-routes.test.ts b/tests/management-integration-routes.test.ts index 8314cacc1..b65b74d3d 100644 --- a/tests/management-integration-routes.test.ts +++ b/tests/management-integration-routes.test.ts @@ -15,6 +15,7 @@ import { setIntegrationPathTestHooks, } from "../src/server/management/integration-routes"; import type { OcxConfig } from "../src/types"; +import { catalogConvergenceFactory } from "./helpers/catalog-convergence"; /** * Route contract for devlog/_plan/260802_client_toggle_api/040 §6-§7. @@ -122,7 +123,7 @@ async function rawApi(path: string, init: RequestInit = {}): Promise {}, refreshCodexCatalog: async () => {} }, + { saveConfigPreservingClaudeCode: () => {}, createManagementConvergeCodex: catalogConvergenceFactory() }, ); } diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 880f1373c..e850d3f84 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -35,6 +35,7 @@ import type { OcxConfig } from "../src/types"; import { fakeChatGptJwt } from "./helpers/fake-chatgpt-jwt"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; import * as destinationPolicy from "../src/lib/destination-policy"; +import { catalogConvergenceFactory } from "./helpers/catalog-convergence"; // Full-suite Windows load: startServer + multi-step provider PATCH/GET flows exceed the // default 5s per-test budget (same flake class as 810fa115 / claude-management-api). @@ -684,7 +685,7 @@ describe("provider management validation", () => { }), requestUrl, cfg, - { refreshCodexCatalog: async () => {} }, + { createManagementConvergeCodex: catalogConvergenceFactory() }, ); expect(response?.status).toBe(409); @@ -1364,7 +1365,7 @@ describe("provider management validation", () => { body: JSON.stringify(body), }); return handleManagementAPI(request, new URL(request.url), liveConfig, { - refreshCodexCatalog: async () => undefined, + createManagementConvergeCodex: catalogConvergenceFactory(), }); }; const canonical = await post({ name: "openai", provider: canonicalDirect }); @@ -1419,7 +1420,7 @@ describe("provider management validation", () => { body: JSON.stringify({ name: "openai", provider: canonicalDirect }), }); const response = await handleManagementAPI(request, new URL(request.url), liveConfig, { - refreshCodexCatalog: async () => undefined, + createManagementConvergeCodex: catalogConvergenceFactory(), }); expect(response?.status).toBe(400); expect(await response?.json()).toMatchObject({ @@ -1573,7 +1574,7 @@ describe("provider management validation", () => { body: JSON.stringify({ disabled: false }), }); const response = await handleManagementAPI(request, new URL(request.url), liveConfig, { - refreshCodexCatalog: async () => undefined, + createManagementConvergeCodex: catalogConvergenceFactory(), }); expect(response?.status).toBe(200); expect(resolvedError).toHaveBeenCalledTimes(1); @@ -1629,7 +1630,7 @@ describe("provider management validation", () => { body: JSON.stringify({ disabled: false }), }); const response = await handleManagementAPI(request, new URL(request.url), liveConfig, { - refreshCodexCatalog: async () => undefined, + createManagementConvergeCodex: catalogConvergenceFactory(), }); expect(response?.status).toBe(400); expect(await response?.json()).toMatchObject({ error }); @@ -1688,7 +1689,7 @@ describe("provider management validation", () => { body: JSON.stringify({ disabled: false }), }); const response = await handleManagementAPI(request, new URL(request.url), liveConfig, { - refreshCodexCatalog: async () => undefined, + createManagementConvergeCodex: catalogConvergenceFactory(), }); expect(response?.status).toBe(400); expect(liveConfig.providers.openai).toMatchObject({ @@ -1740,7 +1741,7 @@ describe("provider management validation", () => { body: JSON.stringify({ disabled: false }), }); const response = await handleManagementAPI(request, new URL(request.url), liveConfig, { - refreshCodexCatalog: async () => undefined, + createManagementConvergeCodex: catalogConvergenceFactory(), }); expect(response?.status).toBe(200); expect(liveConfig.providers.openai).toEqual({ @@ -1835,7 +1836,7 @@ describe("provider management validation", () => { const deps = { clearThreadAccountMap: () => { affinityClears += 1; }, clearProviderQuotaCache: () => { quotaCacheClears += 1; }, - refreshCodexCatalog: async () => { catalogRefreshes += 1; }, + createManagementConvergeCodex: catalogConvergenceFactory(() => { catalogRefreshes += 1; }), primeCodexPoolQuotas: (_config: OcxConfig, reason: string) => { primes.push(reason); }, }; const patch = async (name: string, body: unknown) => { @@ -1913,7 +1914,7 @@ describe("provider management validation", () => { body: JSON.stringify(body), }); return handleManagementAPI(req, new URL(req.url), liveConfig, { - refreshCodexCatalog: async () => { catalogRefreshes += 1; }, + createManagementConvergeCodex: catalogConvergenceFactory(() => { catalogRefreshes += 1; }), }); }; @@ -1995,7 +1996,10 @@ describe("provider management validation", () => { body: JSON.stringify(body), }); return handleManagementAPI(req, new URL(req.url), liveConfig, { - refreshCodexCatalog: async () => { catalogRefreshes += 1; }, + // This branch replaced the best-effort `refreshCodexCatalog` dep with the + // convergence entry point; every other test in this file already wires it + // that way, and this one arrived from dev still using the old shape. + createManagementConvergeCodex: catalogConvergenceFactory(() => { catalogRefreshes += 1; }), }); }; diff --git a/tests/model-visibility-management-api.test.ts b/tests/model-visibility-management-api.test.ts index 77ec85b14..d7ea73f42 100644 --- a/tests/model-visibility-management-api.test.ts +++ b/tests/model-visibility-management-api.test.ts @@ -5,6 +5,7 @@ import { nativeModelRows } from "../src/codex/catalog"; import { loadConfig, saveConfig } from "../src/config"; import { handleManagementAPI } from "../src/server/management-api"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; +import { catalogConvergenceFactory } from "./helpers/catalog-convergence"; const TEST_DIR = join(import.meta.dir, `.tmp-model-visibility-management-${process.pid}`); const previousOpencodexHome = process.env.OPENCODEX_HOME; @@ -52,7 +53,7 @@ async function putWithConfig(body: unknown, config = loadConfig()): Promise { refreshes += 1; } }); + }), url, config, { createManagementConvergeCodex: catalogConvergenceFactory(() => { refreshes += 1; }) }); if (!response) throw new Error("model visibility route was not handled"); return response; } diff --git a/tests/native-codex-toggle.test.ts b/tests/native-codex-toggle.test.ts new file mode 100644 index 000000000..92d006857 --- /dev/null +++ b/tests/native-codex-toggle.test.ts @@ -0,0 +1,158 @@ +/** + * The Codex switch route. + * + * The thing worth proving here is not that a boolean lands in a file. It is that + * turning Codex OFF is not `ocx stop`: the proxy keeps serving, `/healthz` keeps + * answering, other clients keep routing, and only Codex goes back to its own + * path. A per-client switch that took the whole proxy down would be a kill + * switch with a misleading label. + * + * The second thing is ordering. Intent is persisted BEFORE artifacts converge, + * so a process that dies between the two leaves a decision the next start can + * act on — rather than artifacts the next start silently undoes. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; + +import { handleManagementAPI } from "../src/server/management-api"; +import type { ManagementApiDeps } from "../src/server/management/context"; +import type { OcxConfig } from "../src/types"; + +let fixtureRoot = ""; +let previousOpencodexHome: string | undefined; +const cleanup: string[] = []; + +function baseConfig(): OcxConfig { + return { + port: 10100, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + } as OcxConfig["providers"], + defaultProvider: "openai", + }; +} + +function testDeps(overrides: Partial = {}): ManagementApiDeps { + return { + fetchAllModels: async () => [] as never, + ...overrides, + } as ManagementApiDeps; +} + +function dispatch(config: OcxConfig, path: string, init?: RequestInit, deps: ManagementApiDeps = testDeps()) { + const url = new URL(`http://127.0.0.1:10100${path}`); + return handleManagementAPI( + new Request(url, { ...init, headers: { Host: url.host, ...(init?.headers ?? {}) } }), + url, + config, + deps, + ); +} + +async function put(config: OcxConfig, body: unknown, deps?: ManagementApiDeps) { + const res = await dispatch(config, "/api/native-integrations/codex", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, deps); + return { status: res!.status, body: await res!.json() as Record }; +} + +function persistedCodexIntent(): unknown { + const raw = JSON.parse(readFileSync(join(fixtureRoot, "config.json"), "utf8")) as Record; + return (raw.clientIntegrations as Record | undefined)?.codex; +} + +beforeEach(() => { + previousOpencodexHome = process.env.OPENCODEX_HOME; + fixtureRoot = mkdtempSync(join(tmpdir(), "ocx-codex-toggle-")); + cleanup.push(fixtureRoot); + process.env.OPENCODEX_HOME = fixtureRoot; + writeFileSync(join(fixtureRoot, "config.json"), JSON.stringify(baseConfig(), null, 2)); + writeFileSync(join(fixtureRoot, "service-state.json"), JSON.stringify({ + version: 2, + codexHome: process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), + opencodexHome: fixtureRoot, + backend: "scheduler", + })); +}); + +afterEach(() => { + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + while (cleanup.length) rmSync(cleanup.pop()!, { recursive: true, force: true }); +}); + +describe("request validation", () => { + test("a non-boolean enabled is rejected before anything is written", async () => { + const result = await put(baseConfig(), { enabled: "false" }); + expect(result.status).toBe(400); + expect(persistedCodexIntent()).toBeUndefined(); + }); + + test("a missing enabled is rejected before anything is written", async () => { + const result = await put(baseConfig(), {}); + expect(result.status).toBe(400); + expect(persistedCodexIntent()).toBeUndefined(); + }); +}); + +describe("turning Codex off", () => { + test("persists the decision so it survives the next start", async () => { + const result = await put(baseConfig(), { enabled: false }); + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ ok: true, clientId: "codex" }); + // The decision is on disk. Without this, an OFF lasts until the next + // `ocx start` re-syncs over it, which is the defect this phase exists for. + expect(persistedCodexIntent()).toBe(false); + }); + + /** + * THE PROXY STAYS UP. The user asked for this in exactly these terms: they may + * want everything except Codex routed. If turning Codex off stopped the proxy, + * every other client would lose its routing too. + */ + test("the management API keeps serving other routes afterwards", async () => { + await put(baseConfig(), { enabled: false }); + + // `/healthz` is served by the request router rather than the management API, + // so the observable claim HERE is that the management surface itself is + // still answering — the route did not tear the process down or leave a + // lock/flight wedged behind it. + const others = await dispatch(baseConfig(), "/api/native-integrations"); + expect(others!.status).toBe(200); + const body = await others!.json() as { clients: { clientId: string }[] }; + expect(body.clients.some(c => c.clientId === "claude")).toBe(true); + + // And the switch is re-usable immediately: a wedged in-flight guard would + // return 409 here rather than a normal answer. + const again = await put(baseConfig(), { enabled: false }); + expect(again.status).toBe(200); + }); + + test("turning it off twice is honest about the second one changing nothing", async () => { + const first = await put(baseConfig(), { enabled: false }); + const second = await put(baseConfig(), { enabled: false }); + expect(first.body.changed).toBe(true); + expect(second.body.changed).toBe(false); + expect(persistedCodexIntent()).toBe(false); + }); +}); + +describe("turning Codex back on", () => { + test("removes the key rather than storing true", async () => { + await put(baseConfig(), { enabled: false }); + expect(persistedCodexIntent()).toBe(false); + + const result = await put(baseConfig(), { enabled: true }); + expect(result.status).toBe(200); + // Absence is ON, so an untouched config and a re-enabled one are identical. + expect(persistedCodexIntent()).toBeUndefined(); + }); +}); diff --git a/tests/native-main-claim.test.ts b/tests/native-main-claim.test.ts index d5c76f5b1..c92546213 100644 --- a/tests/native-main-claim.test.ts +++ b/tests/native-main-claim.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -156,3 +156,31 @@ describe("native-main shared and exclusive claims", () => { await successor.release(); }); }); + +describe("the default hardener is actually reached from a claim", () => { + /** + * Nearly every test in this file injects `hardenPath: noHardening`, which is + * right for the behavior they are testing and wrong as a whole: an audit + * deleted the hardening call from `openClaimDatabase` and 89 tests across + * three files stayed green. The primitive had 77 tests; the edge that reaches + * it from a claim had none. + * + * So this one runs a claim with the DEFAULT hardener and inspects the file it + * left behind. On POSIX that is the mode; the Windows branch is proven + * separately in tests/windows-secret-acl.test.ts, where the ACL runner can be + * observed. + */ + test("a shared claim narrows a permissive claim database to 0600", async () => { + const context = fixture(); + const path = nativeMainClaimPath(context); + mkdirSync(join(context.codexHome), { recursive: true }); + writeFileSync(path, ""); + chmodSync(path, 0o644); + expect(statSync(path).mode & 0o777).toBe(0o644); + + // No hardenPath override: this is the production default. + await withNativeMainSharedClaim(context, async () => undefined); + + expect(statSync(path).mode & 0o777).toBe(0o600); + }); +}); diff --git a/tests/responses-shadow-intercept.test.ts b/tests/responses-shadow-intercept.test.ts index e946f65b4..dbc3d3e4d 100644 --- a/tests/responses-shadow-intercept.test.ts +++ b/tests/responses-shadow-intercept.test.ts @@ -11,6 +11,7 @@ import { handleResponses, isShadowSourceModel } from "../src/server/responses"; import { shouldInterceptShadowCall } from "../src/lib/shadow-call"; import { handleManagementAPI } from "../src/server/management-api"; import type { OcxConfig } from "../src/types"; +import { catalogConvergenceFactory } from "./helpers/catalog-convergence"; const originalFetch = globalThis.fetch; @@ -197,7 +198,9 @@ async function shadowApi(config: OcxConfig, method: string, body?: unknown): Pro headers, body: body === undefined ? undefined : JSON.stringify(body), }); - const res = await handleManagementAPI(req, new URL(req.url), config, { refreshCodexCatalog: async () => {} }); + const res = await handleManagementAPI(req, new URL(req.url), config, { + createManagementConvergeCodex: catalogConvergenceFactory(), + }); expect(res).not.toBeNull(); expect(res!.status).toBe(200); return await res!.json() as Record; diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts index 17970dbba..490743f33 100644 --- a/tests/server-combo-failover-e2e.test.ts +++ b/tests/server-combo-failover-e2e.test.ts @@ -28,6 +28,7 @@ import { } from "../src/codex/routing"; import { startServer } from "../src/server"; import { fakeChatGptJwt } from "./helpers/fake-chatgpt-jwt"; +import { catalogConvergenceFactory } from "./helpers/catalog-convergence"; // Full-suite Windows load: startServer + combo rename/delete management flows exceed the // default 5s per-test budget (same flake class as 810fa115 / claude-management-api). @@ -343,7 +344,7 @@ async function management( body: body === undefined ? undefined : JSON.stringify(body), }); return handleManagementAPI(request, new URL(request.url), config, { - refreshCodexCatalog: async () => {}, + createManagementConvergeCodex: catalogConvergenceFactory(), }); } diff --git a/tests/service.test.ts b/tests/service.test.ts index eeb6af4e9..c3645b894 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -643,9 +643,9 @@ describe("service lifecycle cleanup ordering", () => { expect(stopCase).toContain("ops.stop();"); expect(stopCase).toContain("await stopTrackedProxyForServiceCommand();"); - expect(stopCase).toContain("restoreNativeCodex();"); + expect(stopCase).toContain("restoreNativeCodexAsync();"); expect(stopCase.indexOf("ops.stop();")).toBeLessThan(stopCase.indexOf("stopTrackedProxyForServiceCommand();")); - expect(stopCase.indexOf("stopTrackedProxyForServiceCommand();")).toBeLessThan(stopCase.indexOf("restoreNativeCodex();")); + expect(stopCase.indexOf("stopTrackedProxyForServiceCommand();")).toBeLessThan(stopCase.indexOf("restoreNativeCodexAsync();")); }); test("direct service uninstall kills the tracked proxy before deleting service assets", async () => { @@ -655,10 +655,10 @@ describe("service lifecycle cleanup ordering", () => { expect(uninstallCase).toContain("ops.stop();"); expect(uninstallCase).toContain("await stopTrackedProxyForServiceCommand();"); expect(uninstallCase).toContain("ops.uninstall();"); - expect(uninstallCase).toContain("restoreNativeCodex();"); + expect(uninstallCase).toContain("restoreNativeCodexAsync();"); expect(uninstallCase.indexOf("ops.stop();")).toBeLessThan(uninstallCase.indexOf("stopTrackedProxyForServiceCommand();")); expect(uninstallCase.indexOf("stopTrackedProxyForServiceCommand();")).toBeLessThan(uninstallCase.indexOf("ops.uninstall();")); - expect(uninstallCase.indexOf("ops.uninstall();")).toBeLessThan(uninstallCase.indexOf("restoreNativeCodex();")); + expect(uninstallCase.indexOf("ops.uninstall();")).toBeLessThan(uninstallCase.indexOf("restoreNativeCodexAsync();")); }); test("Windows service install ends the running task before rewriting its assets, with write retry", async () => { @@ -982,7 +982,9 @@ describe("launchctl load verification", () => { const out = runLaunchctl(["print", "gui/501/x"], { run: (() => ({ status: 0, stdout: " ok ", stderr: "" })) as never, }); - expect(out).toEqual({ ok: true, stdout: "ok", stderr: "" }); + // `status` is carried through now: a boolean cannot tell "no such service" + // (113) from "no such domain" (112), and only the first is an answer. + expect(out).toEqual({ ok: true, stdout: "ok", stderr: "", status: 0 }); }); /** diff --git a/tests/system-restart.test.ts b/tests/system-restart.test.ts index dd95ee28c..836ade832 100644 --- a/tests/system-restart.test.ts +++ b/tests/system-restart.test.ts @@ -327,6 +327,7 @@ describe("acceptSystemRestart", () => { let scheduled: (() => void | Promise) | null = null; const timers: Array<{ fn: () => void; ms: number; cancelled: boolean }> = []; let rejectStop!: (reason?: unknown) => void; + let now = 1_000; acceptSystemRestart({ isDraining: () => false, @@ -339,6 +340,7 @@ describe("acceptSystemRestart", () => { timers.push(timer); return () => { timer.cancelled = true; }; }, + now: () => now, setDraining: () => { calls.push("latched"); }, drainAndShutdown: () => { calls.push("drain"); diff --git a/tests/uninstall.test.ts b/tests/uninstall.test.ts index 5243ecc8e..b09dbc75e 100644 --- a/tests/uninstall.test.ts +++ b/tests/uninstall.test.ts @@ -23,8 +23,13 @@ describe("full uninstall command", () => { const cli = await readText("src/cli/index.ts"); expect(cli).toContain("ocx recover-history --legacy-openai"); - expect(cli).toContain("function handleRecoverHistory()"); - expect(cli).toContain("restoreLegacyOpenaiHistory"); + expect(cli).toContain("async function handleRecoverHistory()"); + // The command still performs legacy recovery, but through the serialized + // history job rather than by calling the writer inline — the operation name + // is what keeps it distinct from a generic restore, which must not touch the + // backup manifest this one deliberately leaves alone. + expect(cli).toContain("recover-legacy-openai"); + expect(cli).toContain("runCodexHistoryJob"); }); test("service cleanup has a quiet best-effort helper", async () => { diff --git a/tests/windows-secret-acl.test.ts b/tests/windows-secret-acl.test.ts index ce6e4edb1..26e07df0f 100644 --- a/tests/windows-secret-acl.test.ts +++ b/tests/windows-secret-acl.test.ts @@ -10,26 +10,32 @@ * - hardenSecretDir mirrors the same contract for directories. */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { chmodSync, existsSync, mkdtempSync, renameSync, rmSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, statSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { forgetEphemeralSecretPath, hardenSecretDir, + hardenSecretDirAsync, forgetHardenedSecretPath, hardenSecretPath, hardenSecretPathAsync, + hardenedSecretDirCountForTests, hardenedSecretPathCountForTests, resetHardenedStateForTests, setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests, setNowForTests, setPlatformForTests, + setStatForTests, timedOutSecretPathCountForTests, type HardenResult, type IcaclsResult, } from "../src/lib/windows-secret-acl"; import { atomicWriteFile } from "../src/config"; +import { hardenStableLockFile } from "../src/codex/native-main-lock-file"; +import { nativeMainClaimPath, withNativeMainSharedClaim } from "../src/codex/native-main-claim"; +import { NATIVE_MAIN_OWNER_DB, retainNativeMainOwner } from "../src/codex/native-main-owner"; let testDir = ""; @@ -693,3 +699,1030 @@ describe("ephemeral ACL memo release (#840 refinement)", () => { } }); }); + + +/** + * F4 — the ACL success memo, run against BOTH entry points. + * + * These were written for `hardenSecretPath` alone, and an audit then removed the + * async attribution entirely: all 46 tests stayed green. That is the worse half + * to leave uncovered — `hardenStableLockFile`, which hardens the coordinator + * database, calls `hardenSecretPathAsync` + * (`src/codex/native-main-lock-file.ts:148`), as do `config.ts:292` and + * `native-profile-manager.ts:153-154`. Covering only the synchronous twin proved + * the path production does not take. + */ +type HardenFn = (path: string, opts: { required: boolean }) => Promise; + +/** + * All FOUR public entry points, not two. + * + * `hardenSecretDir`/`hardenSecretDirAsync` share the same memo machinery through + * `hardenedDirectories`, and an audit proved the gap: inserting + * `if (directory && cache.has(targetPath)) return { ok: true }` — pathname-only + * satisfaction for directories alone — left all 55 file-only tests green. That + * reaches real callers: config (`src/config.ts:1292,1760`), management-auth, + * tray, spill-store, and `native-profile-manager.ts:153`. + * + * Each entry carries its own `create`, so a directory case makes a directory. + */ +interface EntryPoint { + readonly label: string; + readonly harden: HardenFn; + readonly create: (path: string) => void; +} + +const ENTRY_POINTS: readonly EntryPoint[] = [ + { + label: "file-sync", + harden: async (path, opts) => hardenSecretPath(path, opts), + create: path => writeFileSync(path, "x", "utf8"), + }, + { + label: "file-async", + harden: (path, opts) => hardenSecretPathAsync(path, opts), + create: path => writeFileSync(path, "x", "utf8"), + }, + { + label: "dir-sync", + harden: async (path, opts) => hardenSecretDir(path, opts), + create: path => mkdirSync(path, { recursive: true }), + }, + { + label: "dir-async", + harden: (path, opts) => hardenSecretDirAsync(path, opts), + create: path => mkdirSync(path, { recursive: true }), + }, +]; + +/** + * Async on purpose. A synchronous version's `finally` runs the moment the body + * returns its promise — before a single `await` inside it — so every seam is torn + * down while the test is still running. The symptom is subtle: the stat seam + * reverts to the real filesystem and identity checks silently start comparing + * real inodes, so a test asserting a re-harden sees one that never happened. + */ +async function withWin32(body: () => Promise): Promise { + setPlatformForTests("win32"); + const previousUsername = process.env.USERNAME; + process.env.USERNAME = "ocx-test-user"; + try { + await body(); + } finally { + if (previousUsername === undefined) delete process.env.USERNAME; + else process.env.USERNAME = previousUsername; + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + setPlatformForTests(null); + setStatForTests(null); + } +} + +/** Install the same behavior on both runner seams so one body drives either path. */ +function runner(onCall: (args: string[]) => void): void { + const result = { success: true, exitCode: 0, timedOut: false, stdout: "" }; + setIcaclsRunnerForTests(args => { onCall(args); return result; }); + setAsyncIcaclsRunnerForTests(async args => { onCall(args); return result; }); +} + +for (const { label, harden, create } of ENTRY_POINTS) { + // Files and directories keep separate memos; a case must read its own. + const memoCount = label.startsWith("dir") + ? hardenedSecretDirCountForTests + : hardenedSecretPathCountForTests; + + describe(`F4 memo attribution — ${label} entry point`, () => { + /** + * The original defect. The memo was a Set of PATH STRINGS, so unlinking a + * hardened path and recreating a different file at the same name left the + * replacement reported as hardened while it had never seen icacls. + * + * Ephemeral temps escaped this only because atomic writers call + * forgetEphemeralSecretPath once the temp is gone (`src/config.ts:214` and + * friends). Nothing does that for a stable path. + */ + test("a file replaced at an already-hardened stable path is hardened again", async () => { + resetHardenedStateForTests(); + const stable = join(testDir, `replaced-${label}.sqlite`); + create(stable); + + await withWin32(async () => { + let grants = 0; + runner(args => { if (args.includes("/grant:r")) grants += 1; }); + // Driven through the seam rather than the real filesystem: ext4 recycles + // an unlinked inode immediately (100/100 cycles) while APFS recycled none + // in 200, so a real-file version of this test asserts different things on + // different platforms — it passed on macOS with a fix broken on Linux. + let current = { dev: 1n, ino: 10n, ctimeNs: 100n }; + setStatForTests(() => current); + + expect(await harden(stable, { required: true })).toEqual({ ok: true }); + expect(grants).toBe(1); + + current = { dev: 1n, ino: 11n, ctimeNs: 300n }; + expect(await harden(stable, { required: true })).toEqual({ ok: true }); + expect(grants).toBe(2); + }); + }); + + /** + * "What is at this path now" is not "what did icacls operate on". Swapping + * the file during the final /remove:g made a post-call read record the + * REPLACEMENT as hardened, so the next acquisition skipped ACL work on a file + * that had never seen it. + */ + test("a file replaced DURING hardening is not credited, and required fails closed", async () => { + resetHardenedStateForTests(); + const stable = join(testDir, `raced-${label}.sqlite`); + create(stable); + + await withWin32(async () => { + let grants = 0; + let current = { dev: 1n, ino: 10n, ctimeNs: 100n }; + setStatForTests(() => current); + runner(args => { + if (args.includes("/grant:r")) grants += 1; + // Another process replaces the file before the sequence returns. + if (args.includes("/remove:g")) current = { dev: 1n, ino: 99n, ctimeNs: 500n }; + }); + + await expect(harden(stable, { required: true })).rejects.toThrow( + /changed during hardening/, + ); + expect(grants).toBe(1); + // No memo was left for the replacement, so a later harden still runs. + expect(memoCount()).toBe(0); + + // An optional caller hitting the same race soft-fails with the same + // honest diagnostic. The runner keeps swapping the file, so this second + // attempt races too rather than settling on the replacement. + current = { dev: 1n, ino: 10n, ctimeNs: 100n }; + const optional = await harden(stable, { required: false }); + expect(optional.ok).toBe(false); + expect(optional.diagnostics).toMatch(/changed during hardening/); + }); + }); + + /** + * The bug the object/freshness split exists to prevent. + * + * icacls changes permissions and a permission change moves ctime — probed + * directly, {ctimeChangedByChmod: true}. Requiring the FULL identity to be + * unchanged across the ACL call rejects its own successful work, so on + * Windows every first harden of every path would have failed closed. + */ + test("ctime moving during hardening is the ACL's own doing, not a substitution", async () => { + resetHardenedStateForTests(); + const stable = join(testDir, `acl-ctime-${label}.sqlite`); + create(stable); + + await withWin32(async () => { + let grants = 0; + let ctime = 100n; + setStatForTests(() => ({ dev: 1n, ino: 10n, ctimeNs: ctime })); + runner(args => { + if (args.includes("/grant:r")) grants += 1; + ctime += 1n; // editing the DACL moves ctime; same file throughout + }); + + expect(await harden(stable, { required: true })).toEqual({ ok: true }); + expect(grants).toBe(1); + // The memo kept the POST-harden freshness, so this is a no-op rather + // than an endless re-harden. + expect(await harden(stable, { required: true })).toEqual({ ok: true }); + expect(grants).toBe(1); + }); + }); + + /** + * Each identity component is proven on its own. Earlier tests mirrored the + * implementation's identity string in their setup, which is not coverage of + * it: removing `dev` from production left all forty tests green. + */ + const components: { name: string; second: { dev: bigint; ino: bigint; ctimeNs: bigint } }[] = [ + // The path resolves to another device: a different object entirely. + { name: "dev", second: { dev: 2n, ino: 10n, ctimeNs: 100n } }, + // Ordinary replacement where the filesystem does not recycle inodes. + { name: "ino", second: { dev: 1n, ino: 11n, ctimeNs: 100n } }, + // The ext4 case: the same inode handed straight back, only ctime moved. + { name: "ctimeNs", second: { dev: 1n, ino: 10n, ctimeNs: 200n } }, + ]; + + for (const { name, second } of components) { + test(`a change in ${name} alone forces a re-harden`, async () => { + resetHardenedStateForTests(); + const stable = join(testDir, `component-${name}-${label}.sqlite`); + create(stable); + + await withWin32(async () => { + let grants = 0; + runner(args => { if (args.includes("/grant:r")) grants += 1; }); + let current = { dev: 1n, ino: 10n, ctimeNs: 100n }; + setStatForTests(() => current); + + expect(await harden(stable, { required: true })).toEqual({ ok: true }); + expect(grants).toBe(1); + expect(await harden(stable, { required: true })).toEqual({ ok: true }); + expect(grants).toBe(1); + + current = second; + expect(await harden(stable, { required: true })).toEqual({ ok: true }); + expect(grants).toBe(2); + }); + }); + } + + /** + * A memo lookup that cannot see what is at the path NOW must not answer. + * A mutation turning "cannot observe" into "satisfied" survived every other + * broken-change check, because all of them could still observe the file. + */ + test("a stat failure after a successful harden forces the harden to run again", async () => { + resetHardenedStateForTests(); + const stable = join(testDir, `unreadable-${label}.sqlite`); + create(stable); + + await withWin32(async () => { + let grants = 0; + runner(args => { if (args.includes("/grant:r")) grants += 1; }); + let readable = true; + setStatForTests(() => { + if (!readable) throw Object.assign(new Error("EACCES"), { code: "EACCES" }); + return { dev: 1n, ino: 10n, ctimeNs: 100n }; + }); + + expect(await harden(stable, { required: true })).toEqual({ ok: true }); + expect(grants).toBe(1); + + readable = false; + await expect(harden(stable, { required: true })).rejects.toThrow( + /changed during hardening/, + ); + expect(grants).toBe(2); + expect(memoCount()).toBe(0); + }); + }); + + /** + * NTFS is reported to return a zero inode from the non-bigint stat, and the + * guard treats zero as unobservable. That guard had no test at all: removing + * it left every case green, because none ever produced one. + */ + test("a zero inode is unobservable, not an identity", async () => { + resetHardenedStateForTests(); + const stable = join(testDir, `zero-ino-${label}.sqlite`); + create(stable); + + await withWin32(async () => { + let grants = 0; + runner(args => { if (args.includes("/grant:r")) grants += 1; }); + setStatForTests(() => ({ dev: 1n, ino: 0n, ctimeNs: 100n })); + + await expect(harden(stable, { required: true })).rejects.toThrow( + /changed during hardening/, + ); + expect(grants).toBe(1); + expect(memoCount()).toBe(0); + }); + }); + + /** + * Observed absence retires the memo. Otherwise a success entry outlives the + * file it describes, and any later file whose identity happens to match + * satisfies the cache. Microsoft documents that Windows file IDs may be + * reused over time, so this is not a hypothetical on the target platform. + */ + test("observing the path absent retires its success memo", async () => { + resetHardenedStateForTests(); + const stable = join(testDir, `vanishes-${label}.sqlite`); + create(stable); + + await withWin32(async () => { + runner(() => {}); + setStatForTests(() => ({ dev: 1n, ino: 10n, ctimeNs: 100n })); + + expect(await harden(stable, { required: true })).toEqual({ ok: true }); + expect(memoCount()).toBe(1); + + rmSync(stable, { recursive: true, force: true }); + expect(await harden(stable, { required: true })).toEqual({ ok: true }); + expect(memoCount()).toBe(0); + }); + }); + }); +} + +describe("hardenStableLockFile — the production call edge, not just the primitive", () => { + /** + * The gap this closes was invisible for three audit rounds. + * + * `hardenStableLockFile` is what actually hardens the coordinator database, via + * `native-main-claim.ts:81` and `native-main-owner.ts:147`. Deleting its entire + * Windows ACL delegation left 86 tests green across three files — because every + * test proved the primitive, and nothing proved production still called it. + * + * So this asserts the edge itself: on win32 the required async hardener runs + * against that exact path, and its failure propagates rather + * than leaving a coordinator database other accounts can read. + */ + test("on win32 it delegates to the required async hardener for that exact path", async () => { + resetHardenedStateForTests(); + const lockPath = join(testDir, "coordinator-claim.sqlite"); + writeFileSync(lockPath, "x", "utf8"); + + setPlatformForTests("win32"); + const previousUsername = process.env.USERNAME; + process.env.USERNAME = "ocx-test-user"; + const seen: string[][] = []; + setAsyncIcaclsRunnerForTests(async args => { + seen.push(args); + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + try { + await hardenStableLockFile(lockPath, "win32"); + // The ACL sequence ran against this exact path. + expect(seen.length).toBeGreaterThan(0); + expect(seen.every(args => args[0] === lockPath)).toBe(true); + expect(seen.some(args => args.includes("/grant:r"))).toBe(true); + // The redundant `timeoutMemoKey: path` argument was REMOVED from this call + // site rather than asserted. `timeoutMemoKey()` already falls back to + // `targetPath`, so passing the target path had no observable effect and any + // test pinning it would have passed with the mechanism gone. The option + // exists for atomic writers that mint a fresh temp per write and need the + // stable destination as the key (#612); this caller has no temp. + } finally { + if (previousUsername === undefined) delete process.env.USERNAME; + else process.env.USERNAME = previousUsername; + setAsyncIcaclsRunnerForTests(null); + setPlatformForTests(null); + } + }); + + test("a required ACL failure propagates instead of leaving the file unhardened", async () => { + resetHardenedStateForTests(); + const lockPath = join(testDir, "coordinator-fails.sqlite"); + writeFileSync(lockPath, "x", "utf8"); + + setPlatformForTests("win32"); + const previousUsername = process.env.USERNAME; + process.env.USERNAME = "ocx-test-user"; + setAsyncIcaclsRunnerForTests(async () => ({ + success: false, exitCode: 5, timedOut: false, stdout: "", stderr: "", + })); + try { + await expect(hardenStableLockFile(lockPath, "win32")).rejects.toThrow(); + } finally { + if (previousUsername === undefined) delete process.env.USERNAME; + else process.env.USERNAME = previousUsername; + setAsyncIcaclsRunnerForTests(null); + setPlatformForTests(null); + } + }); + + /** + * On POSIX the mode IS the mechanism — there is no ACL fallback — so this + * asserts the resulting bits, not merely that no ACL command ran. + * + * Asserting absence alone is what let an audit delete `chmodSync` outright + * with 89 tests still green. An existing permissive database is not repaired + * by reopening it with creation mode 0600, so "no ACL command ran" was true of + * both the working and the broken implementation. + */ + test("on posix it narrows an existing permissive file to 0600 and runs no ACL command", async () => { + resetHardenedStateForTests(); + const lockPath = join(testDir, "coordinator-posix.sqlite"); + writeFileSync(lockPath, "x", "utf8"); + chmodSync(lockPath, 0o644); + expect(statSync(lockPath).mode & 0o777).toBe(0o644); + + let calls = 0; + setAsyncIcaclsRunnerForTests(async () => { + calls += 1; + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + try { + await hardenStableLockFile(lockPath, "linux"); + expect(statSync(lockPath).mode & 0o777).toBe(0o600); + expect(calls).toBe(0); + } finally { + setAsyncIcaclsRunnerForTests(null); + } + }); + + /** + * And a POSIX chmod failure may not be swallowed. It was, unconditionally, + * which told the caller a coordinator database had been hardened when its + * permissive bits were untouched and no ACL fallback exists off Windows. + */ + test("on posix a chmod failure propagates rather than reporting success", async () => { + resetHardenedStateForTests(); + const missing = join(testDir, "never-created.sqlite"); + await expect(hardenStableLockFile(missing, "linux")).rejects.toThrow(); + }); +}); + +describe("the production default hardener is reached, with the resolved platform", () => { + /** + * The symmetric hole, found twice. + * + * `native-main-claim.ts` and `native-main-owner.ts` both default to + * `hardenStableLockFile` when no `hardenPath` is injected, and nearly every + * test in their own files injects one. An audit replaced each default with a + * no-op and 91 tests stayed green. + * + * Threading the resolved `platform` into that default is the other half. A + * test that omits `platform` cannot tell a threaded platform from a wrapper + * re-reading `process.platform`, because on this host they agree. So these + * force `platform: "win32"` from the OUTER API, inject no `hardenPath`, and + * require the real ACL runner to execute — which can only happen if the + * default is called AND the platform reached it. + */ + const forcedWindows = async ( + run: (codexHome: string) => Promise, + onGrant: () => void = () => {}, + gate?: Promise, + ): Promise => { + const seen: string[][] = []; + setPlatformForTests("win32"); + const previousUsername = process.env.USERNAME; + process.env.USERNAME = "ocx-test-user"; + setAsyncIcaclsRunnerForTests(async args => { + seen.push(args); + if (args.includes("/grant:r")) onGrant(); + // A deferred runner lets a test observe the window WHILE hardening is in + // flight, which is the only way to assert nothing was published early. + if (gate) await gate; + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + const codexHome = mkdtempSync(join(tmpdir(), "ocx-default-harden-")); + try { + await run(codexHome); + } finally { + if (previousUsername === undefined) delete process.env.USERNAME; + else process.env.USERNAME = previousUsername; + setAsyncIcaclsRunnerForTests(null); + setPlatformForTests(null); + rmSync(codexHome, { recursive: true, force: true }); + } + return seen; + }; + + test("a claim with no hardenPath runs the real ACL sequence under a forced win32", async () => { + resetHardenedStateForTests(); + let expected = ""; + let operationStarted = false; + let result: unknown; + let releaseAcl!: () => void; + const aclBlocked = new Promise(done => { releaseAcl = done; }); + const seen = await forcedWindows(async codexHome => { + expected = nativeMainClaimPath({ codexHome } as never); + const claim = withNativeMainSharedClaim( + { codexHome } as never, + async () => { operationStarted = true; return "operation-ran"; }, + { platform: "win32" }, + ); + // Recording "acl started" and then "operation" only proves the ACL BEGAN + // first. Wrapping the harden in Promise.race([harden, sleep(1)]) — an + // early continuation that on real Windows would let icacls still be in + // flight — passed that version of this test. So hold the ACL unresolved + // and require that the operation has NOT started. + await Bun.sleep(30); + expect(operationStarted).toBe(false); + releaseAcl(); + result = await claim; + expect(operationStarted).toBe(true); + }, () => {}, aclBlocked); + expect(seen.some(args => args.includes("/grant:r"))).toBe(true); + // The SUCCESS path, which "some ACL ran" cannot see: the protected operation + // actually ran and its value came back. A claim that hardens and then + // silently skips its operation passed every other assertion here. + expect(result).toBe("operation-ran"); + // The EXACT target, not merely that some ACL ran. Redirecting the caller at + // an unrelated existing file passed every other dimension of this test. + expect(seen.every(args => args[0] === expected)).toBe(true); + }); + + test("an owner with no hardenPath runs the real ACL sequence under a forced win32", async () => { + resetHardenedStateForTests(); + let expected = ""; + const trace: string[] = []; + let releaseAcl!: () => void; + const aclBlocked = new Promise(done => { releaseAcl = done; }); + const seen = await forcedWindows(async codexHome => { + expected = join(codexHome, NATIVE_MAIN_OWNER_DB); + const owner = retainNativeMainOwner({ codexHome } as never, { platform: "win32", retryMs: 10 }); + const unsubscribe = owner.subscribe(snapshot => { trace.push(snapshot.status); }); + try { + // While the ACL is still in flight the owner may NOT report held: that + // would be a caller believing it owns a database whose hardening has not + // finished. Ordering is the claim, and no terminal snapshot can make it. + await Bun.sleep(30); + expect(trace).toEqual(["acquiring"]); + releaseAcl(); + + const deadline = Date.now() + 5_000; + while (owner.snapshot().status === "acquiring" && Date.now() < deadline) { + await Bun.sleep(10); + } + // And it must actually SUCCEED. Every earlier version accepted any + // non-acquiring state, so an owner that hardened correctly and then + // failed to acquire passed. + expect(owner.snapshot()).toMatchObject({ status: "held" }); + expect(trace).toEqual(["acquiring", "held"]); + } finally { + unsubscribe(); + await owner.release(); + } + }, () => {}, aclBlocked); + expect(seen.some(args => args.includes("/grant:r"))).toBe(true); + expect(seen.every(args => args[0] === expected)).toBe(true); + }); +}); + +describe("a required hardening failure stops the operation it protects", () => { + /** + * Proving the default hardener RUNS is not proving its failure matters. + * + * Appending `.catch(() => {})` at either call site left 93 tests green: the + * forced-Windows tests above observe a successful invocation, and the + * wrapper's own failure test cannot see a caller swallowing the rejection. + * + * What is at stake is the whole point of `required: true` — a coordinator + * database whose ACL could not be applied must not go on to be used, because + * on Windows the ACL is the only thing keeping other accounts out of it. + */ + const forcedWindowsFailure = async ( + run: (codexHome: string) => Promise, + onAttempt: (args: string[]) => void = () => {}, + ): Promise => { + setPlatformForTests("win32"); + const previousUsername = process.env.USERNAME; + process.env.USERNAME = "ocx-test-user"; + setAsyncIcaclsRunnerForTests(async args => { + // Count only the first step of each sequence, so the counter is attempts + // rather than icacls invocations. + if (args.includes("/grant:r")) onAttempt(args); + return { success: false, exitCode: 5, timedOut: false, stdout: "", stderr: "" }; + }); + const codexHome = mkdtempSync(join(tmpdir(), "ocx-harden-fail-")); + try { + await run(codexHome); + } finally { + if (previousUsername === undefined) delete process.env.USERNAME; + else process.env.USERNAME = previousUsername; + setAsyncIcaclsRunnerForTests(null); + setPlatformForTests(null); + rmSync(codexHome, { recursive: true, force: true }); + } + }; + + test("a claim whose ACL cannot be applied never runs its operation", async () => { + resetHardenedStateForTests(); + let operationRan = false; + const targets: string[] = []; + let expected = ""; + await forcedWindowsFailure(async codexHome => { + expected = nativeMainClaimPath({ codexHome } as never); + // The exact code matters, not merely that it threw. A denied ACL is a + // permanent refusal; classifying it as BUSY would send a caller back to + // retry something that will fail identically forever. Broadening isBusy() + // to match the ACL message passed a test that only asserted "rejects". + let code: string | undefined; + try { + await withNativeMainSharedClaim( + { codexHome } as never, + async () => { operationRan = true; }, + { platform: "win32" }, + ); + throw new Error("expected the claim to refuse"); + } catch (error) { + code = (error as { code?: string }).code; + } + expect(code).toBe("NATIVE_MAIN_CLAIM_UNAVAILABLE"); + }, args => { targets.push(args[0]!); }); + expect(operationRan).toBe(false); + expect(targets).toEqual([expected]); + }); + + /** + * Asserted as an ORDERED TRACE, not a terminal snapshot. + * + * This is the generalization that should have come eight rounds earlier. Every + * previous version of this test read `owner.snapshot()` after the fact, and a + * final snapshot structurally cannot prove that something never happened: an + * implementation publishing `held` and then immediately `unavailable` passed, + * while every subscriber saw the forbidden intermediate state. + * + * So subscribe first, record every published state, and assert the whole + * sequence plus the events that must be absent. + */ + test("an owner whose ACL cannot be applied goes acquiring -> unavailable, with no held, contended, or retry", async () => { + resetHardenedStateForTests(); + let hardenAttempts = 0; + const targets: string[] = []; + let expected = ""; + await forcedWindowsFailure(async codexHome => { + expected = join(codexHome, NATIVE_MAIN_OWNER_DB); + const owner = retainNativeMainOwner({ codexHome } as never, { platform: "win32", retryMs: 10 }); + const trace: string[] = []; + const unsubscribe = owner.subscribe(snapshot => { trace.push(snapshot.status); }); + try { + const deadline = Date.now() + 5_000; + while (owner.snapshot().status === "acquiring" && Date.now() < deadline) { + await Bun.sleep(10); + } + // Long enough for a scheduled retry (retryMs: 10) to have fired. + await Bun.sleep(60); + + expect(owner.snapshot()).toMatchObject({ + status: "unavailable", + reason: "lock-unavailable", + }); + // `held` would mean a caller briefly believed it owned an unhardened + // database; `contended` schedules a retry, turning a permanent denial + // into an endless reacquire loop. Neither may appear at any point. + expect(trace).not.toContain("held"); + expect(trace).not.toContain("contended"); + // The RAW history, undeduplicated. Collapsing consecutive duplicates was + // itself the defect the previous version shipped: a retry loop + // republishing `unavailable` normalizes to the same two entries, so the + // test claimed "no retry churn" while discarding the churn. Never project + // an event trace unless the projection is part of the contract. + expect(trace).toEqual(["acquiring", "unavailable"]); + // And the ACL was attempted exactly once: a hidden retry would harden again. + expect(hardenAttempts).toBe(1); + // Exactly one attempt, against exactly the owner's own database. + expect(targets).toEqual([expected]); + } finally { + unsubscribe(); + await owner.release(); + } + }, args => { hardenAttempts += 1; targets.push(args[0]!); }); + }); +}); + +/** + * Failure POLICY, across all four entry points. + * + * The memo-attribution matrix above is parameterized over all four, but the + * failure-policy tests were written only against the file APIs. Four + * directory-only mutations survived the whole suite: required soft-failing on an + * ordinary failure, required soft-failing on a timeout, optional throwing on an + * ordinary failure, and optional throwing on a timeout. The production code was + * right in each case; nothing executable said so. + * + * That is the same projection class one level up — parameterizing one property + * over four entry points does not parameterize the others. + */ +for (const { label, harden, create } of ENTRY_POINTS) { + describe(`failure policy — ${label} entry point`, () => { + const withFailingAcl = async ( + result: IcaclsResult, + body: (target: string) => Promise, + ): Promise => { + resetHardenedStateForTests(); + const target = join(testDir, `policy-${label}-${result.timedOut ? "timeout" : "error"}`); + create(target); + setPlatformForTests("win32"); + const previousUsername = process.env.USERNAME; + process.env.USERNAME = "ocx-test-user"; + setIcaclsRunnerForTests(() => result); + setAsyncIcaclsRunnerForTests(async () => result); + try { + await body(target); + } finally { + if (previousUsername === undefined) delete process.env.USERNAME; + else process.env.USERNAME = previousUsername; + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + setPlatformForTests(null); + } + }; + + const ordinaryFailure: IcaclsResult = { + success: false, exitCode: 5, timedOut: false, stdout: "", stderr: "", + }; + const timeout: IcaclsResult = { + success: false, exitCode: null, timedOut: true, stdout: "", stderr: "", + }; + + test("required fails closed on an ordinary ACL failure", async () => { + await withFailingAcl(ordinaryFailure, async target => { + await expect(harden(target, { required: true })).rejects.toThrow(); + }); + }); + + test("required fails closed on a timeout", async () => { + await withFailingAcl(timeout, async target => { + await expect(harden(target, { required: true })).rejects.toThrow(); + }); + }); + + test("optional soft-fails on an ordinary ACL failure, with a diagnostic", async () => { + await withFailingAcl(ordinaryFailure, async target => { + const result = await harden(target, { required: false }); + expect(result.ok).toBe(false); + expect(typeof result.diagnostics).toBe("string"); + expect(result.diagnostics!.length).toBeGreaterThan(0); + }); + }); + + test("optional soft-fails on a timeout, with a diagnostic", async () => { + await withFailingAcl(timeout, async target => { + const result = await harden(target, { required: false }); + expect(result.ok).toBe(false); + expect(result.diagnostics).toMatch(/timed out|ETIMEDOUT/i); + }); + }); + }); +} + +for (const { label, harden, create } of ENTRY_POINTS) { + const memoCount = label.startsWith("dir") + ? hardenedSecretDirCountForTests + : hardenedSecretPathCountForTests; + + describe(`a memo entry proven wrong is retired, not kept — ${label}`, () => { + /** + * The cache state nothing justified. + * + * `memoSatisfied` used to return false on a mismatch and leave the entry in + * place, so after a mismatch and a failed re-harden the stale value survived + * and restoring the old identity satisfied it again with no ACL work. Biting + * that needs exact-identity ABA, outside this unit's proof bound — but scope + * is not a reason to keep an entry proven not to describe what is at the path. + * + * Parameterized over all four entry points because the retirement is shared + * machinery and the first version tested only `hardenSecretPath`: restricting + * the deletion to non-directory caches passed all 113 tests. + */ + test("a mismatch retires the entry even when the re-harden then fails", async () => { + resetHardenedStateForTests(); + const target = join(testDir, `retired-${label}.sqlite`); + create(target); + + await withWin32(async () => { + let grants = 0; + let aclSucceeds = true; + const result = () => (aclSucceeds + ? { success: true, exitCode: 0, timedOut: false, stdout: "" } + : { success: false, exitCode: 5, timedOut: false, stdout: "", stderr: "" }); + setIcaclsRunnerForTests(args => { + if (args.includes("/grant:r")) grants += 1; + return result(); + }); + setAsyncIcaclsRunnerForTests(async args => { + if (args.includes("/grant:r")) grants += 1; + return result(); + }); + + const original = { dev: 1n, ino: 10n, ctimeNs: 100n }; + let current = original; + setStatForTests(() => current); + + expect(await harden(target, { required: true })).toEqual({ ok: true }); + expect(grants).toBe(1); + expect(memoCount()).toBe(1); + + // Something else is at the path, and the re-harden FAILS. A successful + // re-harden would overwrite the memo and hide whether the miss retired + // it — which is what a first version of this test did. + current = { dev: 1n, ino: 11n, ctimeNs: 500n }; + aclSucceeds = false; + await expect(harden(target, { required: true })).rejects.toThrow(); + expect(grants).toBe(2); + expect(memoCount()).toBe(0); + + // And restoring the original identity is not silently re-satisfied. + current = original; + aclSucceeds = true; + expect(await harden(target, { required: true })).toEqual({ ok: true }); + expect(grants).toBe(3); + }); + }); + + /** + * The unreadable branch, isolated from `recordHarden`. + * + * Letting the ACL succeed here proves nothing: `recordHarden` would delete + * the entry for its own reasons, so a terminal count of zero cannot say WHICH + * mechanism retired it. Retaining the memo specifically when the observation + * is unreadable passed that version. So the ACL fails BEFORE `recordHarden` + * runs, and the re-satisfaction check is what shows the lookup retired it. + */ + test("an unreadable observation retires the entry, and the lookup is what does it", async () => { + resetHardenedStateForTests(); + const target = join(testDir, `retired-unreadable-${label}.sqlite`); + create(target); + + await withWin32(async () => { + let grants = 0; + let aclSucceeds = true; + const result = () => (aclSucceeds + ? { success: true, exitCode: 0, timedOut: false, stdout: "" } + : { success: false, exitCode: 5, timedOut: false, stdout: "", stderr: "" }); + setIcaclsRunnerForTests(args => { + if (args.includes("/grant:r")) grants += 1; + return result(); + }); + setAsyncIcaclsRunnerForTests(async args => { + if (args.includes("/grant:r")) grants += 1; + return result(); + }); + + const identity = { dev: 1n, ino: 10n, ctimeNs: 100n }; + let readable = true; + setStatForTests(() => { + if (!readable) throw Object.assign(new Error("EACCES"), { code: "EACCES" }); + return identity; + }); + + expect(await harden(target, { required: true })).toEqual({ ok: true }); + expect(grants).toBe(1); + expect(memoCount()).toBe(1); + + // Unreadable AND the ACL fails, so recordHarden never reaches its own + // deletion: whatever retires the entry here is the lookup. + readable = false; + aclSucceeds = false; + await expect(harden(target, { required: true })).rejects.toThrow(); + expect(grants).toBe(2); + expect(memoCount()).toBe(0); + + // Restoring the exact original identity must not be silently satisfied. + readable = true; + aclSucceeds = true; + expect(await harden(target, { required: true })).toEqual({ ok: true }); + expect(grants).toBe(3); + }); + }); + }); +} + +/** + * Memo attribution across the full cross-product. + * + * Four public entry points x {required, optional} x how the memo can be wrong. + * That last axis is itself two axes, which took two rounds to see: + * + * - WHICH identity component moved: dev alone, ino alone, freshness alone. A + * memo that ignores freshness but still compares dev:ino passed a matrix + * whose "stale" case moved both ino and ctimeNs together. + * - WHY the identity is unobservable: a thrown stat, or a zero inode. Both + * produce observe() === null, and the zero-inode case is the one NTFS is + * reported to produce — so testing only EACCES leaves the platform this + * module exists for uncovered. + * + * Requiredness runs through all of it. Optional means a failure is REPORTED + * rather than thrown; it never means the memo may describe a different file, an + * unobservable one, or one that is gone. + */ +const IDENTITY_MOVES = [ + { name: "dev-only", to: { dev: 2n, ino: 10n, ctimeNs: 100n } }, + { name: "ino-only", to: { dev: 1n, ino: 11n, ctimeNs: 100n } }, + { name: "freshness-only", to: { dev: 1n, ino: 10n, ctimeNs: 200n } }, +] as const; + +const UNOBSERVABLE = [ + { name: "stat throws", stat: () => { throw Object.assign(new Error("EACCES"), { code: "EACCES" }); } }, + // NTFS is reported to return a zero inode from this stat form; observe() + // treats that as unobservable, and it had only ever been driven required. + { name: "zero inode", stat: () => ({ dev: 1n, ino: 0n, ctimeNs: 100n }) }, +] as const; + +for (const { label, harden, create } of ENTRY_POINTS) { + const memoCount = label.startsWith("dir") + ? hardenedSecretDirCountForTests + : hardenedSecretPathCountForTests; + + for (const required of [true, false]) { + const mode = required ? "required" : "optional"; + const expectRefused = async (target: string): Promise => { + if (required) { + await expect(harden(target, { required })).rejects.toThrow(); + return; + } + const result = await harden(target, { required }); + expect(result.ok).toBe(false); + expect(result.diagnostics).toBeTruthy(); + }; + + /** Both runner seams, so one body drives the sync and async entry points. */ + const runner = (onGrant: () => void, succeeds: () => boolean): void => { + const result = () => (succeeds() + ? { success: true, exitCode: 0, timedOut: false, stdout: "" } + : { success: false, exitCode: 5, timedOut: false, stdout: "", stderr: "" }); + setIcaclsRunnerForTests(args => { + if (args.includes("/grant:r")) onGrant(); + return result(); + }); + setAsyncIcaclsRunnerForTests(async args => { + if (args.includes("/grant:r")) onGrant(); + return result(); + }); + }; + + describe(`memo attribution — ${label} / ${mode}`, () => { + for (const move of IDENTITY_MOVES) { + test(`a ${move.name} change retires the memo even when the re-harden fails`, async () => { + resetHardenedStateForTests(); + const target = join(testDir, `attr-${move.name}-${label}-${mode}.sqlite`); + create(target); + + await withWin32(async () => { + let grants = 0; + let aclSucceeds = true; + runner(() => { grants += 1; }, () => aclSucceeds); + + const original = { dev: 1n, ino: 10n, ctimeNs: 100n }; + let current: { dev: bigint; ino: bigint; ctimeNs: bigint } = original; + setStatForTests(() => current); + + expect(await harden(target, { required })).toEqual({ ok: true }); + expect(grants).toBe(1); + expect(memoCount()).toBe(1); + + // One component moves, and the re-harden FAILS — a successful one + // would overwrite the memo and hide whether the miss retired it. + current = move.to; + aclSucceeds = false; + await expectRefused(target); + expect(grants).toBe(2); + expect(memoCount()).toBe(0); + + // Restoring the original identity is not silently re-satisfied. + current = original; + aclSucceeds = true; + expect(await harden(target, { required })).toEqual({ ok: true }); + expect(grants).toBe(3); + }); + }); + } + + for (const cause of UNOBSERVABLE) { + test(`an identity unobservable because ${cause.name} does not answer`, async () => { + resetHardenedStateForTests(); + const target = join(testDir, `attr-unobs-${cause.name.replace(/\s+/g, "-")}-${label}-${mode}.sqlite`); + create(target); + + await withWin32(async () => { + let grants = 0; + let aclSucceeds = true; + runner(() => { grants += 1; }, () => aclSucceeds); + + let observable = true; + setStatForTests(() => (observable + ? { dev: 1n, ino: 10n, ctimeNs: 100n } + : cause.stat())); + + expect(await harden(target, { required })).toEqual({ ok: true }); + expect(grants).toBe(1); + expect(memoCount()).toBe(1); + + // Unobservable AND the ACL fails, so recordHarden never reaches its + // own deletion: whatever retires the entry here is the lookup. + observable = false; + aclSucceeds = false; + await expectRefused(target); + expect(grants).toBe(2); + expect(memoCount()).toBe(0); + + observable = true; + aclSucceeds = true; + expect(await harden(target, { required })).toEqual({ ok: true }); + expect(grants).toBe(3); + }); + }); + } + + test("observed absence retires the memo", async () => { + resetHardenedStateForTests(); + const target = join(testDir, `attr-absent-${label}-${mode}.sqlite`); + create(target); + + await withWin32(async () => { + let grants = 0; + runner(() => { grants += 1; }, () => true); + const identity = { dev: 1n, ino: 10n, ctimeNs: 100n }; + setStatForTests(() => identity); + + expect(await harden(target, { required })).toEqual({ ok: true }); + expect(grants).toBe(1); + expect(memoCount()).toBe(1); + + // Gone. A harden of an absent path is a no-op that must still forget it. + rmSync(target, { recursive: true, force: true }); + expect(await harden(target, { required })).toEqual({ ok: true }); + expect(grants).toBe(1); + expect(memoCount()).toBe(0); + + // Back at the SAME identity: it must harden again rather than be + // satisfied by an entry that outlived the file it described. + create(target); + expect(await harden(target, { required })).toEqual({ ok: true }); + expect(grants).toBe(2); + }); + }); + }); + } +}