diff --git a/devlog/_plan/260806_wp13_toggles_resume/000_plan.md b/devlog/_plan/260806_wp13_toggles_resume/000_plan.md new file mode 100644 index 000000000..60e741e4e --- /dev/null +++ b/devlog/_plan/260806_wp13_toggles_resume/000_plan.md @@ -0,0 +1,127 @@ +# 260806 — WP13/WP14 resume: Codex CLI toggle truth, Claude Desktop toggle, composed acceptance + +PR-ONLY unit: the branch `codex/260806-wp13-toggles` (from `origin/dev` @ `b3a1d90a8`) +is pushed and opened as PR(s) against `dev`, but **never merged** in this session — +that boundary is a user instruction, not a preference. + +This unit resumes the paused tail of two prior campaigns: + +- `devlog/_fin/260804_codex_write_substrate/` — WP13 (composed acceptance, issue + [#1048](https://github.com/lidge-jun/opencodex/issues/1048)) was deferred; WP14's PR + deliverable landed as PR #998, so "WP14" here means the *new* toggle work opened as a PR. +- `devlog/_plan/260803_codex_desktop_toggle/` — 040 (Codex toggle CLI truth) and 050 + (Claude Desktop toggle) were written but never implemented. + +## Stale-check verdicts (explorer audit, 2026-08-06, tree @ b3a1d90a8) + +All three pre-written docs are **NEEDS AMENDMENT**, none is ALREADY LANDED, none is +implementable as written. What changed under them: + +### Landed since the docs were written + +- Durable `clientIntegrations` desired state exists for `codex`/`grok` only + (`src/types.ts:551-556`, `src/config.ts:986-1012`), with owner + `setIntegrationEnabled` (`src/codex/desired-state.ts:90`) and field-scoped + `mutatePersistedConfig` (`src/config.ts:2197`). +- Dashboard `PUT /api/native-integrations/codex` persists intent before artifact work + (`src/server/management/native-integration-routes.ts:58-87,199-293`); startup honors + Codex OFF via `syncCodexOnStartIfEnabled` (`src/cli/index.ts:320`, + `src/codex/desired-state.ts:160-176`). Tests: `tests/native-codex-toggle.test.ts:106-156`, + `tests/codex-desired-state.test.ts:167-223`. +- Production injection runs under `withCodexWriteLock` (`src/codex/inject.ts:871-956`); + the typed lock model exists (`src/codex/codex-write-lock.ts:67-125`) with a real + two-process contention test (`tests/codex-inject-write-lock.test.ts:56-127`). +- c24 (Grok OFF survives restart) is **landed at unit level**: persist-before-strip + (`native-integration-routes.ts:342-374`), startup predicate `shouldSyncGrokOnStart` + (`src/cli/index.ts:350-354`, `desired-state.ts:58-76,195-197`), covered by + `tests/codex-desired-state.test.ts:233-243`. No full-process E2E; the composed + acceptance phase may add it, but c24 is not a standalone work-phase. + +### Still missing (the actual work) + +1. **CLI restore/eject do not persist desired state.** `ocx restore`/`eject` + (dispatch `src/cli/index.ts:774-819`) call `restoreNativeCodexAsync` without writing + `clientIntegrations.codex=false`; `restore back`/`eject back` do not persist ON. + Startup would resurrect routing the CLI just removed (040's core defect, alive). +2. **No artifact-level restore truth.** `restoreNativeCodexAsync` + (`src/codex/inject.ts:1193-1217`) reports `inline.success` even when the history + worker fails; no per-artifact result envelope exists. +3. **`syncModelsToCodex` and `ocx ensure` are ungated** (`src/codex/sync.ts:49-129`, + `src/cli/index.ts:379-424`): they bypass the desired-state gate. +4. **Claude Desktop has no toggle at all**: no `claude-desktop` key in + `clientIntegrations`, no native-toggle route (union is claude|grok|codex, + `native-integration-routes.ts:31`), auto-apply calls the writer directly ignoring + desired state (`agent-settings-routes.ts:131-150`), status does not classify + standard/gateway/foreign/not_installed (`agent-settings-routes.ts:767-815`), and no + `removeDesktop3pConfig`/read-only inspector exists. Audit correction: the current + status GET reads via `existsSync`/`readFileSync` and never calls the writer, so + reads are non-mutating **today**; the risk 050 guards against is a *future* OFF or + status path routing through `writeDesktop3pConfig`, whose eager `mkdirSync` + (`src/claude/desktop-3p.ts:331-345`) would manufacture a library. 020 adds the + dedicated read-only inspector rather than fixing an active violation. +5. **No composed acceptance suite.** WP13's P01-P36 doc cites pre-substrate line + numbers and pre-substrate RED claims (lock absence, no production caller) that are + no longer true. The surviving target: compose real entry points — CLI + restore/eject/ensure/sync, management toggle routes, startup gate — against a temp + home, including refusal, foreign-home, and race paths. + +### External evidence (Luna swarm, 3 lanes, all sources opened) + +- Anthropic's official configuration reference (claude.com/docs/third-party/ + claude-desktop/configuration, accessed 2026-08-06) now documents the configLibrary + (`~/Library/Application Support/Claude-3p/configLibrary/`, `_meta.json` + `.json`), + gateway fields `inferenceGatewayBaseUrl`/`ApiKey`/`AuthScheme` (bearer|x-api-key), + `inferenceModels` (string or object entries; first entry is default), + `modelDiscoveryEnabled`, and `supports1m`/`prefer1m`. The schema-drift risk recorded + in memory (private fields) is RESOLVED: the fields 050 relies on are documented. +- No official spec for behavior when the selected `.json` is missing — community + evidence shows "configuration needs attention" symptoms only (UNVERIFIED). 050's rule + stands: never leave `appliedId` pointing at a missing file; select the standard `{}` + profile before removing ours. +- No native 1P-restore control is documented; community tools restore standard mode by + selecting an official/empty profile then removing the 3P one — matching 050's pivot. +- Codex CLI reads config.toml at session start (restart-scoped); `model_provider` + selects from `model_providers`; no official restore-after-proxy runbook exists, so + our restore semantics remain artifact-based, not documented-contract-based. + +## Phase map (one decade doc per PABCD cycle) + +- **010 (WP-B)** Codex toggle completion, consuming existing `clientIntegrations`: + CLI restore/eject persist OFF, restore back/eject back persist ON, artifact-level + restore result (history failure classified, never silent), and desired-state gating + for **every** direct `syncModelsToCodex` caller with 040's discriminated skip + semantics (`040_codex_toggle.md:222-235`) and fresh checks at irreversible + boundaries (`:600-618`): `ocx ensure` (`src/cli/index.ts:379-424`), `ocx sync` + (`:856-871`), restore/eject dispatch (`:774-819`), `src/cli/models.ts:102-107`, + `src/cli/provider.ts:232-237`, + `src/server/management/config-routes.ts:261-268`, and the toggle enable path + itself (`src/server/management/native-integration-routes.ts:262`), which today + interprets only `applied.ok` and needs the same discriminated-skip handling. + Source doc: + `260803_codex_desktop_toggle/040_codex_toggle.md` with the line-map above; drop its + four-client-coordinator premise — extend the landed two-key schema instead. +- **020 (WP-C)** Claude Desktop toggle per 050's amended contract: add + `claude-desktop` to `clientIntegrations` and the native route union; a dedicated + read-only inspector for status classification (absent library = `not_installed`; + reads never write); OFF = write+select `{}` standard profile, then remove the + opencodex profile and its credential-bearing backup; OFF with no owned state = + successful no-op; GUI switch. Also gate the Desktop auto-apply path with 050's + before/after-await desired-state guards (`050_desktop_toggle.md:747-783`, + `agent-settings-routes.ts:131-150`) so a concurrent OFF cannot lose to an in-flight + apply. +- **030 (WP-D)** Composed acceptance, **reduced workstation-only scope**: one suite + through real entry points against temp homes — CLI process invocations, management + routes, startup gate — covering refusal/foreign-home/race/restore truth, including + the missing Grok E2E (disable → fresh start path → fence stays absent). This is a + deliberate subset of issue #1048's 36-entry two-execution-class program: the + disposable-host service-lifecycle class (`050_composed_acceptance.md:24-35,55-99, + 108-177,568-580`) needs `ocx service` on a throwaway host, which this session's + safety boundary forbids. **#1048 therefore stays OPEN** after 030; the PR references + it without a closing keyword and states which entries remain. +- **WP-E** Push branch, open template-complete PR(s) against dev referencing #1048, + PR CI green. **No merge, no promotion.** dev/preview/main tips proven unchanged. + +Verification per phase: `bun run typecheck`, `bun run test`, `bun run lint:gui` (gui +touched phases), `bun run privacy:scan`, temp-home live proof (`mktemp -d`; never the +real `~/.codex`/`~/.opencodex`; never `ocx start/stop/service` — launchd owns the live +proxy on :10100). Every new mechanism gets a broken-change check. diff --git a/devlog/_plan/260806_wp13_toggles_resume/010_codex_toggle_amendments.md b/devlog/_plan/260806_wp13_toggles_resume/010_codex_toggle_amendments.md new file mode 100644 index 000000000..45c35aab8 --- /dev/null +++ b/devlog/_plan/260806_wp13_toggles_resume/010_codex_toggle_amendments.md @@ -0,0 +1,98 @@ +# 010 — Codex toggle: design amendments over 040 + +`040_codex_toggle.md` stays the diff-level source for messages, exit codes, the +seven-caller skip table, and the `CodexHistoryFailureReason` discriminator +(current anchor `src/codex/history-provider.ts:167-175`). Two of its structural +premises are replaced here; where this document and 040 disagree, this document +wins. + +## Amendment 1 — no four-client coordinator; the write lock is the serializer + +040 imports `runClientIntegrationFlight`, `requirePersistedClientIntent`, and +`mutateClientIntegrationEnabled` from a WP3 shared contract that was never built +(`040_codex_toggle.md:33-38,431-454,578-618`), and `000_plan.md` r3 drops that +premise. What actually exists: + +- `setIntegrationEnabled` persists intent and explicitly does not linearize + (`src/codex/desired-state.ts:14-19,90`). +- The management route has a route-local single flight + (`native-integration-routes.ts:199-224`) that serializes toggles *within* the + server process only. +- `withCodexWriteLock` serializes Codex artifact writes *across* processes + (`src/codex/codex-write-lock.ts:67-125`; production caller `inject.ts:871-956`). + +Replacement design, Codex-only: + +1. Intent writes stay `setIntegrationEnabled` — one owner, no new mutation API. +2. The race 040 worried about (CLI OFF vs route ON, two processes) is closed by + **revalidation under each artifact's own serialization boundary**, not by a + cross-surface flight and not by one global lock. The three artifact families + already have distinct serializers, and the Codex lock is *released* before + the history worker launches (`inject.ts:951-966`), so "inside + withCodexWriteLock" cannot cover history. Concretely: + - config/profile writes re-read persisted desired state inside their + `withCodexWriteLock` transaction (`codex-write-lock.ts:315-345` — the + callback is synchronous, and the desired-state read is a synchronous file + read, so it fits); + - the history worker re-reads desired state inside + `withHistoryWriteSerialization` (`history-worker.ts:119-131`), returning + the existing `blocked`-style envelope with a new reason + `"desired_disabled"`/`"desired_enabled"` instead of mutating; + - catalog restore re-reads inside `withCatalogWriteSerialization` + (`inject.ts:1241-1246`). + A lost race becomes the discriminated skip (`status:"skipped"`, + `skippedReason:"desired_disabled"` or `"desired_enabled"` for the restore + direction). Each lock provides mutual exclusion for its artifact; the + re-read inside it provides the freshness 040's + `requirePersistedClientIntent` wanted (`040:600-618`). The small window + where different artifacts observe different intent is acceptable: each + artifact converges to the latest persisted intent, and the startup gate + re-converges the remainder on the next start. +3. The route keeps its local flight for HTTP idempotency; the CLI needs no + flight because the lock + revalidation is the correctness boundary. + +## Amendment 2 — the OFF path stays on the async worker boundary + +040's CLI diff wraps synchronous `restoreNativeCodex()` with a `beforeWrite` +hook (`040:184-205`). The CLI has since moved to `restoreNativeCodexAsync()` +with history in a Worker (`inject.ts:1193-1218`); reverting to the inline path +would regress the event-loop isolation the substrate campaign built. Instead: + +- `restoreNativeCodexAsync` gains the artifact-level result 040 demands + (`040:294-329`): a per-artifact envelope `{ config, catalog, history }` — + catalog is a first-class member because restore performs it independently + (`inject.ts:1241-1246`) and a `completed`-vs-not outcome exists today that + the summary silently flattens. Profile restoration is reported inside the + `config` member (it rides the same journal transaction). `history` carries + `CodexHistoryFailureReason` (`"busy" | "permission"`) instead of being folded + into `inline.success` (defect at `inject.ts:1193-1217`). Aggregate `success` + is false if ANY member failed. +- Persist-OFF ordering for `ocx restore`/`eject`: `setIntegrationEnabled(false)` + FIRST (so a crash mid-restore leaves intent durable and startup will not + resurrect routing), then the async restore; the history job revalidates + desired state under the lock per Amendment 1 before mutating. `restore back`/ + `eject back` persist ON first, then sync — and a sync skip caused by a + concurrent OFF prints 040's competing-OFF error with exit 2. +- `success` for the command means: config (incl. profile) AND catalog restored + AND history either restored or classified (`busy` → retry advice, exit 1; + `permission` → ACL advice, exit 1). No path reports success with an + unclassified hole in any artifact. + +## Test impact (from the audit, folded in) + +- `tests/codex-sync-api.test.ts:73-81` — expects the new `status:"applied"`. +- `tests/cli-restore-back.test.ts:11-35` — drop source-string assertions, + assert behavior through a temp-home process run. +- `tests/native-codex-toggle.test.ts:106-156` — new envelopes and seams. +- `tests/codex-desired-state.test.ts`, `tests/codex-inject-write-lock.test.ts` + — extend for revalidation-under-lock; not intrinsically broken. + +## Commit order (typecheck green at every commit) + +1. `CodexHistoryFailureReason` + artifact envelope in history-provider/inject + (additive, no callers change behavior yet). +2. Discriminated `status` on `syncModelsToCodex` + all seven callers updated in + the same commit (exit-code contract lands here). +3. Revalidation-under-lock in inject/restore/history job. +4. CLI restore/eject persist intent + new messages; process-level tests. +5. Route/context wiring + GUI, if any surface text changes. diff --git a/devlog/_plan/260806_wp13_toggles_resume/020_desktop_toggle_amendments.md b/devlog/_plan/260806_wp13_toggles_resume/020_desktop_toggle_amendments.md new file mode 100644 index 000000000..4a5e8cab4 --- /dev/null +++ b/devlog/_plan/260806_wp13_toggles_resume/020_desktop_toggle_amendments.md @@ -0,0 +1,116 @@ +# 020 — Claude Desktop toggle: design amendments over 050 + +`050_desktop_toggle.md` stays the diff-level source for the standard-mode pivot +order, the credential-cleanup contract, status classification, refusal copy, GUI +wiring, and the i18n keys. Its structural premises are replaced here; where this +document and 050 disagree, this document wins. + +## Amendment 1 — the WP3/WP5 shared contract is narrower than 050 assumes + +050's OUT section claims WP3 already owns `clientIntegrations["claude-desktop"]`, +`clientIntegrationEnabled`, `mutateClientIntegrationEnabled`, a four-client +union, required `desiredEnabled`, and a per-client flight. None of that four-client +contract exists. What exists after WP-B (this branch): + +- `clientIntegrationsSchema` with `codex`/`grok` keys plus `.passthrough()` + (`src/config.ts:986-989`) — so adding `"claude-desktop": z.boolean().optional() + .catch(undefined)` is additive and old configs stay valid. +- Desired-state owner `setIntegrationEnabled` / read helpers in + `src/codex/desired-state.ts` (Codex+Grok today; extend the id union). +- The native route union `"claude" | "grok" | "codex"` + (`native-integration-routes.ts:31`) with typed success/refusal envelopes and a + route-local single flight (`:199-224`). WP-C extends this union with + `"claude-desktop"` and reuses the same envelope/flight pattern; no new + coordinator is invented (mirrors 010 Amendment 1). +- **Envelope widening is explicit WP-C work.** The current envelopes carry no + desired state on the server (`native-integration-routes.ts:42-76`) or in the + GUI parser (`gui/src/pages/integrations/native-api.ts:20-46`), while 050 + requires `desiredEnabled` on every Desktop status, success, and post-commit + refusal (`050:685-690`). WP-C adds `desiredEnabled: boolean` to the shared + status/success envelope and to post-commit refusals for ALL clients (the + field is derived from the same persisted read each route already does), and + widens the GUI parser accordingly. Pre-commit refusals that never read config + may omit it; everything after the intent read includes it. +- 050's `runClientIntegrationFlight(...)` call in the auto-apply diff + (`050:757-783`) is replaced by: the route-local flight for HTTP callers, plus + the two persisted-intent re-reads it already specifies (before `fetchAllModels` + and immediately before the writer). The second re-read is the real guard; the + flight is idempotency, not correctness. + +## Amendment 2 — inspector first, writer never on the read path + +Confirmed by the WP-A audit: current reads are non-mutating, but there is no +classifying inspector. WP-C adds `inspectDesktop3pConfigLibrary()` in +`src/claude/desktop-3p.ts` as the single read-only owner: + +- absent library dir → `not_installed`; reads NEVER create directories or files + (the writer's eager `mkdirSync` at `desktop-3p.ts:331-345` stays write-only). +- `_meta.json` present → resolve the applied id, prove the selected `.json` + exists and parses as an object, classify `standard` (`{}` / no + `inferenceProvider`), `gateway_ours` (opencodex fingerprint current), + `gateway_drifted`, `foreign`, `no_owned_state` (library exists but nothing we + own — the OFF no-op case), or `broken` (selected file missing/unparseable). +- Typed unsafe handling per 050 (`050:704-715,1044-1054`): malformed + `_meta.json` → `metadata_unreadable` refusal (never guess); an applied id that + fails the safe-filename shape → refuse without touching the path (no + traversal); multiple rows matching our fingerprint after an interrupted + cleanup → the remover prefers the SELECTED opencodex row and reports the rest + as residue; invalid `inferenceProvider`/credential-field shapes are split by + ownership per `050:704-715`: a profile our fingerprint/metadata claims but + whose provider or credential shape no longer matches is **`unsafe`** — it + refuses convergence without any Desktop write and is never masked as + `foreign`/`no_owned_state`; only a profile with NO ownership marker is + `foreign` (a valid user-selected third-party profile). In neither case are + field values parsed further or echoed into envelopes or logs. +- The official schema is verified current (Anthropic configuration reference, + 2026-08-06, Luna lane 1): configLibrary paths per-OS, `_meta.json` + sibling + `.json`, gateway fields, `supports1m`/`prefer1m`. Missing-selected-file + behavior is officially UNVERIFIED → never leave `appliedId` dangling. + +## Amendment 3 — OFF pivot and cleanup, unchanged from 050 but restated as the contract + +1. OFF with `not_installed` or no owned state → successful idempotent no-op; + desired OFF persisted; no filesystem footprint. +2. OFF with our applied profile → write+select a credential-free `{}` standard + profile FIRST (new id, `_meta.json` updated atomically), THEN remove our old + `.json` and `.json.bak`. Success requires both absent; residue → + `cleanup_incomplete` refusal with paths only (never contents/credentials), + desired stays OFF, old metadata row kept as the retry locator. +3. Enable direction: explicit CLI apply (`src/cli/claude-desktop.ts`) and + management `/apply` persist desired ON (+ `desktopAutoApply` semantics per + 050) before writing. +4. Auto-apply (`agent-settings-routes.ts:131-150,518-528`) gains the gates from + 050: skip on desired OFF, `desktopAutoApply === false`, missing profile, and + `not_installed`/`no_owned_state`/`foreign` library kinds; re-read persisted + intent after the `fetchAllModels` await, immediately before the writer. + +## Amendment 4 — GUI consumes the three-plus-one union + +050's GUI diffs assume a WP3 four-client `native-api.ts` contract. Actual: the +runtime allowlists currently admit `claude|grok|codex`; WP-C extends them with +`claude-desktop` and the Desktop-specific refusal reasons +(`metadata_unreadable`, `cleanup_incomplete`, residual detail). Toggle lands in +`overview-clients.ts` `claudeDesktopRow` with desired state separate from +observed `applied`; `ClaudeDesktop.tsx` shows desired OFF honestly; six locales +get the exact keys 050 lists. A GUI screenshot is REQUIRED in the PR (gui is +touched). + +## Test plan (per 050 IN, adjusted) + +- `tests/desktop-3p-removal.test.ts` NEW: pivot order (standard profile selected + before removal), crash-boundary residue → `cleanup_incomplete`, idempotent + no-op OFF, `not_installed` reads create nothing (assert directory absent + after status), interrupted-cleanup double-row preference. +- `tests/native-claude-desktop-toggle.test.ts` NEW: route union, persistence + ordering (intent before artifacts), refusal envelopes, auto-apply suppression + including the post-await re-read (in-process race). +- `tests/claude-messages-endpoint.test.ts` MODIFY (050 IN list, restored): prove + Desktop OFF leaves the shared `/v1/messages` transport and health live — + the toggle disables a client's lifecycle, never the proxy surface + (`050:1092-1097`). +- Profile preservation stays binding: `src/claude/desktop-profile.ts` + assignments/defaults are consumed unchanged (`050:92-95`); the standard `{}` + profile is written by the remover path, not by re-deriving profile fields. +- `gui/tests/*` per 050 IN list; `bun run lint:gui` joins the battery. +- Broken-change check: mutate the post-await re-read guard → auto-apply race + test goes red; restore → green. diff --git a/devlog/_plan/260806_wp13_toggles_resume/030_composed_acceptance_amendments.md b/devlog/_plan/260806_wp13_toggles_resume/030_composed_acceptance_amendments.md new file mode 100644 index 000000000..34c0133e2 --- /dev/null +++ b/devlog/_plan/260806_wp13_toggles_resume/030_composed_acceptance_amendments.md @@ -0,0 +1,104 @@ +# 030 — composed acceptance: reduced workstation scope over 050 + +`devlog/_fin/260804_codex_write_substrate/050_composed_acceptance.md` remains the +source for the harness rules (real child processes, no imported handlers, seeded +temp roots, lock-path preflight/teardown allowlist, no-sleep synchronization) and +the scenario intents. This document fixes its stale RED claims and cuts the scope +to what this session's safety boundary allows. Where they disagree, this wins. + +## What changed under the doc (from the WP-A/WP-B audits) + +050's "RED today" claims predate the substrate landing and are now largely GREEN: +`withCodexWriteLock` has production callers (`inject.ts:871-956` and the restore +path), the typed acquisition/refusal taxonomy exists (`codex-write-lock.ts:67-125`), +a real two-process contention test exists (`tests/codex-inject-write-lock.test.ts`), +`convergeCodexCatalog` owns the catalog commit, and — new on THIS branch — desired- +state revalidation sits inside all three artifact serializers plus the cache +reacquisition permit, and every sync caller discriminates skips. What remains +unproven is COMPOSITION: that the real entry points, invoked as production +processes, reach those mechanisms rather than writing around them. + +## Scope cut (binding, from 000_plan.md r3) + +- **IN:** one new `tests/codex-composed-acceptance.test.ts` in the ordinary suite, + workstation-safe rows only, real spawned `src/cli/index.ts` children and a real + HTTP server on a temp `OPENCODEX_HOME` per 050's harness rules. +- **OUT:** the disposable-host service class (P09/P10/P18/P34-P36), the + `scripts/disposable-host/` job, `/healthz`-under-SQLite-contention (scenario C's + timing bound needs a controlled host), and the full 36-row census. Issue #1048 + STAYS OPEN; the PR references it without a closing keyword and names the rows + this suite covers versus defers. + +## The reduced scenario set + +Composed rows chosen to cross module boundaries this branch touched, one case each: + +Deferred workstation-safe IDs (explicit, for #1048 traceability): P01, P03, +P11-P17, P20-P33 — deferred, not covered here; covered IDs are named per +scenario below. The disposable-host class (P09/P10/P18/P34-P36) is excluded by +the safety boundary as before. + +1. **A-reduced — entry-to-funnel for the toggle-relevant rows.** Real child + invocations of: P02 (`ocx start`, then kill), P04 (`ocx ensure`), + P05 (`ocx sync`), P07 (`ocx restore`), P08 (`ocx restore back`), P06 + (`ocx sync-cache`); plus HTTP P19 (`POST /api/sync`) and the toggle routes + (`PUT /api/native-integrations/{codex,grok,claude-desktop}`) against the real + server. Each asserts the discriminated result contract (applied vs skipped vs + refused) and, where desired OFF is seeded, that NO codex artifact + (config/profile/catalog/cache/history) is created or changed — byte-level + before/after manifest of the temp `CODEX_HOME`. +2. **B-reduced — lost transition at a real entry.** Desired ON; child A enters + P19-equivalent sync against a held local provider fixture; a second production + config mutation persists OFF; release; assert A commits nothing and reports the + discriminated skip. (The WP-B unit test proved this at the injector seam; this + case proves it through the HTTP entry.) +3. **D-reduced — foreign home creates nothing.** Foreign-ownership evidence seeded + per 050; invoke P02, P04, P19, P07 as real children; byte manifest asserts zero + artifacts (lock DB, catalog, cache, config, history) created anywhere under the + temp root or the case's OS-runtime lock path. +4. **E — same effective user, different env homes, one lock.** As written in 050 + (workstation-safe): child A holds the lock via a held injection; child B with + different HOME/USERPROFILE but same CODEX_HOME gets typed `busy` naming the same + lock id; no lock artifact under either fake home. +5. **Grok E2E (from 000_plan.md).** Disable Grok via the real route, then run the + real `ocx start` startup path in a child with the persisted config; assert + the Grok fence stays absent and the startup log/result reports the + desired-state skip. +6. **Restore truth composed.** With history DB held by a `BEGIN IMMEDIATE` holder + child, run `ocx restore` as a real child. The CLI prints only `{success, + message}` (`src/cli/index.ts:817-826`), so the envelope is not directly + observable from a spawned child; the case asserts the observable contract: + non-zero exit, a message that names the history failure class (busy) rather + than claiming full success, config/catalog bytes restored on disk while + history rows are not, and a rerun after release converging. If the message + contract proves too weak to discriminate, the case adds a machine-readable + CLI output (e.g. `--json` on restore) as part of this phase rather than + weakening the assertion. + +## Harness rules kept verbatim from 050 + +Temp root via `mkdtempSync`; explicit fake `HOME`/`USERPROFILE`/`CODEX_HOME`/ +`OPENCODEX_HOME`; children spawned as `Bun.spawn([process.execPath, +resolve(repoRoot, "src/cli/index.ts"), ...])`; servers on port 0 with `/healthz` +PID verification; local provider fixtures only. **Port safety is config-seeded, +never flag-passed:** every child fixture that starts a server writes +`"port": 0` into its temp `config.json` and invokes `ocx start` WITHOUT +`--port` — the CLI rejects `--port 0` as an invalid flag value +(`src/cli/index.ts:71-85`), while an unseeded temp config would default to +10100 and collide with the live proxy. The harness discovers the actual bound +port from the isolated runtime-port record and verifies `/healthz` reports the +child's own PID before any request. Sentinel-based synchronization +(no sleep-as-readiness); watchdogs per child; lock-path preflight requires the +case's hash-derived DB absent, teardown removes only the four-name allowlist +after identity recheck; a teardown failure fails the case. The suite must pass +inside `bun scripts/test.ts` on this workstation without touching the real homes +or the live proxy. + +## Proof rule (050:37-54 applies per case) + +Every scenario names its RED condition (the mechanism whose removal must fail +it — e.g. delete a revalidation re-read, point a caller past the funnel, skip +the intent persist), and EACH of the six gets its own executed broken-change +demonstration before D closes: mutate, show that scenario red, restore, show +green, `git diff --stat` clean. One aggregate demo is not sufficient; a +scenario without a demonstrated RED is not accepted as proving anything. diff --git a/devlog/_plan/260806_wp13_toggles_resume/assets/integrations-overview.png b/devlog/_plan/260806_wp13_toggles_resume/assets/integrations-overview.png new file mode 100644 index 000000000..60bc1b841 Binary files /dev/null and b/devlog/_plan/260806_wp13_toggles_resume/assets/integrations-overview.png differ diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 088a47c45..7703f6c09 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -771,6 +771,22 @@ export const de: Record = { "integrations.detail.desktopStale": "Die Profildatei hat sich nach dem Anwenden geändert", "integrations.detail.desktopNotServed": "Das Profil ist da, Desktop nutzt aber ein anderes", "integrations.detail.desktopAbsent": "Kein Profil angewendet", + "integrations.detail.desktopDesiredOff": "Die Claude-Desktop-Integration ist deaktiviert", + "integrations.detail.desktopDesiredOnNotApplied": "Die Integration ist aktiviert, aber Desktop verwendet nicht das Gateway-Profil", + "integrations.detail.desktopSelectedElsewhere": "Desktop verwendet ein anderes Profil", + "integrations.detail.desktopProfileDrift": "Das ausgewählte Desktop-Profil wurde geändert", + "integrations.detail.desktopObservedUnsafe": "Das ausgewählte Desktop-Profil kann nicht sicher geändert werden", + "integrations.detail.desktopNotInstalled": "Die Claude-Desktop-Konfigurationsbibliothek ist nicht installiert", + "integrations.dialog.desktop.title": "Claude-Desktop-Integration deaktivieren?", + "integrations.dialog.desktop.changes": "Falls {path} ein von opencodex verwaltetes Gateway-Profil enthält, wählt Desktop zuerst ein neues Standardprofil ohne Zugangsdaten und entfernt danach das alte Profil und dessen Sicherung.", + "integrations.dialog.desktop.breakage": "Claude Desktop verwendet dann statt über opencodex gerouteter Modelle wieder das standardmäßige Claude.", + "integrations.dialog.desktop.undo": "Beim erneuten Aktivieren wird das opencodex-Profil aus deinen gespeicherten Modellzuweisungen neu erstellt.", + "integrations.dialog.desktop.restart": "Claude Desktop liest diese Konfiguration nur beim Start. Beende Desktop vollständig und öffne es erneut, damit die Änderung wirksam wird.", + "integrations.dialog.desktop.confirm": "Deaktivieren", + "integrations.native.error.desktopUnsafeMetadata": "Die Claude-Desktop-Metadaten unter {path} konnten nicht sicher gelesen werden; die Bibliothek wurde nicht geändert.", + "integrations.native.error.desktopCleanupIncomplete": "Claude Desktop zeigt auf den Standardmodus, aber alte opencodex-Zugangsdaten befinden sich noch unter: {paths}.", + "integrations.native.msg.desktopDisabled": "Claude-Desktop-Integration deaktiviert.", + "integrations.native.msg.desktopEnabled": "Claude-Desktop-Integration aktiviert.", "integrations.detail.grokModels": "{count} Modell(e) verbunden", "integrations.detail.grokAbsent": "Kein opencodex-Block in der Konfiguration", "integrations.dialog.grok.title": "Grok-Build-Integration deaktivieren?", @@ -1773,6 +1789,7 @@ export const de: Record = { "claudeDesktop.applyFailed": "Das Profil wurde gespeichert, konnte aber nicht angewendet werden.", "claudeDesktop.updateFailed": "Claude-Desktop-Aktualisierung fehlgeschlagen.", "claudeDesktop.savedApplied": "Profil gespeichert und auf Claude Desktop angewendet.", + "claudeDesktop.appliedMarkerUnsaved": "Auf Claude Desktop angewendet, aber die Anwendungsmarkierung wurde nicht gespeichert – der Status unten kann veraltet sein, bis Sie erneut anwenden.", "claudeDesktop.savedAppliedAnnounce": "Claude-Desktop-Profil gespeichert und angewendet.", "claudeDesktop.saved": "Profil gespeichert.", "claudeDesktop.savedAnnounce": "Claude-Desktop-Profil gespeichert.", @@ -1841,6 +1858,8 @@ export const de: Record = { "claudeDesktop.status.stale": "Konfiguration veraltet — erneut anwenden", "claudeDesktop.status.notApplied": "Nicht angewendet", "claudeDesktop.status.notActiveProfile": "Desktop nutzt ein anderes Profil — erneut anwenden", + "claudeDesktop.status.disabled": "Die Claude-Desktop-Integration ist deaktiviert. Beende Desktop nach dem Aktivieren vollständig und öffne es erneut.", + "claudeDesktop.enableApply": "Aktivieren und anwenden", "claudeDesktop.health.lastRequest": "Letzte Anfrage", "claudeDesktop.health.stats": "{count} Anf. / {errors} Fehl.", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 9a2d21e04..9ae886124 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1226,6 +1226,12 @@ export const en = { "integrations.detail.desktopStale": "The profile file changed after it was applied", "integrations.detail.desktopNotServed": "The profile exists, but Desktop serves another one", "integrations.detail.desktopAbsent": "No profile applied", + "integrations.detail.desktopDesiredOff": "Claude Desktop integration is off", + "integrations.detail.desktopDesiredOnNotApplied": "Integration is on, but Desktop is not using the gateway profile", + "integrations.detail.desktopSelectedElsewhere": "Desktop is using another profile", + "integrations.detail.desktopProfileDrift": "The selected Desktop profile changed", + "integrations.detail.desktopObservedUnsafe": "The selected Desktop profile cannot be changed safely", + "integrations.detail.desktopNotInstalled": "Claude Desktop configuration library is not installed", "integrations.detail.grokModels": "{count} model(s) wired", "integrations.detail.grokAbsent": "No opencodex block in the config", "integrations.dialog.grok.title": "Disable the Grok Build integration?", @@ -1233,6 +1239,12 @@ export const en = { "integrations.dialog.grok.breakage": "Disabling removes the opencodex model aliases from Grok Build. Models used with your xAI account remain available.", "integrations.dialog.grok.undo": "If opencodex is running on a loopback address, turning this back on writes a new block from the models currently available.", "integrations.dialog.grok.confirm": "Disable", + "integrations.dialog.desktop.title": "Disable Claude Desktop integration?", + "integrations.dialog.desktop.changes": "If {path} contains an opencodex-managed gateway profile, Desktop will first select a new credential-free standard profile, then remove the old profile and backup.", + "integrations.dialog.desktop.breakage": "Claude Desktop will return to standard Claude instead of models routed through opencodex.", + "integrations.dialog.desktop.undo": "Turning this back on regenerates the opencodex profile from your saved model assignments.", + "integrations.dialog.desktop.restart": "Claude Desktop reads this configuration only at launch. Fully quit and reopen it for this change to take effect.", + "integrations.dialog.desktop.confirm": "Disable", "integrations.native.msg.nonLoopbackRemoved": "Grok Build can be registered automatically only while opencodex runs on a loopback address. The previous block that pointed to loopback was removed.", "integrations.native.msg.nonLoopbackRemovedNoop": "Grok Build can be registered automatically only while opencodex runs on a loopback address. There was no previous block to remove.", "integrations.native.msg.nonLoopbackSuperseded": "Grok Build can be registered automatically only while opencodex runs on a loopback address. Another process wrote a new block in the meantime, so the block now in the file was not created by this request.", @@ -1240,6 +1252,10 @@ export const en = { "integrations.native.error.homeMismatch": "The installed service home does not match the current home, so the file was left unchanged.", "integrations.native.error.notInstalled": "Grok Build is not installed, so there is nothing to change.", "integrations.native.error.configBusy": "The configuration is being saved elsewhere and could not be changed. Try again shortly.", + "integrations.native.error.desktopUnsafeMetadata": "Claude Desktop metadata at {path} could not be read safely, so its library was not changed.", + "integrations.native.error.desktopCleanupIncomplete": "Claude Desktop is pointed at standard mode, but old opencodex credential files remain at: {paths}.", + "integrations.native.msg.desktopDisabled": "Claude Desktop integration disabled.", + "integrations.native.msg.desktopEnabled": "Claude Desktop integration enabled.", "integrations.state.absent": "Not applied", "integrations.state.current": "Applied", "integrations.state.stale": "Update needed", @@ -1807,6 +1823,7 @@ export const en = { "claudeDesktop.applyFailed": "Profile was saved, but could not be applied.", "claudeDesktop.updateFailed": "Claude Desktop update failed.", "claudeDesktop.savedApplied": "Profile saved and applied to Claude Desktop.", + "claudeDesktop.appliedMarkerUnsaved": "Applied to Claude Desktop, but the applied marker was not saved — the saved-vs-applied state below may read stale until you apply again.", "claudeDesktop.savedAppliedAnnounce": "Claude Desktop profile saved and applied.", "claudeDesktop.saved": "Profile saved.", "claudeDesktop.savedAnnounce": "Claude Desktop profile saved.", @@ -1875,6 +1892,8 @@ export const en = { "claudeDesktop.status.stale": "Config stale — re-apply", "claudeDesktop.status.notApplied": "Not applied", "claudeDesktop.status.notActiveProfile": "Desktop is serving another profile — re-apply", + "claudeDesktop.status.disabled": "Claude Desktop integration is off. Fully quit and reopen Desktop after enabling it.", + "claudeDesktop.enableApply": "Enable and apply", "claudeDesktop.health.lastRequest": "Last request", "claudeDesktop.health.stats": "{count} req / {errors} err", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 1772c2f32..d5f7a915b 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1174,6 +1174,22 @@ export const ja: Record = { "integrations.detail.desktopStale": "適用後にプロファイルが変更されました", "integrations.detail.desktopNotServed": "プロファイルはありますが Desktop は別のものを使用中です", "integrations.detail.desktopAbsent": "適用されたプロファイルはありません", + "integrations.detail.desktopDesiredOff": "Claude Desktop 連携はオフです", + "integrations.detail.desktopDesiredOnNotApplied": "連携はオンですが、Desktop はゲートウェイプロファイルを使用していません", + "integrations.detail.desktopSelectedElsewhere": "Desktop は別のプロファイルを使用しています", + "integrations.detail.desktopProfileDrift": "選択された Desktop プロファイルが変更されました", + "integrations.detail.desktopObservedUnsafe": "選択された Desktop プロファイルは安全に変更できません", + "integrations.detail.desktopNotInstalled": "Claude Desktop の設定ライブラリがインストールされていません", + "integrations.dialog.desktop.title": "Claude Desktop 連携を無効にしますか?", + "integrations.dialog.desktop.changes": "{path} に opencodex 管理のゲートウェイプロファイルがある場合、Desktop は先に認証情報のない標準プロファイルを選択し、その後で古いプロファイルとバックアップを削除します。", + "integrations.dialog.desktop.breakage": "Claude Desktop は opencodex 経由のモデルではなく、標準の Claude に戻ります。", + "integrations.dialog.desktop.undo": "再度有効にすると、保存済みのモデル割り当てから opencodex プロファイルを再生成します。", + "integrations.dialog.desktop.restart": "Claude Desktop は起動時にのみこの設定を読み取ります。変更を反映するには完全に終了して再起動してください。", + "integrations.dialog.desktop.confirm": "無効にする", + "integrations.native.error.desktopUnsafeMetadata": "{path} の Claude Desktop メタデータを安全に読み取れなかったため、ライブラリは変更されませんでした。", + "integrations.native.error.desktopCleanupIncomplete": "Claude Desktop は標準モードを指していますが、古い opencodex 認証情報ファイルが残っています: {paths}。", + "integrations.native.msg.desktopDisabled": "Claude Desktop 連携を無効にしました。", + "integrations.native.msg.desktopEnabled": "Claude Desktop 連携を有効にしました。", "integrations.detail.grokModels": "モデル {count} 個を接続済み", "integrations.detail.grokAbsent": "設定に opencodex ブロックがありません", "integrations.dialog.grok.title": "Grok Build 連携を解除しますか?", @@ -1654,6 +1670,7 @@ export const ja: Record = { "claudeDesktop.applyFailed": "プロファイルは保存されましたが、適用できませんでした。", "claudeDesktop.updateFailed": "Claude Desktop の更新に失敗しました。", "claudeDesktop.savedApplied": "プロファイルを保存し、Claude Desktop に適用しました。", + "claudeDesktop.appliedMarkerUnsaved": "Claude Desktop への適用は完了しましたが、適用マーカーを保存できませんでした。再度適用するまで、下の保存済み/適用済み表示が実際と異なる場合があります。", "claudeDesktop.savedAppliedAnnounce": "Claude Desktop プロファイルを保存して適用しました。", "claudeDesktop.saved": "プロファイルを保存しました。", "claudeDesktop.savedAnnounce": "Claude Desktop プロファイルを保存しました。", @@ -1722,6 +1739,8 @@ export const ja: Record = { "claudeDesktop.status.stale": "設定が古くなっています — 再適用してください", "claudeDesktop.status.notApplied": "未適用", "claudeDesktop.status.notActiveProfile": "Desktop は別のプロファイルを使用中 — 再適用してください", + "claudeDesktop.status.disabled": "Claude Desktop 連携はオフです。有効にした後、Desktop を完全に終了して再起動してください。", + "claudeDesktop.enableApply": "有効にして適用", "claudeDesktop.health.lastRequest": "最終リクエスト", "claudeDesktop.health.stats": "{count} リクエスト / {errors} エラー", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 06df5a7d7..e0ee1af6a 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -795,6 +795,22 @@ export const ko: Record = { "integrations.detail.desktopStale": "적용 후 프로필 파일이 바뀌었습니다", "integrations.detail.desktopNotServed": "프로필은 있지만 Desktop이 다른 것을 씁니다", "integrations.detail.desktopAbsent": "적용된 프로필이 없습니다", + "integrations.detail.desktopDesiredOff": "Claude Desktop 통합이 꺼져 있습니다", + "integrations.detail.desktopDesiredOnNotApplied": "통합은 켜져 있지만 Desktop이 게이트웨이 프로필을 사용하지 않습니다", + "integrations.detail.desktopSelectedElsewhere": "Desktop이 다른 프로필을 사용 중입니다", + "integrations.detail.desktopProfileDrift": "선택된 Desktop 프로필이 변경되었습니다", + "integrations.detail.desktopObservedUnsafe": "선택된 Desktop 프로필은 안전하게 변경할 수 없습니다", + "integrations.detail.desktopNotInstalled": "Claude Desktop 구성 라이브러리가 설치되지 않았습니다", + "integrations.dialog.desktop.title": "Claude Desktop 통합을 끌까요?", + "integrations.dialog.desktop.changes": "{path}에 opencodex 게이트웨이 프로필이 있으면 먼저 자격 증명 없는 표준 프로필을 선택한 뒤 이전 프로필과 백업을 제거합니다.", + "integrations.dialog.desktop.breakage": "Claude Desktop은 opencodex를 통한 모델 대신 표준 Claude로 돌아갑니다.", + "integrations.dialog.desktop.undo": "다시 켜면 저장된 모델 할당으로 opencodex 프로필을 새로 만듭니다.", + "integrations.dialog.desktop.restart": "Claude Desktop은 시작할 때만 이 구성을 읽습니다. 변경하려면 완전히 종료한 뒤 다시 여세요.", + "integrations.dialog.desktop.confirm": "해제", + "integrations.native.error.desktopUnsafeMetadata": "{path}의 Claude Desktop 메타데이터를 안전하게 읽을 수 없어 라이브러리를 변경하지 않았습니다.", + "integrations.native.error.desktopCleanupIncomplete": "Claude Desktop은 표준 모드를 가리키지만 이전 opencodex 자격 증명 파일이 남아 있습니다: {paths}.", + "integrations.native.msg.desktopDisabled": "Claude Desktop 통합을 해제했습니다.", + "integrations.native.msg.desktopEnabled": "Claude Desktop 통합을 켰습니다.", "integrations.detail.grokModels": "모델 {count}개 연결됨", "integrations.detail.grokAbsent": "설정에 opencodex 블록이 없습니다", "integrations.dialog.grok.title": "Grok Build 연동을 해제할까요?", @@ -1800,6 +1816,7 @@ export const ko: Record = { "claudeDesktop.applyFailed": "프로필은 저장했지만 적용하지 못했습니다.", "claudeDesktop.updateFailed": "Claude Desktop 업데이트에 실패했습니다.", "claudeDesktop.savedApplied": "프로필을 저장하고 Claude Desktop에 적용했습니다.", + "claudeDesktop.appliedMarkerUnsaved": "Claude Desktop에는 적용했지만 적용 표시를 저장하지 못했습니다. 다시 적용하기 전까지 아래 저장/적용 상태가 실제와 다르게 보일 수 있습니다.", "claudeDesktop.savedAppliedAnnounce": "Claude Desktop 프로필 저장과 적용을 마쳤습니다.", "claudeDesktop.saved": "프로필을 저장했습니다.", "claudeDesktop.savedAnnounce": "Claude Desktop 프로필을 저장했습니다.", @@ -1868,6 +1885,8 @@ export const ko: Record = { "claudeDesktop.status.stale": "설정 변경됨 — 재적용 필요", "claudeDesktop.status.notApplied": "미적용", "claudeDesktop.status.notActiveProfile": "Desktop이 다른 프로필을 사용 중 — 재적용 필요", + "claudeDesktop.status.disabled": "Claude Desktop 통합이 꺼져 있습니다. 켠 뒤 Desktop을 완전히 종료하고 다시 여세요.", + "claudeDesktop.enableApply": "켜고 적용", "claudeDesktop.health.lastRequest": "마지막 요청", "claudeDesktop.health.stats": "{count} 요청 / {errors} 에러", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index b1b5443a6..f707bb5f5 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1216,6 +1216,22 @@ export const ru: Record = { "integrations.detail.desktopStale": "Файл профиля изменился после применения", "integrations.detail.desktopNotServed": "Профиль есть, но Desktop использует другой", "integrations.detail.desktopAbsent": "Профиль не применён", + "integrations.detail.desktopDesiredOff": "Интеграция Claude Desktop отключена", + "integrations.detail.desktopDesiredOnNotApplied": "Интеграция включена, но Desktop не использует профиль шлюза", + "integrations.detail.desktopSelectedElsewhere": "Desktop использует другой профиль", + "integrations.detail.desktopProfileDrift": "Выбранный профиль Desktop был изменён", + "integrations.detail.desktopObservedUnsafe": "Выбранный профиль Desktop нельзя безопасно изменить", + "integrations.detail.desktopNotInstalled": "Библиотека конфигурации Claude Desktop не установлена", + "integrations.dialog.desktop.title": "Отключить интеграцию Claude Desktop?", + "integrations.dialog.desktop.changes": "Если {path} содержит профиль шлюза, управляемый opencodex, Desktop сначала выберет новый стандартный профиль без учётных данных, а затем удалит старый профиль и резервную копию.", + "integrations.dialog.desktop.breakage": "Claude Desktop вернётся к обычному Claude вместо моделей, маршрутизируемых через opencodex.", + "integrations.dialog.desktop.undo": "При повторном включении профиль opencodex будет создан заново из сохранённых назначений моделей.", + "integrations.dialog.desktop.restart": "Claude Desktop читает эту конфигурацию только при запуске. Полностью закройте и снова откройте Desktop, чтобы изменение вступило в силу.", + "integrations.dialog.desktop.confirm": "Отключить", + "integrations.native.error.desktopUnsafeMetadata": "Не удалось безопасно прочитать метаданные Claude Desktop в {path}, поэтому библиотека не изменялась.", + "integrations.native.error.desktopCleanupIncomplete": "Claude Desktop указывает на стандартный режим, но старые файлы учётных данных opencodex остались в: {paths}.", + "integrations.native.msg.desktopDisabled": "Интеграция Claude Desktop отключена.", + "integrations.native.msg.desktopEnabled": "Интеграция Claude Desktop включена.", "integrations.detail.grokModels": "Подключено моделей: {count}", "integrations.detail.grokAbsent": "В конфигурации нет блока opencodex", "integrations.dialog.grok.title": "Отключить интеграцию Grok Build?", @@ -1696,6 +1712,7 @@ export const ru: Record = { "claudeDesktop.applyFailed": "Профиль сохранён, но применить его не удалось.", "claudeDesktop.updateFailed": "Не удалось обновить Claude Desktop.", "claudeDesktop.savedApplied": "Профиль сохранён и применён к Claude Desktop.", + "claudeDesktop.appliedMarkerUnsaved": "Применено к Claude Desktop, но отметка о применении не сохранена — состояние ниже может показывать устаревшие данные до повторного применения.", "claudeDesktop.savedAppliedAnnounce": "Профиль Claude Desktop сохранён и применён.", "claudeDesktop.saved": "Профиль сохранён.", "claudeDesktop.savedAnnounce": "Профиль Claude Desktop сохранён.", @@ -1764,6 +1781,8 @@ export const ru: Record = { "claudeDesktop.status.stale": "Конфигурация устарела — примените заново", "claudeDesktop.status.notApplied": "Не применено", "claudeDesktop.status.notActiveProfile": "Desktop использует другой профиль — примените заново", + "claudeDesktop.status.disabled": "Интеграция Claude Desktop отключена. После включения полностью закройте и снова откройте Desktop.", + "claudeDesktop.enableApply": "Включить и применить", "claudeDesktop.health.lastRequest": "Последний запрос", "claudeDesktop.health.stats": "{count} запр. / {errors} ошиб.", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index a02531b0b..14113c611 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -788,6 +788,22 @@ export const zh: Record = { "integrations.detail.desktopStale": "应用后配置文件已更改", "integrations.detail.desktopNotServed": "配置存在,但 Desktop 使用的是另一个", "integrations.detail.desktopAbsent": "未应用任何配置", + "integrations.detail.desktopDesiredOff": "Claude Desktop 集成已关闭", + "integrations.detail.desktopDesiredOnNotApplied": "集成已开启,但 Desktop 未使用网关配置", + "integrations.detail.desktopSelectedElsewhere": "Desktop 正在使用其他配置", + "integrations.detail.desktopProfileDrift": "选中的 Desktop 配置已更改", + "integrations.detail.desktopObservedUnsafe": "无法安全更改选中的 Desktop 配置", + "integrations.detail.desktopNotInstalled": "未安装 Claude Desktop 配置库", + "integrations.dialog.desktop.title": "要关闭 Claude Desktop 集成吗?", + "integrations.dialog.desktop.changes": "如果 {path} 包含由 opencodex 管理的网关配置,Desktop 会先选择新的无凭据标准配置,再移除旧配置及其备份。", + "integrations.dialog.desktop.breakage": "Claude Desktop 将不再使用经由 opencodex 路由的模型,而会恢复为标准 Claude。", + "integrations.dialog.desktop.undo": "重新开启后,会根据已保存的模型分配重新生成 opencodex 配置。", + "integrations.dialog.desktop.restart": "Claude Desktop 仅在启动时读取此配置。请完全退出并重新打开 Desktop 以使更改生效。", + "integrations.dialog.desktop.confirm": "停用", + "integrations.native.error.desktopUnsafeMetadata": "无法安全读取 {path} 中的 Claude Desktop 元数据,因此未更改其配置库。", + "integrations.native.error.desktopCleanupIncomplete": "Claude Desktop 已指向标准模式,但仍残留旧的 opencodex 凭据文件:{paths}。", + "integrations.native.msg.desktopDisabled": "Claude Desktop 集成已关闭。", + "integrations.native.msg.desktopEnabled": "Claude Desktop 集成已开启。", "integrations.detail.grokModels": "已接入 {count} 个模型", "integrations.detail.grokAbsent": "配置中没有 opencodex 区块", "integrations.dialog.grok.title": "要停用 Grok Build 集成吗?", @@ -1793,6 +1809,7 @@ export const zh: Record = { "claudeDesktop.applyFailed": "配置已保存,但无法应用。", "claudeDesktop.updateFailed": "Claude Desktop 更新失败。", "claudeDesktop.savedApplied": "配置已保存并应用到 Claude Desktop。", + "claudeDesktop.appliedMarkerUnsaved": "已应用到 Claude Desktop,但应用标记未能保存。在再次应用之前,下方的已保存/已应用状态可能显示不准确。", "claudeDesktop.savedAppliedAnnounce": "Claude Desktop 配置已保存并应用。", "claudeDesktop.saved": "配置已保存。", "claudeDesktop.savedAnnounce": "Claude Desktop 配置已保存。", @@ -1861,6 +1878,8 @@ export const zh: Record = { "claudeDesktop.status.stale": "配置已更改 — 需重新应用", "claudeDesktop.status.notApplied": "未应用", "claudeDesktop.status.notActiveProfile": "Desktop 正在使用其他配置 — 请重新应用", + "claudeDesktop.status.disabled": "Claude Desktop 集成已关闭。开启后请完全退出并重新打开 Desktop。", + "claudeDesktop.enableApply": "开启并应用", "claudeDesktop.health.lastRequest": "最后请求", "claudeDesktop.health.stats": "{count} 请求 / {errors} 错误", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/pages/ClaudeDesktop.tsx b/gui/src/pages/ClaudeDesktop.tsx index 32b3a41f5..f2cddba1d 100644 --- a/gui/src/pages/ClaudeDesktop.tsx +++ b/gui/src/pages/ClaudeDesktop.tsx @@ -43,6 +43,7 @@ interface DesktopModel { } interface DesktopStatus { + desiredEnabled: boolean; applied: boolean; appliedAt: string | null; stale: boolean; @@ -161,7 +162,7 @@ export default function ClaudeDesktop({ const [draftProfile, setProfile] = useState(() => cached.profile); const [savedDraftProfile, setSavedProfile] = useState(() => cached.savedProfile); const [draftDestinations, setDestinations] = useState>(() => cached.destinations); - const [message, setMessage] = useState<{ tone: "ok" | "err"; text: string } | null>(null); + const [message, setMessage] = useState<{ tone: "ok" | "err" | "warn"; text: string } | null>(null); const [announcement, setAnnouncement] = useState(""); const [pending, setPending] = useState(null); // Lane density: search and paging are RENDER-ONLY. modelsByFamily and effectiveDefaults must @@ -320,9 +321,20 @@ export default function ClaudeDesktop({ if (applyAfter) { setPending("apply"); const applyResponse = await fetch(`${apiBase}/api/claude-desktop/apply`, { method: "POST" }); - await readJsonOrThrow<{ error?: string }>(applyResponse, t("claudeDesktop.applyFailed")); - setMessage({ tone: "ok", text: t("claudeDesktop.savedApplied") }); - setAnnouncement(t("claudeDesktop.savedAppliedAnnounce")); + const applyBody = await readJsonOrThrow<{ error?: string; saved?: boolean; warning?: string }>( + applyResponse, + t("claudeDesktop.applyFailed"), + ); + // Partial success: Desktop WAS written, but the applied marker did not + // persist, so the saved-vs-applied strip will read stale. Say so rather + // than showing the clean success the user did not get. + if (applyBody?.saved === false) { + setMessage({ tone: "warn", text: t("claudeDesktop.appliedMarkerUnsaved") }); + setAnnouncement(t("claudeDesktop.appliedMarkerUnsaved")); + } else { + setMessage({ tone: "ok", text: t("claudeDesktop.savedApplied") }); + setAnnouncement(t("claudeDesktop.savedAppliedAnnounce")); + } } else { setMessage({ tone: "ok", text: t("claudeDesktop.saved") }); setAnnouncement(t("claudeDesktop.savedAnnounce")); @@ -408,6 +420,8 @@ export default function ClaudeDesktop({ ? "not-applied" : !status ? "pending" + : !status.desiredEnabled + ? "not-applied" : status.activeProfile === false ? "not-applied" : status.stale @@ -426,6 +440,8 @@ export default function ClaudeDesktop({ ? t("claudeDesktop.loadFail") : !status ? t("claudeDesktop.loading") + : !status.desiredEnabled + ? t("claudeDesktop.status.disabled") : status.activeProfile === false ? t("claudeDesktop.status.notActiveProfile") : status.stale @@ -459,7 +475,7 @@ export default function ClaudeDesktop({ {pending === "save" ? t("claudeDesktop.saving") : t("common.save")} diff --git a/gui/src/pages/integrations/IntegrationsOverview.tsx b/gui/src/pages/integrations/IntegrationsOverview.tsx index cc063bd5a..79788f036 100644 --- a/gui/src/pages/integrations/IntegrationsOverview.tsx +++ b/gui/src/pages/integrations/IntegrationsOverview.tsx @@ -41,6 +41,15 @@ const GROK_DISABLE_COPY: ConsequenceCopy = { confirmKey: "integrations.dialog.grok.confirm", }; +const DESKTOP_DISABLE_COPY: ConsequenceCopy = { + titleKey: "integrations.dialog.desktop.title", + changesKey: "integrations.dialog.desktop.changes", + breakageKey: "integrations.dialog.desktop.breakage", + undoKey: "integrations.dialog.desktop.undo", + sideEffectKey: "integrations.dialog.desktop.restart", + confirmKey: "integrations.dialog.desktop.confirm", +}; + const KIND_KEY: Record = { apply: "integrations.kind.apply", disable: "integrations.kind.disable", @@ -115,7 +124,7 @@ function OverviewCard({ {row.toggle && onToggle && (
navigateHash(row.hash)} - onToggle={row.toggle ? () => requestToggle(row, !row.applied) : null} + onToggle={row.toggle ? () => requestToggle(row, !(row.toggleOn ?? row.applied)) : null} /> ))} @@ -584,7 +593,7 @@ export default function IntegrationsOverview({ )} {pendingToggle && ( setPendingToggle(null)} onConfirm={async () => { await toggleCard(pendingToggle, false); diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index 927f1417a..2d6830da6 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -268,6 +268,9 @@ async function readOptional(request: Promise): Promise { export async function loadCodexRoutingStatus(apiBase: string, signal?: AbortSignal) { const body = await readOptional<{ + desiredEnabled?: unknown; + installed?: unknown; + observedKind?: unknown; routingInjected?: unknown; status?: unknown; recommendedCommand?: unknown; @@ -320,9 +323,15 @@ export async function loadClaudeDesktopStatus(apiBase: string, signal?: AbortSig stale?: unknown; activeProfile?: unknown; appliedAt?: unknown; + desiredEnabled?: unknown; + installed?: unknown; + observedKind?: unknown; }>(fetch(`${apiBase}/api/claude-desktop/status`, { signal })); - if (!body) return null; + if (!body || typeof body.desiredEnabled !== "boolean" || typeof body.installed !== "boolean" || typeof body.observedKind !== "string") return null; return { + desiredEnabled: body.desiredEnabled, + installed: body.installed, + observedKind: body.observedKind, applied: body.applied === true, stale: body.stale === true, // Tri-state on purpose: `null` means undeterminable, which must not be diff --git a/gui/src/pages/integrations/native-api.ts b/gui/src/pages/integrations/native-api.ts index 05fec0e8f..7e05e0fe3 100644 --- a/gui/src/pages/integrations/native-api.ts +++ b/gui/src/pages/integrations/native-api.ts @@ -8,20 +8,24 @@ import { readJsonIfOk } from "../../fetch-json"; * 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 NativeIntegrationClientId = "claude" | "grok" | "codex" | "claude-desktop"; export type NativeIntegrationState = "absent" | "current" | "unsafe"; export type NativeRefusalReason = | "not_installed" | "orphaned_marker" | "home_mismatch" | "config_busy" - | "write_failed"; + | "write_failed" + | "metadata_unreadable" + | "cleanup_incomplete" + | "desired_state_changed"; export interface NativeStatus { clientId: NativeIntegrationClientId; state: NativeIntegrationState; installed: boolean; configPath: string; + desiredEnabled: boolean; disableBlocked: { reason: NativeRefusalReason; message: string } | null; } @@ -35,6 +39,7 @@ export interface NativeToggleEnvelope { changed: boolean; state: NativeIntegrationState; message: string; + desiredEnabled: boolean; reason?: string; } @@ -44,6 +49,8 @@ export interface NativeRefusalEnvelope { clientId: NativeIntegrationClientId; reason: NativeRefusalReason; message: string; + desiredEnabled?: boolean; + residualPaths?: string[]; } export interface NativeErrorEnvelope { @@ -58,7 +65,7 @@ export type NativeErrorBody = NativeErrorEnvelope | NativeRefusalEnvelope; // 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_CLIENTS: ReadonlySet = new Set(["claude", "grok", "codex", "claude-desktop"]); const NATIVE_REFUSAL_CODES: ReadonlySet = new Set([ "native_integration_refused", "native_integration_failed", @@ -69,6 +76,9 @@ const NATIVE_REFUSAL_REASONS: ReadonlySet = new Set "home_mismatch", "config_busy", "write_failed", + "metadata_unreadable", + "cleanup_incomplete", + "desired_state_changed", ]); function isRecord(value: unknown): value is Record { diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts index 6fe89d5ec..73811976b 100644 --- a/gui/src/pages/integrations/overview-clients.ts +++ b/gui/src/pages/integrations/overview-clients.ts @@ -19,7 +19,7 @@ import { type FileIntegrationClientId, type IntegrationStatus, } from "./integration-api"; -import type { NativeStatus } from "./native-api"; +import type { NativeIntegrationClientId, NativeStatus } from "./native-api"; export type OverviewClientId = | "codex" @@ -66,6 +66,8 @@ export interface OverviewRow { installed: boolean; /** Drives the "applied" summary count. */ applied: boolean; + /** Desired switch position; separate from observed application. */ + toggleOn?: boolean; /** * The one line under the title. File clients show their config path — the * thing a user copies when a refusal tells them to finish by hand. The other @@ -75,8 +77,12 @@ export interface OverviewRow { detail: string | null; detailKey: TKey | null; detailVars: Record | null; - /** The client toggled by the inline switch; null means navigation only. */ - toggle: OverviewClientId | null; + /** + * The client toggled by the inline switch; null means navigation only. + * Native clients use their wire ids (`claude-desktop`), which differ from the + * camelCase row id (`claudeDesktop`) — the toggle names the API target. + */ + toggle: OverviewClientId | NativeIntegrationClientId | null; /** A read-time refusal that disables the switch before a doomed mutation. */ toggleBlocked: NativeStatus["disableBlocked"]; /** Live native path used by the consequence dialog and localized refusals. */ @@ -96,6 +102,9 @@ export interface ClaudeCodePayload { authMode?: string; } export interface ClaudeDesktopPayload { + desiredEnabled?: boolean; + installed?: boolean; + observedKind?: string; applied?: boolean; stale?: boolean; activeProfile?: boolean | null; @@ -270,27 +279,35 @@ function claudeRow( * Desktop is not honoring. `null` is undeterminable and must not downgrade a * healthy `current`. */ -function claudeDesktopRow(payload: ClaudeDesktopPayload | null): OverviewRow { +function claudeDesktopRow( + payload: ClaudeDesktopPayload | null, + native: NativeStatus | undefined, + nativeSettled: boolean, +): OverviewRow { const base = { id: "claudeDesktop" as const, hash: "integrations/claude/desktop", // "Desktop" alone is ambiguous next to ten other client names. labelKey: "claudeDesktop.title" as TKey, - toggle: null, - toggleBlocked: null, - togglePath: null, + toggle: "claude-desktop" as const, + toggleBlocked: native?.disableBlocked ?? null, + togglePath: native?.configPath ?? null, status: null, detail: null, detailVars: null, }; - if (!payload) return { ...base, state: "unknown", installed: false, applied: false, detailKey: null }; + if (!payload || !nativeSettled || !native || typeof payload.desiredEnabled !== "boolean") { + return { ...base, toggle: null, state: "unknown", installed: false, applied: false, detailKey: null }; + } + const toggleOn = payload.desiredEnabled; if (payload.applied !== true) { return { ...base, state: "absent", - installed: true, + installed: payload.installed === true, applied: false, - detailKey: "integrations.detail.desktopAbsent", + toggleOn, + detailKey: toggleOn ? "integrations.detail.desktopDesiredOnNotApplied" : "integrations.detail.desktopDesiredOff", }; } const drifted = payload.stale === true || payload.activeProfile === false; @@ -299,6 +316,7 @@ function claudeDesktopRow(payload: ClaudeDesktopPayload | null): OverviewRow { state: drifted ? "stale" : "current", installed: true, applied: true, + toggleOn, // Separate sentences: a drifted file and a profile Desktop is not serving // are different problems with different fixes. detailKey: payload.activeProfile === false @@ -395,7 +413,11 @@ export function buildOverviewRows(sources: OverviewSources): OverviewRows { const rows: OverviewRow[] = [ codexRow(sources.codex), claudeRow(sources.claude, nativeClaude, sources.nativeSettled), - claudeDesktopRow(sources.claudeDesktop), + claudeDesktopRow( + sources.claudeDesktop, + sources.native?.find(client => client.clientId === "claude-desktop"), + sources.nativeSettled, + ), grokRow(sources.grok, nativeGrok, sources.nativeSettled), ]; for (const clientId of FILE_INTEGRATION_CLIENTS) { diff --git a/gui/src/pages/integrations/refusal-copy.ts b/gui/src/pages/integrations/refusal-copy.ts index 824ac6089..6415be022 100644 --- a/gui/src/pages/integrations/refusal-copy.ts +++ b/gui/src/pages/integrations/refusal-copy.ts @@ -68,6 +68,10 @@ function describeNativeRefusal( } if (refusal.reason === "not_installed") return t("integrations.native.error.notInstalled"); if (refusal.reason === "config_busy") return t("integrations.native.error.configBusy"); + if (refusal.reason === "metadata_unreadable") return t("integrations.native.error.desktopUnsafeMetadata", { path: configPath ?? "" }); + if (refusal.reason === "cleanup_incomplete") { + return t("integrations.native.error.desktopCleanupIncomplete", { paths: (refusal.residualPaths ?? []).join(", ") }); + } return refusal.message || t("integrations.error.generic"); } diff --git a/gui/src/ui.tsx b/gui/src/ui.tsx index d4f672c3e..b76d0a213 100644 --- a/gui/src/ui.tsx +++ b/gui/src/ui.tsx @@ -14,9 +14,12 @@ export function Switch({ on, onClick, disabled, label }: { on: boolean; onClick: ); } -export function Notice({ tone, children }: { tone: "ok" | "err"; children: ReactNode }) { +export function Notice({ tone, children }: { tone: "ok" | "err" | "warn"; children: ReactNode }) { + // `warn` is degraded-but-not-failed: the action happened, something adjacent + // did not. It must not render as the clean success the user did not get. + const toneClass = tone === "ok" ? "notice-ok" : tone === "warn" ? "notice-warn" : "notice-err"; return ( -
+
{tone === "ok" ? : } {children}
diff --git a/gui/tests/integrations-overview-rows.test.ts b/gui/tests/integrations-overview-rows.test.ts index 4cd926dc9..5410c9fd8 100644 --- a/gui/tests/integrations-overview-rows.test.ts +++ b/gui/tests/integrations-overview-rows.test.ts @@ -35,6 +35,8 @@ function sources(overrides: Partial = {}): OverviewSources { claude: null, claudeDesktop: null, grok: null, + native: null, + nativeSettled: true, ...overrides, }; } @@ -79,24 +81,32 @@ test("Codex reads routingInjected, not status", () => { }); test("Claude Desktop: applied but not the served profile reads as stale", () => { + const desktopNative = [{ + clientId: "claude-desktop" as const, + state: "current" as const, + installed: true, + configPath: "/tmp/desktop", + desiredEnabled: true, + disableBlocked: null, + }]; const served = buildOverviewRows( - sources({ claudeDesktop: { applied: true, stale: false, activeProfile: true } }), + sources({ native: desktopNative, claudeDesktop: { desiredEnabled: true, installed: true, applied: true, stale: false, activeProfile: true } }), ); expect(rowById(served, "claudeDesktop").state).toBe("current"); const notServed = buildOverviewRows( - sources({ claudeDesktop: { applied: true, stale: false, activeProfile: false } }), + sources({ native: desktopNative, claudeDesktop: { desiredEnabled: true, installed: true, applied: true, stale: false, activeProfile: false } }), ); expect(rowById(notServed, "claudeDesktop").state).toBe("stale"); const drifted = buildOverviewRows( - sources({ claudeDesktop: { applied: true, stale: true, activeProfile: true } }), + sources({ native: desktopNative, claudeDesktop: { desiredEnabled: true, installed: true, applied: true, stale: true, activeProfile: true } }), ); expect(rowById(drifted, "claudeDesktop").state).toBe("stale"); // Undeterminable must not downgrade a healthy applied profile. const unknownProfile = buildOverviewRows( - sources({ claudeDesktop: { applied: true, stale: false, activeProfile: null } }), + sources({ native: desktopNative, claudeDesktop: { desiredEnabled: true, installed: true, applied: true, stale: false, activeProfile: null } }), ); expect(rowById(unknownProfile, "claudeDesktop").state).toBe("current"); }); @@ -133,7 +143,29 @@ test("every client counts toward the summary, not just the file six", () => { codex: { routingInjected: true, status: "at-risk" }, keyCount: 2, claude: { enabled: true }, - claudeDesktop: { applied: true, stale: true, activeProfile: true }, + claudeDesktop: { desiredEnabled: true, installed: true, applied: true, stale: true, activeProfile: true }, + native: [{ + clientId: "claude-desktop", + state: "current", + installed: true, + configPath: "/tmp/desktop", + desiredEnabled: true, + disableBlocked: null, + }, { + clientId: "claude", + state: "current", + installed: true, + configPath: "/tmp/config", + desiredEnabled: true, + disableBlocked: null, + }, { + clientId: "grok", + state: "current", + installed: true, + configPath: "/tmp/grok", + desiredEnabled: true, + disableBlocked: null, + }], grok: { present: true, models: [{}, {}] }, })); const counts = countOverviewRows(rows.rows); diff --git a/gui/tests/integrations-surfaces.test.tsx b/gui/tests/integrations-surfaces.test.tsx index c4090792d..a720859cd 100644 --- a/gui/tests/integrations-surfaces.test.tsx +++ b/gui/tests/integrations-surfaces.test.tsx @@ -123,7 +123,17 @@ beforeEach(() => { if (url.includes("/api/claude-desktop/status")) { return failExtraSources ? json({ error: "nope" }, 500) - : json({ applied: false, stale: false, activeProfile: null, appliedAt: null }); + : json({ desiredEnabled: true, installed: true, observedKind: "standard", applied: false, stale: false, activeProfile: null, appliedAt: null }); + } + if (url.includes("/api/native-integrations")) { + return json({ clients: [{ + clientId: "claude-desktop", + state: "absent", + installed: true, + configPath: "/tmp/desktop", + desiredEnabled: true, + disableBlocked: null, + }] }); } if (url.includes("/api/claude-code")) { return failExtraSources ? json({ error: "nope" }, 500) : json({ enabled: false }); @@ -604,8 +614,7 @@ test("every reachable client gets a card, not just the file six", async () => { .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"); + expect(switchOwners).toContain("claudeDesktop"); // Claude Desktop opens Claude's nested route, not a tab of its own. const desktopLink = container.querySelector( diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index f8a36c602..1b03e49f1 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -1,5 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; -import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { atomicWriteFile } from "../config"; @@ -103,6 +103,45 @@ interface Desktop3pMetadata { [key: string]: unknown; } +export type Desktop3pLibraryKind = + | "not_installed" + | "standard" + | "gateway_ours" + | "gateway_drifted" + | "foreign" + | "no_owned_state" + | "broken" + | "unsafe"; + +export interface Desktop3pLibraryInspection { + kind: Desktop3pLibraryKind; + libraryPath: string; + selectedProfilePath: string | null; + appliedId: string | null; + /** Paths of opencodex-owned rows that are not selected by Desktop. */ + residualPaths: string[]; + /** Bounded reason code; never includes metadata or profile contents. */ + reason?: "metadata_unreadable" | "unsafe_applied_id" | "invalid_owned_profile"; + fingerprint?: string; + /** + * Whether Desktop's applied selection is our owned entry, by ID match alone. + * `null` = undeterminable (no metadata, unreadable metadata, or no appliedId); + * a readable appliedId with no owned entry is a KNOWN false, not unknown. + * Deliberately independent of profile-file health: the status contract + * predates this inspector and callers render tri-state. + */ + ownedProfileActive: boolean | null; +} + +export interface Desktop3pRemovalResult { + ok: boolean; + changed: boolean; + kind: "removed" | "noop" | "cleanup_incomplete" | "unsafe" | "write_failed"; + libraryPath: string; + residualPaths?: string[]; + reason?: string; +} + let desktop3pRegistry = new Map(); let desktop3pAliasesByRoute = new Map(); @@ -327,6 +366,186 @@ function parseMetadata(path: string): Desktop3pMetadata { return { ...parsed, entries: parsed.entries }; } +const SAFE_DESKTOP_PROFILE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isOwnedDesktopEntry(entry: Desktop3pMetadataEntry | undefined): boolean { + return entry?.name === "opencodex" || entry?.name === "opencodex-standard"; +} + +/** A gateway row is removable; the selected standard row must always remain. */ +function isOwnedDesktopGatewayEntry(entry: Desktop3pMetadataEntry | undefined): boolean { + return entry?.name === "opencodex"; +} + +function profilePath(libraryPath: string, id: string): string { + return join(libraryPath, `${id}.json`); +} + +/** + * Read Desktop's selected config without changing its library. + * + * This is intentionally separate from the eager writer below: status probes must + * never manufacture a config-library directory on a machine without Desktop. + */ +export function inspectDesktop3pConfigLibrary( + options: Desktop3pConfigLibraryOptions & { appliedFingerprint?: string | null } = {}, +): Desktop3pLibraryInspection { + const libraryPath = resolveDesktop3pConfigLibraryPath(options); + if (!existsSync(libraryPath)) { + return { kind: "not_installed", libraryPath, selectedProfilePath: null, appliedId: null, residualPaths: [], ownedProfileActive: null }; + } + + const metadataPath = join(libraryPath, "_meta.json"); + if (!existsSync(metadataPath)) { + return { kind: "no_owned_state", libraryPath, selectedProfilePath: null, appliedId: null, residualPaths: [], ownedProfileActive: null }; + } + + let metadata: Desktop3pMetadata; + try { + metadata = parseMetadata(metadataPath); + } catch { + return { + kind: "unsafe", libraryPath, selectedProfilePath: null, appliedId: null, residualPaths: [], reason: "metadata_unreadable", ownedProfileActive: null, + }; + } + const appliedId = typeof metadata.appliedId === "string" ? metadata.appliedId : null; + if (appliedId === null) { + return { kind: "no_owned_state", libraryPath, selectedProfilePath: null, appliedId: null, residualPaths: [], ownedProfileActive: null }; + } + const selected = metadata.entries.find(entry => entry?.id === appliedId); + // A readable appliedId with no owned entry is a KNOWN false, not unknown. + const ownedProfileActive = isOwnedDesktopEntry(selected); + if (!SAFE_DESKTOP_PROFILE_ID.test(appliedId)) { + return { + kind: "unsafe", libraryPath, selectedProfilePath: null, appliedId, residualPaths: [], reason: "unsafe_applied_id", ownedProfileActive, + }; + } + + const selectedProfilePath = profilePath(libraryPath, appliedId); + const residualPaths = metadata.entries + .filter(entry => isOwnedDesktopGatewayEntry(entry) && entry.id !== appliedId && SAFE_DESKTOP_PROFILE_ID.test(entry.id)) + .flatMap(entry => [profilePath(libraryPath, entry.id), `${profilePath(libraryPath, entry.id)}.bak`]) + .filter(existsSync); + if (!existsSync(selectedProfilePath)) { + return { kind: "broken", libraryPath, selectedProfilePath, appliedId, residualPaths, ownedProfileActive }; + } + + let profile: Record; + let fingerprint: string; + try { + const source = readFileSync(selectedProfilePath, "utf8"); + const parsed = JSON.parse(source) as unknown; + if (!isRecord(parsed)) return { kind: "broken", libraryPath, selectedProfilePath, appliedId, residualPaths, ownedProfileActive }; + profile = parsed; + fingerprint = createHash("sha256").update(source).digest("hex").slice(0, 16); + } catch { + return { kind: "broken", libraryPath, selectedProfilePath, appliedId, residualPaths, ownedProfileActive }; + } + if (!isOwnedDesktopEntry(selected)) { + return { kind: "foreign", libraryPath, selectedProfilePath, appliedId, residualPaths, fingerprint, ownedProfileActive }; + } + if (profile.inferenceProvider === undefined) { + return { kind: "standard", libraryPath, selectedProfilePath, appliedId, residualPaths, fingerprint, ownedProfileActive }; + } + const validGateway = profile.inferenceProvider === "gateway" + && profile.inferenceCredentialKind === "static" + && typeof profile.inferenceGatewayBaseUrl === "string" + && typeof profile.inferenceGatewayApiKey === "string"; + if (!validGateway) { + return { + kind: "unsafe", libraryPath, selectedProfilePath, appliedId, residualPaths, fingerprint, reason: "invalid_owned_profile", ownedProfileActive, + }; + } + return { + kind: options.appliedFingerprint && options.appliedFingerprint === fingerprint ? "gateway_ours" : "gateway_drifted", + libraryPath, selectedProfilePath, appliedId, residualPaths, fingerprint, ownedProfileActive, + }; +} + +/** + * Select a credential-free standard profile before deleting an owned gateway. + * The old metadata row remains as a retry locator only until both its profile + * and backup are absent; successful cleanup removes it in the same operation. + */ +export function removeDesktop3pStandardPivot( + options: Desktop3pConfigLibraryOptions & { + appliedFingerprint?: string | null; + unlink?: (path: string) => void; + } = {}, +): Desktop3pRemovalResult { + const inspected = inspectDesktop3pConfigLibrary(options); + if (inspected.kind === "not_installed" || inspected.kind === "no_owned_state") { + return { ok: true, changed: false, kind: "noop", libraryPath: inspected.libraryPath }; + } + if (inspected.kind === "broken" || inspected.kind === "unsafe" || inspected.kind === "gateway_drifted") { + return { ok: false, changed: false, kind: "unsafe", libraryPath: inspected.libraryPath, reason: inspected.reason }; + } + if (!inspected.appliedId || !SAFE_DESKTOP_PROFILE_ID.test(inspected.appliedId)) { + return { ok: false, changed: false, kind: "unsafe", libraryPath: inspected.libraryPath, reason: "unsafe_applied_id" }; + } + + const metadataPath = join(inspected.libraryPath, "_meta.json"); + try { + const metadata = parseMetadata(metadataPath); + const selectedId = inspected.appliedId; + // When Desktop is actively using our gateway, pivot only that selected row + // first. Any second owned row is residue for a later standard-mode retry; + // this preserves the selected-row preference after an interrupted cleanup. + const targetIds = inspected.kind === "gateway_ours" + ? [selectedId] + : metadata.entries + .filter(isOwnedDesktopGatewayEntry) + .map(entry => entry.id) + .filter(id => SAFE_DESKTOP_PROFILE_ID.test(id)); + if (targetIds.length === 0) return { ok: true, changed: false, kind: "noop", libraryPath: inspected.libraryPath }; + + let metadataAfterPivot = metadata; + if (inspected.kind === "gateway_ours") { + const standardId = randomUUID(); + const standardPath = profilePath(inspected.libraryPath, standardId); + atomicWriteFile(standardPath, "{}\n"); + const standardEntry: Desktop3pMetadataEntry = { id: standardId, name: "opencodex-standard" }; + metadataAfterPivot = { ...metadata, appliedId: standardId, entries: [...metadata.entries, standardEntry] }; + atomicWriteFile(metadataPath, JSON.stringify(metadataAfterPivot, null, 2) + "\n"); + } + + const residualPaths: string[] = []; + for (const id of targetIds) { + for (const candidate of [profilePath(inspected.libraryPath, id), `${profilePath(inspected.libraryPath, id)}.bak`]) { + try { + if (existsSync(candidate)) (options.unlink ?? unlinkSync)(candidate); + } catch { + // Only the path is allowed to leave this credential-bearing cleanup boundary. + } + if (existsSync(candidate)) residualPaths.push(candidate); + } + } + const ownedResiduePaths = metadataAfterPivot.entries + .filter(entry => isOwnedDesktopGatewayEntry(entry) && !targetIds.includes(entry.id) && SAFE_DESKTOP_PROFILE_ID.test(entry.id)) + .flatMap(entry => [profilePath(inspected.libraryPath, entry.id), `${profilePath(inspected.libraryPath, entry.id)}.bak`]) + .filter(existsSync); + if (residualPaths.length > 0 || ownedResiduePaths.length > 0) { + return { + ok: false, changed: true, kind: "cleanup_incomplete", libraryPath: inspected.libraryPath, + residualPaths: [...new Set([...residualPaths, ...ownedResiduePaths])], + }; + } + // Do not leave a metadata row pointing at a deleted profile. For a foreign + // selection this only removes proven opencodex residues; appliedId is kept. + atomicWriteFile( + metadataPath, + JSON.stringify({ ...metadataAfterPivot, entries: metadataAfterPivot.entries.filter(entry => !targetIds.includes(entry.id)) }, null, 2) + "\n", + ); + return { ok: true, changed: true, kind: "removed", libraryPath: inspected.libraryPath }; + } catch { + return { ok: false, changed: false, kind: "write_failed", libraryPath: inspected.libraryPath }; + } +} + /** Write and apply the opencodex config in Claude Desktop 3P's config library. */ export function writeDesktop3pConfig( port: number, @@ -343,7 +562,8 @@ export function writeDesktop3pConfig( try { mkdirSync(libraryPath, { recursive: true, mode: 0o700 }); const metadata = parseMetadata(metadataPath); - const existing = metadata.entries.find(entry => entry?.name === "opencodex" && typeof entry.id === "string"); + const selected = metadata.entries.find(entry => entry?.id === metadata.appliedId && isOwnedDesktopGatewayEntry(entry)); + const existing = selected ?? metadata.entries.find(entry => isOwnedDesktopGatewayEntry(entry) && typeof entry.id === "string"); const id = existing?.id ?? randomUUID(); configPath = join(libraryPath, `${id}.json`); const entry: Desktop3pMetadataEntry = existing ? { ...existing, id, name: "opencodex" } : { id, name: "opencodex" }; diff --git a/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts index 4704eef44..85cb2a876 100644 --- a/src/cli/claude-desktop.ts +++ b/src/cli/claude-desktop.ts @@ -1,6 +1,7 @@ import { readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { loadConfig, saveConfigPreservingClaudeCode } from "../config"; +import { setIntegrationEnabled } from "../codex/desired-state"; import { DESKTOP_FAMILIES, moveDesktopRoute, @@ -41,7 +42,11 @@ export async function applyProfile( profile: DesktopProfile, mode: Desktop3pConfigMode, deps: ApplyProfileDeps = {}, -): Promise<{ ok: boolean; path: string; reason?: string }> { +): Promise<{ ok: boolean; path: string; reason?: string; warning?: string }> { + // Explicit apply is an enable action. Persist intent before any Desktop write + // so a process crash cannot leave a gateway profile that startup immediately removes. + const desired = setIntegrationEnabled("claude-desktop", true); + if (!desired.ok) return { ok: false, path: "", reason: desired.message }; const config = loadConfig(); const state = await buildClaudeDesktopState(config, profile); config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: state.profile }; @@ -52,7 +57,7 @@ export async function applyProfile( // serving process installs the map there; a local-only write leaves the // daemon unable to decode aliases, and the provider rejects them (400). const post = deps.postApplyImpl ?? (async (m: Desktop3pConfigMode, p: DesktopProfile) => - runtimeRequest<{ ok?: boolean; path?: string; error?: string }>( + runtimeRequest<{ ok?: boolean; path?: string; error?: string; saved?: boolean; warning?: string }>( "/api/claude-desktop/apply", // The daemon's config may be older than what we just saved, so the // profile travels with the request instead of being re-read there. @@ -61,12 +66,26 @@ export async function applyProfile( try { const applied = await post(mode, state.profile); if (applied.ok === false) return { ok: false, path: applied.path ?? "", reason: applied.error ?? "daemon apply failed" }; - return { ok: true, path: applied.path ?? "" }; + // Partial success: Desktop was written but the applied marker was not + // persisted. Pass the degradation up instead of reporting a clean apply. + const partial = (applied as { saved?: boolean; warning?: string }).saved === false; + return { + ok: true, + path: applied.path ?? "", + ...(partial ? { warning: (applied as { warning?: string }).warning ?? "applied marker was not saved" } : {}), + }; } catch (error) { return { ok: false, path: "", reason: error instanceof Error ? error.message : String(error) }; } } const allModels = await fetchAllModels(config); + // The toggle can persist OFF while fetchAllModels was awaiting (same race the + // management writers fence). Re-read persisted intent immediately before the + // writer; a lost race is a discriminated skip, not a write. + const { claudeDesktopIntegrationEnabledNow } = await import("../codex/desired-state"); + if (!claudeDesktopIntegrationEnabledNow()) { + return { ok: false, path: "", reason: "desired_state_changed" }; + } const routed = filterCatalogVisibleModels(allModels, config).map(model => ({ provider: model.provider, id: model.id, @@ -111,6 +130,9 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf return 1; } console.log(`Claude Desktop 설정을 적용했습니다: ${result.path}`); + // The write landed; only the bookkeeping marker did not. Saying nothing + // would leave the saved-vs-applied display wrong with no explanation. + if (result.warning) console.warn(`⚠️ ${result.warning}`); console.log("Claude Desktop을 완전히 종료한 뒤 다시 열어 주세요."); return 0; } catch (error) { @@ -175,6 +197,7 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf if (flags.includes("--apply")) { const result = await applyProfile(reconciled, "static", deps); if (!result.ok) { console.error(`프로필은 저장했지만 Desktop 적용에 실패했습니다: ${result.reason ?? "unknown error"}`); return 1; } + if (result.warning) console.warn(`⚠️ ${result.warning}`); } console.log("Claude Desktop 프로필을 가져왔습니다."); return 0; diff --git a/src/cli/index.ts b/src/cli/index.ts index 049048fda..e9f3c22c3 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -43,7 +43,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 { setIntegrationEnabled, shouldSyncCodexOnStart, shouldSyncGrokOnStart, syncCodexOnStartIfEnabled } from "../codex/desired-state"; import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job"; import { collectOrcaCodexHomeDiagnostic } from "../codex/home"; import { removeOwnedConfigState } from "../lib/config-ownership"; @@ -351,6 +351,7 @@ async function handleStart(options: { block?: boolean } = {}) { // observes the real sync outcome (ok/warning) so /readyz never advertises a // half-synced proxy as ready while /healthz stays live. const startupSync = await syncCodexOnStartIfEnabled(port, config, undefined, readinessGate); + if (!startupSync.ran) console.log(" Codex integration OFF; startup left Codex native."); // #1046: one warning per startup, after BOTH writes. The server's cache // invalidation happens first and the catalog sync second, so the mtime is only // final here — and neither write site warns on its own, or a boot that hits @@ -409,10 +410,12 @@ async function handleEnsure() { return; } const live = await findLiveProxy(); - if (live) { - await syncModelsToCodex(live.port).catch(e => { + if (live) { + const synced = await syncModelsToCodex(live.port).catch(e => { console.error(`⚠️ Model sync skipped: ${e instanceof Error ? e.message : String(e)}`); + return null; }); + if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native."); // Ensure env file exists for already-running proxy (may have been deleted or pre-dates this feature). await injectSystemEnv(live.port, config).catch(() => {}); // Refresh the Grok Build fence too (same contract as start). live.hostname is the @@ -452,9 +455,11 @@ async function handleEnsure() { } catch (err) { console.error(`⚠️ ${grokSyncFailureMessage(err)}`); } // Always sync the LIVE port: after a fallback-port start, config.port still names the // busy preferred port — syncing that would point Codex at a dead listener. - await syncModelsToCodex(port).catch(e => { + const synced = await syncModelsToCodex(port).catch(e => { console.error(`⚠️ Model sync skipped: ${e instanceof Error ? e.message : String(e)}`); + return null; }); + if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native."); console.log(`✅ Proxy running on port ${port}`); } @@ -817,6 +822,7 @@ switch (command) { } case "restore": case "eject": { + const restoreJson = args[1] === "--json"; if (args[1] === "back") { // Reverse switch: re-point plain `codex` at the RUNNING proxy without touching its // lifecycle — the counterpart of `ocx restore`. Start/stop triggers are unchanged; @@ -826,7 +832,18 @@ switch (command) { console.error("No running proxy found. Run 'ocx start' — it injects opencodex automatically."); process.exit(1); } + const desired = setIntegrationEnabled("codex", true); + if (!desired.ok) { + process.exitCode = desired.reason === "conflict" ? 2 : 1; + console.error(`Codex desired state was not saved (${desired.reason}).`); + break; + } const synced = await syncModelsToCodex(live.port); + if (synced.status === "skipped") { + process.exitCode = 2; + console.error("Codex integration is OFF; restore back did not change Codex. Retry after the competing integration change finishes."); + break; + } if (!synced.ok) { process.exitCode = 1; console.error("Plain `codex` was not switched back to opencodex. Fix the reported Codex config issue and retry."); @@ -836,12 +853,49 @@ switch (command) { console.log(`Plain \`codex\` now routes through opencodex in ${target.effectiveCodexHome} (undo with: ocx restore).`); break; } + const desired = setIntegrationEnabled("codex", false); + if (!desired.ok) { + process.exitCode = desired.reason === "conflict" ? 2 : 1; + if (restoreJson) { + // Machine-readable contract: every restore --json outcome emits one + // schema-complete envelope on stdout, including pre-machinery failures. + const { skippedRestoreEnvelope } = await import("../codex/inject"); + console.log(JSON.stringify(skippedRestoreEnvelope(false, `Codex desired state was not saved (${desired.reason}).`))); + } else { + console.error(`Codex desired state was not saved (${desired.reason}).`); + } + break; + } + // A repeated OFF on an already-clean home is a policy no-op. Do not enter + // restore's native-profile machinery merely to prove there is nothing to + // restore: those locks live in CODEX_HOME and a skip must create nothing. + if (desired.status === "unchanged") { + const { classifyNativeRoutedResidue } = await import("../codex/native-residue"); + if (classifyNativeRoutedResidue().kind === "clean") { + const alreadyOff = "Codex integration is already OFF and native; no Codex files changed."; + if (restoreJson) { + const { skippedRestoreEnvelope } = await import("../codex/inject"); + console.log(JSON.stringify(skippedRestoreEnvelope(true, alreadyOff))); + } else { + console.log(alreadyOff); + } + break; + } + } let r: { success: boolean; message: string }; try { - r = await restoreNativeCodexAsync(); + r = await restoreNativeCodexAsync({ revalidateDesiredState: true }); } catch (err) { r = { success: false, message: err instanceof Error ? err.message : String(err) }; } + if (restoreJson) { + // Spawned callers need the artifact-level result to distinguish a busy + // history worker from a successful native restore. Keep stdout machine + // readable; human framing remains the default command contract. + console.log(JSON.stringify(r)); + if (!r.success) process.exitCode = 1; + break; + } if (r.success) console.log(`✅ ${r.message}`); else { console.error(`⚠️ ${r.message}`); @@ -856,7 +910,7 @@ switch (command) { } } catch { /* best-effort */ } if (r.success) { - console.log("Plain `codex` now runs natively (no proxy). Switch back with: ocx restore back"); + console.log("Codex integration is OFF and plain `codex` now runs natively. Switch back with: ocx restore back"); } else { console.error("Plain `codex` was not fully restored. Inspect $CODEX_HOME/config.toml before using native Codex."); } @@ -900,7 +954,9 @@ switch (command) { case "sync": { const restartCodex = args.slice(1).includes("--restart-codex"); const synced = await syncModelsToCodex((await findLiveProxy())?.port); - if (!synced.ok) { + if (synced.status === "skipped") { + console.log("Codex integration is OFF; sync skipped and no Codex files changed."); + } else if (!synced.ok) { process.exitCode = 1; console.error("Codex sync did not complete. Fix the reported Codex config issue and retry."); } @@ -921,6 +977,10 @@ switch (command) { } case "sync-cache": { const restartCodex = args.slice(1).includes("--restart-codex"); + if (!shouldSyncCodexOnStart(loadConfig())) { + console.log("Codex integration is OFF; cache sync skipped and no Codex files changed."); + break; + } const { withCatalogWriteSerialization } = await import("../codex/catalog-write-serialization"); const { invalidateCodexModelsCacheWithPermit } = await import("../codex/catalog/sync"); const { getCodexHome } = await import("../codex/paths"); diff --git a/src/cli/models.ts b/src/cli/models.ts index d3f3c11fe..11787e9bc 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -102,9 +102,13 @@ function rejectUnexpectedArgs(args: string[], usage: string): void { async function syncCustomModelsIfLive(): Promise { const live = await findLiveProxy(); if (!live) return; - await syncModelsToCodex(live.port).catch(error => { + const synced = await syncModelsToCodex(live.port).catch(error => { console.error(`Warning: custom model saved, but catalog sync failed: ${error instanceof Error ? error.message : String(error)}`); + return null; }); + if (synced?.status === "skipped") { + console.log("Custom model saved; Codex integration is OFF, so its catalog was not changed."); + } } async function handleCustomAdd(args: string[]): Promise { diff --git a/src/cli/provider.ts b/src/cli/provider.ts index 410c80dd1..34eb1cd70 100644 --- a/src/cli/provider.ts +++ b/src/cli/provider.ts @@ -229,12 +229,18 @@ async function handleAdd(args: string[]): Promise { return; } + let codexSyncSkipped = false; if (wantsSync) { const live = await findLiveProxy(); if (live) { - await syncModelsToCodex(live.port).catch(e => { + const synced = await syncModelsToCodex(live.port).catch(e => { console.error(`Warning: sync failed: ${e instanceof Error ? e.message : String(e)}`); + return null; }); + if (synced?.status === "skipped") { + codexSyncSkipped = true; + console.log("Provider saved; Codex integration is OFF, so Codex sync was skipped."); + } } } @@ -249,7 +255,7 @@ async function handleAdd(args: string[]): Promise { console.log(` Set API key with: ocx provider add ${name} --api-key --force`); console.log(` Or set env var: ${envKey}`); } - if (wantsSync) { + if (wantsSync && !codexSyncSkipped) { console.log(` Models synced to Codex.`); } else { console.log(` Apply to Codex: ocx sync`); diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 79113d42f..8ad5becfa 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -2,7 +2,8 @@ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { delimiter, dirname, join, resolve } from "node:path"; -import { expandUserPath, readConfigDiagnostics, websocketsEnabled } from "../../config"; +import { expandUserPath, loadConfig, readConfigDiagnostics, websocketsEnabled } from "../../config"; +import { shouldSyncCodexOnStart } from "../desired-state"; 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"; @@ -652,6 +653,8 @@ interface RetainedCatalogSyncResult { path: string; catalogWritten: boolean; comboOmissions: ComboCatalogOmission[]; + /** `desired_disabled` observed under K after the provider await; nothing was written. */ + skippedReason?: "desired_disabled"; } interface RetainedCatalogSyncWrite { @@ -965,6 +968,20 @@ export async function syncCatalogModels(config: OcxConfig): Promise { + // Desired state can flip OFF during the provider await above. The catalog + // evidence revalidation below cannot see that — intent lives in our config, + // not in the catalog files — so the policy is re-read here, under K, right + // before the only write. A lost race becomes the discriminated skip instead + // of a routed catalog/cache surviving a completed disable. + if (!shouldSyncCodexOnStart(loadConfig())) { + return { + added: 0, + path: prepared.catalogPath, + catalogWritten: false, + comboOmissions, + skippedReason: "desired_disabled" as const, + }; + } const current = revalidateRetainedCatalogSync(config, prepared); if (current === null) return null; return writeRetainedCatalogSync({ @@ -1049,6 +1066,11 @@ export function invalidateCodexModelsCacheWithPermit( owningCodexHome: string, ): boolean { try { + // This permit is a REACQUISITION: refreshCodexModelCatalog's commit released + // K before this rewrite runs, so the commit-path desired-state check cannot + // cover it. A disable landing in that gap must not be overwritten by a + // routed cache write — re-read intent under this permit, same as the commit. + if (!shouldSyncCodexOnStart(loadConfig())) return false; const catalogPath = readCodexCatalogPath(); if (!existsSync(catalogPath)) return false; const catalog = JSON.parse(readFileSync(catalogPath, "utf8")); diff --git a/src/codex/codex-write-lock.ts b/src/codex/codex-write-lock.ts index 07ae7e17f..994920894 100644 --- a/src/codex/codex-write-lock.ts +++ b/src/codex/codex-write-lock.ts @@ -66,7 +66,8 @@ export type CodexWriteLockRefusalReason = export type CodexWriteLockResult = | { status: "acquired"; value: T; waitedMs: number; lockId: string } - | { status: "busy"; reason: "deadline" | "cancelled"; retryable: true; waitedMs: number } + | { status: "skipped"; reason: "desired_disabled" | "desired_enabled"; waitedMs: number } + | { status: "busy"; reason: "deadline" | "cancelled"; retryable: true; waitedMs: number; lockId: string } | { status: "refused"; reason: CodexWriteLockRefusalReason; @@ -124,6 +125,14 @@ export interface CodexWriteCommitContext { readonly coordinator: CodexCoordinatorTransaction; } +/** A synchronous under-lock policy re-read proved the requested apply stale. */ +export class CodexWriteLockSkipped extends Error { + constructor(readonly reason: "desired_disabled" | "desired_enabled") { + super(reason); + this.name = "CodexWriteLockSkipped"; + } +} + /** Rejects an `async` callback at typecheck; a cast thenable is caught at runtime. */ type Synchronous = T extends PromiseLike ? never : T; @@ -291,7 +300,7 @@ export async function withCodexWriteLock( const waited = (): number => Math.round(performance.now() - started); for (;;) { - if (signal?.aborted) return { status: "busy", reason: "cancelled", retryable: true, waitedMs: waited() }; + if (signal?.aborted) return { status: "busy", reason: "cancelled", retryable: true, waitedMs: waited(), lockId: target.lockId }; let transaction: ReturnType | undefined; try { @@ -306,7 +315,7 @@ export async function withCodexWriteLock( 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() }; + return { status: "busy", reason: "deadline", retryable: true, waitedMs: waited(), lockId: target.lockId }; } await sleepJittered(deadline - performance.now(), signal); continue; @@ -345,13 +354,16 @@ export async function withCodexWriteLock( return { status: "acquired", value: value as T, waitedMs: waited(), lockId: target.lockId }; } catch (error) { transaction.rollback(); + if (error instanceof CodexWriteLockSkipped) { + return { status: "skipped", reason: error.reason, waitedMs: waited() }; + } 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() }; + return { status: "busy", reason: "deadline", retryable: true, waitedMs: waited(), lockId: target.lockId }; } await sleepJittered(deadline - performance.now(), signal); continue; diff --git a/src/codex/desired-state.ts b/src/codex/desired-state.ts index 6862796c1..abdda6b28 100644 --- a/src/codex/desired-state.ts +++ b/src/codex/desired-state.ts @@ -70,6 +70,11 @@ export function codexIntegrationEnabled(config: Pick): boolean { + return codexIntegrationEnabled(config); +} + /** * 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 @@ -142,6 +147,20 @@ export function setGrokIntegrationEnabled(enabled: boolean): CodexDesiredStateRe return setIntegrationEnabled("grok", enabled); } +/** Whether Claude Desktop's managed gateway profile is wanted. */ +export function claudeDesktopIntegrationEnabled(config: Pick): boolean { + return integrationEnabled(config, "claude-desktop"); +} + +/** The same question when no admitted config snapshot is in hand. */ +export function claudeDesktopIntegrationEnabledNow(): boolean { + return claudeDesktopIntegrationEnabled(loadConfig()); +} + +export function setClaudeDesktopIntegrationEnabled(enabled: boolean): CodexDesiredStateResult { + return setIntegrationEnabled("claude-desktop", enabled); +} + /** * The startup gate, as a function rather than an `if` buried in `handleStart`. * @@ -167,7 +186,7 @@ export async function syncCodexOnStartIfEnabled( sync: CodexStartupSync = defaultStartupSync, readinessGate?: ReadinessGate, ): Promise<{ ran: boolean; catalogWritten: boolean; cacheSynced: boolean }> { - if (!codexIntegrationEnabled(config)) { + if (!shouldSyncCodexOnStart(config)) { // The user explicitly turned Codex off: there is nothing to sync, so the // proxy is ready as soon as it is up. The gate is driven here so /readyz // does not stay pending forever for a deployment that deliberately disabled diff --git a/src/codex/history-job.ts b/src/codex/history-job.ts index 97a3970e5..fe0c15962 100644 --- a/src/codex/history-job.ts +++ b/src/codex/history-job.ts @@ -24,6 +24,7 @@ import type { HistoryWorkerResult, } from "./history-worker"; import { historyBackupPathFor } from "./history-provider"; +import type { CodexHistoryFailureReason } from "./history-provider"; import { getCodexHome } from "./paths"; /** Where Codex keeps its resume history, and the manifest that shadows it. */ @@ -63,14 +64,15 @@ export interface CodexHistoryJobRequest { readonly canonicalStateDbPath: string; readonly canonicalBackupPath: string; readonly operation: CodexHistoryWorkerOperation; + readonly expectedDesiredEnabled?: boolean; } 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: "blocked"; readonly reason: "busy" | "database" | "unsafe-path" | "desired_disabled" | "desired_enabled" } | { readonly kind: "failed"; readonly reason: "worker-error" | "worker-died" | "timeout"; - readonly message: string }; + readonly message: string; readonly historyFailureReason?: CodexHistoryFailureReason }; /** * Derive the durable history operation from admitted intent. @@ -113,9 +115,11 @@ function isPlausibleWorkerResult( && typeof message.rows === "number" && typeof message.files === "number"; case "blocked": - return message.reason === "busy" || message.reason === "database" || message.reason === "unsafe-path"; + return message.reason === "busy" || message.reason === "database" || message.reason === "unsafe-path" + || message.reason === "desired_disabled" || message.reason === "desired_enabled"; case "error": - return typeof message.message === "string"; + return typeof message.message === "string" + && (message.reason === undefined || message.reason === "busy" || message.reason === "permission"); default: return false; } @@ -134,7 +138,12 @@ export function deriveCodexHistoryOperation(intent: { 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 { + kind: "failed", + reason: "worker-error", + message: result.message, + ...(result.reason ? { historyFailureReason: result.reason } : {}), + }; } return result.outcome === "skipped" ? { kind: "skipped" } @@ -248,6 +257,7 @@ export async function runCodexHistoryJob( canonicalCodexHome: request.canonicalCodexHome, canonicalStateDbPath: request.canonicalStateDbPath, canonicalBackupPath: request.canonicalBackupPath, + ...(request.expectedDesiredEnabled === undefined ? {} : { expectedDesiredEnabled: request.expectedDesiredEnabled }), 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-provider.ts b/src/codex/history-provider.ts index 3916380cd..46a21e980 100644 --- a/src/codex/history-provider.ts +++ b/src/codex/history-provider.ts @@ -166,12 +166,16 @@ function patchFirstLineProviderInPlace(path: string, expectedId: string, provide export type CodexHistoryProvider = "openai" | "opencodex"; +export type CodexHistoryFailureReason = "busy" | "permission"; + export interface CodexHistorySyncResult { rows: number; files: number; ejectedRows?: number; /** Set when a lock/busy error survived retries and the sync was SKIPPED, not empty. */ failed?: true; + /** Why the retry budget was exhausted when `failed` is set. */ + failureReason?: CodexHistoryFailureReason; } interface ThreadRow { @@ -515,19 +519,24 @@ function ejectRemainingOpencodexHistory(db: Database): { rows: number; files: nu return { rows: rows.length, files }; } -export function isRecoverableHistoryError(error: unknown): boolean { +export function classifyRecoverableHistoryError(error: unknown): CodexHistoryFailureReason | null { const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : ""; const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); - return code === "SQLITE_BUSY" + if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED" || code === "EBUSY" - || code === "EPERM" - || code === "EACCES" || message.includes("database is locked") || message.includes("database is busy") - || message.includes("resource busy") + || message.includes("resource busy")) return "busy"; + if (code === "EPERM" + || code === "EACCES" || message.includes("operation not permitted") - || message.includes("permission denied"); + || message.includes("permission denied")) return "permission"; + return null; +} + +export function isRecoverableHistoryError(error: unknown): boolean { + return classifyRecoverableHistoryError(error) !== null; } const HISTORY_RETRY_DELAY_MS = 500; @@ -540,21 +549,29 @@ const HISTORY_RETRY_ATTEMPTS = 2; * error — callers surface that as `failed: true` instead of a silent no-op. Hard errors * (corruption, programming bugs) still throw. */ -export function withHistoryRetry(fn: () => T, io: { sleepFn?: (ms: number) => void; attempts?: number; delayMs?: number } = {}): T | null { +function withHistoryRetryResult(fn: () => T, io: { sleepFn?: (ms: number) => void; attempts?: number; delayMs?: number } = {}): + | { ok: true; value: T } + | { ok: false; reason: CodexHistoryFailureReason } { const sleepFn = io.sleepFn ?? Bun.sleepSync; const attempts = Math.max(1, io.attempts ?? HISTORY_RETRY_ATTEMPTS); const delayMs = io.delayMs ?? HISTORY_RETRY_DELAY_MS; for (let attempt = 0; ; attempt++) { try { - return fn(); + return { ok: true, value: fn() }; } catch (error) { - if (!isRecoverableHistoryError(error)) throw error; - if (attempt >= attempts - 1) return null; + const reason = classifyRecoverableHistoryError(error); + if (!reason) throw error; + if (attempt >= attempts - 1) return { ok: false, reason }; try { sleepFn(delayMs); } catch { /* sleep is best-effort */ } } } } +export function withHistoryRetry(fn: () => T, io: { sleepFn?: (ms: number) => void; attempts?: number; delayMs?: number } = {}): T | null { + const result = withHistoryRetryResult(fn, io); + return result.ok ? result.value : null; +} + /** * True when a READONLY probe proves the openai-direction restore would be a no-op: * zero threads still tagged opencodex AND an empty backup manifest. Used to skip the @@ -581,8 +598,8 @@ export function syncCodexHistoryProvider( && openaiRestoreIsNoop(stateDbPath, backupPath)) { return { rows: 0, files: 0 }; } - return withHistoryRetry(() => syncCodexHistoryProviderUnsafe(provider, stateDbPath, backupPath)) - ?? { rows: 0, files: 0, failed: true }; + const retried = withHistoryRetryResult(() => syncCodexHistoryProviderUnsafe(provider, stateDbPath, backupPath)); + return retried.ok ? retried.value : { rows: 0, files: 0, failed: true, failureReason: retried.reason }; } function syncCodexHistoryProviderUnsafe(provider: CodexHistoryProvider, stateDbPath: string, backupPath: string): CodexHistorySyncResult { @@ -734,8 +751,8 @@ export function migrateHistoryToOpenai( // nothing. A missing DB with a leftover backup manifest does NOT satisfy the gate // (backupEntries > 0), so the guardian's fresh-reinstall re-count protection holds. if (openaiRestoreIsNoop(stateDbPath, backupPath)) return { rows: 0, files: 0 }; - return withHistoryRetry(() => syncCodexHistoryProviderUnsafe("openai", stateDbPath, backupPath), opts) - ?? { rows: 0, files: 0, failed: true }; + const retried = withHistoryRetryResult(() => syncCodexHistoryProviderUnsafe("openai", stateDbPath, backupPath), opts); + return retried.ok ? retried.value : { rows: 0, files: 0, failed: true, failureReason: retried.reason }; } export interface PendingHistoryCount { diff --git a/src/codex/history-worker.ts b/src/codex/history-worker.ts index 584177e7f..35bf9e5bf 100644 --- a/src/codex/history-worker.ts +++ b/src/codex/history-worker.ts @@ -24,11 +24,14 @@ * Design record: devlog/_fin/260804_codex_write_substrate/020_history_isolation.md. */ import { withHistoryWriteSerialization } from "./history-lock"; +import { loadConfig } from "../config"; +import { shouldSyncCodexOnStart } from "./desired-state"; import { writeHistoryProviderTransition, writeLegacyOpenaiHistoryRecovery, type HistoryWriteTarget, } from "./internal/history-writer"; +import type { CodexHistoryFailureReason } from "./history-provider"; /** * The durable operation, mirrored into the request for diagnostics only. @@ -53,6 +56,8 @@ export interface HistoryWorkerRunMessage { readonly canonicalCodexHome: string; readonly canonicalStateDbPath: string; readonly canonicalBackupPath: string; + /** When set, prove this transition's desired direction while H is held. */ + readonly expectedDesiredEnabled?: boolean; /** Env snapshot: a Worker may not observe parent mutations on every platform. */ readonly env?: { readonly CODEX_HOME?: string; readonly OPENCODEX_HOME?: string }; } @@ -62,9 +67,9 @@ export type HistoryWorkerResult = 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 reason: "busy" | "database" | "unsafe-path" | "desired_disabled" | "desired_enabled" } | { readonly type: "error"; readonly requestId: string; readonly jobId: string; - readonly message: string }; + readonly message: string; readonly reason?: CodexHistoryFailureReason }; const OPERATIONS: ReadonlySet = new Set([ "skip", @@ -93,7 +98,8 @@ export function isHistoryWorkerRunMessage(data: unknown): data is HistoryWorkerR && OPERATIONS.has(message.operation) && nonEmpty(message.canonicalCodexHome) && nonEmpty(message.canonicalStateDbPath) - && nonEmpty(message.canonicalBackupPath); + && nonEmpty(message.canonicalBackupPath) + && (message.expectedDesiredEnabled === undefined || typeof message.expectedDesiredEnabled === "boolean"); } /** @@ -120,6 +126,10 @@ export function runHistoryUnitUnderLock( message.canonicalCodexHome, message.canonicalStateDbPath, permit => { + if (message.expectedDesiredEnabled !== undefined + && shouldSyncCodexOnStart(loadConfig()) !== message.expectedDesiredEnabled) { + return { desiredStateChanged: true as const }; + } if (operation === "recover-legacy-openai") { return writeLegacyOpenaiHistoryRecovery(permit, target); } @@ -134,8 +144,22 @@ export function runHistoryUnitUnderLock( return { type: "blocked", requestId, jobId, reason: acquired.reason }; } const result = acquired.value; + if ("desiredStateChanged" in result) { + return { + type: "blocked", + requestId, + jobId, + reason: message.expectedDesiredEnabled ? "desired_disabled" : "desired_enabled", + }; + } if (result.failed === true) { - return { type: "error", requestId, jobId, message: "history_transition_failed" }; + return { + type: "error", + requestId, + jobId, + message: "history_transition_failed", + ...(result.failureReason ? { reason: result.failureReason } : {}), + }; } return { type: "done", diff --git a/src/codex/inject-coordination.ts b/src/codex/inject-coordination.ts index 28c58f8b0..91f9374bc 100644 --- a/src/codex/inject-coordination.ts +++ b/src/codex/inject-coordination.ts @@ -229,7 +229,19 @@ export function recomputeInjectWitness(options: { /** Project a non-acquired lock result into the injection result shape. */ export function codexInjectLockOutcome( result: Exclude, { status: "acquired" }>, -): { success: false; message: string; retryable: boolean } { +): { success: false; message: string; retryable: boolean } | { + success: true; status: "skipped"; skippedReason: "desired_disabled" | "desired_enabled"; message: string; +} { + if (result.status === "skipped") { + return { + success: true, + status: "skipped", + skippedReason: result.reason, + message: result.reason === "desired_disabled" + ? "Codex integration is OFF; no Codex config, catalog, cache, or history was changed." + : "Codex integration was re-enabled; native restore was skipped.", + }; + } if (result.status === "busy") { return { success: false, diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 0e5304a64..62ff9f220 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -7,7 +7,8 @@ import { subagentDefaultSyncEffective, websocketsEnabled, } from "../config"; -import { withCodexWriteLock } from "./codex-write-lock"; +import { CodexWriteLockSkipped, withCodexWriteLock } from "./codex-write-lock"; +import { shouldSyncCodexOnStart } from "./desired-state"; import { resolveCodexHistoryTransition } from "./history-transition"; import { buildInjectWitness, @@ -22,6 +23,7 @@ import { } from "./inject-coordination"; import { readIntegrationRecord } from "./integration-record"; import { classifyNativeRoutedResidue } from "./native-residue"; +import { inspectNativeCodexOwnership } from "../integrations/native/ownership-preflight"; import { resolveCodexCoordinatorDatabasePath, resolveEffectiveUserIdentity, @@ -34,7 +36,7 @@ import { } from "./journal"; import { withCatalogWriteSerialization } from "./catalog-write-serialization"; import { restoreCodexCatalogWithPermit } from "./catalog/sync"; -import { syncCodexHistoryProvider } from "./history-provider"; +import { syncCodexHistoryProvider, type CodexHistoryFailureReason } from "./history-provider"; import { deriveCodexHistoryOperation, resolveCodexHistoryJobTarget, @@ -618,6 +620,8 @@ export function chooseCatalogPathForInjection( export interface CodexInjectResult { success: boolean; message: string; + status?: "skipped"; + skippedReason?: "desired_disabled" | "desired_enabled"; nativeSubagentDefaultsWarning?: string; } @@ -866,6 +870,14 @@ export async function injectCodexConfig( 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. + if (!shouldSyncCodexOnStart(loadConfig())) { + return { + success: true, + status: "skipped", + skippedReason: "desired_disabled", + message: "Codex integration is OFF; no Codex config, catalog, cache, or history was changed.", + }; + } applyNativeArtifacts(); } else { const coordinated = await withCodexWriteLock( @@ -883,6 +895,9 @@ export async function injectCodexConfig( }), }, (ctx) => { + if (!shouldSyncCodexOnStart(loadConfig())) { + throw new CodexWriteLockSkipped("desired_disabled"); + } /* * Publish BEFORE touching the filesystem. `assertPublished` runs after this * callback returns and throws unless a transition was recorded, so writing @@ -965,6 +980,7 @@ export async function injectCodexConfig( // handed down fixed; the Worker never takes a direction from its caller. const historyOutcome = await runCodexHistoryJob({ ...resolveCodexHistoryJobTarget(), + expectedDesiredEnabled: true, operation: deriveCodexHistoryOperation({ direction: "apply", resumeHistory: config?.syncResumeHistory !== false, @@ -1179,71 +1195,323 @@ export function removeCodexConfig( }; } +export type CodexRestoreArtifactState = "ok" | "skipped" | "failed"; + +export interface CodexRestoreConfigResult { + state: CodexRestoreArtifactState; + changed: boolean; + action: "journal-restored" | "owned-fields-stripped" | "external-provider-preserved" | "failed"; + message: string; +} + +export interface CodexRestoreCatalogResult { + state: CodexRestoreArtifactState; + changed: boolean; + removed: number; + kept: number; + path: string | null; + message: string; +} + +export interface CodexRestoreHistoryResult { + state: CodexRestoreArtifactState; + changed: boolean; + reason?: CodexHistoryFailureReason; + rows: number; + files: number; + ejectedRows: number; + message: string; +} + +export interface CodexNativeRestoreResult { + success: boolean; + message: string; + externalProvider?: string; + artifacts: { + config: CodexRestoreConfigResult; + catalog: CodexRestoreCatalogResult; + history: CodexRestoreHistoryResult; + }; +} + +function failedHistoryRestore(reason?: CodexHistoryFailureReason): CodexRestoreHistoryResult { + return { + state: "failed", + changed: false, + ...(reason ? { reason } : {}), + rows: 0, + files: 0, + ejectedRows: 0, + message: reason === "permission" + ? "Codex resume history could NOT be restored because permission was denied." + : "Codex resume history could NOT be restored — the Codex app appears to be holding the history database.", + }; +} + +function externalProviderRestoreResult(activeProvider: string): CodexNativeRestoreResult { + const message = `External Codex provider ${tomlString(activeProvider)} preserved; no native restore was needed.`; + return { + success: true, + message, + externalProvider: activeProvider, + artifacts: { + config: { state: "skipped", changed: false, action: "external-provider-preserved", message }, + catalog: { state: "skipped", changed: false, removed: 0, kept: 0, path: null, message }, + history: { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message }, + }, + }; +} + +/** A foreign service claim is an authority boundary, including explicit CLI restore. */ +function foreignOwnershipRestoreRefusal(message: string): CodexNativeRestoreResult { + return { + success: false, + message: `Codex native restore refused: ${message}`, + artifacts: { + config: { state: "skipped", changed: false, action: "failed", message }, + catalog: { state: "skipped", changed: false, removed: 0, kept: 0, path: null, message }, + history: { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message }, + }, + }; +} + +function desiredEnabledRestoreSkip(): CodexNativeRestoreResult { + const message = "Codex integration was re-enabled; native restore was skipped."; + return skippedRestoreEnvelope(true, message); +} + /** - * Recover native Codex: strip opencodex from config.toml AND drop proxy-routed catalog entries, - * so plain `codex` works when the proxy is stopped. Called by `ocx stop`, the proxy shutdown - * handler, and `ocx restore`. Idempotent + atomic. + * A schema-complete all-skipped envelope for outcomes decided before any + * restore machinery runs. Every `restore --json` path must stay shape-stable + * with `CodexNativeRestoreResult`; consumers never special-case early exits. */ +export function skippedRestoreEnvelope(success: boolean, message: string): CodexNativeRestoreResult { + return { + success, + message, + artifacts: { + config: { state: "skipped", changed: false, action: "owned-fields-stripped", message }, + catalog: { state: "skipped", changed: false, removed: 0, kept: 0, path: null, message }, + history: { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message }, + }, + }; +} + +/** The config/profile half of a native restore, reported as one artifact. */ +function restoreCodexConfigInline(): CodexRestoreConfigResult { + try { + const journal = restoreJournalState(); + const restored = journal.configRestored + ? { success: true, message: "Codex config restored from opencodex journal." } + : removeCodexConfig({ preserveProfile: journal.profileRestored || journal.profileChanged }); + return restored.success + ? { + state: "ok", + changed: journal.configRestored || journal.profileRestored || journal.profileChanged || restored.message.startsWith("Removed"), + action: journal.configRestored ? "journal-restored" : "owned-fields-stripped", + message: restored.message, + } + : { state: "failed", changed: false, action: "failed", message: restored.message }; + } catch (error) { + return { state: "failed", changed: false, action: "failed", message: error instanceof Error ? error.message : String(error) }; + } +} + +/** The catalog half, always inside its own K acquisition. */ +function restoreCodexCatalogArtifact(revalidateDesiredState: boolean): CodexRestoreCatalogResult { + const owningCodexHome = getCodexHome(); + try { + const restored = withCatalogWriteSerialization(owningCodexHome, permit => + revalidateDesiredState && shouldSyncCodexOnStart(loadConfig()) + ? null + : restoreCodexCatalogWithPermit(permit, owningCodexHome)); + return restored.kind === "completed" && restored.value !== null + ? { state: "ok", changed: restored.value.removed > 0, ...restored.value, message: "Codex catalog restored." } + : restored.kind === "completed" + ? { + state: "skipped", changed: false, removed: 0, kept: 0, path: null, + message: "Codex integration was re-enabled; native catalog restoration was skipped.", + } + : { + state: "failed", changed: false, removed: 0, kept: 0, path: DEFAULT_CATALOG_PATH, + message: `Codex catalog could not be restored: ${restored.reason}.`, + }; + } catch (error) { + return { + state: "failed", changed: false, removed: 0, kept: 0, path: DEFAULT_CATALOG_PATH, + message: error instanceof Error ? error.message : String(error), + }; + } +} + /** * 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. + * On a coordinated home the config/profile restore happens INSIDE the Codex + * write lock, publishing a `remove` transition — the same serialization inject + * uses. Without it, an older restore could overwrite a config a concurrent + * enable had just written under the lock, and then honestly report success + * while desired intent said ON. The desired-state re-read under the lock turns + * that lost race into the discriminated `desired_enabled` skip. */ -export async function restoreNativeCodexAsync(): Promise<{ - success: boolean; - message: string; -}> { - const inline = restoreNativeCodex({ skipHistory: true }); +export async function restoreNativeCodexAsync( + options: { revalidateDesiredState?: boolean } = {}, +): Promise { + const activeProvider = currentExternalCodexModelProvider(); + if (activeProvider) { + // External-provider courtesy: only the stale journal is removed. The + // history worker must not launch — it would turn a read-mostly courtesy + // result into a history mutation on a home we do not own. + removeJournal(); + return externalProviderRestoreResult(activeProvider); + } + + // `restore` normally honours a human request even when an unrelated + // service-manager probe is unavailable. A recorded FOREIGN home is not an + // unrelated probe: it is positive evidence another installation owns these + // native artifacts, so do not create profile/claim locks before refusing. + if (options.revalidateDesiredState) { + const ownership = inspectNativeCodexOwnership(); + if (ownership.ownership === "foreign") return foreignOwnershipRestoreRefusal(ownership.reason); + } + + const eligibility = codexWriteCoordinationEligibility({ + coordinatorPath: () => + resolveCodexCoordinatorDatabasePath(resolveEffectiveUserIdentity(), getCodexHome()), + residue: () => classifyNativeRoutedResidue(), + integrationRecord: () => readIntegrationRecord(), + }); + + let config: CodexRestoreConfigResult; + let transitionReceipt: { nativeGeneration: number; currentTxId: string } | undefined; + + if (eligibility.kind === "coordinated") { + // The restore has no candidate bytes to witness; freshness comes from the + // filesystem reads and the desired-state re-read performed under the lock. + const witness = { authoritySnapshotId: "codex-native-restore" }; + const coordinated = await withCodexWriteLock( + { + timeoutMs: DEFAULT_INJECT_LOCK_TIMEOUT_MS, + admitted: witness, + readAdmissionUnderLock: () => witness, + }, + (ctx) => { + if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) { + throw new CodexWriteLockSkipped("desired_enabled"); + } + const published = ctx.coordinator.beginTransition( + { + nativeGeneration: ctx.expectation.nativeBefore, + currentTxId: ctx.currentTxId, + }, + { + txId: ctx.expectation.txId, + direction: "remove", + authoritySnapshotId: ctx.admission.authoritySnapshotId, + nextRetryAt: new Date().toISOString(), + }, + ); + if (published.kind !== "updated") { + throw new CodexWriteConflictError( + `The Codex transition could not be published: ${published.kind}.`, + ); + } + const preImages = captureCodexPreImages(); + let restored: CodexRestoreConfigResult; + try { + restored = restoreCodexConfigInline(); + } catch (error) { + const compensated = restoreCodexPreImages(preImages); + if (!compensated.complete) throw new CodexPartialWriteError(compensated.unrestored); + throw error; + } + return { + config: restored, + receipt: { + nativeGeneration: ctx.expectation.nativeAfter, + currentTxId: ctx.expectation.txId, + }, + }; + }, + ); + if (coordinated.status === "skipped") return desiredEnabledRestoreSkip(); + if (coordinated.status !== "acquired") { + config = { + state: "failed", + changed: false, + action: "failed", + message: coordinated.status === "busy" + ? `Another process is writing Codex configuration right now (waited ${coordinated.waitedMs}ms). Retry shortly.` + : `Codex configuration was not restored: ${coordinated.message}`, + }; + } else { + config = coordinated.value.config; + transitionReceipt = coordinated.value.receipt; + } + } else { + // Legacy-uncoordinated (or unresolvable) homes keep the unserialized path + // they have always had; restore is the escape hatch and must not strand + // them. The plain re-read still honors an intervening re-enable. + if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) { + return desiredEnabledRestoreSkip(); + } + config = restoreCodexConfigInline(); + } + + const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === 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, - }), + ...(options.revalidateDesiredState ? { expectedDesiredEnabled: false } : {}), + operation: deriveCodexHistoryOperation({ direction: "restore", 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}` }; + if (transitionReceipt) { + resolveCodexHistoryTransition(transitionReceipt, outcome); + } + const history: CodexRestoreHistoryResult = outcome.kind === "converged" + ? { + state: "ok", changed: outcome.rows > 0, rows: outcome.rows, files: outcome.files, ejectedRows: 0, + message: outcome.rows > 0 + ? `Resume history restored from opencodex backup (${outcome.rows} thread(s)).` + : "Codex resume history was already native.", + } + : outcome.kind === "skipped" + ? { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message: "Codex resume history was skipped." } + : outcome.kind === "blocked" && (outcome.reason === "desired_disabled" || outcome.reason === "desired_enabled") + ? { + state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, + message: outcome.reason === "desired_disabled" + ? "Codex integration was disabled; history restoration was skipped." + : "Codex integration was enabled; history restoration was skipped.", + } + : outcome.kind === "blocked" && outcome.reason === "busy" + ? failedHistoryRestore("busy") + : outcome.kind === "failed" + ? failedHistoryRestore(outcome.historyFailureReason) + : failedHistoryRestore(); + const base = catalog.removed > 0 + ? `${config.message} Catalog restored to ${catalog.kept} native model(s) (dropped ${catalog.removed} proxy-routed).` + : config.message; + const success = config.state !== "failed" + && catalog.state !== "failed" + && history.state !== "failed"; + return { + success, + message: `${base}${history.state === "failed" ? ` ⚠️ ${history.message}` : ""}`, + artifacts: { config, catalog, history }, + }; } -export function restoreNativeCodex(options: { skipHistory?: boolean } = {}): { - success: boolean; - message: string; -} { +export function restoreNativeCodex(options: { skipHistory?: boolean; revalidateDesiredState?: boolean } = {}): CodexNativeRestoreResult { const activeProvider = currentExternalCodexModelProvider(); if (activeProvider) { removeJournal(); - return { - success: true, - message: `External Codex provider ${tomlString(activeProvider)} preserved; no native restore was needed.`, - }; + return externalProviderRestoreResult(activeProvider); } - const journal = restoreJournalState(); - const cfg = journal.configRestored - ? { - 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 }; + if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) { + return desiredEnabledRestoreSkip(); + } + const config = restoreCodexConfigInline(); + const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true); // 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). @@ -1256,23 +1524,33 @@ export function restoreNativeCodex(options: { skipHistory?: boolean } = {}): { } // `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 + const rawHistory = 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 - ? ` Resume history restored from opencodex backup (${history.rows} thread(s)).` - : history.ejectedRows - ? ` ${history.ejectedRows} opencodex history thread(s) were ejected to openai so native Codex can resume them.` - : ""; - return { success: cfg.success, message: `${msg}${historyMsg}` }; + const history: CodexRestoreHistoryResult = options.skipHistory + ? { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message: "History restoration runs asynchronously." } + : rawHistory.failed + ? failedHistoryRestore(rawHistory.failureReason) + : { + state: "ok", + changed: rawHistory.rows > 0 || (rawHistory.ejectedRows ?? 0) > 0, + rows: rawHistory.rows, + files: rawHistory.files, + ejectedRows: rawHistory.ejectedRows ?? 0, + message: rawHistory.rows > 0 + ? `Resume history restored from opencodex backup (${rawHistory.rows} thread(s)).` + : "Codex resume history was already native.", + }; + const message = catalog.removed > 0 + ? `${config.message} Catalog restored to ${catalog.kept} native model(s) (dropped ${catalog.removed} proxy-routed).` + : config.message; + return { + success: config.state !== "failed" && catalog.state !== "failed" && history.state !== "failed", + message, + artifacts: { config, catalog, history }, + }; } export function getCodexConfigPath(): string { diff --git a/src/codex/internal/history-writer.ts b/src/codex/internal/history-writer.ts index d36017644..7ae98dfe8 100644 --- a/src/codex/internal/history-writer.ts +++ b/src/codex/internal/history-writer.ts @@ -74,7 +74,7 @@ export function writeHistoryProviderTransition( export function writeLegacyOpenaiHistoryRecovery( permit: HistoryWritePermit, target: HistoryWriteTarget, -): { rows: number; files: number; failed?: true } { +): CodexHistorySyncResult { assertHistoryWritePermit(permit, target.canonicalStateDbPath); return restoreLegacyOpenaiHistory(target.canonicalStateDbPath); } diff --git a/src/codex/refresh.ts b/src/codex/refresh.ts index 1cb582e8e..4b5ae4732 100644 --- a/src/codex/refresh.ts +++ b/src/codex/refresh.ts @@ -12,6 +12,8 @@ export interface CodexCatalogRefreshResult { catalogWritten: boolean; cacheSynced: boolean; comboOmissions: ComboCatalogOmission[]; + /** Desired OFF observed under K during the catalog commit; no cache write either. */ + skippedReason?: "desired_disabled"; } interface RefreshDeps { @@ -45,6 +47,11 @@ export async function refreshCodexModelCatalog( const catalogExists = deps.existsSync(result.path); const catalogWritten = result.catalogWritten === true; const comboOmissions = result.comboOmissions ?? []; + if (result.skippedReason === "desired_disabled") { + // The commit path observed OFF under K. Invalidate nothing: rewriting the + // models cache here would be exactly the routed-cache write the skip refused. + return { ...result, catalogExists, catalogWritten: false, cacheSynced: false, comboOmissions }; + } if (!catalogExists) { return { ...result, catalogExists, catalogWritten: false, cacheSynced: false, comboOmissions }; } diff --git a/src/codex/sync.ts b/src/codex/sync.ts index 219d3f6a2..955db3804 100644 --- a/src/codex/sync.ts +++ b/src/codex/sync.ts @@ -5,9 +5,16 @@ import { applyProxyEnv, loadConfig } from "../config"; import type { OcxConfig } from "../types"; import { collectOrcaCodexHomeDiagnostic } from "./home"; import { summarizeComboCatalogOmissions, type ComboCatalogOmission } from "./catalog/aggregation"; +import { shouldSyncCodexOnStart } from "./desired-state"; +import { admitCodexWrite, type CodexAdmission } from "./admission"; export interface CodexSyncResult { + /** `skipped` is policy truth, never evidence that Codex was written. */ + status: "applied" | "skipped" | "refused"; ok: boolean; + skippedReason?: "desired_disabled"; + /** Present when unattended convergence refused another service's native home. */ + authority?: "service-home"; added: number; catalogPath: string | null; catalogExists: boolean; @@ -21,9 +28,13 @@ export interface CodexSyncResult { projectConfigGrouped?: { path: string; issues: string[]; bypass: string }[]; } +type CodexSyncAdmission = Extract | { readonly kind: "admitted" }; + interface CodexSyncDeps { refreshCodexModelCatalog: typeof refreshCodexModelCatalog; injectCodexConfig: typeof injectCodexConfig; + /** The sync entry only needs this admission's service-home verdict. */ + admitCodexWrite?: () => CodexSyncAdmission; currentExternalCodexModelProvider?: typeof currentExternalCodexModelProvider; collectCodexHomeDiagnostic?: typeof collectOrcaCodexHomeDiagnostic; } @@ -52,6 +63,40 @@ export async function syncModelsToCodex( log: Pick | null = console, deps: CodexSyncDeps = defaultDeps, ): Promise { + // `config` can be the server's startup object. The decision, however, is a + // durable user switch and must be read again at this production boundary: a + // PUT OFF while provider discovery is in flight cannot be allowed to commit + // through an older captured object. + if (!shouldSyncCodexOnStart(loadConfig())) { + return { + status: "skipped", + skippedReason: "desired_disabled", + ok: true, + added: 0, + catalogPath: null, + catalogExists: false, + catalogWritten: false, + cacheSynced: false, + message: "Codex integration is OFF; no Codex config, catalog, cache, or history was changed.", + }; + } + // Catalog gathering precedes injection and can itself write the native + // catalog/cache. It therefore needs the same unattended service-home veto as + // the injector, before it gets a chance to create any artifact. + const admission = (deps.admitCodexWrite ?? admitCodexWrite)(); + if (admission.kind === "refused" && admission.authority === "service-home") { + return { + status: "refused", + authority: "service-home", + ok: false, + added: 0, + catalogPath: null, + catalogExists: false, + catalogWritten: false, + cacheSynced: false, + message: admission.message, + }; + } const p = port ?? config.port ?? 10100; const externalProvider = (deps.currentExternalCodexModelProvider ?? currentExternalCodexModelProvider)(); if (externalProvider) { @@ -59,6 +104,7 @@ export async function syncModelsToCodex( log?.log(result.message); reportCodexHomeTarget(log, deps.collectCodexHomeDiagnostic ?? collectOrcaCodexHomeDiagnostic); return { + status: "applied", ok: result.success, added: 0, catalogPath: null, @@ -108,10 +154,25 @@ export async function syncModelsToCodex( } const result = await deps.injectCodexConfig(p, config, { catalogPath: catalogPathForInjection }); + if (result.status === "skipped") { + return { + status: "skipped", + // The apply direction's only under-lock policy skip is desired OFF. + skippedReason: "desired_disabled", + ok: true, + added: 0, + catalogPath: null, + catalogExists: false, + catalogWritten: false, + cacheSynced: false, + message: result.message, + }; + } log?.log(result.message); reportCodexHomeTarget(log, deps.collectCodexHomeDiagnostic ?? collectOrcaCodexHomeDiagnostic); const projectConfigWarnings = printProjectCodexConfigWarnings(log, { cwd: process.cwd() }); return { + status: "applied", ok: result.success, added, catalogPath, diff --git a/src/config.ts b/src/config.ts index c962f0743..784c475ff 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1016,6 +1016,7 @@ const apiKeyEntrySchema = z.object({ const clientIntegrationsSchema = z.object({ codex: z.boolean().optional().catch(undefined), grok: z.boolean().optional().catch(undefined), + "claude-desktop": z.boolean().optional().catch(undefined), }).passthrough(); const configSchema = z.object({ diff --git a/src/server/index.ts b/src/server/index.ts index a5706a1a9..b25f4ce67 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -22,6 +22,8 @@ import { reconcileOAuthProviders } from "../oauth"; import { withCatalogWriteSerialization } from "../codex/catalog-write-serialization"; import { invalidateCodexModelsCacheWithPermit } from "../codex/catalog/sync"; import { getCodexHome } from "../codex/paths"; +import { shouldSyncCodexOnStart } from "../codex/desired-state"; +import { inspectNativeCodexOwnership } from "../integrations/native/ownership-preflight"; import { registerCodexCooldownRecoveryProbeWorker } from "../codex/auth-api"; import { startMemoryWatchdog } from "./memory-watchdog"; import { @@ -162,6 +164,7 @@ import { releaseNativeMainStartupLifecycle, startNativeMainStartupLifecycle, type NativeMainStartupGateDeps, + type NativeMainStartupLifecycle, } from "../codex/native-profile-startup"; import { handleImages } from "./images"; import { handleLive, logLiveSidebandFrame, parseLiveSidebandTarget, resolveLiveSidebandUpgrade } from "./live"; @@ -537,7 +540,19 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server {}, + }; let server: Server; try { server = Bun.serve({ diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index d06d25630..17f7c00b3 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -9,6 +9,7 @@ import { isValidProviderName, loadConfig, multiAgentGuidanceEnabled, + mutatePersistedConfig, providerBaseUrlConfigError, providerHeadersConfigError, saveConfigPreservingClaudeCode, @@ -70,6 +71,49 @@ let grokApplyTestHooks: { now?: () => number; run?: () => Promise } | n class GrokApplyBusyError extends Error {} +/** + * Mirror a durable desired-state transition onto the long-lived server snapshot. + * + * `setIntegrationEnabled` writes DISK only. The server reuses one `config` object + * for every request, so leaving it stale makes the native GET report the opposite + * of what was just persisted, and makes any later whole-snapshot save (the Desktop + * profile PUT does exactly that) write the stale value back over the transition. + * ON is the ABSENCE of the key, matching `setIntegrationEnabled`'s on-disk shape. + */ +function mirrorDesiredEnabledOntoSnapshot(config: OcxConfig, client: "claude-desktop", enabled: boolean): void { + const integrations = { ...(config.clientIntegrations ?? {}) }; + if (enabled) delete integrations[client]; + else integrations[client] = false; + if (Object.keys(integrations).length === 0) delete config.clientIntegrations; + else config.clientIntegrations = integrations; +} + +/** + * Persist ONLY `claudeCode.desktopProfile`, field-scoped, against the CURRENT + * on-disk config. + * + * `saveConfigPreservingClaudeCode(ctx.config)` writes the whole long-lived server + * snapshot. On the apply path that snapshot still carries the `clientIntegrations` + * it was loaded with, so a save right after `setIntegrationEnabled("claude-desktop", + * true)` carried the stale OFF back over the enable and made the route cancel its + * own apply. Mutating one field under the config-mutation lock cannot regress an + * unrelated key another writer just committed. + */ +function persistDesktopProfileField( + config: OcxConfig, + desktopProfile: NonNullable["desktopProfile"], +): { ok: true } | { ok: false; reason: "missing" | "invalid" | "conflict" } { + const outcome = mutatePersistedConfig(persisted => { + persisted.claudeCode = { ...(persisted.claudeCode ?? {}), desktopProfile }; + return { changed: true, value: true }; + }); + // Only mirror into memory once the durable write actually landed; an + // `unavailable` outcome must not leave the snapshot claiming a saved profile. + if (outcome.status === "unavailable") return { ok: false, reason: outcome.reason }; + config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile }; + return { ok: true }; +} + export function grokApplyFlightSnapshot(): { currentBytes: number; highWaterBytes: number; active: number } { return { currentBytes: grokApplyFlight?.bytes ?? 0, @@ -130,23 +174,38 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise /** Best-effort Desktop 3P config auto-reconcile when providers change. */ async function autoApplyDesktopBestEffort(): Promise { try { - if (config.claudeCode?.desktopAutoApply === false) return; - if (!config.claudeCode?.desktopProfile) return; - const { writeDesktop3pConfig } = await import("../../claude/desktop-3p"); + const { claudeDesktopIntegrationEnabled } = await import("../../codex/desired-state"); + const admitted = loadConfig(); + if (!claudeDesktopIntegrationEnabled(admitted)) return; + if (admitted.claudeCode?.desktopAutoApply === false) return; + if (!admitted.claudeCode?.desktopProfile) return; + const { inspectDesktop3pConfigLibrary, writeDesktop3pConfig } = await import("../../claude/desktop-3p"); + const beforeKind = inspectDesktop3pConfigLibrary({ + appliedFingerprint: admitted.claudeCode.desktopProfile.appliedFingerprint ?? null, + }).kind; + if (["not_installed", "no_owned_state", "foreign", "unsafe", "broken"].includes(beforeKind)) return; const { filterCatalogVisibleModels, desktopVisibleNativeSlugs } = await import("../../codex/catalog"); - const allModels = await fetchAllModels(config); - const routed = filterCatalogVisibleModels(allModels, config).map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow })); - const result = writeDesktop3pConfig( - config.port ?? 10100, - [...desktopVisibleNativeSlugs(config)], + const allModels = await (deps.fetchAllModels ?? fetchAllModels)(admitted); + const current = loadConfig(); + // This is the real guard: the catalog await admits a concurrent explicit OFF. + if (!claudeDesktopIntegrationEnabled(current)) return; + if (current.claudeCode?.desktopAutoApply === false || !current.claudeCode?.desktopProfile) return; + const afterKind = inspectDesktop3pConfigLibrary({ + appliedFingerprint: current.claudeCode.desktopProfile.appliedFingerprint ?? null, + }).kind; + if (["not_installed", "no_owned_state", "foreign", "unsafe", "broken"].includes(afterKind)) return; + const routed = filterCatalogVisibleModels(allModels, current).map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow })); + const result = (deps.writeDesktop3pConfig ?? writeDesktop3pConfig)( + current.port ?? 10100, + [...desktopVisibleNativeSlugs(current)], routed, - config.apiKeys?.[0]?.key, + current.apiKeys?.[0]?.key, "static", - config.claudeCode.desktopProfile, + current.claudeCode.desktopProfile, ); if (result.written && result.fingerprint) { - config.claudeCode = { ...config.claudeCode, desktopProfile: { ...config.claudeCode.desktopProfile, appliedFingerprint: result.fingerprint, appliedAt: new Date().toISOString() } }; - saveConfigPreservingClaudeCode(config); + current.claudeCode = { ...current.claudeCode, desktopProfile: { ...current.claudeCode.desktopProfile, appliedFingerprint: result.fingerprint, appliedAt: new Date().toISOString() } }; + saveConfigPreservingClaudeCode(current); } } catch { /* best-effort */ } } @@ -700,6 +759,14 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise } if (url.pathname === "/api/claude-desktop/apply" && req.method === "POST") { try { + const { setIntegrationEnabled, claudeDesktopIntegrationEnabled } = await import("../../codex/desired-state"); + const desired = setIntegrationEnabled("claude-desktop", true); + if (!desired.ok) return jsonResponse({ error: desired.message }, desired.retryable ? 409 : 500); + // Disk now says ON; the reused server snapshot must agree, or the native + // GET reports OFF and a later whole-snapshot save undoes this transition. + mirrorDesiredEnabledOntoSnapshot(config, "claude-desktop", true); + // Disk now says ON; the reused server snapshot must agree, or the native + // GET reports OFF and a later whole-snapshot save undoes this transition. // #859: the CLI delegates here so the registry is built in the serving // process. Accept an optional mode; default stays static for back-compat. let mode: "static" | "hybrid" | "discovery" = "static"; @@ -734,8 +801,19 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise } } const state = await buildClaudeDesktopState(config, profileOverride); - config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: state.profile }; - saveConfigPreservingClaudeCode(config); + // `setIntegrationEnabled` above wrote desired ON to DISK; it does not touch + // this long-lived server snapshot. Saving the snapshot wholesale would carry + // its stale `clientIntegrations` back over that write and turn the enable + // action into an immediate self-cancelling OFF — the guard below would then + // refuse the apply it was asked to perform. Persist ONLY the profile field. + const profileSaved = persistDesktopProfileField(config, state.profile); + if (!profileSaved.ok) { + return jsonResponse({ + error: `Claude Desktop profile could not be saved (${profileSaved.reason}); nothing was applied.`, + saved: false, + applied: false, + }, profileSaved.reason === "conflict" ? 409 : 500); + } const { writeDesktop3pConfig } = await import("../../claude/desktop-3p"); const { desktopVisibleNativeSlugs } = await import("../../codex/catalog"); const routed = state.models @@ -744,19 +822,47 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const slash = model.route.indexOf("/"); return { provider: model.route.slice(0, slash), id: model.route.slice(slash + 1), contextWindow: model.contextWindow }; }); - const result = writeDesktop3pConfig( - Number(url.port) || config.port, - [...desktopVisibleNativeSlugs(config)], + // State construction can await catalog work; never write from the stale + // config captured before that await if another request turned Desktop off. + const latest = loadConfig(); + if (!claudeDesktopIntegrationEnabled(latest)) { + return jsonResponse({ + error: "Claude Desktop apply was cancelled because the desired state changed to off.", + code: "claude_desktop_apply_skipped", + reason: "desired_state_changed", + desiredEnabled: false, + saved: true, + applied: false, + }, 409); + } + const result = (deps.writeDesktop3pConfig ?? writeDesktop3pConfig)( + Number(url.port) || latest.port, + [...desktopVisibleNativeSlugs(latest)], routed, - config.apiKeys?.[0]?.key, + latest.apiKeys?.[0]?.key, mode, state.profile, ); if (!result.written) return jsonResponse({ error: result.reason ?? "Claude Desktop apply failed", saved: true, path: result.path }, 500); // Persist applied fingerprint + timestamp so GUI can show saved-vs-applied state. if (result.fingerprint) { - config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: { ...state.profile, appliedFingerprint: result.fingerprint, appliedAt: new Date().toISOString() } }; - saveConfigPreservingClaudeCode(config); + // The Desktop write already landed, so a failed bookkeeping save is not + // an apply failure: report the miss instead of claiming a clean apply. + const marked = persistDesktopProfileField(config, { + ...state.profile, + appliedFingerprint: result.fingerprint, + appliedAt: new Date().toISOString(), + }); + if (!marked.ok) { + return jsonResponse({ + ok: true, + applied: true, + saved: false, + path: result.path, + fingerprint: result.fingerprint, + warning: `Claude Desktop was applied, but the applied marker was not saved (${marked.reason}).`, + }); + } } return jsonResponse({ ok: true, saved: true, applied: true, path: result.path, fingerprint: result.fingerprint }); } catch (error) { @@ -767,47 +873,32 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise // Desktop applied-state + health status. if (url.pathname === "/api/claude-desktop/status" && req.method === "GET") { try { - const { readFileSync: readFile, existsSync } = await import("node:fs"); - const { createHash } = await import("node:crypto"); - const { join } = await import("node:path"); - const { resolveDesktop3pConfigLibraryPath } = await import("../../claude/desktop-3p"); - const libraryPath = resolveDesktop3pConfigLibraryPath(); - const metaPath = join(libraryPath, "_meta.json"); - let onDiskFingerprint: string | null = null; - let configPath: string | null = null; - // Desktop serves ONLY the profile named by _meta.json's appliedId, so an - // opencodex entry that merely EXISTS does not mean Desktop is using it. - // null = undeterminable (no metadata / unreadable / no appliedId). - let activeProfile: boolean | null = null; - if (existsSync(metaPath)) { - try { - const meta = JSON.parse(readFile(metaPath, "utf8")); - const entry = Array.isArray(meta.entries) ? meta.entries.find((e: { name?: string }) => e?.name === "opencodex") : undefined; - const appliedId = typeof meta.appliedId === "string" ? meta.appliedId : null; - // A readable appliedId with no opencodex entry is a KNOWN false, not unknown. - activeProfile = appliedId === null ? null : (entry?.id ? appliedId === entry.id : false); - if (entry?.id) { - configPath = join(libraryPath, `${entry.id}.json`); - if (existsSync(configPath)) { - const onDisk = readFile(configPath, "utf8"); - onDiskFingerprint = createHash("sha256").update(onDisk).digest("hex").slice(0, 16); - } - } - } catch { /* unreadable metadata */ } - } - const savedFingerprint = config.claudeCode?.desktopProfile?.appliedFingerprint ?? null; - const appliedAt = config.claudeCode?.desktopProfile?.appliedAt ?? null; - const stale = savedFingerprint !== null && onDiskFingerprint !== null && savedFingerprint !== onDiskFingerprint; + const { claudeDesktopIntegrationEnabled } = await import("../../codex/desired-state"); + const { inspectDesktop3pConfigLibrary } = await import("../../claude/desktop-3p"); + const persisted = loadConfig(); + const savedFingerprint = persisted.claudeCode?.desktopProfile?.appliedFingerprint ?? null; + const observed = inspectDesktop3pConfigLibrary({ appliedFingerprint: savedFingerprint }); + const desiredEnabled = claudeDesktopIntegrationEnabled(persisted); + const applied = observed.kind === "gateway_ours" || observed.kind === "gateway_drifted"; + const stale = observed.kind === "gateway_drifted"; const { getDesktopHealth } = await import("../../claude/desktop-health"); const health = getDesktopHealth(); return jsonResponse({ - applied: savedFingerprint !== null, - appliedAt, + desiredEnabled, + installed: observed.kind !== "not_installed", + observedKind: observed.kind, + applied, + appliedAt: persisted.claudeCode?.desktopProfile?.appliedAt ?? null, savedFingerprint, - onDiskFingerprint, - configPath, + onDiskFingerprint: observed.fingerprint ?? null, + configPath: observed.selectedProfilePath, stale, - activeProfile, + // Tri-state by ID match, independent of profile health: null = + // undeterminable (no/unreadable metadata or no appliedId); a readable + // appliedId with no owned entry is a KNOWN false. Predates the inspector. + activeProfile: observed.ownedProfileActive, + drift: desiredEnabled ? !applied || stale : applied || observed.kind === "unsafe", + driftReason: desiredEnabled ? (!applied ? "desired_on_not_current" : stale ? "profile_drift" : null) : (applied ? "desired_off_gateway_selected" : null), health, }); } catch (error) { diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index b35169283..624836336 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -261,11 +261,16 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise & { + desiredEnabled: boolean; +}; + function refusal( status: number, clientId: NativeIntegrationClientId, reason: NativeRefusalReason, message: string, + extra: Pick = {}, ): Response { return jsonResponse({ error: status >= 500 ? "native integration change failed" : "native integration change refused", code: status >= 500 ? "native_integration_failed" : "native_integration_refused", - clientId, reason, message, + clientId, reason, message, ...extra, } satisfies NativeRefusalEnvelope, status); } +function postCommitRefusal( + status: number, + clientId: NativeIntegrationClientId, + reason: NativeRefusalReason, + message: string, + extra: Pick, +): Response { + return jsonResponse({ + error: status >= 500 ? "native integration change failed" : "native integration change refused", + code: status >= 500 ? "native_integration_failed" : "native_integration_refused", + clientId, reason, message, ...extra, + } satisfies NativePostCommitRefusalEnvelope, status); +} + +function desktopStatus(config: ManagementContext["config"]): NativeStatus { + const seen = inspectDesktop3pConfigLibrary({ + appliedFingerprint: config.claudeCode?.desktopProfile?.appliedFingerprint ?? null, + }); + const state: NativeStatus["state"] = seen.kind === "gateway_ours" + ? "current" + : seen.kind === "unsafe" || seen.kind === "broken" ? "unsafe" : "absent"; + const disableBlocked = seen.kind === "unsafe" || seen.kind === "broken" || seen.kind === "foreign" + ? { + reason: seen.kind === "unsafe" && seen.reason === "metadata_unreadable" ? "metadata_unreadable" as const : "write_failed" as const, + message: "Claude Desktop configuration cannot be changed safely.", + } + : null; + return { + clientId: "claude-desktop", + state, + installed: seen.kind !== "not_installed", + configPath: seen.libraryPath, + desiredEnabled: config.clientIntegrations?.["claude-desktop"] !== false, + disableBlocked, + }; +} + /** Absent means ON: the six read sites all treat only an explicit `false` as off. */ export function claudeCodeEnabled(config: ManagementContext["config"]): boolean { return config.claudeCode?.enabled !== false; @@ -98,17 +151,30 @@ function claudeStatus(config: ManagementContext["config"], configPath: string): // The surface exists wherever the proxy does; there is no separate install. installed: true, configPath, + desiredEnabled: claudeCodeEnabled(config), // Nothing can refuse this disable: no external file, no shared teardown. disableBlocked: null, }; } +function codexStatus(config: ManagementContext["config"], configPath: string): NativeStatus { + const desiredEnabled = config.clientIntegrations?.codex !== false; + return { + clientId: "codex", + state: desiredEnabled ? "current" : "absent", + installed: true, + configPath, + desiredEnabled, + disableBlocked: null, + }; +} + /** * Grok's GET row (030 §field table). `disableBlocked` is ADVISORY — the file * can change before the PUT, which re-checks with the same inspector and whose * answer is authoritative. */ -function grokStatus(): NativeStatus { +function grokStatus(config: ManagementContext["config"]): NativeStatus { const seen = inspectGrokConfig(); let disableBlocked: NativeStatus["disableBlocked"] = null; if (seen.kind === "orphaned_marker") { @@ -137,6 +203,7 @@ function grokStatus(): NativeStatus { state, installed: seen.kind !== "not_installed", configPath: grokConfigPath(), + desiredEnabled: config.clientIntegrations?.grok !== false, disableBlocked, }; } @@ -260,9 +327,19 @@ async function handleCodexToggle(ctx: ManagementContext): Promise { const port = runtime?.port ?? ctx.config.port; const { syncModelsToCodex } = await import("../../codex/sync"); const applied = await syncModelsToCodex(port); + if (applied.status === "skipped") { + return jsonResponse({ + ok: true, clientId: "codex", changed: durable && persisted.status === "committed", + state: "absent", + desiredEnabled: enabled, + message: "Codex integration is OFF; enable did not change Codex.", + reason: "apply_incomplete", + } satisfies NativeToggleEnvelope); + } return jsonResponse({ ok: true, clientId: "codex", changed: durable && persisted.status === "committed", state: applied.ok ? "current" : "absent", + desiredEnabled: enabled, message: applied.ok ? "Codex now routes through opencodex" : `Codex intent saved, but applying it did not complete: ${applied.message}`, @@ -273,17 +350,28 @@ async function handleCodexToggle(ctx: ManagementContext): Promise { } // OFF. Restore the native path; the proxy keeps serving every other client. + if (durable && persisted.status === "unchanged") { + const { classifyNativeRoutedResidue } = await import("../../codex/native-residue"); + if (classifyNativeRoutedResidue().kind === "clean") { + return jsonResponse({ + ok: true, clientId: "codex", changed: false, state: "absent", desiredEnabled: false, + message: "Codex integration is already OFF and native; no Codex files changed.", + } satisfies NativeToggleEnvelope); + } + } const { restoreNativeCodexAsync } = await import("../../codex/inject"); - const restored = await restoreNativeCodexAsync(); + const restored = await restoreNativeCodexAsync({ revalidateDesiredState: true }); return jsonResponse({ ok: true, clientId: "codex", changed: durable && persisted.status === "committed", state: restored.success ? "absent" : "unsafe", + desiredEnabled: enabled, 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" }), + artifacts: restored.artifacts, } satisfies NativeToggleEnvelope); })(); try { @@ -361,6 +449,7 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { ); } const durable = persisted.ok; + const desiredEnabled = durable ? loadConfig().clientIntegrations?.grok !== false : enabled; if (!enabled) { /* @@ -369,24 +458,25 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { * gated: writing our own fence tears nothing down. */ const owned = assertNativeTeardownOwned(); - if (!owned.ok) return refusal(409, "grok", "home_mismatch", owned.message); + if (!owned.ok) return postCommitRefusal(409, "grok", "home_mismatch", owned.message, { desiredEnabled }); const result = stripGrokConfig(); if (result.skippedReason === "no-grok-home") { - return refusal(404, "grok", "not_installed", NOT_INSTALLED_MESSAGE); + return postCommitRefusal(404, "grok", "not_installed", NOT_INSTALLED_MESSAGE, { desiredEnabled }); } if (result.skippedReason === "orphaned-marker" || !result.ok) { // Orphaned can still arrive between the preflight and the strip; the // writer refuses it correctly and we map the refusal, never a retry lie. return result.skippedReason === "orphaned-marker" - ? refusal(409, "grok", "orphaned_marker", ORPHANED_MARKER_MESSAGE) - : refusal(500, "grok", "write_failed", result.message); + ? postCommitRefusal(409, "grok", "orphaned_marker", ORPHANED_MARKER_MESSAGE, { desiredEnabled }) + : postCommitRefusal(500, "grok", "write_failed", result.message, { desiredEnabled }); } // The writer's own read IS the last read within this synchronous // operation (012 Rev 3 N4): strip removed the fence, so absent. return jsonResponse({ ok: true, clientId: "grok", changed: result.changed, state: "absent", + desiredEnabled, message: result.changed ? "Grok integration disabled — the opencodex block was removed. Re-enabling regenerates it from the current model list." : "Grok integration is already off", @@ -427,13 +517,13 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { } catch (error) { // A catalog failure must never write an empty fence (syncGrokConfig // guards this; the route inherits the rule). Nothing was written. - return refusal(500, "grok", "write_failed", - `The model catalog is unavailable, so nothing was written (${error instanceof Error ? error.message : String(error)}). Try again once provider discovery recovers.`); + return postCommitRefusal(500, "grok", "write_failed", + `The model catalog is unavailable, so nothing was written (${error instanceof Error ? error.message : String(error)}). Try again once provider discovery recovers.`, { desiredEnabled }); } const recheck = inspectGrokConfig(); - if (recheck.kind === "not_installed") return refusal(404, "grok", "not_installed", NOT_INSTALLED_MESSAGE); - if (recheck.kind === "orphaned_marker") return refusal(409, "grok", "orphaned_marker", ORPHANED_MARKER_MESSAGE); + if (recheck.kind === "not_installed") return postCommitRefusal(404, "grok", "not_installed", NOT_INSTALLED_MESSAGE, { desiredEnabled }); + if (recheck.kind === "orphaned_marker") return postCommitRefusal(409, "grok", "orphaned_marker", ORPHANED_MARKER_MESSAGE, { desiredEnabled }); const inject = deps.injectGrokConfig ?? injectGrokConfig; const result = inject(port, models, { @@ -455,7 +545,7 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { const after = inspectGrokConfig(); switch (after.kind) { case "orphaned_marker": - return refusal(409, "grok", "orphaned_marker", ORPHANED_MARKER_MESSAGE); + return postCommitRefusal(409, "grok", "orphaned_marker", ORPHANED_MARKER_MESSAGE, { desiredEnabled }); case "present": // A well-formed fence arrived from elsewhere between the strip and // this read (`ocx ensure`, another proxy, a hand edit). It is not @@ -464,14 +554,16 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { return jsonResponse({ ok: true, clientId: "grok", changed: result.changed, state: "current", reason: "non_loopback_superseded", + desiredEnabled, message: "opencodex is bound to a non-loopback address, so this request did not write a block — but a well-formed opencodex block is present in the Grok config, written by something else. The card shows what is on disk.", } satisfies NativeToggleEnvelope); case "not_installed": - return refusal(404, "grok", "not_installed", NOT_INSTALLED_MESSAGE); + return postCommitRefusal(404, "grok", "not_installed", NOT_INSTALLED_MESSAGE, { desiredEnabled }); case "absent": return jsonResponse({ ok: true, clientId: "grok", changed: result.changed, state: "absent", reason: "non_loopback_removed", + desiredEnabled, message: "opencodex is bound to a non-loopback address, so Grok cannot be auto-registered. The previously generated block was removed because it pointed at a loopback address that no longer serves.", } satisfies NativeToggleEnvelope); default: { @@ -484,17 +576,18 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { } if (result.skippedReason === "no-grok-home") { - return refusal(404, "grok", "not_installed", NOT_INSTALLED_MESSAGE); + return postCommitRefusal(404, "grok", "not_installed", NOT_INSTALLED_MESSAGE, { desiredEnabled }); } if (result.skippedReason === "orphaned-marker") { - return refusal(409, "grok", "orphaned_marker", ORPHANED_MARKER_MESSAGE); + return postCommitRefusal(409, "grok", "orphaned_marker", ORPHANED_MARKER_MESSAGE, { desiredEnabled }); } if (!result.ok) { - return refusal(500, "grok", "write_failed", result.message); + return postCommitRefusal(500, "grok", "write_failed", result.message, { desiredEnabled }); } return jsonResponse({ ok: true, clientId: "grok", changed: result.changed, state: "current", + desiredEnabled, message: result.changed ? "Grok integration enabled — the opencodex block was regenerated from the current model list." : "Grok integration is already on", } satisfies NativeToggleEnvelope); })(); @@ -505,13 +598,93 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { } } +let claudeDesktopToggleFlight: Promise | null = null; + +async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise { + if (claudeDesktopToggleFlight) { + return refusal(409, "claude-desktop", "config_busy", + "Another Claude Desktop change is already in flight. Nothing was written — try again in a moment."); + } + claudeDesktopToggleFlight = (async (): Promise => { + let body: { enabled?: unknown }; + try { + body = await readManagementJsonBody(ctx.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 { setIntegrationEnabled } = await import("../../codex/desired-state"); + const persisted = setIntegrationEnabled("claude-desktop", body.enabled); + if (!persisted.ok) { + return refusal(persisted.retryable ? 409 : 500, "claude-desktop", persisted.retryable ? "config_busy" : "write_failed", persisted.message); + } + const desiredEnabled = loadConfig().clientIntegrations?.["claude-desktop"] !== false; + const current = loadConfig(); + const fingerprint = current.claudeCode?.desktopProfile?.appliedFingerprint ?? null; + + if (!body.enabled) { + const removed = (ctx.deps.removeDesktop3pStandardPivot ?? removeDesktop3pStandardPivot)({ appliedFingerprint: fingerprint }); + if (removed.kind === "cleanup_incomplete") { + return postCommitRefusal(500, "claude-desktop", "cleanup_incomplete", + "Claude Desktop now points at standard mode, but credential cleanup is incomplete.", + { desiredEnabled, residualPaths: removed.residualPaths ?? [] }); + } + if (!removed.ok) { + return postCommitRefusal(409, "claude-desktop", removed.reason === "metadata_unreadable" ? "metadata_unreadable" : "write_failed", + "Claude Desktop configuration could not be changed safely.", { desiredEnabled }); + } + return jsonResponse({ + ok: true, clientId: "claude-desktop", changed: removed.changed, state: "absent", desiredEnabled, + message: removed.changed ? "Claude Desktop integration disabled." : "Claude Desktop integration is already off.", + } satisfies NativeToggleEnvelope); + } + + const fetchModels = ctx.deps.fetchAllModels ?? defaultFetchAllModels; + try { + const fetched = await fetchModels(current); + const latest = loadConfig(); + const latestDesiredEnabled = latest.clientIntegrations?.["claude-desktop"] !== false; + if (!latestDesiredEnabled) { + return postCommitRefusal(409, "claude-desktop", "desired_state_changed", + "Claude Desktop enable was cancelled because the desired state changed to off.", { desiredEnabled: latestDesiredEnabled }); + } + const routed = filterCatalogVisibleModels(fetched, latest).map(model => ({ + provider: model.provider, id: model.id, contextWindow: model.contextWindow, + })); + const runtime = (ctx.deps.readRuntimePort ?? readRuntimePort)(process.pid); + const result = (ctx.deps.writeDesktop3pConfig ?? writeDesktop3pConfig)( + runtime?.port ?? latest.port, + [...visibleNativeSlugs(latest)], + routed, + latest.apiKeys?.[0]?.key, + "static", + latest.claudeCode?.desktopProfile, + ); + if (!result.written) return postCommitRefusal(500, "claude-desktop", "write_failed", "Claude Desktop apply failed.", { desiredEnabled: latestDesiredEnabled }); + return jsonResponse({ + ok: true, clientId: "claude-desktop", changed: true, state: "current", desiredEnabled: latestDesiredEnabled, + message: "Claude Desktop integration enabled.", + } satisfies NativeToggleEnvelope); + } catch { + return postCommitRefusal(500, "claude-desktop", "write_failed", "Claude Desktop apply failed.", { desiredEnabled }); + } + })(); + try { + return await claudeDesktopToggleFlight; + } finally { + claudeDesktopToggleFlight = null; + } +} + export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps } = ctx; if (url.pathname === "/api/native-integrations" && req.method === "GET") { const { getConfigPath } = await import("../../config"); return jsonResponse({ - clients: [claudeStatus(config, getConfigPath()), grokStatus()], + clients: [claudeStatus(config, getConfigPath()), grokStatus(config), codexStatus(config, getConfigPath()), desktopStatus(config)], } satisfies NativeStatusListEnvelope); } @@ -532,6 +705,7 @@ export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Pro return jsonResponse({ ok: true, clientId: "claude", changed: false, state: enabled ? "current" : "absent", + desiredEnabled: enabled, message: enabled ? "Claude inbound is already on" : "Claude inbound is already off", } satisfies NativeToggleEnvelope); } @@ -571,6 +745,7 @@ export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Pro return jsonResponse({ ok: true, clientId: "claude", changed: true, state: enabled ? "current" : "absent", + desiredEnabled: enabled, message: enabled ? "Claude inbound enabled" : "Claude inbound disabled", } satisfies NativeToggleEnvelope); } @@ -583,5 +758,9 @@ export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Pro return handleCodexToggle(ctx); } + if (url.pathname === "/api/native-integrations/claude-desktop" && req.method === "PUT") { + return handleClaudeDesktopToggle(ctx); + } + return null; } diff --git a/src/types.ts b/src/types.ts index d38fbc189..8d877b66b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -553,6 +553,8 @@ export interface OcxClientIntegrationsConfig { codex?: boolean; /** Durable desired state for Grok Build. MISSING MEANS ON. */ grok?: boolean; + /** Durable desired state for Claude Desktop. MISSING MEANS ON. */ + "claude-desktop"?: boolean; } export interface OcxConfig { diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index c419de596..fd783368b 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -4397,7 +4397,12 @@ describe("GitHub Actions hardening", () => { expect(commentApply).toContain("omitted required field(s)"); // Job-scoped permissions only (no top-level issues:write; no actions:write). + // The Copilot migration replaced `models: read` with `copilot-requests: write` + // as the inference credential; the job stays contents-read + issues-write. expect(workflow).toMatch( + // dev resolved the same Copilot-migration drift with an alternation that + // also accepts the pre-migration `models: read`; take theirs so the gate + // holds on whichever branch supplies the workflow file. /jobs:\s*\n\s*translate:[\s\S]*?permissions:\s*\n(?:\s*#.*\n)*\s*contents: read\s*\n(?:\s*#.*\n)*\s*issues: write\s*\n(?:\s*#.*\n)*\s*(?:copilot-requests: write|models: read)/, ); const translateJob = workflow.split(/\n {2}translate:\n/)[1]!.split(/\n {2}[a-zA-Z]/)[0]!; diff --git a/tests/claude-messages-endpoint.test.ts b/tests/claude-messages-endpoint.test.ts index b254ac1b5..b5a5accd1 100644 --- a/tests/claude-messages-endpoint.test.ts +++ b/tests/claude-messages-endpoint.test.ts @@ -37,6 +37,7 @@ import { let testDir = ""; let previousHome: string | undefined; +let previousDesktopConfigDir: string | undefined; let isolatedCodexHome: IsolatedCodexHome | null = null; const originalFetch = globalThis.fetch; @@ -45,12 +46,16 @@ beforeEach(() => { isolatedCodexHome = installIsolatedCodexHome("ocx-claude-endpoint-"); testDir = mkdtempSync(join(tmpdir(), "ocx-claude-endpoint-")); process.env.OPENCODEX_HOME = testDir; + previousDesktopConfigDir = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = join(testDir, "claude-desktop"); globalThis.fetch = originalFetch; }); afterEach(() => { if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; + if (previousDesktopConfigDir === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = previousDesktopConfigDir; isolatedCodexHome?.restore(); isolatedCodexHome = null; globalThis.fetch = originalFetch; @@ -189,6 +194,37 @@ test("non-streaming /v1/messages returns an Anthropic message JSON", async () => } }); +test("Desktop OFF leaves Claude messages and health live", async () => { + const upstream = mockChatUpstream(); + saveConfig(mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`)); + const server = startServer(0); + try { + const disabled = await fetch(new URL("/api/native-integrations/claude-desktop", server.url), { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: false }), + }); + expect(disabled.status).toBe(200); + expect((await disabled.json()) as { desiredEnabled: boolean }).toMatchObject({ desiredEnabled: false }); + + const message = await fetch(new URL("/v1/messages", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "mock/test-model", + max_tokens: 16, + messages: [{ role: "user", content: "still live" }], + }), + }); + expect(message.status).toBe(200); + expect((await message.json()) as { type: string }).toMatchObject({ type: "message" }); + expect((await fetch(new URL("/healthz", server.url))).status).toBe(200); + } finally { + await server.stop(true); + upstream.stop(true); + } +}); + test("native generated-agent passthrough preserves legacy thinking", async () => { let captured: Record | null = null; const upstream = Bun.serve({ diff --git a/tests/cli-restore-back.test.ts b/tests/cli-restore-back.test.ts index cf72a94c6..a82700290 100644 --- a/tests/cli-restore-back.test.ts +++ b/tests/cli-restore-back.test.ts @@ -1,38 +1,94 @@ import { describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { claimOwnedServiceHome } from "./helpers/owned-service-home"; -const cliSource = readFileSync(join(import.meta.dir, "..", "src", "cli", "index.ts"), "utf8"); -const helpSource = readFileSync(join(import.meta.dir, "..", "src", "cli", "help.ts"), "utf8"); const repoRoot = join(import.meta.dir, ".."); -describe("ocx restore back", () => { - test("restore/eject accept `back` to re-point codex at the RUNNING proxy only", () => { - const restoreCase = cliSource.slice(cliSource.indexOf('case "restore":'), cliSource.indexOf('case "recover-history":')); +function ownedEnvironment(codexHome: string, ocxHome: string): Record { + const home = join(ocxHome, "home"); + mkdirSync(home, { recursive: true }); + return { HOME: home, USERPROFILE: home, ...claimOwnedServiceHome(codexHome, ocxHome, home).env }; +} - // The reverse switch must be liveness-gated (never inject a dead port) and reuse the - // same inject path as `ocx start` — no parallel injector. - expect(restoreCase).toContain('if (args[1] === "back")'); - expect(restoreCase).toContain("await findLiveProxy()"); - expect(restoreCase).toContain("await syncModelsToCodex(live.port)"); - expect(restoreCase.indexOf("findLiveProxy()")).toBeLessThan(restoreCase.indexOf("syncModelsToCodex(live.port)")); - expect(restoreCase).toContain("if (!synced.ok)"); - 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("restoreNativeCodexAsync()"); - expect(restoreCase).toContain("process.exitCode = 1"); - expect(restoreCase).toContain("was not fully restored"); +describe("ocx restore back", () => { + test("restore durably disables Codex in an isolated home", () => { + const codexHome = mkdtempSync(join(tmpdir(), "ocx-cli-restore-codex-")); + const ocxHome = mkdtempSync(join(tmpdir(), "ocx-cli-restore-home-")); + try { + writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5"\n', "utf8"); + writeFileSync(join(ocxHome, "config.json"), JSON.stringify({ providers: {}, defaultProvider: "openai", checkForUpdates: false }), "utf8"); + const result = spawnSync(process.execPath, ["run", "src/cli/index.ts", "restore"], { + cwd: repoRoot, + env: { ...process.env, ...ownedEnvironment(codexHome, ocxHome), CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome, CI: "1" }, + encoding: "utf8", + }); + expect(result.status).toBe(0); + expect(JSON.parse(readFileSync(join(ocxHome, "config.json"), "utf8")).clientIntegrations.codex).toBe(false); + expect(`${result.stdout}\n${result.stderr}`).toContain("Codex integration is OFF and plain `codex` now runs natively."); + } finally { + rmSync(codexHome, { recursive: true, force: true }); + rmSync(ocxHome, { recursive: true, force: true }); + } }); - test("sync propagates injection refusal as a nonzero CLI result", () => { - const syncCase = cliSource.slice(cliSource.indexOf('case "sync":'), cliSource.indexOf('case "v2":')); + test("restore --json emits a schema-complete envelope on the already-OFF no-op path", () => { + const codexHome = mkdtempSync(join(tmpdir(), "ocx-cli-json-noop-codex-")); + const ocxHome = mkdtempSync(join(tmpdir(), "ocx-cli-json-noop-home-")); + try { + writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5"\n', "utf8"); + writeFileSync(join(ocxHome, "config.json"), JSON.stringify({ + providers: {}, defaultProvider: "openai", checkForUpdates: false, + clientIntegrations: { codex: false }, + }), "utf8"); + const result = spawnSync(process.execPath, ["run", "src/cli/index.ts", "restore", "--json"], { + cwd: repoRoot, + env: { ...process.env, ...ownedEnvironment(codexHome, ocxHome), CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome }, + encoding: "utf8", + }); + expect(result.status).toBe(0); + const envelope = JSON.parse(result.stdout) as { + success: boolean; + artifacts: Record<"config" | "catalog" | "history", { state: string; changed: boolean; message: string }>; + }; + // Early exits must stay shape-stable with CodexNativeRestoreResult: + // consumers never special-case a valid outcome. + expect(envelope.success).toBe(true); + for (const key of ["config", "catalog", "history"] as const) { + expect(envelope.artifacts[key].state).toBe("skipped"); + expect(envelope.artifacts[key].changed).toBe(false); + expect(typeof envelope.artifacts[key].message).toBe("string"); + } + expect(envelope.artifacts.catalog).toHaveProperty("removed", 0); + expect(envelope.artifacts.history).toHaveProperty("rows", 0); + } finally { + rmSync(codexHome, { recursive: true, force: true }); + rmSync(ocxHome, { recursive: true, force: true }); + } + }); - expect(syncCase).toContain("await syncModelsToCodex"); - expect(syncCase).toContain("if (!synced.ok)"); - expect(syncCase).toContain("process.exitCode = 1"); + test("sync treats durable OFF as a successful no-write policy result", () => { + const codexHome = mkdtempSync(join(tmpdir(), "ocx-cli-sync-off-codex-")); + const ocxHome = mkdtempSync(join(tmpdir(), "ocx-cli-sync-off-home-")); + try { + const configPath = join(codexHome, "config.toml"); + writeFileSync(configPath, 'model = "gpt-5"\n', "utf8"); + writeFileSync(join(ocxHome, "config.json"), JSON.stringify({ providers: {}, defaultProvider: "openai", clientIntegrations: { codex: false }, checkForUpdates: false }), "utf8"); + const before = statSync(configPath).mtimeMs; + const result = spawnSync(process.execPath, ["run", "src/cli/index.ts", "sync"], { + cwd: repoRoot, + env: { ...process.env, ...ownedEnvironment(codexHome, ocxHome), CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome, CI: "1" }, + encoding: "utf8", + }); + expect(result.status).toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain("Codex integration is OFF; sync skipped and no Codex files changed."); + expect(statSync(configPath).mtimeMs).toBe(before); + } finally { + rmSync(codexHome, { recursive: true, force: true }); + rmSync(ocxHome, { recursive: true, force: true }); + } }); test("sync exits nonzero when managed-default cleanup is ambiguous", () => { @@ -48,14 +104,22 @@ describe("ocx restore back", () => { "", ].join("\n"), "utf8"); writeFileSync(join(ocxHome, "config.json"), JSON.stringify({ - providers: {}, - defaultProvider: "openai", + providers: { + fixture: { + adapter: "openai-chat", + baseUrl: "http://127.0.0.1:1/v1", + apiKey: "fixture-key", + allowPrivateNetwork: true, + models: ["fixture-model"], + }, + }, + defaultProvider: "fixture", checkForUpdates: false, }), "utf8"); const result = spawnSync(process.execPath, ["run", "src/cli/index.ts", "sync"], { cwd: repoRoot, - env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome, CI: "1" }, + env: { ...process.env, ...ownedEnvironment(codexHome, ocxHome), CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome, CI: "1" }, encoding: "utf8", }); @@ -69,8 +133,24 @@ describe("ocx restore back", () => { }); test("help documents both directions of the switch", () => { - expect(helpSource).toContain("ocx restore [back]"); - expect(helpSource).toContain("ocx eject [back]"); - expect(helpSource).toContain("ocx restore back"); + const ocxHome = mkdtempSync(join(tmpdir(), "ocx-cli-help-home-")); + try { + writeFileSync(join(ocxHome, "config.json"), JSON.stringify({ + providers: {}, defaultProvider: "openai", checkForUpdates: false, + }), "utf8"); + const run = (...cliArgs: string[]) => spawnSync(process.execPath, ["run", "src/cli/index.ts", ...cliArgs], { + cwd: repoRoot, + env: { ...process.env, OPENCODEX_HOME: ocxHome, CI: "1" }, + encoding: "utf8", + }); + const usage = run("help"); + expect(usage.status).toBe(0); + expect(`${usage.stdout}\n${usage.stderr}`).toContain("ocx restore back"); + const restoreHelp = run("help", "restore"); + expect(restoreHelp.status).toBe(0); + expect(`${restoreHelp.stdout}\n${restoreHelp.stderr}`).toContain("ocx restore [back]"); + } finally { + rmSync(ocxHome, { recursive: true, force: true }); + } }); }); diff --git a/tests/codex-composed-acceptance.test.ts b/tests/codex-composed-acceptance.test.ts new file mode 100644 index 000000000..315b32e83 --- /dev/null +++ b/tests/codex-composed-acceptance.test.ts @@ -0,0 +1,495 @@ +/** + * Workstation-safe composed acceptance for the native-integration toggles. + * + * These tests deliberately execute `src/cli/index.ts` in child Bun processes + * and use a real server. Calling a route handler or an injector in this + * process would miss exactly the configuration, runtime-record, and lock + * boundaries this suite is intended to cover. + */ +import { afterEach, describe, expect, test } from "bun:test"; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative, resolve } from "node:path"; +import { createHash } from "node:crypto"; +import { Database } from "bun:sqlite"; + +import { + canonicalizeCodexHome, +} from "../src/codex/codex-write-lock"; +import { + resolveCodexCoordinatorDatabasePath, + resolveEffectiveUserIdentity, +} from "../src/codex/user-identity"; +import { claimOwnedServiceHome } from "./helpers/owned-service-home"; + +const repoRoot = resolve(import.meta.dir, ".."); +const cliPath = resolve(repoRoot, "src/cli/index.ts"); +const lockChildPath = resolve(repoRoot, "tests/helpers/codex-write-lock-child.ts"); +const roots: Fixture[] = []; + +type CliResult = { exitCode: number; stdout: string; stderr: string }; +type RuntimeRecord = { pid: number; port: number; hostname?: string }; +type StartedServer = { process: ReturnType; runtime: RuntimeRecord }; + +/** A byte manifest: paths plus bytes, not mtimes or parsed JSON. */ +function manifest(root: string): Record { + const entries: Record = {}; + const walk = (dir: string) => { + for (const name of readdirSync(dir).sort()) { + const path = join(dir, name); + const stat = lstatSync(path); + const key = relative(root, path); + if (stat.isDirectory()) walk(path); + else if (stat.isFile()) entries[key] = readFileSync(path).toString("base64"); + else entries[key] = `non-file:${stat.mode}`; + } + }; + walk(root); + return entries; +} + +async function waitFor(read: () => T | null | Promise, label: string, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = await read(); + if (value !== null) return value; + // The record/marker above, rather than elapsed time, is the readiness + // condition. This only yields while watching that explicit sentinel. + await Bun.sleep(20); + } + throw new Error(`timed out waiting for ${label}`); +} + +class Fixture { + readonly root = mkdtempSync(join(tmpdir(), "ocx-composed-")); + readonly codex = join(this.root, "codex"); + readonly ocx = join(this.root, "ocx"); + readonly homeA = join(this.root, "home-a"); + readonly homeB = join(this.root, "home-b"); + readonly userprofileA = join(this.root, "userprofile-a"); + readonly userprofileB = join(this.root, "userprofile-b"); + readonly runtime = join(this.root, "runtime"); + readonly provider = join(this.root, "fixture"); + readonly dataToken = "composed-data-token"; + readonly managementToken = "composed-admin-token"; + readonly lockPath: string; + readonly lockAllowlist: string[]; + readonly serviceManagerEnv: Record; + readonly children: Array> = []; + + constructor() { + for (const path of [this.codex, this.ocx, this.homeA, this.homeB, this.userprofileA, this.userprofileB, this.runtime, this.provider]) { + mkdirSync(path, { recursive: true, mode: 0o700 }); + } + this.lockPath = resolveCodexCoordinatorDatabasePath(resolveEffectiveUserIdentity(), realpathSync.native(this.codex)); + this.lockAllowlist = [this.lockPath, `${this.lockPath}-journal`, `${this.lockPath}-wal`, `${this.lockPath}-shm`]; + for (const path of this.lockAllowlist) { + if (existsSync(path)) throw new Error(`lock preflight found pre-existing case path: ${path}`); + } + writeFileSync(join(this.codex, "config.toml"), 'model = "gpt-5"\n'); + this.serviceManagerEnv = claimOwnedServiceHome(this.codex, this.ocx, this.homeA).env; + } + + env(home = this.homeA, userprofile = this.userprofileA): Record { + // Do not inherit ambient homes or proxy configuration. `process.execPath` + // is absolute, so a PATH is intentionally unnecessary for CLI children. + return { + HOME: home, + USERPROFILE: userprofile, + CODEX_HOME: this.codex, + OPENCODEX_HOME: this.ocx, + XDG_RUNTIME_DIR: this.runtime, + OPENCODEX_API_AUTH_TOKEN: this.dataToken, + // `/api/*` is the management plane, distinct from the data-plane token. + // A fixed fixture value avoids reading the generated credential file. + OPENCODEX_ADMIN_AUTH_TOKEN: this.managementToken, + NO_PROXY: "127.0.0.1,localhost", + ...this.serviceManagerEnv, + }; + } + + writeConfig(overrides: Record = {}): void { + writeFileSync(join(this.ocx, "config.json"), JSON.stringify({ + port: 0, + hostname: "127.0.0.1", + syncResumeHistory: false, + claudeCode: { systemEnv: false }, + providers: { + fixture: { + adapter: "openai-chat", + baseUrl: "http://127.0.0.1:1/v1", + apiKey: "fixture-key", + allowPrivateNetwork: true, + liveModels: false, + models: ["fixture-model"], + }, + }, + defaultProvider: "fixture", + ...overrides, + }, null, 2)); + } + + spawnCli(argv: string[], home = this.homeA, userprofile = this.userprofileA) { + const child = Bun.spawn([process.execPath, cliPath, ...argv], { + cwd: this.root, + env: this.env(home, userprofile), + stdout: "pipe", + stderr: "pipe", + }); + this.children.push(child); + return child; + } + + async runCli(argv: string[], home = this.homeA, userprofile = this.userprofileA, timeoutMs = 15_000): Promise { + const child = this.spawnCli(argv, home, userprofile); + const completed = await Promise.race([ + Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]), + new Promise((_, reject) => setTimeout(() => reject(new Error(`CLI watchdog: ocx ${argv.join(" ")}`)), timeoutMs)), + ]); + const [stdout, stderr, exitCode] = completed; + return { exitCode, stdout, stderr }; + } + + async start(): Promise { + const child = this.spawnCli(["start"]); + const runtimePath = join(this.ocx, "runtime-port.json"); + const runtime = await waitFor(() => { + if (!existsSync(runtimePath)) return null; + try { + const record = JSON.parse(readFileSync(runtimePath, "utf8")) as RuntimeRecord; + return Number.isInteger(record.pid) && record.pid === child.pid && Number.isInteger(record.port) && record.port > 0 + ? record + : null; + } catch { + return null; + } + }, "runtime-port record"); + const health = await waitFor(async () => { + try { + const response = await fetch(`http://127.0.0.1:${runtime.port}/healthz`, { signal: AbortSignal.timeout(500) }); + const body = await response.json() as { pid?: unknown; port?: unknown }; + return response.ok && body.pid === child.pid && body.port === runtime.port ? body : null; + } catch { + return null; + } + }, "child /healthz"); + expect(health).toMatchObject({ pid: child.pid, port: runtime.port }); + return { process: child, runtime }; + } + + async stop(server: StartedServer): Promise { + if (server.process.exitCode === null) server.process.kill("SIGTERM"); + const exitCode = await Promise.race([ + server.process.exited, + new Promise((_, reject) => setTimeout(() => reject(new Error("server shutdown watchdog")), 10_000)), + ]); + expect(exitCode).toBe(0); + } + + async request(runtime: RuntimeRecord, path: string, init: RequestInit = {}): Promise<{ status: number; body: Record }> { + const response = await fetch(`http://127.0.0.1:${runtime.port}${path}`, { + ...init, + headers: { + "x-opencodex-api-key": this.managementToken, + ...(init.body ? { "content-type": "application/json" } : {}), + ...(init.headers ?? {}), + }, + signal: AbortSignal.timeout(10_000), + }); + return { status: response.status, body: await response.json() as Record }; + } + + async cleanup(): Promise { + for (const child of this.children) { + if (child.exitCode === null) child.kill("SIGTERM"); + } + for (const child of this.children) { + if (child.exitCode === null) await Promise.race([ + child.exited, + new Promise((_, reject) => setTimeout(() => reject(new Error(`child ${child.pid} did not exit`)), 10_000)), + ]); + } + // Re-resolve before the limited four-name removal: never glob or inspect a + // shared runtime namespace beyond the exact identities this case created. + const checked = resolveCodexCoordinatorDatabasePath(resolveEffectiveUserIdentity(), realpathSync.native(this.codex)); + if (checked !== this.lockPath) throw new Error("lock teardown identity changed"); + for (const path of this.lockAllowlist) { + if (existsSync(path)) unlinkSync(path); + } + rmSync(this.root, { recursive: true, force: true }); + } +} + +function fixture(): Fixture { + const value = new Fixture(); + roots.push(value); + return value; +} + +afterEach(async () => { + while (roots.length) await roots.pop()!.cleanup(); +}); + +describe("WP13 composed toggle acceptance", () => { + /** RED: remove `shouldSyncCodexOnStart` or the under-lock desired-state read; an OFF row writes native bytes. */ + test("A-reduced: real CLI and HTTP entry points preserve an OFF Codex home", async () => { + const fx = fixture(); + fx.writeConfig({ clientIntegrations: { codex: false, grok: false, "claude-desktop": false } }); + mkdirSync(join(fx.homeA, ".grok")); + writeFileSync(join(fx.homeA, ".grok", "config.toml"), "# user config\n"); + const before = manifest(fx.codex); + const server = await fx.start(); + try { + expect(manifest(fx.codex)).toEqual(before); + for (const argv of [["ensure"], ["sync"], ["restore"], ["sync-cache"]]) { + const result = await fx.runCli(argv); + expect(result.exitCode).toBe(0); + expect(manifest(fx.codex)).toEqual(before); + } + const sync = await fx.request(server.runtime, "/api/sync", { method: "POST" }); + expect(sync.status).toBe(200); + expect(sync.body).toMatchObject({ status: "skipped", skippedReason: "desired_disabled", ok: true }); + for (const clientId of ["codex", "grok", "claude-desktop"] as const) { + const toggle = await fx.request(server.runtime, `/api/native-integrations/${clientId}`, { + method: "PUT", body: JSON.stringify({ enabled: false }), + }); + expect([200, 404]).toContain(toggle.status); + expect(toggle.body).toHaveProperty("desiredEnabled", false); + } + expect(manifest(fx.codex)).toEqual(before); + // P08 is intentionally the ON control: it must reach the same running + // server through the real CLI without passing a port flag. + const enabled = await fx.request(server.runtime, "/api/native-integrations/codex", { + method: "PUT", body: JSON.stringify({ enabled: true }), + }); + expect(enabled.status).toBe(200); + const back = await fx.runCli(["restore", "back"]); + // The fixture records itself as the active service install, so the + // production ownership preflight admits this home and P08 completes the + // enable transition through the real CLI. + expect(back.exitCode).toBe(0); + expect((await fx.request(server.runtime, "/api/native-integrations/codex", { + method: "PUT", body: JSON.stringify({ enabled: false }), + })).body).toMatchObject({ desiredEnabled: false }); + } finally { + await fx.stop(server); + } + }, 45_000); + + /** RED: bypass the persisted OFF mutation or the under-lock re-read; stale P19 writes its candidate after gather. */ + test("B-reduced: a held local provider cannot commit after the HTTP route persists OFF", async () => { + const fx = fixture(); + let hold = false; + let release!: () => void; + let entered!: () => void; + const released = new Promise(resolveRelease => { release = resolveRelease; }); + const enteredGather = new Promise(resolveEntered => { entered = resolveEntered; }); + const provider = Bun.serve({ + port: 0, + fetch: async request => { + if (new URL(request.url).pathname.endsWith("/models")) { + if (hold) { + entered(); + await released; + } + return Response.json({ data: [{ id: "held-model" }] }); + } + return new Response("not found", { status: 404 }); + }, + }); + try { + // Keep the asynchronous startup registry from becoming the held flight. + // The route reloads this persisted config, so enable discovery only once + // its own request is about to begin. + fx.writeConfig({ clientIntegrations: { codex: false } }); + const server = await fx.start(); + try { + writeFileSync(join(fx.codex, "opencodex-catalog.json"), JSON.stringify({ models: [] })); + fx.writeConfig({ providers: { fixture: { + adapter: "openai-chat", baseUrl: `http://127.0.0.1:${provider.port}/v1`, apiKey: "fixture-key", + allowPrivateNetwork: true, liveModels: true, + } }, defaultProvider: "fixture", clientIntegrations: { codex: true } }); + hold = true; + const stale = fx.request(server.runtime, "/api/sync", { method: "POST" }); + await Promise.race([ + enteredGather, + stale.then(result => Promise.reject(new Error( + `held /api/sync completed before provider discovery: ${result.status} ${JSON.stringify(result.body)}`, + ))), + ]); + const off = await fx.request(server.runtime, "/api/native-integrations/codex", { + method: "PUT", body: JSON.stringify({ enabled: false }), + }); + expect(off.status).toBe(200); + const afterOff = manifest(fx.codex); + release(); + const result = await stale; + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ status: "skipped", skippedReason: "desired_disabled", ok: true }); + expect(manifest(fx.codex)).toEqual(afterOff); + } finally { + release(); + await fx.stop(server); + } + } finally { + provider.stop(true); + } + }, 45_000); + + /** RED: omit `admitCodexWrite` ownership refusal; start/ensure/P19 create a coordinator or native artifact. */ + test("D-reduced: foreign service-home evidence refuses real CLI and HTTP writers before artifacts", async () => { + const fx = fixture(); + fx.writeConfig(); + writeFileSync(join(fx.ocx, "service-state.json"), JSON.stringify({ + version: 2, + codexHome: join(fx.root, "foreign-codex"), + opencodexHome: join(fx.root, "foreign-ocx"), + backend: "scheduler", + })); + const before = manifest(fx.codex); + const server = await fx.start(); + try { + const ensure = await fx.runCli(["ensure"]); + expect(ensure.exitCode).toBe(0); + const sync = await fx.request(server.runtime, "/api/sync", { method: "POST" }); + expect(sync.status).toBe(409); + expect(String(sync.body.message ?? sync.body.error)).toMatch(/Refusing|service|ownership/i); + const restore = await fx.runCli(["restore"]); + expect(restore.exitCode).toBe(1); + expect(manifest(fx.codex)).toEqual(before); + expect(fx.lockAllowlist.some(existsSync)).toBe(false); + } finally { + await fx.stop(server); + } + }, 45_000); + + /** RED: key N by HOME/USERPROFILE instead of effective uid plus canonical CODEX_HOME; both children acquire. */ + test("E: separate fake homes share the effective-user Codex lock", async () => { + const fx = fixture(); + fx.writeConfig(); + // The current lock result exposes `busy` but not the lock id. The parent + // derives the one production id and checks both children use its database; + // a typed busy result is still required from the contender. + const held = join(fx.root, "held"); + const release = join(fx.root, "release"); + const holder = Bun.spawn([process.execPath, lockChildPath], { + cwd: repoRoot, + env: { ...fx.env(fx.homeA, fx.userprofileA), OCX_LOCK_CHILD_PAYLOAD: JSON.stringify({ timeoutMs: 5_000, holdMarker: held, releaseMarker: release }) }, + stdout: "pipe", stderr: "pipe", + }); + fx.children.push(holder); + await waitFor(() => existsSync(held) ? true : null, "held coordinator lock"); + const contender = Bun.spawn([process.execPath, lockChildPath], { + cwd: repoRoot, + env: { ...fx.env(fx.homeB, fx.userprofileB), OCX_LOCK_CHILD_PAYLOAD: JSON.stringify({ timeoutMs: 0 }) }, + stdout: "pipe", stderr: "pipe", + }); + fx.children.push(contender); + const [out, code] = await Promise.all([new Response(contender.stdout).text(), contender.exited]); + expect(code).toBe(0); + const identity = canonicalizeCodexHome(fx.codex); + expect(identity.ok).toBe(true); + expect(JSON.parse(out)).toMatchObject({ + status: "busy", reason: "deadline", lockId: identity.ok ? identity.home.lockId : "unreachable", + }); + expect(existsSync(fx.lockPath)).toBe(true); + expect(existsSync(join(fx.homeA, "native-write-locks"))).toBe(false); + expect(existsSync(join(fx.homeB, "native-write-locks"))).toBe(false); + writeFileSync(release, "release"); + expect(await holder.exited).toBe(0); + }, 30_000); + + /** RED: delete the durable Grok intent or bypass `shouldSyncGrokOnStart`; startup recreates the fence. */ + test("Grok E2E: route-disabled Grok stays absent across a real startup", async () => { + const fx = fixture(); + fx.writeConfig(); + const grokHome = join(fx.homeA, ".grok"); + mkdirSync(grokHome); + writeFileSync(join(grokHome, "config.toml"), "# user grok config\n"); + const first = await fx.start(); + try { + const disabled = await fx.request(first.runtime, "/api/native-integrations/grok", { + method: "PUT", body: JSON.stringify({ enabled: false }), + }); + expect(disabled.status).toBe(200); + expect(disabled.body).toMatchObject({ desiredEnabled: false, state: "absent" }); + } finally { + await fx.stop(first); + } + const second = await fx.start(); + const secondOutput = new Response(second.process.stdout).text(); + try { + expect(readFileSync(join(grokHome, "config.toml"), "utf8")).not.toContain("opencodex managed block"); + } finally { + await fx.stop(second); + } + expect(await secondOutput).not.toContain("Grok Build config updated"); + }, 45_000); + + /** RED: report restore success after a blocked history worker; config recovery must not hide history contention. */ + test("Restore truth: JSON distinguishes a busy history restore from native artifact recovery", async () => { + const fx = fixture(); + fx.writeConfig({ clientIntegrations: { codex: false } }); + const original = 'model = "gpt-5"\n'; + const injected = `${original}# Auto-injected by opencodex\nopenai_base_url = "http://127.0.0.1:45678/v1"\n`; + const profile = "# opencodex profile\n"; + writeFileSync(join(fx.codex, "config.toml"), injected); + writeFileSync(join(fx.codex, "opencodex.config.toml"), profile); + writeFileSync(join(fx.codex, "opencodex-journal.json"), JSON.stringify({ + version: 1, + originalConfig: Buffer.from(original).toString("base64"), + originalProfile: null, + injectedConfigHash: createHash("sha256").update(injected).digest("hex"), + injectedProfileHash: createHash("sha256").update(profile).digest("hex"), + pid: process.pid, + timestamp: new Date().toISOString(), + })); + const stateDb = join(fx.codex, "state_5.sqlite"); + const rollout = join(fx.codex, "restore-rollout.jsonl"); + writeFileSync(rollout, `${JSON.stringify({ type: "session_meta", payload: { id: "restore-1", model_provider: "opencodex", source: "cli" } })}\n`); + const seeded = new Database(stateDb); + seeded.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)"); + seeded.run("INSERT INTO threads VALUES ('restore-1', ?, 'opencodex', 'cli', 'hello', 1)", [rollout]); + seeded.close(); + const historyBefore = readFileSync(stateDb); + const held = join(fx.root, "history-held"); + const release = join(fx.root, "history-release"); + const holder = Bun.spawn([process.execPath, "--eval", ` + import { Database } from "bun:sqlite"; + import { existsSync, writeFileSync } from "node:fs"; + const db = new Database(${JSON.stringify(stateDb)}); + db.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + writeFileSync(${JSON.stringify(held)}, "held"); + const waiter = new Int32Array(new SharedArrayBuffer(4)); + while (!existsSync(${JSON.stringify(release)})) Atomics.wait(waiter, 0, 0, 20); + db.exec("COMMIT"); db.close(); + `], { cwd: repoRoot, env: fx.env(), stdout: "pipe", stderr: "pipe" }); + fx.children.push(holder); + await waitFor(() => existsSync(held) ? true : null, "history BEGIN IMMEDIATE"); + const blocked = await fx.runCli(["restore", "--json"], fx.homeA, fx.userprofileA, 15_000); + expect(blocked.exitCode).toBe(1); + const envelope = JSON.parse(blocked.stdout) as { success: boolean; artifacts: { history: { state: string; reason?: string } } }; + expect(envelope).toMatchObject({ success: false, artifacts: { history: { state: "failed", reason: "busy" } } }); + expect(readFileSync(join(fx.codex, "config.toml"), "utf8")).toBe(original); + expect(readFileSync(stateDb).equals(historyBefore)).toBe(true); + writeFileSync(release, "release"); + expect(await holder.exited).toBe(0); + const converged = await fx.runCli(["restore", "--json"]); + expect(converged.exitCode).toBe(0); + expect(JSON.parse(converged.stdout)).toMatchObject({ success: true, artifacts: { history: { state: "ok" } } }); + const after = new Database(stateDb, { readonly: true }); + expect(after.query<{ model_provider: string }, []>("SELECT model_provider FROM threads WHERE id = 'restore-1'").get()?.model_provider).toBe("openai"); + after.close(); + }, 45_000); +}); diff --git a/tests/codex-desired-state.test.ts b/tests/codex-desired-state.test.ts index 9a45ce5d4..e069955b2 100644 --- a/tests/codex-desired-state.test.ts +++ b/tests/codex-desired-state.test.ts @@ -19,6 +19,7 @@ import { setCodexIntegrationEnabled, setGrokIntegrationEnabled, grokIntegrationEnabled, + shouldSyncCodexOnStart, shouldSyncGrokOnStart, syncCodexOnStartIfEnabled, } from "../src/codex/desired-state"; @@ -186,6 +187,11 @@ describe("the startup gate", () => { expect(calls).toBe(0); }); + test("the shared sync predicate has the same absent-means-on semantics", () => { + expect(shouldSyncCodexOnStart(baseConfig())).toBe(true); + expect(shouldSyncCodexOnStart({ ...baseConfig(), clientIntegrations: { codex: false } })).toBe(false); + }); + test("absence, an empty object, and an explicit true all still sync", async () => { for (const clientIntegrations of [undefined, {}, { codex: true }]) { let calls = 0; diff --git a/tests/codex-history-job.test.ts b/tests/codex-history-job.test.ts index 0c9846198..6cae854c6 100644 --- a/tests/codex-history-job.test.ts +++ b/tests/codex-history-job.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -135,27 +136,67 @@ test("an overrun Worker returns a typed timeout rather than hanging", async () = /** * 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. + * it when told to stand down, or every restore would run the transition twice — + * once unserialized on the caller thread, which is the path this phase removed. * - * 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. + * Proven by BEHAVIOR in a child process. The provider resolves its state + * database from a module-load constant, so the fixture `CODEX_HOME` must be in + * the environment before the module loads — a spawned child gives exactly that. + * The fixture DB holds a restorable opencodex-tagged row; `skipHistory: true` + * must leave it tagged, and the default must restore it. */ 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 })"); + const repoRoot = join(import.meta.dir, ".."); + const root = mkdtempSync(join(tmpdir(), "ocx-restore-skiphistory-")); + const fixtureCodexHome = join(root, ".codex"); + const fixtureOcxHome = join(root, ".opencodex"); + mkdirSync(fixtureCodexHome, { recursive: true }); + mkdirSync(fixtureOcxHome, { recursive: true }); + try { + writeFileSync(join(fixtureCodexHome, "config.toml"), 'model = "gpt-5"\n', "utf8"); + const rollout = join(fixtureCodexHome, "rollout.jsonl"); + writeFileSync(rollout, JSON.stringify({ + type: "session_meta", + payload: { id: "thread-1", model_provider: "opencodex", source: "cli", cwd: fixtureCodexHome }, + }) + "\n"); + const dbPath = join(fixtureCodexHome, "state_5.sqlite"); + const db = new Database(dbPath); + db.run(`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)`); + db.run(`INSERT INTO threads VALUES ('thread-1', ?, 'opencodex', 'cli', 'hello', 1)`, rollout); + db.close(); + + const runRestore = (optionsLiteral: string) => spawnSync(process.execPath, ["--eval", [ + 'const { restoreNativeCodex } = require("./src/codex/inject");', + `const result = restoreNativeCodex(${optionsLiteral});`, + 'console.log(JSON.stringify({ history: result.artifacts.history.state }));', + ].join("\n")], { + cwd: repoRoot, + env: { ...process.env, CODEX_HOME: fixtureCodexHome, OPENCODEX_HOME: fixtureOcxHome }, + encoding: "utf8", + }); + const provider = () => { + const check = new Database(dbPath, { readonly: true }); + const row = check.query<{ model_provider: string }, []>( + "SELECT model_provider FROM threads WHERE id = 'thread-1'", + ).get(); + check.close(); + return row?.model_provider; + }; + + // skipHistory: the wrapper owns history, so the synchronous body writes none. + const skipped = runRestore("{ skipHistory: true }"); + expect(skipped.status).toBe(0); + expect(JSON.parse(skipped.stdout.trim().split("\n").filter(Boolean).pop() ?? "{}")).toEqual({ history: "skipped" }); + expect(provider()).toBe("opencodex"); + + // Default: the same body restores history itself. + const restored = runRestore("{}"); + expect(restored.status).toBe(0); + expect(JSON.parse(restored.stdout.trim().split("\n").filter(Boolean).pop() ?? "{}")).toEqual({ history: "ok" }); + expect(provider()).toBe("openai"); + } finally { + rmSync(root, { recursive: true, force: true }); + } }); diff --git a/tests/codex-history-provider.test.ts b/tests/codex-history-provider.test.ts index 386c62b06..856a68da6 100644 --- a/tests/codex-history-provider.test.ts +++ b/tests/codex-history-provider.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { Database } from "bun:sqlite"; import { describe, expect, setDefaultTimeout, test } from "bun:test"; -import { countPendingOpencodexHistory, isRecoverableHistoryError, migrateHistoryToOpenai, restoreLegacyOpenaiHistory, setHistoryDbBusyTimeoutForTests, syncCodexHistoryProvider, withHistoryRetry } from "../src/codex/history-provider"; +import { classifyRecoverableHistoryError, countPendingOpencodexHistory, isRecoverableHistoryError, migrateHistoryToOpenai, restoreLegacyOpenaiHistory, setHistoryDbBusyTimeoutForTests, syncCodexHistoryProvider, withHistoryRetry } from "../src/codex/history-provider"; // Windows CI: a transient file lock can consume the full production 5s busy timeout, tripping // bun's 5s default per-test timeout by itself. Fail fast into withHistoryRetry instead. @@ -303,6 +303,12 @@ describe("history lock retry", () => { expect(isRecoverableHistoryError(new TypeError("undefined is not a function"))).toBe(false); }); + test("classifies exhausted history failures for restore callers", () => { + expect(classifyRecoverableHistoryError(Object.assign(new Error("x"), { code: "SQLITE_BUSY" }))).toBe("busy"); + expect(classifyRecoverableHistoryError(Object.assign(new Error("x"), { code: "EACCES" }))).toBe("permission"); + expect(classifyRecoverableHistoryError(new Error("malformed database schema"))).toBeNull(); + }); + test("withHistoryRetry succeeds after one recoverable failure, sleeping between attempts", () => { const sleeps: number[] = []; let calls = 0; diff --git a/tests/codex-inject-write-lock.test.ts b/tests/codex-inject-write-lock.test.ts index 1ce4f92dc..0610e0a5b 100644 --- a/tests/codex-inject-write-lock.test.ts +++ b/tests/codex-inject-write-lock.test.ts @@ -25,7 +25,7 @@ function seedNative(): void { writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5"\n'); } -function runInject(port: number, lockTimeoutMs = 0): { success: boolean; retryable: boolean; message: string } { +function runInject(port: number, lockTimeoutMs = 0): { success: boolean; status?: "skipped"; retryable: boolean; message: string } { const result = spawnSync(process.execPath, [CHILD], { cwd: repoRoot, encoding: "utf8", @@ -37,7 +37,7 @@ function runInject(port: number, lockTimeoutMs = 0): { success: boolean; retryab }, }); const line = (result.stdout ?? "").trim().split("\n").filter(Boolean).pop() ?? "{}"; - return JSON.parse(line) as { success: boolean; retryable: boolean; message: string }; + return JSON.parse(line) as { success: boolean; status?: "skipped"; retryable: boolean; message: string }; } beforeEach(() => { @@ -54,6 +54,20 @@ afterEach(() => { }); describe("the lock is on the production path", () => { + test("a persisted OFF observed under N skips the real injector without writing", () => { + seedNative(); + const configPath = join(codexHome, "config.toml"); + const before = readFileSync(configPath, "utf8"); + writeFileSync(join(opencodexHome, "config.json"), JSON.stringify({ + providers: {}, defaultProvider: "openai", clientIntegrations: { codex: false }, + })); + + const result = runInject(20200); + + expect(result).toMatchObject({ success: true, status: "skipped" }); + expect(readFileSync(configPath, "utf8")).toBe(before); + }); + test("a clean first apply coordinates and records a transition", () => { seedNative(); const result = runInject(10100); diff --git a/tests/codex-models-cache-invalidate.test.ts b/tests/codex-models-cache-invalidate.test.ts index e013ac226..0bff240fc 100644 --- a/tests/codex-models-cache-invalidate.test.ts +++ b/tests/codex-models-cache-invalidate.test.ts @@ -54,6 +54,27 @@ describe("invalidateCodexModelsCache write gate (#476 / #518)", () => { expect(cache.models).toEqual([{ slug: "gpt-5.5" }]); }); + test("refuses the cache rewrite when desired state flipped OFF between commit and reacquisition", () => { + // The commit-path desired-state check runs under the FIRST catalog permit; + // refreshCodexModelCatalog then releases K before invalidateCodexModelsCache + // reacquires it. An OFF landing in that gap must gate this second write too — + // otherwise a routed models_cache survives a completed disable while the + // injector honestly reports status:"skipped". + writeFileSync(join(codexHome, "opencodex-catalog.json"), JSON.stringify({ + models: [{ slug: "gpt-5.5" }], + }, null, 2) + "\n"); + mkdirSync(join(opencodexHome, ".opencodex"), { recursive: true }); + writeFileSync(join(opencodexHome, "config.json"), JSON.stringify({ + port: 10100, + defaultProvider: "openai", + providers: {}, + clientIntegrations: { codex: false }, + }, null, 2) + "\n"); + + expect(invalidateCodexModelsCache()).toBe(false); + expect(existsSync(join(codexHome, "models_cache.json"))).toBe(false); + }); + test("returns false for a missing catalog and does not warn/restart app-servers", () => { const errors: string[] = []; const logs: string[] = []; diff --git a/tests/codex-retained-root-serialization.test.ts b/tests/codex-retained-root-serialization.test.ts index 36c7fdb85..c4bf147cc 100644 --- a/tests/codex-retained-root-serialization.test.ts +++ b/tests/codex-retained-root-serialization.test.ts @@ -16,6 +16,7 @@ import { resolveCodexCatalogSerializationDatabasePath, resolveEffectiveUserIdentity, } from "../src/codex/user-identity"; +import { claimOwnedServiceHome } from "./helpers/owned-service-home"; const repoRoot = resolve(import.meta.dir, ".."); const sandboxes: Sandbox[] = []; @@ -64,6 +65,7 @@ function makeSandbox(prefix: string): Sandbox { mkdirSync(path, { recursive: true }); chmodSync(path, 0o700); } + const serviceManagerEnv = claimOwnedServiceHome(codexHome, opencodexHome, home).env; const sandbox = { root, codexHome, @@ -79,6 +81,7 @@ function makeSandbox(prefix: string): Sandbox { TMP: runtime, XDG_RUNTIME_DIR: runtime, LOCALAPPDATA: join(home, "LocalAppData"), + ...serviceManagerEnv, }, }; sandboxes.push(sandbox); @@ -225,8 +228,8 @@ test("native restore cannot read-transform-write the catalog while another proce async function runPublisher( sandbox: Sandbox, kind: "convergence" | "retained", + config: Record, ): 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"); @@ -255,55 +258,69 @@ for (const publisher of ["convergence", "retained"] as const) { const initial = readFileSync(catalogPath, "utf8"); const requested = join(sandbox.root, "provider-requested"); const release = join(sandbox.root, "provider-release"); + let requests = 0; + const provider = Bun.serve({ + port: 0, + fetch: async request => { + if (!new URL(request.url).pathname.endsWith("/models")) return new Response("not found", { status: 404 }); + if (requests++ === 0) { + writeFileSync(requested, "requested"); + while (!existsSync(release)) await Bun.sleep(5); + } + return Response.json({ data: [{ id: "race-model" }] }); + }, + }); const config = { - port: 10100, - defaultProvider: "together", + port: 0, + hostname: "127.0.0.1", + defaultProvider: "fixture", providers: { - together: { + fixture: { adapter: "openai-chat", - baseUrl: "https://api.together.xyz/v1", - apiKey: "race-key", - models: ["fallback-model"], + baseUrl: `http://127.0.0.1:${provider.port}/v1`, + apiKey: "fixture-key", + allowPrivateNetwork: true, + liveModels: true, }, }, + disabledModels: ["gpt-5.5"], }; - 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}`); + writeFileSync(join(sandbox.opencodexHome, "config.json"), JSON.stringify(config)); + try { + const sync = Bun.spawn([process.execPath, "--eval", ` + const config = ${JSON.stringify(config)}; + 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, config); + 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); + } finally { + provider.stop(true); } - 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); } diff --git a/tests/codex-sync-api.test.ts b/tests/codex-sync-api.test.ts index 85064b582..1ec9f790a 100644 --- a/tests/codex-sync-api.test.ts +++ b/tests/codex-sync-api.test.ts @@ -1,22 +1,44 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { syncModelsToCodex } from "../src/codex/sync"; import { MANAGED_AGENTS_TABLE_MARKER, MANAGED_SUBAGENT_DEFAULT_MARKER } from "../src/codex/subagent-defaults"; import type { OcxConfig } from "../src/types"; import type { OrcaCodexHomeDiagnostic } from "../src/codex/home"; +import { claimOwnedServiceHome } from "./helpers/owned-service-home"; const TEST_DIR = join(import.meta.dir, ".tmp-codex-sync-api"); const TEST_CODEX_HOME = join(TEST_DIR, "codex"); +const TEST_OCX_HOME = join(TEST_DIR, "ocx"); +const TEST_HOME = join(TEST_DIR, "home"); +const repoRoot = join(import.meta.dir, ".."); let prevCodexHome: string | undefined; +let prevOpenCodexHome: string | undefined; +let prevHome: string | undefined; +let prevUserProfile: string | undefined; const config = { - port: 10100, - defaultProvider: "openai", - providers: {}, + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-chat", + baseUrl: "http://127.0.0.1:1/v1", + apiKey: "fixture-key", + allowPrivateNetwork: true, + models: ["fixture-model"], + }, + }, } as OcxConfig; +function claimTempHome(codexHome: string, ocxHome: string, home: string): void { + claimOwnedServiceHome(codexHome, ocxHome, home); +} + +const admittedSync = () => ({ kind: "admitted" as const }); + function homeDiagnostic(overrides: Partial = {}): OrcaCodexHomeDiagnostic { return { applicable: false, @@ -33,15 +55,31 @@ function homeDiagnostic(overrides: Partial = {}): OrcaC describe("GUI/CLI Codex sync backend", () => { beforeEach(() => { prevCodexHome = process.env.CODEX_HOME; + prevOpenCodexHome = process.env.OPENCODEX_HOME; + prevHome = process.env.HOME; + prevUserProfile = process.env.USERPROFILE; if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_CODEX_HOME, { recursive: true }); + mkdirSync(TEST_OCX_HOME, { recursive: true }); + mkdirSync(TEST_HOME, { recursive: true }); process.env.CODEX_HOME = TEST_CODEX_HOME; + process.env.OPENCODEX_HOME = TEST_OCX_HOME; + process.env.HOME = TEST_HOME; + process.env.USERPROFILE = TEST_HOME; writeFileSync(join(TEST_CODEX_HOME, "config.toml"), 'model = "gpt-5.5"\n', "utf8"); + writeFileSync(join(TEST_OCX_HOME, "config.json"), JSON.stringify(config)); + claimTempHome(TEST_CODEX_HOME, TEST_OCX_HOME, TEST_HOME); }); afterEach(() => { if (prevCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = prevCodexHome; + if (prevOpenCodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = prevOpenCodexHome; + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); }); test("returns the structured sync result used by POST /api/sync", async () => { @@ -51,6 +89,7 @@ describe("GUI/CLI Codex sync backend", () => { const logs: string[] = []; const errors: string[] = []; const result = await syncModelsToCodex(12345, config, { log: line => logs.push(String(line)), error: line => errors.push(String(line)) }, { + admitCodexWrite: admittedSync, refreshCodexModelCatalog: async () => ({ added: 3, path: "/tmp/opencodex-catalog.json", @@ -71,6 +110,7 @@ describe("GUI/CLI Codex sync backend", () => { expect(injectedPort).toBe(12345); expect(injectedCatalogPath).toBe("/tmp/opencodex-catalog.json"); expect(result).toEqual({ + status: "applied", ok: true, added: 3, catalogPath: "/tmp/opencodex-catalog.json", @@ -83,6 +123,97 @@ describe("GUI/CLI Codex sync backend", () => { expect(errors).toEqual([]); }); + test("returns a policy skip without touching the catalog or config", async () => { + let refreshed = false; + let injected = false; + writeFileSync(join(TEST_OCX_HOME, "config.json"), JSON.stringify({ + ...config, + clientIntegrations: { codex: false }, + })); + const result = await syncModelsToCodex(12345, config, null, { + admitCodexWrite: admittedSync, + refreshCodexModelCatalog: async () => { + refreshed = true; + throw new Error("must not refresh"); + }, + injectCodexConfig: async () => { + injected = true; + throw new Error("must not inject"); + }, + }); + + expect(result).toMatchObject({ status: "skipped", skippedReason: "desired_disabled", ok: true }); + expect(refreshed).toBe(false); + expect(injected).toBe(false); + }); + + /** + * The lost-transition race, with a REAL second process. The caller's config + * snapshot says ON; while provider discovery is awaited, another process + * persists OFF. The under-lock re-read inside the real injector must observe + * the fresh persisted intent and skip — the snapshot must not win. + * + * Runs entirely in a child process with its own temp CODEX_HOME, because the + * injector resolves its config path at module load: an in-process variant + * would silently address the suite's isolated home instead of the fixture. + */ + test("a competing OFF during catalog discovery becomes the discriminated skip", async () => { + const raceRoot = mkdtempSync(join(tmpdir(), "ocx-sync-lost-transition-")); + const raceCodexHome = join(raceRoot, ".codex"); + const raceOcxHome = join(raceRoot, ".opencodex"); + const raceHome = join(raceRoot, "home"); + mkdirSync(raceCodexHome, { recursive: true }); + mkdirSync(raceOcxHome, { recursive: true }); + mkdirSync(raceHome, { recursive: true }); + try { + writeFileSync(join(raceCodexHome, "config.toml"), 'model = "gpt-5"\n', "utf8"); + writeFileSync(join(raceOcxHome, "config.json"), JSON.stringify(config)); + claimTempHome(raceCodexHome, raceOcxHome, raceHome); + const script = [ + 'const { spawnSync } = require("node:child_process");', + 'const { loadConfig } = require("./src/config");', + 'const { syncModelsToCodex } = require("./src/codex/sync");', + 'const { injectCodexConfig } = require("./src/codex/inject");', + '(async () => {', + ' const snapshot = loadConfig(); // admitted BEFORE the flip: reads as ON', + ' const result = await syncModelsToCodex(12345, snapshot, null, {', + ' refreshCodexModelCatalog: async () => {', + ' // The provider-discovery window: a second real process persists OFF.', + ' const flip = spawnSync(process.execPath, ["--eval",', + ' \'const { setIntegrationEnabled } = require("./src/codex/desired-state");\'', + ' + \'const r = setIntegrationEnabled("codex", false);\'', + ' + \'if (!r.ok) { console.error(JSON.stringify(r)); process.exit(1); }\',', + ' ], { cwd: process.cwd(), env: process.env, encoding: "utf8" });', + ' if (flip.status !== 0) throw new Error("flip failed: " + flip.stderr);', + ' return { added: 0, path: "/tmp/none.json", catalogExists: false, catalogWritten: false, cacheSynced: false, comboOmissions: [] };', + ' },', + ' injectCodexConfig, // the REAL injector; its under-lock re-read is the claim', + ' });', + ' console.log(JSON.stringify({ status: result.status, skippedReason: result.skippedReason, ok: result.ok }));', + '})();', + ].join("\n"); + const before = readFileSync(join(raceCodexHome, "config.toml"), "utf8"); + const child = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, + env: { + ...process.env, + HOME: raceHome, + USERPROFILE: raceHome, + CODEX_HOME: raceCodexHome, + OPENCODEX_HOME: raceOcxHome, + }, + encoding: "utf8", + }); + expect(child.status).toBe(0); + const line = child.stdout.trim().split("\n").filter(Boolean).pop() ?? "{}"; + expect(JSON.parse(line)).toMatchObject({ status: "skipped", skippedReason: "desired_disabled", ok: true }); + // The stale ON snapshot wrote nothing: the fixture config is untouched. + expect(readFileSync(join(raceCodexHome, "config.toml"), "utf8")).toBe(before); + } finally { + rmSync(raceRoot, { recursive: true, force: true }); + } + }); + test("surfaces combo catalog omissions in sync result and CLI stderr (#484)", async () => { const logs: string[] = []; const errors: string[] = []; @@ -93,6 +224,7 @@ describe("GUI/CLI Codex sync backend", () => { message: "[opencodex] Combo \"k3k3\" is omitted from the catalog because member capabilities are incomplete: kimi/k3, xianyu/kimi-k3.", }; const result = await syncModelsToCodex(12345, config, { log: line => logs.push(String(line)), error: line => errors.push(String(line)) }, { + admitCodexWrite: admittedSync, refreshCodexModelCatalog: async () => ({ added: 1, path: "/tmp/opencodex-catalog.json", @@ -122,6 +254,7 @@ describe("GUI/CLI Codex sync backend", () => { message: "[opencodex] Combo \"disjoint\" is omitted from the catalog because members have no common input modalities: a/m1, b/m2.", }; const result = await syncModelsToCodex(12345, config, { log: () => {}, error: line => errors.push(String(line)) }, { + admitCodexWrite: admittedSync, refreshCodexModelCatalog: async () => ({ added: 0, path: "/tmp/opencodex-catalog.json", @@ -149,6 +282,7 @@ describe("GUI/CLI Codex sync backend", () => { let injectedCatalogPath: string | null | undefined = "unset"; const result = await syncModelsToCodex(undefined, config, null, { + admitCodexWrite: admittedSync, refreshCodexModelCatalog: async () => { throw new Error("catalog boom"); }, @@ -167,6 +301,7 @@ describe("GUI/CLI Codex sync backend", () => { test("returns native subagent default conflicts as structured warnings", async () => { const result = await syncModelsToCodex(10100, config, null, { + admitCodexWrite: admittedSync, refreshCodexModelCatalog: async () => ({ added: 0, path: "/tmp/opencodex-catalog.json", @@ -238,6 +373,7 @@ describe("GUI/CLI Codex sync backend", () => { action: "migrate the installed service", }); const result = await syncModelsToCodex(10100, config, { log: line => logs.push(String(line)), error: line => errors.push(String(line)) }, { + admitCodexWrite: admittedSync, refreshCodexModelCatalog: async () => { refreshed = true; throw new Error("must not refresh"); @@ -253,6 +389,7 @@ describe("GUI/CLI Codex sync backend", () => { expect(refreshed).toBe(false); expect(injectedCatalogPath).toBeUndefined(); expect(result).toEqual({ + status: "applied", ok: true, added: 0, catalogPath: null, diff --git a/tests/desktop-3p-removal.test.ts b/tests/desktop-3p-removal.test.ts new file mode 100644 index 000000000..fdd5f1edd --- /dev/null +++ b/tests/desktop-3p-removal.test.ts @@ -0,0 +1,140 @@ +import { expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { + inspectDesktop3pConfigLibrary, + removeDesktop3pStandardPivot, +} from "../src/claude/desktop-3p"; + +function envFor(path: string): NodeJS.ProcessEnv { + return { ...process.env, OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: path }; +} + +function appliedFingerprint(path: string): string { + return createHash("sha256").update(readFileSync(path, "utf8")).digest("hex").slice(0, 16); +} + +test("an absent Desktop library is read-only and OFF is an idempotent no-op", () => { + const library = join(mkdtempSync(join(tmpdir(), "ocx-desktop-remove-")), "missing"); + const options = { env: envFor(library) }; + expect(inspectDesktop3pConfigLibrary(options).kind).toBe("not_installed"); + expect(removeDesktop3pStandardPivot(options)).toMatchObject({ ok: true, changed: false, kind: "noop" }); + expect(existsSync(library)).toBe(false); +}); + +test("OFF selects a credential-free standard profile before deleting the owned profile and backup", () => { + const library = mkdtempSync(join(tmpdir(), "ocx-desktop-remove-")); + const id = "owned-profile"; + mkdirSync(library, { recursive: true }); + writeFileSync(join(library, "_meta.json"), JSON.stringify({ appliedId: id, entries: [{ id, name: "opencodex" }] })); + writeFileSync(join(library, `${id}.json`), JSON.stringify({ + inferenceProvider: "gateway", + inferenceCredentialKind: "static", + inferenceGatewayBaseUrl: "http://127.0.0.1:10100", + // Shape-only value: deliberately inert; never a credential. + inferenceGatewayApiKey: "not-a-secret", + })); + writeFileSync(join(library, `${id}.json.bak`), "{}"); + + const result = removeDesktop3pStandardPivot({ env: envFor(library), appliedFingerprint: appliedFingerprint(join(library, `${id}.json`)) }); + expect(result).toMatchObject({ ok: true, changed: true, kind: "removed" }); + expect(existsSync(join(library, `${id}.json`))).toBe(false); + expect(existsSync(join(library, `${id}.json.bak`))).toBe(false); + const metadata = JSON.parse(readFileSync(join(library, "_meta.json"), "utf8")) as { appliedId: string; entries: Array<{ id: string }> }; + expect(metadata.entries.map(entry => entry.id)).not.toContain(id); + const standard = JSON.parse(readFileSync(join(library, `${metadata.appliedId}.json`), "utf8")) as Record; + expect(standard).toEqual({}); +}); + +test("a selected path traversal id is refused without following it", () => { + const library = mkdtempSync(join(tmpdir(), "ocx-desktop-remove-")); + writeFileSync(join(library, "_meta.json"), JSON.stringify({ appliedId: "../outside", entries: [] })); + const result = inspectDesktop3pConfigLibrary({ env: envFor(library) }); + expect(result).toMatchObject({ kind: "unsafe", reason: "unsafe_applied_id" }); + expect(removeDesktop3pStandardPivot({ env: envFor(library) }).kind).toBe("unsafe"); +}); + +test("a selected foreign standard profile is never mutated, but owned residue can be cleaned", () => { + const library = mkdtempSync(join(tmpdir(), "ocx-desktop-remove-")); + const foreign = "foreign-standard"; + const owned = "owned-residue"; + writeFileSync(join(library, "_meta.json"), JSON.stringify({ + appliedId: foreign, + entries: [{ id: foreign, name: "someone-else" }, { id: owned, name: "opencodex" }], + })); + writeFileSync(join(library, `${foreign}.json`), "{}\n"); + writeFileSync(join(library, `${owned}.json`), JSON.stringify({ + inferenceProvider: "gateway", inferenceCredentialKind: "static", + inferenceGatewayBaseUrl: "http://127.0.0.1:10100", inferenceGatewayApiKey: "not-a-secret", + })); + + expect(inspectDesktop3pConfigLibrary({ env: envFor(library) })).toMatchObject({ kind: "foreign", appliedId: foreign }); + expect(removeDesktop3pStandardPivot({ env: envFor(library) })).toMatchObject({ ok: true, changed: true, kind: "removed" }); + expect(readFileSync(join(library, `${foreign}.json`), "utf8")).toBe("{}\n"); + expect(existsSync(join(library, `${owned}.json`))).toBe(false); + expect(JSON.parse(readFileSync(join(library, "_meta.json"), "utf8"))).toMatchObject({ appliedId: foreign, entries: [{ id: foreign }] }); +}); + +test("an owned but drifted gateway profile is refused without a write", () => { + const library = mkdtempSync(join(tmpdir(), "ocx-desktop-remove-")); + const id = "drifted-owned"; + writeFileSync(join(library, "_meta.json"), JSON.stringify({ appliedId: id, entries: [{ id, name: "opencodex" }] })); + writeFileSync(join(library, `${id}.json`), JSON.stringify({ + inferenceProvider: "gateway", inferenceCredentialKind: "static", + inferenceGatewayBaseUrl: "http://127.0.0.1:10100", inferenceGatewayApiKey: "not-a-secret", + })); + const before = readFileSync(join(library, "_meta.json"), "utf8"); + + expect(inspectDesktop3pConfigLibrary({ env: envFor(library), appliedFingerprint: "other" }).kind).toBe("gateway_drifted"); + expect(removeDesktop3pStandardPivot({ env: envFor(library), appliedFingerprint: "other" })).toMatchObject({ ok: false, changed: false, kind: "unsafe" }); + expect(readFileSync(join(library, "_meta.json"), "utf8")).toBe(before); + expect(existsSync(join(library, `${id}.json`))).toBe(true); +}); + +test("a delete interruption leaves the standard pivot selected and reports only residual paths", () => { + const library = mkdtempSync(join(tmpdir(), "ocx-desktop-remove-")); + const id = "owned-profile"; + writeFileSync(join(library, "_meta.json"), JSON.stringify({ appliedId: id, entries: [{ id, name: "opencodex" }] })); + writeFileSync(join(library, `${id}.json`), JSON.stringify({ + inferenceProvider: "gateway", inferenceCredentialKind: "static", + inferenceGatewayBaseUrl: "http://127.0.0.1:10100", inferenceGatewayApiKey: "not-a-secret", + })); + writeFileSync(join(library, `${id}.json.bak`), "{}"); + + const result = removeDesktop3pStandardPivot({ + env: envFor(library), + appliedFingerprint: appliedFingerprint(join(library, `${id}.json`)), + unlink: path => { + if (path.endsWith(".bak")) throw new Error("injected delete failure"); + unlinkSync(path); + }, + }); + expect(result).toMatchObject({ ok: false, changed: true, kind: "cleanup_incomplete" }); + expect(result.residualPaths).toEqual([join(library, `${id}.json.bak`)]); + const metadata = JSON.parse(readFileSync(join(library, "_meta.json"), "utf8")) as { appliedId: string }; + expect(JSON.parse(readFileSync(join(library, `${metadata.appliedId}.json`), "utf8"))).toEqual({}); +}); + +test("interrupted cleanup prefers the selected opencodex row and reports another owned row as residue", () => { + const library = mkdtempSync(join(tmpdir(), "ocx-desktop-remove-")); + const selected = "selected-owned"; + const residual = "residual-owned"; + writeFileSync(join(library, "_meta.json"), JSON.stringify({ + appliedId: selected, + entries: [{ id: selected, name: "opencodex" }, { id: residual, name: "opencodex" }], + })); + for (const id of [selected, residual]) { + writeFileSync(join(library, `${id}.json`), JSON.stringify({ + inferenceProvider: "gateway", inferenceCredentialKind: "static", + inferenceGatewayBaseUrl: "http://127.0.0.1:10100", inferenceGatewayApiKey: "not-a-secret", + })); + } + + const result = removeDesktop3pStandardPivot({ env: envFor(library), appliedFingerprint: appliedFingerprint(join(library, `${selected}.json`)) }); + expect(result).toMatchObject({ ok: false, changed: true, kind: "cleanup_incomplete" }); + expect(existsSync(join(library, `${selected}.json`))).toBe(false); + expect(result.residualPaths).toContain(join(library, `${residual}.json`)); +}); diff --git a/tests/helpers/codex-inject-race-child.ts b/tests/helpers/codex-inject-race-child.ts index 9da07838a..4894ec8e6 100644 --- a/tests/helpers/codex-inject-race-child.ts +++ b/tests/helpers/codex-inject-race-child.ts @@ -28,6 +28,7 @@ const result = await injectCodexConfig(payload.port ?? 10100, config, { console.log(JSON.stringify({ success: result.success, + status: result.status, 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 index fdecc73a2..6b401bed2 100644 --- a/tests/helpers/codex-write-lock-child.ts +++ b/tests/helpers/codex-write-lock-child.ts @@ -62,6 +62,6 @@ const result = await withCodexWriteLock( console.log(JSON.stringify({ status: result.status, ...(result.status === "acquired" ? { value: result.value, lockId: result.lockId } : {}), - ...(result.status === "busy" ? { reason: result.reason } : {}), + ...(result.status === "busy" ? { reason: result.reason, lockId: result.lockId } : {}), ...(result.status === "refused" ? { reason: result.reason } : {}), })); diff --git a/tests/helpers/owned-service-home.ts b/tests/helpers/owned-service-home.ts new file mode 100644 index 000000000..6880ad15f --- /dev/null +++ b/tests/helpers/owned-service-home.ts @@ -0,0 +1,62 @@ +import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; +import { delimiter, join } from "node:path"; + +export interface OwnedServiceHome { + /** Add this to child-process environments so Linux never reaches the host bus. */ + readonly env: Record; +} + +/** + * Seed the same state and service-manager definition that an installed proxy + * records, scoped entirely to a test home. + * + * Linux CI has no user systemd bus. The production probe correctly treats that + * as unproven ownership, so the fixture supplies a read-only `systemctl show` + * response on its own PATH together with the unit that response describes. + */ +export function claimOwnedServiceHome( + codexHome: string, + opencodexHome: string, + home: string, +): OwnedServiceHome { + writeFileSync(join(opencodexHome, "service-state.json"), JSON.stringify({ + version: 2, + codexHome, + opencodexHome, + backend: "scheduler", + })); + + if (process.platform === "darwin") { + const launchAgents = join(home, "Library", "LaunchAgents"); + mkdirSync(launchAgents, { recursive: true, mode: 0o700 }); + writeFileSync(join(launchAgents, "com.opencodex.proxy.plist"), [ + "", + "EnvironmentVariables", + `CODEX_HOME${codexHome}`, + `OPENCODEX_HOME${opencodexHome}`, + "", + ].join("\n")); + } + + if (process.platform !== "linux") return { env: {} }; + + const unitDir = join(home, ".config", "systemd", "user"); + mkdirSync(unitDir, { recursive: true, mode: 0o700 }); + writeFileSync(join(unitDir, "opencodex-proxy.service"), [ + "[Service]", + `Environment=\"CODEX_HOME=${codexHome}\"`, + `Environment=\"OPENCODEX_HOME=${opencodexHome}\"`, + ].join("\n")); + + const binDir = join(home, ".ocx-test-bin"); + mkdirSync(binDir, { recursive: true, mode: 0o700 }); + const systemctl = join(binDir, "systemctl"); + writeFileSync(systemctl, [ + "#!/bin/sh", + "if [ \"$1\" != \"--user\" ] || [ \"$2\" != \"show\" ] || [ \"$3\" != \"opencodex-proxy\" ]; then exit 64; fi", + "printf '%s\\n' 'LoadState=loaded' 'ActiveState=inactive' 'FragmentPath=fixture' 'NeedDaemonReload=no'", + ].join("\n")); + chmodSync(systemctl, 0o700); + + return { env: { PATH: [binDir, process.env.PATH ?? ""].filter(Boolean).join(delimiter) } }; +} diff --git a/tests/native-claude-desktop-toggle.test.ts b/tests/native-claude-desktop-toggle.test.ts new file mode 100644 index 000000000..9b1844f01 --- /dev/null +++ b/tests/native-claude-desktop-toggle.test.ts @@ -0,0 +1,246 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleManagementAPI } from "../src/server/management-api"; +import { setIntegrationEnabled } from "../src/codex/desired-state"; +import type { ManagementApiDeps } from "../src/server/management/context"; +import type { OcxConfig } from "../src/types"; + +let root = ""; +let library = ""; +let previousHome: string | undefined; +let previousLibrary: string | undefined; + +function config(): OcxConfig { + return { + port: 10100, + providers: {}, + defaultProvider: "openai", + } as OcxConfig; +} + +function persistedIntent(): unknown { + const raw = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as { clientIntegrations?: Record }; + return raw.clientIntegrations?.["claude-desktop"]; +} + +async function dispatch(path: string, init?: RequestInit, deps: ManagementApiDeps = {}, inputConfig: OcxConfig = config()) { + const url = new URL(`http://127.0.0.1:10100${path}`); + return handleManagementAPI(new Request(url, { + ...init, + headers: { Host: url.host, ...(init?.headers ?? {}) }, + }), url, inputConfig, deps); +} + +async function toggle(enabled: boolean, deps: ManagementApiDeps = {}) { + const response = await dispatch("/api/native-integrations/claude-desktop", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }, deps); + return { status: response!.status, body: await response!.json() as Record }; +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-desktop-toggle-")); + library = join(root, "desktop-library"); + previousHome = process.env.OPENCODEX_HOME; + previousLibrary = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + process.env.OPENCODEX_HOME = root; + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = library; + writeFileSync(join(root, "config.json"), JSON.stringify(config())); +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousLibrary === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = previousLibrary; + rmSync(root, { recursive: true, force: true }); +}); + +test("the native route advertises Claude Desktop and OFF persists intent before removal", async () => { + let sawPersistedOff = false; + const result = await toggle(false, { + removeDesktop3pStandardPivot: () => { + sawPersistedOff = persistedIntent() === false; + return { ok: true, changed: false, kind: "noop", libraryPath: library }; + }, + }); + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ clientId: "claude-desktop", desiredEnabled: false }); + expect(sawPersistedOff).toBe(true); + expect(persistedIntent()).toBe(false); + + const status = await dispatch("/api/native-integrations"); + const clients = (await status!.json() as { clients: Array<{ clientId: string }> }).clients; + expect(clients.map(client => client.clientId)).toEqual(expect.arrayContaining(["claude", "grok", "codex", "claude-desktop"])); +}); + +test("OFF on a missing or empty library is an idempotent no-op with no footprint", async () => { + const missing = await toggle(false); + expect(missing.body).toMatchObject({ ok: true, changed: false, desiredEnabled: false }); + expect(existsSync(library)).toBe(false); + + // A present-but-empty directory has no owned state and stays untouched too. + const empty = join(root, "empty-library"); + mkdirSync(empty); + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = empty; + const again = await toggle(false); + expect(again.body).toMatchObject({ ok: true, changed: false, desiredEnabled: false }); + expect(existsSync(empty)).toBe(true); + expect(existsSync(join(empty, "_meta.json"))).toBe(false); +}); + +test("post-commit unsafe and incomplete refusals disclose desired OFF without contents", async () => { + writeFileSync(join(root, "config.json"), JSON.stringify(config())); + writeFileSync(join(root, "metadata-marker"), ""); + const unsafe = await toggle(false, { + removeDesktop3pStandardPivot: () => ({ ok: false, changed: false, kind: "unsafe", libraryPath: library, reason: "metadata_unreadable" }), + }); + expect(unsafe.status).toBe(409); + expect(unsafe.body).toMatchObject({ reason: "metadata_unreadable", desiredEnabled: false }); + + writeFileSync(join(root, "config.json"), JSON.stringify(config())); + const incomplete = await toggle(false, { + removeDesktop3pStandardPivot: () => ({ + ok: false, changed: true, kind: "cleanup_incomplete", libraryPath: library, residualPaths: [join(library, "owned.json.bak")], + }), + }); + expect(incomplete.status).toBe(500); + expect(incomplete.body).toMatchObject({ + reason: "cleanup_incomplete", + desiredEnabled: false, + residualPaths: [join(library, "owned.json.bak")], + }); +}); + +test("auto-apply re-reads desired state after catalog fetch and skips a concurrent OFF", async () => { + const profile = { + version: 1 as const, + assignments: {}, + defaults: { opus: null, fable: null, sonnet: null, haiku: null }, + }; + const persisted = { ...config(), claudeCode: { desktopProfile: profile, injectAgents: false } }; + writeFileSync(join(root, "config.json"), JSON.stringify(persisted)); + writeFileSync(join(root, "config.json.bak"), JSON.stringify(persisted)); + writeFileSync(join(root, "config.json"), JSON.stringify(persisted)); + const id = "selected-owned"; + const { mkdirSync } = await import("node:fs"); + mkdirSync(library); + writeFileSync(join(library, "_meta.json"), JSON.stringify({ appliedId: id, entries: [{ id, name: "opencodex" }] })); + writeFileSync(join(library, `${id}.json`), JSON.stringify({ + inferenceProvider: "gateway", inferenceCredentialKind: "static", + // Shape-only value: deliberately inert; never a credential. + inferenceGatewayBaseUrl: "http://127.0.0.1:10100", inferenceGatewayApiKey: "not-a-secret", + })); + let release!: () => void; + let started!: () => void; + const fetched = new Promise(resolve => { release = () => resolve([]); }); + const fetchStarted = new Promise(resolve => { started = resolve; }); + let writes = 0; + const request = dispatch("/api/subagent-models", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ models: [] }), + }, { + fetchAllModels: () => { + started(); + return fetched; + }, + writeDesktop3pConfig: () => { + writes++; + return { written: true, path: join(library, "new.json"), fingerprint: "fingerprint" }; + }, + }, persisted); + await fetchStarted; + expect(setIntegrationEnabled("claude-desktop", false).ok).toBe(true); + release(); + expect((await request)!.status).toBe(200); + expect(writes).toBe(0); +}); + +test("explicit enable re-reads desired state after catalog fetch and skips a concurrent OFF", async () => { + let release!: () => void; + let started!: () => void; + const fetched = new Promise(resolve => { release = () => resolve([]); }); + const fetchStarted = new Promise(resolve => { started = resolve; }); + let writes = 0; + const request = toggle(true, { + fetchAllModels: () => { + started(); + return fetched; + }, + writeDesktop3pConfig: () => { + writes++; + return { written: true, path: join(library, "new.json"), fingerprint: "fingerprint" }; + }, + }); + await fetchStarted; + expect(setIntegrationEnabled("claude-desktop", false).ok).toBe(true); + release(); + const result = await request; + expect(result.status).toBe(409); + expect(result.body).toMatchObject({ reason: "desired_state_changed", desiredEnabled: false }); + expect(writes).toBe(0); +}); + +test("POST /apply enables from a stale OFF server snapshot instead of cancelling itself", async () => { + // The regression: /apply persisted ON, then saved the WHOLE long-lived server + // config — whose snapshot still said OFF — over that write, so its own + // post-await guard read OFF and refused the apply it had just been asked for. + expect(setIntegrationEnabled("claude-desktop", false).ok).toBe(true); + expect(persistedIntent()).toBe(false); + // The server object captured at startup, still carrying the OFF it booted with. + const staleSnapshot = { ...config(), clientIntegrations: { "claude-desktop": false } } as OcxConfig; + + let writes = 0; + const response = await dispatch("/api/claude-desktop/apply", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: "static" }), + }, { + fetchAllModels: async () => [], + writeDesktop3pConfig: () => { + writes++; + return { written: true, path: join(library, "applied.json"), fingerprint: "fingerprint" }; + }, + }, staleSnapshot); + + expect(response!.status).toBe(200); + expect(writes).toBe(1); + // Desired ON survives the profile/fingerprint saves that follow it. + expect(persistedIntent()).toBeUndefined(); +}); + +test("POST /apply leaves the reused server snapshot agreeing with disk", async () => { + // Disk-only repair is not enough: the server reuses ONE config object per + // request, so a stale snapshot makes the native GET report the opposite of + // what was persisted, and lets a later whole-snapshot save undo the enable. + expect(setIntegrationEnabled("claude-desktop", false).ok).toBe(true); + const staleSnapshot = { ...config(), clientIntegrations: { "claude-desktop": false } } as OcxConfig; + const deps: ManagementApiDeps = { + fetchAllModels: async () => [], + writeDesktop3pConfig: () => ({ written: true, path: join(library, "applied.json"), fingerprint: "fingerprint" }), + }; + + expect((await dispatch("/api/claude-desktop/apply", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: "static" }), + }, deps, staleSnapshot))!.status).toBe(200); + + // (1) the SAME snapshot object now reports ON through the native GET + const status = await dispatch("/api/native-integrations", undefined, deps, staleSnapshot); + const clients = (await status!.json() as { clients: Array<{ clientId: string; desiredEnabled: boolean }> }).clients; + expect(clients.find(client => client.clientId === "claude-desktop")?.desiredEnabled).toBe(true); + + // (2) a later whole-snapshot save (the Desktop profile PUT) cannot write OFF back + await dispatch("/api/claude-desktop", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile: { mode: "static" } }), + }, deps, staleSnapshot); + expect(persistedIntent()).toBeUndefined(); +}); diff --git a/tests/native-codex-toggle.test.ts b/tests/native-codex-toggle.test.ts index 92d006857..9aa62df73 100644 --- a/tests/native-codex-toggle.test.ts +++ b/tests/native-codex-toggle.test.ts @@ -108,6 +108,11 @@ describe("turning Codex off", () => { const result = await put(baseConfig(), { enabled: false }); expect(result.status).toBe(200); expect(result.body).toMatchObject({ ok: true, clientId: "codex" }); + expect(result.body.artifacts).toMatchObject({ + config: { state: expect.any(String) }, + catalog: { state: expect.any(String) }, + history: { state: expect.any(String) }, + }); // 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); diff --git a/tests/native-grok-toggle.test.ts b/tests/native-grok-toggle.test.ts index 4b9326c1b..2a405e9e3 100644 --- a/tests/native-grok-toggle.test.ts +++ b/tests/native-grok-toggle.test.ts @@ -419,6 +419,7 @@ test("a foreign-home install state refuses disable and writes nothing (audit r1 const { status, body } = await put(baseConfig(), false); expect(status).toBe(409); expect(body.reason).toBe("home_mismatch"); + expect(body.desiredEnabled).toBe(false); expect(String(body.message)).toContain("/foreign/codex-home"); // Nothing was written: the fence is still there. expect(readConfig()).toContain(BEGIN); diff --git a/tests/shutdown-launcher.test.ts b/tests/shutdown-launcher.test.ts index 2ba68a2d4..08316ea58 100644 --- a/tests/shutdown-launcher.test.ts +++ b/tests/shutdown-launcher.test.ts @@ -1,9 +1,10 @@ import { afterAll, describe, expect, test } from "bun:test"; import { spawn, spawnSync, type ChildProcess } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { claimOwnedServiceHome } from "./helpers/owned-service-home"; /** * Regression: `ocx start` + Ctrl-C must NOT orphan the Bun proxy. @@ -25,6 +26,14 @@ const runnable = process.platform !== "win32" && nodeAvailable; const spawned: ChildProcess[] = []; const tmpHomes: string[] = []; +function claimTempHome(home: string): { homeDir: string; userProfile: string; serviceManagerEnv: Record } { + const homeDir = join(home, "user-home"); + const userProfile = join(home, "user-profile"); + mkdirSync(homeDir, { recursive: true }); + mkdirSync(userProfile, { recursive: true }); + return { homeDir, userProfile, serviceManagerEnv: claimOwnedServiceHome(home, home, homeDir).env }; +} + afterAll(() => { for (const c of spawned) { try { c.kill("SIGKILL"); } catch { /* already gone */ } @@ -74,6 +83,7 @@ describe.skipIf(!runnable)("ocx launcher graceful shutdown", () => { const home = mkdtempSync(join(tmpdir(), "ocx-shutdown-")); tmpHomes.push(home); const port = await freePort(); + const identity = claimTempHome(home); // Seed a native Codex config so the proxy actually injects on start (injectCodexConfig // no-ops when no config.toml exists) — this lets us prove the config is RESTORED. @@ -82,7 +92,14 @@ describe.skipIf(!runnable)("ocx launcher graceful shutdown", () => { const child = spawn("node", [BIN_OCX, "start", "--port", String(port)], { stdio: "ignore", - env: { ...process.env, OPENCODEX_HOME: home, CODEX_HOME: home }, + env: { + ...process.env, + HOME: identity.homeDir, + USERPROFILE: identity.userProfile, + OPENCODEX_HOME: home, + CODEX_HOME: home, + ...identity.serviceManagerEnv, + }, }); spawned.push(child);