From 480a9fe4b936e99616a53c8f6b1695a11870e2e9 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:21:18 +0900 Subject: [PATCH 01/50] =?UTF-8?q?docs(devlog):=20260806=20WP13/WP14=20resu?= =?UTF-8?q?me=20plan=20=E2=80=94=20stale-checked=20roadmap,=20PR-only=20bo?= =?UTF-8?q?undary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../260806_wp13_toggles_resume/000_plan.md | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 devlog/_plan/260806_wp13_toggles_resume/000_plan.md 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..4f2850b64 --- /dev/null +++ b/devlog/_plan/260806_wp13_toggles_resume/000_plan.md @@ -0,0 +1,108 @@ +# 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 inspect exists. 050's read-never-writes rule is + still violated by `writeDesktop3pConfig`'s eager `mkdirSync` + (`src/claude/desktop-3p.ts:343-345`) on the write path only — reads must never + route through it. +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), gate `ocx ensure`/`sync` + on desired OFF. 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; read-only + 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. +- **030 (WP-D)** Composed acceptance (issue #1048): 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). +- **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. From d69f05f5914df76f7c4b00d4a557d39effbfb2c4 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:26:53 +0900 Subject: [PATCH 02/50] =?UTF-8?q?docs(devlog):=20260806=20plan=20r2=20?= =?UTF-8?q?=E2=80=94=20audit=20blockers=20folded=20in=20(reads=20non-mutat?= =?UTF-8?q?ing=20today,=20full=20sync-caller=20gating,=20workstation-only?= =?UTF-8?q?=20030=20leaves=20#1048=20open)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../260806_wp13_toggles_resume/000_plan.md | 48 ++++++++++++------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/devlog/_plan/260806_wp13_toggles_resume/000_plan.md b/devlog/_plan/260806_wp13_toggles_resume/000_plan.md index 4f2850b64..2abc7710b 100644 --- a/devlog/_plan/260806_wp13_toggles_resume/000_plan.md +++ b/devlog/_plan/260806_wp13_toggles_resume/000_plan.md @@ -53,10 +53,12 @@ implementable as written. What changed under them: `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 inspect exists. 050's read-never-writes rule is - still violated by `writeDesktop3pConfig`'s eager `mkdirSync` - (`src/claude/desktop-3p.ts:343-345`) on the write path only — reads must never - route through it. + `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 @@ -86,19 +88,33 @@ implementable as written. What changed under them: - **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), gate `ocx ensure`/`sync` - on desired OFF. 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. + 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`, and + `src/server/management/config-routes.ts:261-268`. 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; read-only - 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. -- **030 (WP-D)** Composed acceptance (issue #1048): 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). + `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. From 0cfeda04f2bfa3de9a9d92fe9d2b77b3d6ef7b46 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:28:23 +0900 Subject: [PATCH 03/50] =?UTF-8?q?docs(devlog):=20260806=20plan=20r3=20?= =?UTF-8?q?=E2=80=94=20the=20enable=20path=20is=20a=20syncModelsToCodex=20?= =?UTF-8?q?caller=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devlog/_plan/260806_wp13_toggles_resume/000_plan.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/devlog/_plan/260806_wp13_toggles_resume/000_plan.md b/devlog/_plan/260806_wp13_toggles_resume/000_plan.md index 2abc7710b..60e741e4e 100644 --- a/devlog/_plan/260806_wp13_toggles_resume/000_plan.md +++ b/devlog/_plan/260806_wp13_toggles_resume/000_plan.md @@ -93,8 +93,11 @@ implementable as written. What changed under them: 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`, and - `src/server/management/config-routes.ts:261-268`. Source doc: + `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 From 813375fb1d5341ba4264c509cbc1cea1bae078d0 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:35:17 +0900 Subject: [PATCH 04/50] =?UTF-8?q?docs(devlog):=20010=20amendments=20?= =?UTF-8?q?=E2=80=94=20write-lock=20revalidation=20replaces=20the=20phanto?= =?UTF-8?q?m=20coordinator;=20OFF=20path=20stays=20async?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../010_codex_toggle_amendments.md | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 devlog/_plan/260806_wp13_toggles_resume/010_codex_toggle_amendments.md 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..88305a78a --- /dev/null +++ b/devlog/_plan/260806_wp13_toggles_resume/010_codex_toggle_amendments.md @@ -0,0 +1,77 @@ +# 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 the artifact lock**, not by a cross-surface flight: + every artifact-mutating path (inject, restore, history job) re-reads the + persisted desired state from disk *inside* its `withCodexWriteLock` section, + immediately before writing, and converts a lost race into the discriminated + skip (`status:"skipped", skippedReason:"desired_disabled"` or + `"desired_enabled"` for the restore direction). The lock already provides + the mutual exclusion; the re-read provides the freshness 040's + `requirePersistedClientIntent` wanted (`040:600-618`). +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: a + per-artifact envelope `{ config, profile, history }` where `history` carries + `CodexHistoryFailureReason` (`"busy" | "permission"`) instead of being folded + into `inline.success` (defect at `inject.ts:1193-1217`). +- 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+profile restored AND history either + restored or classified (`busy` → retry advice, exit 1; `permission` → ACL + advice, exit 1). No path reports success with an unclassified history hole. + +## 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. From 9971d2bc480b36c3e72713b852427f469b809e31 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:37:02 +0900 Subject: [PATCH 05/50] =?UTF-8?q?docs(devlog):=20010=20r2=20=E2=80=94=20pe?= =?UTF-8?q?r-artifact=20serializers=20own=20revalidation;=20catalog=20join?= =?UTF-8?q?s=20the=20restore=20envelope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../010_codex_toggle_amendments.md | 49 +++++++++++++------ 1 file changed, 35 insertions(+), 14 deletions(-) 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 index 88305a78a..45c35aab8 100644 --- a/devlog/_plan/260806_wp13_toggles_resume/010_codex_toggle_amendments.md +++ b/devlog/_plan/260806_wp13_toggles_resume/010_codex_toggle_amendments.md @@ -25,14 +25,29 @@ 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 the artifact lock**, not by a cross-surface flight: - every artifact-mutating path (inject, restore, history job) re-reads the - persisted desired state from disk *inside* its `withCodexWriteLock` section, - immediately before writing, and converts a lost race into the discriminated - skip (`status:"skipped", skippedReason:"desired_disabled"` or - `"desired_enabled"` for the restore direction). The lock already provides - the mutual exclusion; the re-read provides the freshness 040's - `requirePersistedClientIntent` wanted (`040:600-618`). + **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. @@ -43,19 +58,25 @@ 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: a - per-artifact envelope `{ config, profile, history }` where `history` carries +- `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`). + 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+profile restored AND history either - restored or classified (`busy` → retry advice, exit 1; `permission` → ACL - advice, exit 1). No path reports success with an unclassified history hole. +- `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) From 1d02e2c264b24053e479a8ec1e8ec2b421708db5 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:44:23 +0900 Subject: [PATCH 06/50] feat(codex): expose native restore artifact outcomes --- src/codex/history-job.ts | 13 +- src/codex/history-provider.ts | 45 ++++-- src/codex/history-worker.ts | 11 +- src/codex/inject.ts | 205 +++++++++++++++++++-------- src/codex/internal/history-writer.ts | 2 +- 5 files changed, 193 insertions(+), 83 deletions(-) diff --git a/src/codex/history-job.ts b/src/codex/history-job.ts index 97a3970e5..c121487a2 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. */ @@ -70,7 +71,7 @@ export type CodexHistoryJobOutcome = | { readonly kind: "skipped" } | { readonly kind: "blocked"; readonly reason: "busy" | "database" | "unsafe-path" } | { readonly kind: "failed"; readonly reason: "worker-error" | "worker-died" | "timeout"; - readonly message: string }; + readonly message: string; readonly historyFailureReason?: CodexHistoryFailureReason }; /** * Derive the durable history operation from admitted intent. @@ -115,7 +116,8 @@ function isPlausibleWorkerResult( case "blocked": return message.reason === "busy" || message.reason === "database" || message.reason === "unsafe-path"; 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 +136,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" } 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..3e1ee3a65 100644 --- a/src/codex/history-worker.ts +++ b/src/codex/history-worker.ts @@ -29,6 +29,7 @@ import { writeLegacyOpenaiHistoryRecovery, type HistoryWriteTarget, } from "./internal/history-writer"; +import type { CodexHistoryFailureReason } from "./history-provider"; /** * The durable operation, mirrored into the request for diagnostics only. @@ -64,7 +65,7 @@ export type HistoryWorkerResult = | { readonly type: "blocked"; readonly requestId: string; readonly jobId: string; readonly reason: "busy" | "database" | "unsafe-path" } | { readonly type: "error"; readonly requestId: string; readonly jobId: string; - readonly message: string }; + readonly message: string; readonly reason?: CodexHistoryFailureReason }; const OPERATIONS: ReadonlySet = new Set([ "skip", @@ -135,7 +136,13 @@ export function runHistoryUnitUnderLock( } const result = acquired.value; 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.ts b/src/codex/inject.ts index 0e5304a64..9e6e064cb 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -34,7 +34,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, @@ -1179,71 +1179,140 @@ export function removeCodexConfig( }; } -/** - * 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. - */ -/** - * Restore native Codex, running history in a Worker under H. - * - * Prefer this everywhere. The synchronous variant below exists only for the - * process-exit path, where awaiting a thread is its own hazard. - */ -export async function restoreNativeCodexAsync(): Promise<{ +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.", + }; +} + +/** Restore native Codex, running history in a Worker under H. */ +export async function restoreNativeCodexAsync(): Promise { const inline = restoreNativeCodex({ skipHistory: true }); const outcome = await runCodexHistoryJob({ ...resolveCodexHistoryJobTarget(), - operation: deriveCodexHistoryOperation({ - direction: "restore", - // Restore always returns history to native when it runs at all; the - // opt-out belongs to apply, which is what put opencodex there. - resumeHistory: true, - legacyMode: false, - }), + 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}` }; + 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 === "busy" + ? failedHistoryRestore("busy") + : outcome.kind === "failed" + ? failedHistoryRestore(outcome.historyFailureReason) + : failedHistoryRestore(); + const success = inline.artifacts.config.state !== "failed" + && inline.artifacts.catalog.state !== "failed" + && history.state !== "failed"; + return { + ...inline, + success, + message: `${inline.message}${history.state === "failed" ? ` ⚠️ ${history.message}` : ""}`, + artifacts: { ...inline.artifacts, history }, + }; } -export function restoreNativeCodex(options: { skipHistory?: boolean } = {}): { - success: boolean; - message: string; -} { +export function restoreNativeCodex(options: { skipHistory?: boolean } = {}): CodexNativeRestoreResult { const activeProvider = currentExternalCodexModelProvider(); if (activeProvider) { removeJournal(); + const message = `External Codex provider ${tomlString(activeProvider)} preserved; no native restore was needed.`; return { success: true, - message: `External Codex provider ${tomlString(activeProvider)} preserved; no native restore was needed.`, + 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 }, + }, }; } - const journal = restoreJournalState(); - const cfg = journal.configRestored - ? { - success: true, - message: "Codex config restored from opencodex journal.", - } - : removeCodexConfig({ - preserveProfile: journal.profileRestored || journal.profileChanged, - }); + let config: CodexRestoreConfigResult; + try { + const journal = restoreJournalState(); + const restored = journal.configRestored + ? { success: true, message: "Codex config restored from opencodex journal." } + : removeCodexConfig({ preserveProfile: journal.profileRestored || journal.profileChanged }); + config = 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) { + config = { state: "failed", changed: false, action: "failed", message: error instanceof Error ? error.message : String(error) }; + } 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 }; + let catalog: CodexRestoreCatalogResult; + try { + const restored = withCatalogWriteSerialization(owningCodexHome, permit => restoreCodexCatalogWithPermit(permit, owningCodexHome)); + catalog = restored.kind === "completed" + ? { state: "ok", changed: restored.value.removed > 0, ...restored.value, message: "Codex catalog restored." } + : { + state: "failed", changed: false, removed: 0, kept: 0, path: DEFAULT_CATALOG_PATH, + message: `Codex catalog could not be restored: ${restored.reason}.`, + }; + } catch (error) { + catalog = { + state: "failed", changed: false, removed: 0, kept: 0, path: DEFAULT_CATALOG_PATH, + message: error instanceof Error ? error.message : String(error), + }; + } // 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 +1325,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); } From 96191c6ff24727e266b83f954e1c1a132fd7660f Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:46:20 +0900 Subject: [PATCH 07/50] feat(codex): distinguish skipped sync outcomes --- src/cli/index.ts | 20 +++++++++++++++---- src/cli/models.ts | 6 +++++- src/cli/provider.ts | 10 ++++++++-- src/codex/desired-state.ts | 7 ++++++- src/codex/sync.ts | 19 ++++++++++++++++++ src/server/management/config-routes.ts | 3 ++- .../management/native-integration-routes.ts | 8 ++++++++ 7 files changed, 64 insertions(+), 9 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index fdcec562d..bfc922f53 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -318,6 +318,7 @@ async function handleStart(options: { block?: boolean } = {}) { await maybeShowStarPrompt(); // once-only Yes/No GitHub-star prompt on first interactive start const startupSync = await syncCodexOnStartIfEnabled(port, config); + 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 @@ -376,10 +377,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 @@ -419,9 +422,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}`); } @@ -783,6 +788,11 @@ switch (command) { process.exit(1); } 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."); @@ -856,7 +866,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."); } 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/desired-state.ts b/src/codex/desired-state.ts index f75f261b3..3512e8212 100644 --- a/src/codex/desired-state.ts +++ b/src/codex/desired-state.ts @@ -66,6 +66,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 @@ -162,7 +167,7 @@ export async function syncCodexOnStartIfEnabled( config: Pick, sync: CodexStartupSync = defaultStartupSync, ): Promise<{ ran: boolean; catalogWritten: boolean; cacheSynced: boolean }> { - if (!codexIntegrationEnabled(config)) { + if (!shouldSyncCodexOnStart(config)) { return { ran: false, catalogWritten: false, cacheSynced: false }; } // The `.catch` is deliberate and stays: a failure to APPLY must not stop the diff --git a/src/codex/sync.ts b/src/codex/sync.ts index 219d3f6a2..ab3234ce3 100644 --- a/src/codex/sync.ts +++ b/src/codex/sync.ts @@ -5,9 +5,13 @@ 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"; export interface CodexSyncResult { + /** `skipped` is policy truth, never evidence that Codex was written. */ + status: "applied" | "skipped"; ok: boolean; + skippedReason?: "desired_disabled"; added: number; catalogPath: string | null; catalogExists: boolean; @@ -52,6 +56,19 @@ export async function syncModelsToCodex( log: Pick | null = console, deps: CodexSyncDeps = defaultDeps, ): Promise { + if (!shouldSyncCodexOnStart(config)) { + 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.", + }; + } const p = port ?? config.port ?? 10100; const externalProvider = (deps.currentExternalCodexModelProvider ?? currentExternalCodexModelProvider)(); if (externalProvider) { @@ -59,6 +76,7 @@ export async function syncModelsToCodex( log?.log(result.message); reportCodexHomeTarget(log, deps.collectCodexHomeDiagnostic ?? collectOrcaCodexHomeDiagnostic); return { + status: "applied", ok: result.success, added: 0, catalogPath: null, @@ -112,6 +130,7 @@ export async function syncModelsToCodex( reportCodexHomeTarget(log, deps.collectCodexHomeDiagnostic ?? collectOrcaCodexHomeDiagnostic); const projectConfigWarnings = printProjectCodexConfigWarnings(log, { cwd: process.cwd() }); return { + status: "applied", ok: result.success, added, catalogPath, diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index b35169283..77f06ea31 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -262,10 +262,11 @@ export async function handleConfigRoutes(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", + 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", From e4831714b96bdff22404736eb16b5ed43ddd1ec0 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:49:09 +0900 Subject: [PATCH 08/50] feat(codex): revalidate desired state under artifact locks --- src/codex/codex-write-lock.ts | 12 +++++ src/codex/history-job.ts | 7 ++- src/codex/history-worker.ts | 21 ++++++++- src/codex/inject-coordination.ts | 12 ++++- src/codex/inject.ts | 45 ++++++++++++++++--- src/codex/sync.ts | 13 ++++++ .../management/native-integration-routes.ts | 2 +- 7 files changed, 100 insertions(+), 12 deletions(-) diff --git a/src/codex/codex-write-lock.ts b/src/codex/codex-write-lock.ts index 07ae7e17f..4b429a9cb 100644 --- a/src/codex/codex-write-lock.ts +++ b/src/codex/codex-write-lock.ts @@ -66,6 +66,7 @@ export type CodexWriteLockRefusalReason = export type CodexWriteLockResult = | { status: "acquired"; value: T; waitedMs: number; lockId: string } + | { status: "skipped"; reason: "desired_disabled"; waitedMs: number } | { status: "busy"; reason: "deadline" | "cancelled"; retryable: true; waitedMs: number } | { status: "refused"; @@ -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") { + 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; @@ -345,6 +354,9 @@ 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."); diff --git a/src/codex/history-job.ts b/src/codex/history-job.ts index c121487a2..fe0c15962 100644 --- a/src/codex/history-job.ts +++ b/src/codex/history-job.ts @@ -64,12 +64,13 @@ 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 historyFailureReason?: CodexHistoryFailureReason }; @@ -114,7 +115,8 @@ 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" && (message.reason === undefined || message.reason === "busy" || message.reason === "permission"); @@ -255,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-worker.ts b/src/codex/history-worker.ts index 3e1ee3a65..35bf9e5bf 100644 --- a/src/codex/history-worker.ts +++ b/src/codex/history-worker.ts @@ -24,6 +24,8 @@ * 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, @@ -54,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 }; } @@ -63,7 +67,7 @@ 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 reason?: CodexHistoryFailureReason }; @@ -94,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"); } /** @@ -121,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); } @@ -135,6 +144,14 @@ 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", diff --git a/src/codex/inject-coordination.ts b/src/codex/inject-coordination.ts index 28c58f8b0..28e3a902d 100644 --- a/src/codex/inject-coordination.ts +++ b/src/codex/inject-coordination.ts @@ -229,7 +229,17 @@ 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"; message: string; +} { + if (result.status === "skipped") { + return { + success: true, + status: "skipped", + skippedReason: result.reason, + message: "Codex integration is OFF; no Codex config, catalog, cache, or history was changed.", + }; + } if (result.status === "busy") { return { success: false, diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 9e6e064cb..eec98e609 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, @@ -618,6 +619,8 @@ export function chooseCatalogPathForInjection( export interface CodexInjectResult { success: boolean; message: string; + status?: "skipped"; + skippedReason?: "desired_disabled"; nativeSubagentDefaultsWarning?: string; } @@ -866,6 +869,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 +894,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 +979,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, @@ -1233,10 +1248,13 @@ function failedHistoryRestore(reason?: CodexHistoryFailureReason): CodexRestoreH } /** Restore native Codex, running history in a Worker under H. */ -export async function restoreNativeCodexAsync(): Promise { - const inline = restoreNativeCodex({ skipHistory: true }); +export async function restoreNativeCodexAsync( + options: { revalidateDesiredState?: boolean } = {}, +): Promise { + const inline = restoreNativeCodex({ skipHistory: true, revalidateDesiredState: options.revalidateDesiredState }); const outcome = await runCodexHistoryJob({ ...resolveCodexHistoryJobTarget(), + ...(options.revalidateDesiredState ? { expectedDesiredEnabled: false } : {}), operation: deriveCodexHistoryOperation({ direction: "restore", resumeHistory: true, legacyMode: false }), }); const history: CodexRestoreHistoryResult = outcome.kind === "converged" @@ -1248,6 +1266,13 @@ export async function restoreNativeCodexAsync(): Promise restoreCodexCatalogWithPermit(permit, owningCodexHome)); - catalog = restored.kind === "completed" + const restored = withCatalogWriteSerialization(owningCodexHome, permit => + options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig()) + ? null + : restoreCodexCatalogWithPermit(permit, owningCodexHome)); + catalog = 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}.`, diff --git a/src/codex/sync.ts b/src/codex/sync.ts index ab3234ce3..87b1fb865 100644 --- a/src/codex/sync.ts +++ b/src/codex/sync.ts @@ -126,6 +126,19 @@ export async function syncModelsToCodex( } const result = await deps.injectCodexConfig(p, config, { catalogPath: catalogPathForInjection }); + if (result.status === "skipped") { + return { + status: "skipped", + skippedReason: result.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() }); diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index cb82bebcd..9c6548ebe 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -282,7 +282,7 @@ async function handleCodexToggle(ctx: ManagementContext): Promise { // OFF. Restore the native path; the proxy keeps serving every other client. 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", From b45f05d343e8982497315a8c98fc08edf1fbc634 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:49:46 +0900 Subject: [PATCH 09/50] feat(cli): persist Codex restore intent before mutation --- src/cli/index.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index bfc922f53..49c5bc42f 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -41,7 +41,7 @@ import { maybeShowStarPrompt } from "./star-prompt"; import { scheduleCatalogPrewarm } from "./catalog-prewarm"; import { maybeShowUpdatePrompt } from "../update/notify"; import { syncModelsToCodex } from "../codex/sync"; -import { shouldSyncGrokOnStart, syncCodexOnStartIfEnabled } from "../codex/desired-state"; +import { setIntegrationEnabled, shouldSyncGrokOnStart, syncCodexOnStartIfEnabled } from "../codex/desired-state"; import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job"; import { collectOrcaCodexHomeDiagnostic } from "../codex/home"; import { removeOwnedConfigState } from "../lib/config-ownership"; @@ -787,6 +787,12 @@ 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; @@ -802,9 +808,15 @@ 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; + console.error(`Codex desired state was not saved (${desired.reason}).`); + 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) }; } @@ -822,7 +834,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."); } From 39064409c3375a881a3521c19be9af7a1ed88dd2 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:52:55 +0900 Subject: [PATCH 10/50] test(codex): cover desired-state toggle outcomes --- .../management/native-integration-routes.ts | 3 + tests/cli-restore-back.test.ts | 63 ++++++++++++------- tests/codex-desired-state.test.ts | 6 ++ tests/codex-history-job.test.ts | 2 +- tests/codex-history-provider.test.ts | 8 ++- tests/codex-inject-write-lock.test.ts | 18 +++++- tests/codex-sync-api.test.ts | 24 +++++++ tests/helpers/codex-inject-race-child.ts | 1 + tests/native-codex-toggle.test.ts | 5 ++ 9 files changed, 102 insertions(+), 28 deletions(-) diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index 9c6548ebe..ca04cb396 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -23,6 +23,7 @@ import { injectGrokConfig, stripGrokConfig, type GrokInjectModel } from "../../g import { inspectGrokConfig } from "../../grok/inspect"; import { grokConfigPath } from "../../grok/status"; import { assertNativeTeardownOwned } from "../../integrations/native/ownership-preflight"; +import type { CodexNativeRestoreResult } from "../../codex/inject"; import type { OcxConfig } from "../../types"; import { jsonResponse } from "../auth-cors"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; @@ -63,6 +64,7 @@ export interface NativeToggleEnvelope { message: string; /** Present when the outcome needs more than success/failure to be honest. */ reason?: string; + artifacts?: CodexNativeRestoreResult["artifacts"]; } export interface NativeRefusalEnvelope { @@ -292,6 +294,7 @@ async function handleCodexToggle(ctx: ManagementContext): Promise { ...(restored.success ? (durable ? {} : { reason: "not_durable" }) : { reason: "restore_incomplete" }), + artifacts: restored.artifacts, } satisfies NativeToggleEnvelope); })(); try { diff --git a/tests/cli-restore-back.test.ts b/tests/cli-restore-back.test.ts index cf72a94c6..66b31a7df 100644 --- a/tests/cli-restore-back.test.ts +++ b/tests/cli-restore-back.test.ts @@ -1,38 +1,53 @@ import { describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -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":')); - - // 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"); + 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, 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":')); - - 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, 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", () => { 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..da92ea62b 100644 --- a/tests/codex-history-job.test.ts +++ b/tests/codex-history-job.test.ts @@ -157,5 +157,5 @@ test("the synchronous restore body is gated on skipHistory", () => { expect(gate).toBeLessThan(historyCall); // And the async wrapper is the thing that sets it. - expect(source).toContain("restoreNativeCodex({ skipHistory: true })"); + expect(source).toContain("restoreNativeCodex({ skipHistory: 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-sync-api.test.ts b/tests/codex-sync-api.test.ts index 85064b582..70f13d781 100644 --- a/tests/codex-sync-api.test.ts +++ b/tests/codex-sync-api.test.ts @@ -71,6 +71,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 +84,28 @@ 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; + const result = await syncModelsToCodex(12345, { + ...config, + clientIntegrations: { codex: false }, + }, null, { + 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); + }); + test("surfaces combo catalog omissions in sync result and CLI stderr (#484)", async () => { const logs: string[] = []; const errors: string[] = []; @@ -253,6 +276,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/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/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); From 5788def426fc6cb946384bced693280fc7af2889 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 12:24:22 +0900 Subject: [PATCH 11/50] fix(codex): keep external-provider restore free of history work --- src/codex/inject.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/codex/inject.ts b/src/codex/inject.ts index eec98e609..a05cc7608 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -1252,6 +1252,11 @@ export async function restoreNativeCodexAsync( options: { revalidateDesiredState?: boolean } = {}, ): Promise { const inline = restoreNativeCodex({ skipHistory: true, revalidateDesiredState: options.revalidateDesiredState }); + // External-provider courtesy: the inline body already reported all three + // artifacts as skipped and touched nothing but the stale journal. Launching + // the history worker here would turn a read-mostly courtesy result into a + // history mutation (or a spurious busy failure) on a home we do not own. + if (inline.externalProvider) return inline; const outcome = await runCodexHistoryJob({ ...resolveCodexHistoryJobTarget(), ...(options.revalidateDesiredState ? { expectedDesiredEnabled: false } : {}), From d2137315928fdf34dfe552fca0ad2e63b6d73c1a Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 12:30:32 +0900 Subject: [PATCH 12/50] fix(codex): serialize restore config writes under the write lock --- src/codex/codex-write-lock.ts | 4 +- src/codex/inject-coordination.ts | 6 +- src/codex/inject.ts | 257 +++++++++++++++++++++++-------- src/codex/sync.ts | 3 +- 4 files changed, 201 insertions(+), 69 deletions(-) diff --git a/src/codex/codex-write-lock.ts b/src/codex/codex-write-lock.ts index 4b429a9cb..879497955 100644 --- a/src/codex/codex-write-lock.ts +++ b/src/codex/codex-write-lock.ts @@ -66,7 +66,7 @@ export type CodexWriteLockRefusalReason = export type CodexWriteLockResult = | { status: "acquired"; value: T; waitedMs: number; lockId: string } - | { status: "skipped"; reason: "desired_disabled"; waitedMs: number } + | { status: "skipped"; reason: "desired_disabled" | "desired_enabled"; waitedMs: number } | { status: "busy"; reason: "deadline" | "cancelled"; retryable: true; waitedMs: number } | { status: "refused"; @@ -127,7 +127,7 @@ export interface CodexWriteCommitContext { /** A synchronous under-lock policy re-read proved the requested apply stale. */ export class CodexWriteLockSkipped extends Error { - constructor(readonly reason: "desired_disabled") { + constructor(readonly reason: "desired_disabled" | "desired_enabled") { super(reason); this.name = "CodexWriteLockSkipped"; } diff --git a/src/codex/inject-coordination.ts b/src/codex/inject-coordination.ts index 28e3a902d..91f9374bc 100644 --- a/src/codex/inject-coordination.ts +++ b/src/codex/inject-coordination.ts @@ -230,14 +230,16 @@ export function recomputeInjectWitness(options: { export function codexInjectLockOutcome( result: Exclude, { status: "acquired" }>, ): { success: false; message: string; retryable: boolean } | { - success: true; status: "skipped"; skippedReason: "desired_disabled"; message: string; + success: true; status: "skipped"; skippedReason: "desired_disabled" | "desired_enabled"; message: string; } { if (result.status === "skipped") { return { success: true, status: "skipped", skippedReason: result.reason, - message: "Codex integration is OFF; no Codex config, catalog, cache, or history was changed.", + 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") { diff --git a/src/codex/inject.ts b/src/codex/inject.ts index a05cc7608..e62f9e1c6 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -620,7 +620,7 @@ export interface CodexInjectResult { success: boolean; message: string; status?: "skipped"; - skippedReason?: "desired_disabled"; + skippedReason?: "desired_disabled" | "desired_enabled"; nativeSubagentDefaultsWarning?: string; } @@ -1247,21 +1247,194 @@ function failedHistoryRestore(reason?: CodexHistoryFailureReason): CodexRestoreH }; } -/** Restore native Codex, running history in a Worker under H. */ +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 }, + }, + }; +} + +function desiredEnabledRestoreSkip(): CodexNativeRestoreResult { + const message = "Codex integration was re-enabled; native restore was skipped."; + return { + success: true, + 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. + * + * 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( options: { revalidateDesiredState?: boolean } = {}, ): Promise { - const inline = restoreNativeCodex({ skipHistory: true, revalidateDesiredState: options.revalidateDesiredState }); - // External-provider courtesy: the inline body already reported all three - // artifacts as skipped and touched nothing but the stale journal. Launching - // the history worker here would turn a read-mostly courtesy result into a - // history mutation (or a spurious busy failure) on a home we do not own. - if (inline.externalProvider) return inline; + 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); + } + + 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(), ...(options.revalidateDesiredState ? { expectedDesiredEnabled: false } : {}), operation: deriveCodexHistoryOperation({ direction: "restore", resumeHistory: true, legacyMode: false }), }); + 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, @@ -1283,14 +1456,16 @@ export async function restoreNativeCodexAsync( : outcome.kind === "failed" ? failedHistoryRestore(outcome.historyFailureReason) : failedHistoryRestore(); - const success = inline.artifacts.config.state !== "failed" - && inline.artifacts.catalog.state !== "failed" + 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 { - ...inline, success, - message: `${inline.message}${history.state === "failed" ? ` ⚠️ ${history.message}` : ""}`, - artifacts: { ...inline.artifacts, history }, + message: `${base}${history.state === "failed" ? ` ⚠️ ${history.message}` : ""}`, + artifacts: { config, catalog, history }, }; } @@ -1298,59 +1473,13 @@ export function restoreNativeCodex(options: { skipHistory?: boolean; revalidateD const activeProvider = currentExternalCodexModelProvider(); if (activeProvider) { removeJournal(); - 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 }, - }, - }; - } - let config: CodexRestoreConfigResult; - try { - const journal = restoreJournalState(); - const restored = journal.configRestored - ? { success: true, message: "Codex config restored from opencodex journal." } - : removeCodexConfig({ preserveProfile: journal.profileRestored || journal.profileChanged }); - config = 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) { - config = { state: "failed", changed: false, action: "failed", message: error instanceof Error ? error.message : String(error) }; + return externalProviderRestoreResult(activeProvider); } - const owningCodexHome = getCodexHome(); - let catalog: CodexRestoreCatalogResult; - try { - const restored = withCatalogWriteSerialization(owningCodexHome, permit => - options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig()) - ? null - : restoreCodexCatalogWithPermit(permit, owningCodexHome)); - catalog = 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) { - catalog = { - state: "failed", changed: false, removed: 0, kept: 0, path: DEFAULT_CATALOG_PATH, - message: error instanceof Error ? error.message : String(error), - }; + 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). diff --git a/src/codex/sync.ts b/src/codex/sync.ts index 87b1fb865..b63ad39aa 100644 --- a/src/codex/sync.ts +++ b/src/codex/sync.ts @@ -129,7 +129,8 @@ export async function syncModelsToCodex( if (result.status === "skipped") { return { status: "skipped", - skippedReason: result.skippedReason ?? "desired_disabled", + // The apply direction's only under-lock policy skip is desired OFF. + skippedReason: "desired_disabled", ok: true, added: 0, catalogPath: null, From 134956b3ed29f7361b39ae3076af5d7b2ca0328e Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 12:31:56 +0900 Subject: [PATCH 13/50] fix(codex): re-read desired state inside the catalog commit --- src/codex/catalog/sync.ts | 19 ++++++++++++++++++- src/codex/refresh.ts | 7 +++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 79113d42f..03b74f6de 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({ 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 }; } From f11bb6d6b380ecd7370db9fc54236ea43df20110 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 12:38:18 +0900 Subject: [PATCH 14/50] test(codex): behavioral coverage for lost-transition and restore gating --- tests/cli-restore-back.test.ts | 23 ++++++++-- tests/codex-history-job.test.ts | 81 +++++++++++++++++++++++++-------- tests/codex-sync-api.test.ts | 64 +++++++++++++++++++++++++- 3 files changed, 143 insertions(+), 25 deletions(-) diff --git a/tests/cli-restore-back.test.ts b/tests/cli-restore-back.test.ts index 66b31a7df..2cb92850a 100644 --- a/tests/cli-restore-back.test.ts +++ b/tests/cli-restore-back.test.ts @@ -4,7 +4,6 @@ import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node import { tmpdir } from "node:os"; import { join } from "node:path"; -const helpSource = readFileSync(join(import.meta.dir, "..", "src", "cli", "help.ts"), "utf8"); const repoRoot = join(import.meta.dir, ".."); describe("ocx restore back", () => { @@ -84,8 +83,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-history-job.test.ts b/tests/codex-history-job.test.ts index da92ea62b..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-sync-api.test.ts b/tests/codex-sync-api.test.ts index 70f13d781..796133307 100644 --- a/tests/codex-sync-api.test.ts +++ b/tests/codex-sync-api.test.ts @@ -1,6 +1,7 @@ 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"; @@ -9,6 +10,7 @@ import type { OrcaCodexHomeDiagnostic } from "../src/codex/home"; const TEST_DIR = join(import.meta.dir, ".tmp-codex-sync-api"); const TEST_CODEX_HOME = join(TEST_DIR, "codex"); +const repoRoot = join(import.meta.dir, ".."); let prevCodexHome: string | undefined; const config = { @@ -106,6 +108,66 @@ describe("GUI/CLI Codex sync backend", () => { 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"); + mkdirSync(raceCodexHome, { recursive: true }); + mkdirSync(raceOcxHome, { recursive: true }); + try { + writeFileSync(join(raceCodexHome, "config.toml"), 'model = "gpt-5"\n', "utf8"); + writeFileSync(join(raceOcxHome, "config.json"), JSON.stringify({ + providers: {}, defaultProvider: "openai", checkForUpdates: false, + })); + 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, 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[] = []; From 9c6fc9b9c20635249a1fe618b8cdfa194c6d5963 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 12:55:41 +0900 Subject: [PATCH 15/50] fix(codex): gate the cache reacquisition write on desired state The commit-path OFF check runs under the first catalog permit; the models_cache rewrite reacquires K after release, so a disable landing in the gap could still publish a routed cache. Re-read intent under the second permit too. --- src/codex/catalog/sync.ts | 5 +++++ tests/codex-models-cache-invalidate.test.ts | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 03b74f6de..8ad5becfa 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -1066,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/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[] = []; From 4167b8607bd71f99c7f383347992543a1c4cc8fe Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 13:02:48 +0900 Subject: [PATCH 16/50] =?UTF-8?q?docs(devlog):=20020=20amendments=20?= =?UTF-8?q?=E2=80=94=20Desktop=20toggle=20adapted=20to=20the=20real=20thre?= =?UTF-8?q?e-client=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../020_desktop_toggle_amendments.md | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 devlog/_plan/260806_wp13_toggles_resume/020_desktop_toggle_amendments.md 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..7fd169a24 --- /dev/null +++ b/devlog/_plan/260806_wp13_toggles_resume/020_desktop_toggle_amendments.md @@ -0,0 +1,87 @@ +# 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). +- 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`, or `broken` (selected file missing/unparseable). +- 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). +- `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). +- `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. From d66e7640393e4492711ceb81b6ea99f6720ce368 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 13:05:17 +0900 Subject: [PATCH 17/50] =?UTF-8?q?docs(devlog):=20020=20r2=20=E2=80=94=20de?= =?UTF-8?q?siredEnabled=20widening,=20full=20inspector=20state=20set,=20tr?= =?UTF-8?q?ansport-liveness=20test=20restored?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../020_desktop_toggle_amendments.md | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) 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 index 7fd169a24..61ae20c48 100644 --- a/devlog/_plan/260806_wp13_toggles_resume/020_desktop_toggle_amendments.md +++ b/devlog/_plan/260806_wp13_toggles_resume/020_desktop_toggle_amendments.md @@ -22,6 +22,15 @@ contract exists. What exists after WP-B (this branch): 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` @@ -39,7 +48,15 @@ classifying inspector. WP-C adds `inspectDesktop3pConfigLibrary()` in - `_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`, or `broken` (selected file missing/unparseable). + `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 → classified + `foreign`, never parsed further, never 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 @@ -78,10 +95,17 @@ touched). - `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). + 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. From 4ce7c8d581afd223a12ddeed484d944bff69a426 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 13:06:26 +0900 Subject: [PATCH 18/50] =?UTF-8?q?docs(devlog):=20020=20r3=20=E2=80=94=20ow?= =?UTF-8?q?ned-but-drifted=20credentials=20are=20unsafe,=20not=20foreign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../020_desktop_toggle_amendments.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 index 61ae20c48..4a5e8cab4 100644 --- a/devlog/_plan/260806_wp13_toggles_resume/020_desktop_toggle_amendments.md +++ b/devlog/_plan/260806_wp13_toggles_resume/020_desktop_toggle_amendments.md @@ -55,8 +55,13 @@ classifying inspector. WP-C adds `inspectDesktop3pConfigLibrary()` in 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 → classified - `foreign`, never parsed further, never echoed into envelopes or logs. + 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 From bab425e17617c1d5d2e3898d71153ef127fe2fdb Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 13:09:54 +0900 Subject: [PATCH 19/50] feat(claude): add Desktop desired-state schema --- src/codex/desired-state.ts | 14 ++++++++++++++ src/config.ts | 1 + src/types.ts | 2 ++ 3 files changed, 17 insertions(+) diff --git a/src/codex/desired-state.ts b/src/codex/desired-state.ts index 3512e8212..ecfd74edb 100644 --- a/src/codex/desired-state.ts +++ b/src/codex/desired-state.ts @@ -143,6 +143,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`. * diff --git a/src/config.ts b/src/config.ts index 8f9513deb..e054d2363 100644 --- a/src/config.ts +++ b/src/config.ts @@ -986,6 +986,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/types.ts b/src/types.ts index 171628caf..4d763a927 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 { From f1803c99410bc375f4bcec9e13a965c05954f578 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 13:11:24 +0900 Subject: [PATCH 20/50] feat(claude): inspect and safely remove Desktop gateway profiles --- src/claude/desktop-3p.ts | 199 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 198 insertions(+), 1 deletion(-) diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index f8a36c602..ffe03e7b6 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,37 @@ 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; +} + +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 +358,172 @@ 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"; +} + +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: [] }; + } + + const metadataPath = join(libraryPath, "_meta.json"); + if (!existsSync(metadataPath)) { + return { kind: "no_owned_state", libraryPath, selectedProfilePath: null, appliedId: null, residualPaths: [] }; + } + + let metadata: Desktop3pMetadata; + try { + metadata = parseMetadata(metadataPath); + } catch { + return { + kind: "unsafe", libraryPath, selectedProfilePath: null, appliedId: null, residualPaths: [], reason: "metadata_unreadable", + }; + } + const appliedId = typeof metadata.appliedId === "string" ? metadata.appliedId : null; + if (appliedId === null) { + return { kind: "no_owned_state", libraryPath, selectedProfilePath: null, appliedId: null, residualPaths: [] }; + } + if (!SAFE_DESKTOP_PROFILE_ID.test(appliedId)) { + return { + kind: "unsafe", libraryPath, selectedProfilePath: null, appliedId, residualPaths: [], reason: "unsafe_applied_id", + }; + } + + const selectedProfilePath = profilePath(libraryPath, appliedId); + const selected = metadata.entries.find(entry => entry?.id === appliedId); + const residualPaths = metadata.entries + .filter(entry => isOwnedDesktopEntry(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 }; + } + + 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 }; + profile = parsed; + fingerprint = createHash("sha256").update(source).digest("hex").slice(0, 16); + } catch { + return { kind: "broken", libraryPath, selectedProfilePath, appliedId, residualPaths }; + } + if (profile.inferenceProvider === undefined) { + return { kind: "standard", libraryPath, selectedProfilePath, appliedId, residualPaths, fingerprint }; + } + if (!isOwnedDesktopEntry(selected)) { + return { kind: "foreign", libraryPath, selectedProfilePath, appliedId, residualPaths, fingerprint }; + } + 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", + }; + } + return { + kind: options.appliedFingerprint && options.appliedFingerprint === fingerprint ? "gateway_ours" : "gateway_drifted", + libraryPath, selectedProfilePath, appliedId, residualPaths, fingerprint, + }; +} + +/** + * Select a credential-free standard profile before deleting an owned gateway. + * The old metadata row intentionally remains as a retry locator until both its + * profile and backup are absent. + */ +export function removeDesktop3pStandardPivot( + options: Desktop3pConfigLibraryOptions & { appliedFingerprint?: string | null } = {}, +): 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 === "foreign" || inspected.kind === "broken" || inspected.kind === "unsafe") { + return { ok: false, changed: false, kind: "unsafe", libraryPath: inspected.libraryPath, reason: inspected.reason }; + } + if (inspected.kind === "standard" && inspected.residualPaths.length === 0) { + return { ok: true, changed: false, kind: "noop", libraryPath: inspected.libraryPath }; + } + 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; + const selectedEntry = metadata.entries.find(entry => entry.id === selectedId); + if (!selectedEntry || !isOwnedDesktopEntry(selectedEntry)) { + // A selected standard profile with outstanding owned rows gets cleanup on + // retry; a selected foreign profile never grants us deletion authority. + if (inspected.kind !== "standard") { + return { ok: false, changed: false, kind: "unsafe", libraryPath: inspected.libraryPath }; + } + } + const targetIds = inspected.kind === "standard" + ? metadata.entries.filter(isOwnedDesktopEntry).map(entry => entry.id).filter(id => SAFE_DESKTOP_PROFILE_ID.test(id)) + : [selectedId]; + if (targetIds.length === 0) return { ok: true, changed: false, kind: "noop", libraryPath: inspected.libraryPath }; + + if (inspected.kind !== "standard") { + const standardId = randomUUID(); + const standardPath = profilePath(inspected.libraryPath, standardId); + atomicWriteFile(standardPath, "{}\n"); + const standardEntry: Desktop3pMetadataEntry = { id: standardId, name: "opencodex-standard" }; + atomicWriteFile( + metadataPath, + JSON.stringify({ ...metadata, appliedId: standardId, entries: [...metadata.entries, standardEntry] }, 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)) unlinkSync(candidate); + } catch { + // Only the path is allowed to leave this credential-bearing cleanup boundary. + } + if (existsSync(candidate)) residualPaths.push(candidate); + } + } + if (residualPaths.length > 0 || inspected.residualPaths.length > 0) { + return { + ok: false, changed: true, kind: "cleanup_incomplete", libraryPath: inspected.libraryPath, + residualPaths: [...new Set([...residualPaths, ...inspected.residualPaths])], + }; + } + 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, From 7f1220b4c519807ecc71bd3d4e2ffb4f784fda12 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 13:14:21 +0900 Subject: [PATCH 21/50] feat(claude): add Desktop native integration toggle --- .../management/native-integration-routes.ts | 132 +++++++++++++++++- 1 file changed, 126 insertions(+), 6 deletions(-) diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index ca04cb396..f29a34a5d 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -17,8 +17,9 @@ * Design of record: devlog/_fin/260803_integrations_toggle_all/030 (routes), * 011 (Claude Code), 012 (Grok). */ -import { readRuntimePort, saveConfigPreservingClaudeCode } from "../../config"; +import { loadConfig, readRuntimePort, saveConfigPreservingClaudeCode } from "../../config"; import { filterCatalogVisibleModels, nativeOpenAiContextWindow, visibleNativeSlugs } from "../../codex/catalog"; +import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p"; import { injectGrokConfig, stripGrokConfig, type GrokInjectModel } from "../../grok/inject"; import { inspectGrokConfig } from "../../grok/inspect"; import { grokConfigPath } from "../../grok/status"; @@ -29,7 +30,7 @@ import { jsonResponse } from "../auth-cors"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import type { ManagementContext } from "./context"; -export type NativeIntegrationClientId = "claude" | "grok" | "codex"; +export type NativeIntegrationClientId = "claude" | "grok" | "codex" | "claude-desktop"; /** Every reason this module can decline, in one place (audit r3 #6). */ export type NativeRefusalReason = @@ -37,13 +38,16 @@ export type NativeRefusalReason = | "orphaned_marker" | "home_mismatch" | "config_busy" - | "write_failed"; + | "write_failed" + | "metadata_unreadable" + | "cleanup_incomplete"; export interface NativeStatus { clientId: NativeIntegrationClientId; state: "absent" | "current" | "unsafe"; installed: boolean; configPath: string; + desiredEnabled: boolean; /** * Set when a disable would be refused right now. ADVISORY: the file can * change before the PUT, which re-checks and whose answer is authoritative. @@ -62,6 +66,7 @@ export interface NativeToggleEnvelope { changed: boolean; state: NativeStatus["state"]; message: string; + desiredEnabled: boolean; /** Present when the outcome needs more than success/failure to be honest. */ reason?: string; artifacts?: CodexNativeRestoreResult["artifacts"]; @@ -73,6 +78,9 @@ export interface NativeRefusalEnvelope { clientId: NativeIntegrationClientId; reason: NativeRefusalReason; message: string; + /** Available after intent persistence; absent only for pre-commit refusals. */ + desiredEnabled?: boolean; + residualPaths?: string[]; } function refusal( @@ -80,14 +88,38 @@ function refusal( 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 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; @@ -100,6 +132,7 @@ 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, }; @@ -110,7 +143,7 @@ function claudeStatus(config: ManagementContext["config"], configPath: string): * 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") { @@ -139,6 +172,7 @@ function grokStatus(): NativeStatus { state, installed: seen.kind !== "not_installed", configPath: grokConfigPath(), + desiredEnabled: config.clientIntegrations?.grok !== false, disableBlocked, }; } @@ -266,6 +300,7 @@ async function handleCodexToggle(ctx: ManagementContext): Promise { 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); @@ -273,6 +308,7 @@ async function handleCodexToggle(ctx: ManagementContext): Promise { 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}`, @@ -288,6 +324,7 @@ async function handleCodexToggle(ctx: ManagementContext): Promise { 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}`, @@ -398,6 +435,7 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { return jsonResponse({ ok: true, clientId: "grok", changed: result.changed, state: "absent", + desiredEnabled: enabled, 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", @@ -475,6 +513,7 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { return jsonResponse({ ok: true, clientId: "grok", changed: result.changed, state: "current", reason: "non_loopback_superseded", + desiredEnabled: enabled, 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": @@ -483,6 +522,7 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { return jsonResponse({ ok: true, clientId: "grok", changed: result.changed, state: "absent", reason: "non_loopback_removed", + desiredEnabled: enabled, 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: { @@ -506,6 +546,7 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { return jsonResponse({ ok: true, clientId: "grok", changed: result.changed, state: "current", + desiredEnabled: enabled, message: result.changed ? "Grok integration enabled — the opencodex block was regenerated from the current model list." : "Grok integration is already on", } satisfies NativeToggleEnvelope); })(); @@ -516,13 +557,86 @@ 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 = removeDesktop3pStandardPivot({ appliedFingerprint: fingerprint }); + if (removed.kind === "cleanup_incomplete") { + return refusal(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 refusal(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 routed = filterCatalogVisibleModels(await fetchModels(current), current).map(model => ({ + provider: model.provider, id: model.id, contextWindow: model.contextWindow, + })); + const runtime = (ctx.deps.readRuntimePort ?? readRuntimePort)(process.pid); + const result = writeDesktop3pConfig( + runtime?.port ?? current.port, + [...visibleNativeSlugs(current)], + routed, + current.apiKeys?.[0]?.key, + "static", + current.claudeCode?.desktopProfile, + ); + if (!result.written) return refusal(500, "claude-desktop", "write_failed", "Claude Desktop apply failed.", { desiredEnabled }); + return jsonResponse({ + ok: true, clientId: "claude-desktop", changed: true, state: "current", desiredEnabled, + message: "Claude Desktop integration enabled.", + } satisfies NativeToggleEnvelope); + } catch { + return refusal(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), desktopStatus(config)], } satisfies NativeStatusListEnvelope); } @@ -543,6 +657,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); } @@ -582,6 +697,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); } @@ -594,5 +710,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; } From 9e7e699d3a001838f32859e27154952124a63950 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 13:14:42 +0900 Subject: [PATCH 22/50] feat(claude): gate Desktop auto-apply on persisted intent --- src/cli/claude-desktop.ts | 5 + .../management/agent-settings-routes.ts | 94 +++++++++---------- 2 files changed, 52 insertions(+), 47 deletions(-) diff --git a/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts index 4704eef44..ea0993ade 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, @@ -42,6 +43,10 @@ export async function applyProfile( mode: Desktop3pConfigMode, deps: ApplyProfileDeps = {}, ): Promise<{ ok: boolean; path: string; reason?: 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 }; diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index d06d25630..30058367f 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -130,23 +130,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 allModels = await 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 = writeDesktop3pConfig( - config.port ?? 10100, - [...desktopVisibleNativeSlugs(config)], + 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 +715,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise } if (url.pathname === "/api/claude-desktop/apply" && req.method === "POST") { try { + const { setIntegrationEnabled } = await import("../../codex/desired-state"); + const desired = setIntegrationEnabled("claude-desktop", true); + if (!desired.ok) return jsonResponse({ error: desired.message }, desired.retryable ? 409 : 500); // #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"; @@ -767,47 +785,29 @@ 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, + activeProfile: applied, + 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) { From b6378e1953e44f9ea3b9c7588048572573dd4cf7 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 13:20:25 +0900 Subject: [PATCH 23/50] feat(gui): expose Claude Desktop desired toggle state --- gui/src/i18n/de.ts | 18 ++++++++ gui/src/i18n/en.ts | 18 ++++++++ gui/src/i18n/ja.ts | 18 ++++++++ gui/src/i18n/ko.ts | 18 ++++++++ gui/src/i18n/ru.ts | 18 ++++++++ gui/src/i18n/zh.ts | 18 ++++++++ gui/src/pages/ClaudeDesktop.tsx | 7 +++- .../integrations/IntegrationsOverview.tsx | 21 +++++++--- gui/src/pages/integrations/integration-api.ts | 8 +++- gui/src/pages/integrations/native-api.ts | 14 +++++-- .../pages/integrations/overview-clients.ts | 34 +++++++++++---- gui/src/pages/integrations/refusal-copy.ts | 4 ++ gui/tests/integrations-overview-rows.test.ts | 42 ++++++++++++++++--- gui/tests/integrations-surfaces.test.tsx | 15 +++++-- 14 files changed, 226 insertions(+), 27 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a4d633e10..1e5e0f3cd 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -764,6 +764,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": "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.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.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.detail.grokModels": "{count} Modell(e) verbunden", "integrations.detail.grokAbsent": "Kein opencodex-Block in der Konfiguration", "integrations.dialog.grok.title": "Grok-Build-Integration deaktivieren?", @@ -1818,6 +1834,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": "Claude Desktop integration is off. Fully quit and reopen Desktop after enabling it.", + "claudeDesktop.enableApply": "Enable and apply", "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 df67c7502..6be86d3e9 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1219,6 +1219,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?", @@ -1226,6 +1232,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.", @@ -1233,6 +1245,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", @@ -1851,6 +1867,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 eb98a71eb..a3530e8f0 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1167,6 +1167,22 @@ export const ja: Record = { "integrations.detail.desktopStale": "適用後にプロファイルが変更されました", "integrations.detail.desktopNotServed": "プロファイルはありますが Desktop は別のものを使用中です", "integrations.detail.desktopAbsent": "適用されたプロファイルはありません", + "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.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.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.detail.grokModels": "モデル {count} 個を接続済み", "integrations.detail.grokAbsent": "設定に opencodex ブロックがありません", "integrations.dialog.grok.title": "Grok Build 連携を解除しますか?", @@ -1699,6 +1715,8 @@ export const ja: Record = { "claudeDesktop.status.stale": "設定が古くなっています — 再適用してください", "claudeDesktop.status.notApplied": "未適用", "claudeDesktop.status.notActiveProfile": "Desktop は別のプロファイルを使用中 — 再適用してください", + "claudeDesktop.status.disabled": "Claude Desktop integration is off. Fully quit and reopen Desktop after enabling it.", + "claudeDesktop.enableApply": "Enable and apply", "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 339cdbe97..e198a2216 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -788,6 +788,22 @@ export const ko: Record = { "integrations.detail.desktopStale": "적용 후 프로필 파일이 바뀌었습니다", "integrations.detail.desktopNotServed": "프로필은 있지만 Desktop이 다른 것을 씁니다", "integrations.detail.desktopAbsent": "적용된 프로필이 없습니다", + "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.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.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.detail.grokModels": "모델 {count}개 연결됨", "integrations.detail.grokAbsent": "설정에 opencodex 블록이 없습니다", "integrations.dialog.grok.title": "Grok Build 연동을 해제할까요?", @@ -1845,6 +1861,8 @@ export const ko: Record = { "claudeDesktop.status.stale": "설정 변경됨 — 재적용 필요", "claudeDesktop.status.notApplied": "미적용", "claudeDesktop.status.notActiveProfile": "Desktop이 다른 프로필을 사용 중 — 재적용 필요", + "claudeDesktop.status.disabled": "Claude Desktop integration is off. Fully quit and reopen Desktop after enabling it.", + "claudeDesktop.enableApply": "Enable and apply", "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 7904e27c3..7223cb651 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1209,6 +1209,22 @@ export const ru: Record = { "integrations.detail.desktopStale": "Файл профиля изменился после применения", "integrations.detail.desktopNotServed": "Профиль есть, но Desktop использует другой", "integrations.detail.desktopAbsent": "Профиль не применён", + "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.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.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.detail.grokModels": "Подключено моделей: {count}", "integrations.detail.grokAbsent": "В конфигурации нет блока opencodex", "integrations.dialog.grok.title": "Отключить интеграцию Grok Build?", @@ -1741,6 +1757,8 @@ export const ru: Record = { "claudeDesktop.status.stale": "Конфигурация устарела — примените заново", "claudeDesktop.status.notApplied": "Не применено", "claudeDesktop.status.notActiveProfile": "Desktop использует другой профиль — примените заново", + "claudeDesktop.status.disabled": "Claude Desktop integration is off. Fully quit and reopen Desktop after enabling it.", + "claudeDesktop.enableApply": "Enable and apply", "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 8b16dbe6c..4bc9a459e 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -781,6 +781,22 @@ export const zh: Record = { "integrations.detail.desktopStale": "应用后配置文件已更改", "integrations.detail.desktopNotServed": "配置存在,但 Desktop 使用的是另一个", "integrations.detail.desktopAbsent": "未应用任何配置", + "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.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.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.detail.grokModels": "已接入 {count} 个模型", "integrations.detail.grokAbsent": "配置中没有 opencodex 区块", "integrations.dialog.grok.title": "要停用 Grok Build 集成吗?", @@ -1838,6 +1854,8 @@ export const zh: Record = { "claudeDesktop.status.stale": "配置已更改 — 需重新应用", "claudeDesktop.status.notApplied": "未应用", "claudeDesktop.status.notActiveProfile": "Desktop 正在使用其他配置 — 请重新应用", + "claudeDesktop.status.disabled": "Claude Desktop integration is off. Fully quit and reopen Desktop after enabling it.", + "claudeDesktop.enableApply": "Enable and apply", "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..785aeb768 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; @@ -408,6 +409,8 @@ export default function ClaudeDesktop({ ? "not-applied" : !status ? "pending" + : !status.desiredEnabled + ? "not-applied" : status.activeProfile === false ? "not-applied" : status.stale @@ -426,6 +429,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 +464,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..54bcc4a54 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; @@ -321,8 +324,11 @@ export async function loadClaudeDesktopStatus(apiBase: string, signal?: AbortSig activeProfile?: unknown; appliedAt?: 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..f73051f63 100644 --- a/gui/src/pages/integrations/native-api.ts +++ b/gui/src/pages/integrations/native-api.ts @@ -8,20 +8,23 @@ 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"; export interface NativeStatus { clientId: NativeIntegrationClientId; state: NativeIntegrationState; installed: boolean; configPath: string; + desiredEnabled: boolean; disableBlocked: { reason: NativeRefusalReason; message: string } | null; } @@ -35,6 +38,7 @@ export interface NativeToggleEnvelope { changed: boolean; state: NativeIntegrationState; message: string; + desiredEnabled: boolean; reason?: string; } @@ -44,6 +48,8 @@ export interface NativeRefusalEnvelope { clientId: NativeIntegrationClientId; reason: NativeRefusalReason; message: string; + desiredEnabled?: boolean; + residualPaths?: string[]; } export interface NativeErrorEnvelope { @@ -58,7 +64,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 +75,8 @@ const NATIVE_REFUSAL_REASONS: ReadonlySet = new Set "home_mismatch", "config_busy", "write_failed", + "metadata_unreadable", + "cleanup_incomplete", ]); 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..3e3e85db3 100644 --- a/gui/src/pages/integrations/overview-clients.ts +++ b/gui/src/pages/integrations/overview-clients.ts @@ -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 @@ -96,6 +98,9 @@ export interface ClaudeCodePayload { authMode?: string; } export interface ClaudeDesktopPayload { + desiredEnabled?: boolean; + installed?: boolean; + observedKind?: string; applied?: boolean; stale?: boolean; activeProfile?: boolean | null; @@ -270,27 +275,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 +312,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 +409,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/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( From 0b97b91efe9050b4c3173bfbe0c2c5ae39d09372 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 13:21:22 +0900 Subject: [PATCH 24/50] test(claude): cover Desktop standard-mode removal --- tests/desktop-3p-removal.test.ts | 51 ++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/desktop-3p-removal.test.ts diff --git a/tests/desktop-3p-removal.test.ts b/tests/desktop-3p-removal.test.ts new file mode 100644 index 000000000..a80319efc --- /dev/null +++ b/tests/desktop-3p-removal.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, 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 }; +} + +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", + inferenceGatewayApiKey: "not-printed", + })); + writeFileSync(join(library, `${id}.json.bak`), "{}"); + + const result = removeDesktop3pStandardPivot({ env: envFor(library) }); + 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 }; + 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"); +}); From f4607d5f6db9dc04d066d55deac374d7dffc61cb Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 13:30:23 +0900 Subject: [PATCH 25/50] test(claude): close Desktop toggle regression coverage --- gui/src/i18n/de.ts | 36 ++-- gui/src/i18n/ja.ts | 36 ++-- gui/src/i18n/ko.ts | 36 ++-- gui/src/i18n/ru.ts | 36 ++-- gui/src/i18n/zh.ts | 36 ++-- src/claude/desktop-3p.ts | 7 +- .../management/agent-settings-routes.ts | 4 +- src/server/management/context.ts | 4 + .../management/native-integration-routes.ts | 4 +- tests/claude-messages-endpoint.test.ts | 36 ++++ tests/desktop-3p-removal.test.ts | 46 ++++- tests/native-claude-desktop-toggle.test.ts | 161 ++++++++++++++++++ 12 files changed, 345 insertions(+), 97 deletions(-) create mode 100644 tests/native-claude-desktop-toggle.test.ts diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 1e5e0f3cd..8422975ce 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -764,22 +764,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": "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.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.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.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?", @@ -1834,8 +1834,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": "Claude Desktop integration is off. Fully quit and reopen Desktop after enabling it.", - "claudeDesktop.enableApply": "Enable and apply", + "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/ja.ts b/gui/src/i18n/ja.ts index a3530e8f0..debf52467 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1167,22 +1167,22 @@ export const ja: Record = { "integrations.detail.desktopStale": "適用後にプロファイルが変更されました", "integrations.detail.desktopNotServed": "プロファイルはありますが Desktop は別のものを使用中です", "integrations.detail.desktopAbsent": "適用されたプロファイルはありません", - "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.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.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.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 連携を解除しますか?", @@ -1715,8 +1715,8 @@ export const ja: Record = { "claudeDesktop.status.stale": "設定が古くなっています — 再適用してください", "claudeDesktop.status.notApplied": "未適用", "claudeDesktop.status.notActiveProfile": "Desktop は別のプロファイルを使用中 — 再適用してください", - "claudeDesktop.status.disabled": "Claude Desktop integration is off. Fully quit and reopen Desktop after enabling it.", - "claudeDesktop.enableApply": "Enable and apply", + "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 e198a2216..de55722d1 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -788,22 +788,22 @@ export const ko: Record = { "integrations.detail.desktopStale": "적용 후 프로필 파일이 바뀌었습니다", "integrations.detail.desktopNotServed": "프로필은 있지만 Desktop이 다른 것을 씁니다", "integrations.detail.desktopAbsent": "적용된 프로필이 없습니다", - "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.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.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.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 연동을 해제할까요?", @@ -1861,8 +1861,8 @@ export const ko: Record = { "claudeDesktop.status.stale": "설정 변경됨 — 재적용 필요", "claudeDesktop.status.notApplied": "미적용", "claudeDesktop.status.notActiveProfile": "Desktop이 다른 프로필을 사용 중 — 재적용 필요", - "claudeDesktop.status.disabled": "Claude Desktop integration is off. Fully quit and reopen Desktop after enabling it.", - "claudeDesktop.enableApply": "Enable and apply", + "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 7223cb651..0b5e9d039 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1209,22 +1209,22 @@ export const ru: Record = { "integrations.detail.desktopStale": "Файл профиля изменился после применения", "integrations.detail.desktopNotServed": "Профиль есть, но Desktop использует другой", "integrations.detail.desktopAbsent": "Профиль не применён", - "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.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.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.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?", @@ -1757,8 +1757,8 @@ export const ru: Record = { "claudeDesktop.status.stale": "Конфигурация устарела — примените заново", "claudeDesktop.status.notApplied": "Не применено", "claudeDesktop.status.notActiveProfile": "Desktop использует другой профиль — примените заново", - "claudeDesktop.status.disabled": "Claude Desktop integration is off. Fully quit and reopen Desktop after enabling it.", - "claudeDesktop.enableApply": "Enable and apply", + "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 4bc9a459e..9208d65d6 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -781,22 +781,22 @@ export const zh: Record = { "integrations.detail.desktopStale": "应用后配置文件已更改", "integrations.detail.desktopNotServed": "配置存在,但 Desktop 使用的是另一个", "integrations.detail.desktopAbsent": "未应用任何配置", - "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.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.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.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 集成吗?", @@ -1854,8 +1854,8 @@ export const zh: Record = { "claudeDesktop.status.stale": "配置已更改 — 需重新应用", "claudeDesktop.status.notApplied": "未应用", "claudeDesktop.status.notActiveProfile": "Desktop 正在使用其他配置 — 请重新应用", - "claudeDesktop.status.disabled": "Claude Desktop integration is off. Fully quit and reopen Desktop after enabling it.", - "claudeDesktop.enableApply": "Enable and apply", + "claudeDesktop.status.disabled": "Claude Desktop 集成已关闭。开启后请完全退出并重新打开 Desktop。", + "claudeDesktop.enableApply": "开启并应用", "claudeDesktop.health.lastRequest": "最后请求", "claudeDesktop.health.stats": "{count} 请求 / {errors} 错误", "claudeDesktop.effort.supported": "effort", diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index ffe03e7b6..7088a61e9 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -457,7 +457,10 @@ export function inspectDesktop3pConfigLibrary( * profile and backup are absent. */ export function removeDesktop3pStandardPivot( - options: Desktop3pConfigLibraryOptions & { appliedFingerprint?: string | null } = {}, + options: Desktop3pConfigLibraryOptions & { + appliedFingerprint?: string | null; + unlink?: (path: string) => void; + } = {}, ): Desktop3pRemovalResult { const inspected = inspectDesktop3pConfigLibrary(options); if (inspected.kind === "not_installed" || inspected.kind === "no_owned_state") { @@ -505,7 +508,7 @@ export function removeDesktop3pStandardPivot( for (const id of targetIds) { for (const candidate of [profilePath(inspected.libraryPath, id), `${profilePath(inspected.libraryPath, id)}.bak`]) { try { - if (existsSync(candidate)) unlinkSync(candidate); + if (existsSync(candidate)) (options.unlink ?? unlinkSync)(candidate); } catch { // Only the path is allowed to leave this credential-bearing cleanup boundary. } diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 30058367f..e42fc66a8 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -141,7 +141,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise }).kind; if (["not_installed", "no_owned_state", "foreign", "unsafe", "broken"].includes(beforeKind)) return; const { filterCatalogVisibleModels, desktopVisibleNativeSlugs } = await import("../../codex/catalog"); - const allModels = await fetchAllModels(admitted); + 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; @@ -151,7 +151,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise }).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 = writeDesktop3pConfig( + const result = (deps.writeDesktop3pConfig ?? writeDesktop3pConfig)( current.port ?? 10100, [...desktopVisibleNativeSlugs(current)], routed, diff --git a/src/server/management/context.ts b/src/server/management/context.ts index f39690ba5..c3b456cc0 100644 --- a/src/server/management/context.ts +++ b/src/server/management/context.ts @@ -4,6 +4,7 @@ import type { StartupInstallAction } from "../startup-action-control"; import type { ManagementPrincipal } from "../management-auth"; import type { CatalogModel } from "../../codex/catalog"; import type { injectGrokConfig } from "../../grok/inject"; +import type { removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p"; import type { RuntimePortState } from "../../config"; import type { CatalogDisposition, ConvergeCodex } from "../../codex/convergence-types"; @@ -31,6 +32,9 @@ export interface ManagementApiDeps { * tests). Production leaves this unset and uses the real writer. */ injectGrokConfig?: typeof injectGrokConfig; + /** Desktop mutation seams keep route tests inside temporary config libraries. */ + removeDesktop3pStandardPivot?: typeof removeDesktop3pStandardPivot; + writeDesktop3pConfig?: typeof writeDesktop3pConfig; /** * Runtime-state seam: the fence must name the host/port the RUNNING process * bound (agent-settings-routes.ts:99-103 pattern), and a test must not depend diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index f29a34a5d..e9577cb89 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -584,7 +584,7 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise { 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; @@ -187,6 +192,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/desktop-3p-removal.test.ts b/tests/desktop-3p-removal.test.ts index a80319efc..da4032e92 100644 --- a/tests/desktop-3p-removal.test.ts +++ b/tests/desktop-3p-removal.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -49,3 +49,47 @@ test("a selected path traversal id is refused without following it", () => { expect(result).toMatchObject({ kind: "unsafe", reason: "unsafe_applied_id" }); expect(removeDesktop3pStandardPivot({ env: envFor(library) }).kind).toBe("unsafe"); }); + +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-printed", + })); + writeFileSync(join(library, `${id}.json.bak`), "{}"); + + const result = removeDesktop3pStandardPivot({ + env: envFor(library), + 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-printed", + })); + } + + const result = removeDesktop3pStandardPivot({ env: envFor(library) }); + 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/native-claude-desktop-toggle.test.ts b/tests/native-claude-desktop-toggle.test.ts new file mode 100644 index 000000000..bf75e3151 --- /dev/null +++ b/tests/native-claude-desktop-toggle.test.ts @@ -0,0 +1,161 @@ +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.some(client => client.clientId === "claude-desktop")).toBe(true); +}); + +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", + inferenceGatewayBaseUrl: "http://127.0.0.1:10100", inferenceGatewayApiKey: "not-printed", + })); + 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); +}); From c002b5f13674fc71b9b0e361fd1bdceb4a269166 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 13:39:05 +0900 Subject: [PATCH 26/50] fix(claude): restore the tri-state activeProfile contract on Desktop status The inspector now carries ownedProfileActive (ID-match tri-state, null when metadata or appliedId is absent/unreadable) so the status route stops flattening undeterminable into false. --- src/claude/desktop-3p.ts | 35 ++++++++++++------- .../management/agent-settings-routes.ts | 5 ++- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index 7088a61e9..a8bb4e77d 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -123,6 +123,14 @@ export interface Desktop3pLibraryInspection { /** 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 { @@ -383,12 +391,12 @@ export function inspectDesktop3pConfigLibrary( ): Desktop3pLibraryInspection { const libraryPath = resolveDesktop3pConfigLibraryPath(options); if (!existsSync(libraryPath)) { - return { kind: "not_installed", libraryPath, selectedProfilePath: null, appliedId: null, residualPaths: [] }; + 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: [] }; + return { kind: "no_owned_state", libraryPath, selectedProfilePath: null, appliedId: null, residualPaths: [], ownedProfileActive: null }; } let metadata: Desktop3pMetadata; @@ -396,16 +404,19 @@ export function inspectDesktop3pConfigLibrary( metadata = parseMetadata(metadataPath); } catch { return { - kind: "unsafe", libraryPath, selectedProfilePath: null, appliedId: null, residualPaths: [], reason: "metadata_unreadable", + 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: [] }; + return { kind: "no_owned_state", libraryPath, selectedProfilePath: null, appliedId: null, residualPaths: [], ownedProfileActive: null }; } + const ownedEntry = metadata.entries.find(entry => isOwnedDesktopEntry(entry)); + // A readable appliedId with no owned entry is a KNOWN false, not unknown. + const ownedProfileActive = ownedEntry?.id ? appliedId === ownedEntry.id : false; if (!SAFE_DESKTOP_PROFILE_ID.test(appliedId)) { return { - kind: "unsafe", libraryPath, selectedProfilePath: null, appliedId, residualPaths: [], reason: "unsafe_applied_id", + kind: "unsafe", libraryPath, selectedProfilePath: null, appliedId, residualPaths: [], reason: "unsafe_applied_id", ownedProfileActive, }; } @@ -416,7 +427,7 @@ export function inspectDesktop3pConfigLibrary( .flatMap(entry => [profilePath(libraryPath, entry.id), `${profilePath(libraryPath, entry.id)}.bak`]) .filter(existsSync); if (!existsSync(selectedProfilePath)) { - return { kind: "broken", libraryPath, selectedProfilePath, appliedId, residualPaths }; + return { kind: "broken", libraryPath, selectedProfilePath, appliedId, residualPaths, ownedProfileActive }; } let profile: Record; @@ -424,17 +435,17 @@ export function inspectDesktop3pConfigLibrary( try { const source = readFileSync(selectedProfilePath, "utf8"); const parsed = JSON.parse(source) as unknown; - if (!isRecord(parsed)) return { kind: "broken", libraryPath, selectedProfilePath, appliedId, residualPaths }; + 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 }; + return { kind: "broken", libraryPath, selectedProfilePath, appliedId, residualPaths, ownedProfileActive }; } if (profile.inferenceProvider === undefined) { - return { kind: "standard", libraryPath, selectedProfilePath, appliedId, residualPaths, fingerprint }; + return { kind: "standard", libraryPath, selectedProfilePath, appliedId, residualPaths, fingerprint, ownedProfileActive }; } if (!isOwnedDesktopEntry(selected)) { - return { kind: "foreign", libraryPath, selectedProfilePath, appliedId, residualPaths, fingerprint }; + return { kind: "foreign", libraryPath, selectedProfilePath, appliedId, residualPaths, fingerprint, ownedProfileActive }; } const validGateway = profile.inferenceProvider === "gateway" && profile.inferenceCredentialKind === "static" @@ -442,12 +453,12 @@ export function inspectDesktop3pConfigLibrary( && typeof profile.inferenceGatewayApiKey === "string"; if (!validGateway) { return { - kind: "unsafe", libraryPath, selectedProfilePath, appliedId, residualPaths, fingerprint, reason: "invalid_owned_profile", + 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, + libraryPath, selectedProfilePath, appliedId, residualPaths, fingerprint, ownedProfileActive, }; } diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index e42fc66a8..51c0a8763 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -805,7 +805,10 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise onDiskFingerprint: observed.fingerprint ?? null, configPath: observed.selectedProfilePath, stale, - activeProfile: applied, + // 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, From 67ebfb78efaa996806849f22fc78462b1d191de0 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 13:45:21 +0900 Subject: [PATCH 27/50] fix(claude): gate Desktop cleanup by selected ownership --- src/claude/desktop-3p.ts | 73 ++++++++++++---------- tests/desktop-3p-removal.test.ts | 57 +++++++++++++++-- tests/native-claude-desktop-toggle.test.ts | 3 +- 3 files changed, 94 insertions(+), 39 deletions(-) diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index a8bb4e77d..1b03e49f1 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -373,6 +373,11 @@ function isRecord(value: unknown): value is Record { } 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"; } @@ -411,9 +416,9 @@ export function inspectDesktop3pConfigLibrary( if (appliedId === null) { return { kind: "no_owned_state", libraryPath, selectedProfilePath: null, appliedId: null, residualPaths: [], ownedProfileActive: null }; } - const ownedEntry = metadata.entries.find(entry => isOwnedDesktopEntry(entry)); + const selected = metadata.entries.find(entry => entry?.id === appliedId); // A readable appliedId with no owned entry is a KNOWN false, not unknown. - const ownedProfileActive = ownedEntry?.id ? appliedId === ownedEntry.id : false; + const ownedProfileActive = isOwnedDesktopEntry(selected); if (!SAFE_DESKTOP_PROFILE_ID.test(appliedId)) { return { kind: "unsafe", libraryPath, selectedProfilePath: null, appliedId, residualPaths: [], reason: "unsafe_applied_id", ownedProfileActive, @@ -421,9 +426,8 @@ export function inspectDesktop3pConfigLibrary( } const selectedProfilePath = profilePath(libraryPath, appliedId); - const selected = metadata.entries.find(entry => entry?.id === appliedId); const residualPaths = metadata.entries - .filter(entry => isOwnedDesktopEntry(entry) && entry.id !== appliedId && SAFE_DESKTOP_PROFILE_ID.test(entry.id)) + .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)) { @@ -441,12 +445,12 @@ export function inspectDesktop3pConfigLibrary( } catch { return { kind: "broken", libraryPath, selectedProfilePath, appliedId, residualPaths, ownedProfileActive }; } - if (profile.inferenceProvider === undefined) { - return { kind: "standard", libraryPath, selectedProfilePath, appliedId, residualPaths, fingerprint, 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" @@ -464,8 +468,8 @@ export function inspectDesktop3pConfigLibrary( /** * Select a credential-free standard profile before deleting an owned gateway. - * The old metadata row intentionally remains as a retry locator until both its - * profile and backup are absent. + * 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 & { @@ -477,12 +481,9 @@ export function removeDesktop3pStandardPivot( if (inspected.kind === "not_installed" || inspected.kind === "no_owned_state") { return { ok: true, changed: false, kind: "noop", libraryPath: inspected.libraryPath }; } - if (inspected.kind === "foreign" || inspected.kind === "broken" || inspected.kind === "unsafe") { + 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.kind === "standard" && inspected.residualPaths.length === 0) { - return { ok: true, changed: false, kind: "noop", libraryPath: inspected.libraryPath }; - } if (!inspected.appliedId || !SAFE_DESKTOP_PROFILE_ID.test(inspected.appliedId)) { return { ok: false, changed: false, kind: "unsafe", libraryPath: inspected.libraryPath, reason: "unsafe_applied_id" }; } @@ -491,28 +492,25 @@ export function removeDesktop3pStandardPivot( try { const metadata = parseMetadata(metadataPath); const selectedId = inspected.appliedId; - const selectedEntry = metadata.entries.find(entry => entry.id === selectedId); - if (!selectedEntry || !isOwnedDesktopEntry(selectedEntry)) { - // A selected standard profile with outstanding owned rows gets cleanup on - // retry; a selected foreign profile never grants us deletion authority. - if (inspected.kind !== "standard") { - return { ok: false, changed: false, kind: "unsafe", libraryPath: inspected.libraryPath }; - } - } - const targetIds = inspected.kind === "standard" - ? metadata.entries.filter(isOwnedDesktopEntry).map(entry => entry.id).filter(id => SAFE_DESKTOP_PROFILE_ID.test(id)) - : [selectedId]; + // 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 }; - if (inspected.kind !== "standard") { + 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" }; - atomicWriteFile( - metadataPath, - JSON.stringify({ ...metadata, appliedId: standardId, entries: [...metadata.entries, standardEntry] }, null, 2) + "\n", - ); + metadataAfterPivot = { ...metadata, appliedId: standardId, entries: [...metadata.entries, standardEntry] }; + atomicWriteFile(metadataPath, JSON.stringify(metadataAfterPivot, null, 2) + "\n"); } const residualPaths: string[] = []; @@ -526,12 +524,22 @@ export function removeDesktop3pStandardPivot( if (existsSync(candidate)) residualPaths.push(candidate); } } - if (residualPaths.length > 0 || inspected.residualPaths.length > 0) { + 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, ...inspected.residualPaths])], + 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 }; @@ -554,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/tests/desktop-3p-removal.test.ts b/tests/desktop-3p-removal.test.ts index da4032e92..fdd5f1edd 100644 --- a/tests/desktop-3p-removal.test.ts +++ b/tests/desktop-3p-removal.test.ts @@ -1,4 +1,5 @@ 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"; @@ -12,6 +13,10 @@ 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) }; @@ -29,15 +34,17 @@ test("OFF selects a credential-free standard profile before deleting the owned p inferenceProvider: "gateway", inferenceCredentialKind: "static", inferenceGatewayBaseUrl: "http://127.0.0.1:10100", - inferenceGatewayApiKey: "not-printed", + // Shape-only value: deliberately inert; never a credential. + inferenceGatewayApiKey: "not-a-secret", })); writeFileSync(join(library, `${id}.json.bak`), "{}"); - const result = removeDesktop3pStandardPivot({ env: envFor(library) }); + 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 }; + 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({}); }); @@ -50,18 +57,56 @@ test("a selected path traversal id is refused without following it", () => { 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-printed", + 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); @@ -84,11 +129,11 @@ test("interrupted cleanup prefers the selected opencodex row and reports another 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-printed", + inferenceGatewayBaseUrl: "http://127.0.0.1:10100", inferenceGatewayApiKey: "not-a-secret", })); } - const result = removeDesktop3pStandardPivot({ env: envFor(library) }); + 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/native-claude-desktop-toggle.test.ts b/tests/native-claude-desktop-toggle.test.ts index bf75e3151..f42a57532 100644 --- a/tests/native-claude-desktop-toggle.test.ts +++ b/tests/native-claude-desktop-toggle.test.ts @@ -132,7 +132,8 @@ test("auto-apply re-reads desired state after catalog fetch and skips a concurre 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-printed", + // 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; From 1208f46b668558549172c4eef0bdf9502e18a8ed Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 13:48:21 +0900 Subject: [PATCH 28/50] fix(management): fence Desktop toggle writers --- gui/src/pages/integrations/native-api.ts | 4 +- .../management/agent-settings-routes.ts | 21 +++- .../management/native-integration-routes.ts | 99 +++++++++++++------ tests/native-claude-desktop-toggle.test.ts | 27 ++++- tests/native-grok-toggle.test.ts | 1 + 5 files changed, 116 insertions(+), 36 deletions(-) diff --git a/gui/src/pages/integrations/native-api.ts b/gui/src/pages/integrations/native-api.ts index f73051f63..7e05e0fe3 100644 --- a/gui/src/pages/integrations/native-api.ts +++ b/gui/src/pages/integrations/native-api.ts @@ -17,7 +17,8 @@ export type NativeRefusalReason = | "config_busy" | "write_failed" | "metadata_unreadable" - | "cleanup_incomplete"; + | "cleanup_incomplete" + | "desired_state_changed"; export interface NativeStatus { clientId: NativeIntegrationClientId; @@ -77,6 +78,7 @@ const NATIVE_REFUSAL_REASONS: ReadonlySet = new Set "write_failed", "metadata_unreadable", "cleanup_incomplete", + "desired_state_changed", ]); function isRecord(value: unknown): value is Record { diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 51c0a8763..593d7463b 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -715,7 +715,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise } if (url.pathname === "/api/claude-desktop/apply" && req.method === "POST") { try { - const { setIntegrationEnabled } = await import("../../codex/desired-state"); + 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); // #859: the CLI delegates here so the registry is built in the serving @@ -762,11 +762,24 @@ 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 }; }); + // 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 = writeDesktop3pConfig( - Number(url.port) || config.port, - [...desktopVisibleNativeSlugs(config)], + Number(url.port) || latest.port, + [...desktopVisibleNativeSlugs(latest)], routed, - config.apiKeys?.[0]?.key, + latest.apiKeys?.[0]?.key, mode, state.profile, ); diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index e9577cb89..6055ce6fe 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -40,7 +40,8 @@ export type NativeRefusalReason = | "config_busy" | "write_failed" | "metadata_unreadable" - | "cleanup_incomplete"; + | "cleanup_incomplete" + | "desired_state_changed"; export interface NativeStatus { clientId: NativeIntegrationClientId; @@ -78,11 +79,15 @@ export interface NativeRefusalEnvelope { clientId: NativeIntegrationClientId; reason: NativeRefusalReason; message: string; - /** Available after intent persistence; absent only for pre-commit refusals. */ + /** Present on every post-commit refusal; absent only for pre-commit refusals. */ desiredEnabled?: boolean; residualPaths?: string[]; } +export type NativePostCommitRefusalEnvelope = Omit & { + desiredEnabled: boolean; +}; + function refusal( status: number, clientId: NativeIntegrationClientId, @@ -97,6 +102,20 @@ function refusal( } 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, @@ -138,6 +157,18 @@ function claudeStatus(config: ManagementContext["config"], configPath: string): }; } +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 @@ -409,6 +440,7 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { ); } const durable = persisted.ok; + const desiredEnabled = durable ? loadConfig().clientIntegrations?.grok !== false : enabled; if (!enabled) { /* @@ -417,25 +449,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: enabled, + 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", @@ -476,13 +508,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, { @@ -504,7 +536,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 @@ -513,16 +545,16 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { return jsonResponse({ ok: true, clientId: "grok", changed: result.changed, state: "current", reason: "non_loopback_superseded", - desiredEnabled: enabled, + 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: enabled, + 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: { @@ -535,18 +567,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: enabled, + desiredEnabled, message: result.changed ? "Grok integration enabled — the opencodex block was regenerated from the current model list." : "Grok integration is already on", } satisfies NativeToggleEnvelope); })(); @@ -586,12 +618,12 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise ({ + 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 ?? current.port, - [...visibleNativeSlugs(current)], + runtime?.port ?? latest.port, + [...visibleNativeSlugs(latest)], routed, - current.apiKeys?.[0]?.key, + latest.apiKeys?.[0]?.key, "static", - current.claudeCode?.desktopProfile, + latest.claudeCode?.desktopProfile, ); - if (!result.written) return refusal(500, "claude-desktop", "write_failed", "Claude Desktop apply failed.", { desiredEnabled }); + 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, + ok: true, clientId: "claude-desktop", changed: true, state: "current", desiredEnabled: latestDesiredEnabled, message: "Claude Desktop integration enabled.", } satisfies NativeToggleEnvelope); } catch { - return refusal(500, "claude-desktop", "write_failed", "Claude Desktop apply failed.", { desiredEnabled }); + return postCommitRefusal(500, "claude-desktop", "write_failed", "Claude Desktop apply failed.", { desiredEnabled }); } })(); try { @@ -636,7 +675,7 @@ export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Pro if (url.pathname === "/api/native-integrations" && req.method === "GET") { const { getConfigPath } = await import("../../config"); return jsonResponse({ - clients: [claudeStatus(config, getConfigPath()), grokStatus(config), desktopStatus(config)], + clients: [claudeStatus(config, getConfigPath()), grokStatus(config), codexStatus(config, getConfigPath()), desktopStatus(config)], } satisfies NativeStatusListEnvelope); } diff --git a/tests/native-claude-desktop-toggle.test.ts b/tests/native-claude-desktop-toggle.test.ts index f42a57532..e20e5ea4d 100644 --- a/tests/native-claude-desktop-toggle.test.ts +++ b/tests/native-claude-desktop-toggle.test.ts @@ -75,7 +75,7 @@ test("the native route advertises Claude Desktop and OFF persists intent before const status = await dispatch("/api/native-integrations"); const clients = (await status!.json() as { clients: Array<{ clientId: string }> }).clients; - expect(clients.some(client => client.clientId === "claude-desktop")).toBe(true); + 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 () => { @@ -160,3 +160,28 @@ test("auto-apply re-reads desired state after catalog fetch and skips a concurre 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); +}); 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); From e53752ef2cb07b0b3351c30778d6055a0d4a373f Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 13:52:14 +0900 Subject: [PATCH 29/50] fix(cli): fence the no-daemon Desktop apply behind a post-await intent re-read --- src/cli/claude-desktop.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts index ea0993ade..18347fcb1 100644 --- a/src/cli/claude-desktop.ts +++ b/src/cli/claude-desktop.ts @@ -72,6 +72,13 @@ export async function applyProfile( } } 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, From bac8eb979b914487152431cee47113a82bfb011c Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 14:00:58 +0900 Subject: [PATCH 30/50] fix(gui): make the Desktop toggle build under tsc, not just bun test The toggle field names the API wire id (claude-desktop) which is not an OverviewClientId; widen the union explicitly. The status parser narrowed on fields absent from its readOptional shape. --- gui/src/pages/integrations/integration-api.ts | 3 +++ gui/src/pages/integrations/overview-clients.ts | 10 +++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index 54bcc4a54..2d6830da6 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -323,6 +323,9 @@ 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 || typeof body.desiredEnabled !== "boolean" || typeof body.installed !== "boolean" || typeof body.observedKind !== "string") return null; return { diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts index 3e3e85db3..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" @@ -77,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. */ From 90dee1a3f318fa148709d85e8400f7b98ba396fe Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 14:05:01 +0900 Subject: [PATCH 31/50] =?UTF-8?q?docs(devlog):=20030=20amendments=20?= =?UTF-8?q?=E2=80=94=20workstation-only=20composed=20acceptance,=20#1048?= =?UTF-8?q?=20stays=20open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../030_composed_acceptance_amendments.md | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 devlog/_plan/260806_wp13_toggles_resume/030_composed_acceptance_amendments.md 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..5e5429e51 --- /dev/null +++ b/devlog/_plan/260806_wp13_toggles_resume/030_composed_acceptance_amendments.md @@ -0,0 +1,84 @@ +# 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: + +1. **A-reduced — entry-to-funnel for the toggle-relevant rows.** Real child + invocations of: P02 (`ocx start`, bind port 0, 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 (bind port 0) 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; assert exit code 1, the artifact + envelope reports history `busy` while config/catalog restored, and a rerun + after release converges — the original 040 defect, proven at the CLI boundary. + +## 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; 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 + +Each scenario names its RED condition (mutate the mechanism it proves — e.g. +remove a revalidation, point a caller past the funnel — and the case must fail). +At least one broken-change demonstration is executed and recorded for the suite +as a whole before D closes. From 4b7a572f644afc1337e720b922b01ca375cad5a4 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 14:07:06 +0900 Subject: [PATCH 32/50] =?UTF-8?q?docs(devlog):=20030=20r2=20=E2=80=94=20pe?= =?UTF-8?q?r-case=20broken-change=20proof,=20observable=20CLI=20contract?= =?UTF-8?q?=20for=20scenario=206,=20deferred-ID=20ledger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../030_composed_acceptance_amendments.md | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) 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 index 5e5429e51..6f83cf239 100644 --- a/devlog/_plan/260806_wp13_toggles_resume/030_composed_acceptance_amendments.md +++ b/devlog/_plan/260806_wp13_toggles_resume/030_composed_acceptance_amendments.md @@ -33,6 +33,11 @@ processes, reach those mechanisms rather than writing around them. 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`, bind port 0, then kill), P04 (`ocx ensure`), P05 (`ocx sync`), P07 (`ocx restore`), P08 (`ocx restore back`), P06 @@ -60,9 +65,15 @@ Composed rows chosen to cross module boundaries this branch touched, one case ea 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; assert exit code 1, the artifact - envelope reports history `busy` while config/catalog restored, and a rerun - after release converges — the original 040 defect, proven at the CLI boundary. + 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 @@ -76,9 +87,11 @@ 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 +## Proof rule (050:37-54 applies per case) -Each scenario names its RED condition (mutate the mechanism it proves — e.g. -remove a revalidation, point a caller past the funnel — and the case must fail). -At least one broken-change demonstration is executed and recorded for the suite -as a whole before D closes. +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. From 21491e6ca40a27a8e46b4a9bdc418a925908404e Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 14:07:56 +0900 Subject: [PATCH 33/50] =?UTF-8?q?docs(devlog):=20030=20r3=20=E2=80=94=20po?= =?UTF-8?q?rt=20safety=20is=20config-seeded,=20never=20flag-passed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../030_composed_acceptance_amendments.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) 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 index 6f83cf239..34c0133e2 100644 --- a/devlog/_plan/260806_wp13_toggles_resume/030_composed_acceptance_amendments.md +++ b/devlog/_plan/260806_wp13_toggles_resume/030_composed_acceptance_amendments.md @@ -39,7 +39,7 @@ 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`, bind port 0, then kill), P04 (`ocx ensure`), + 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 @@ -61,9 +61,9 @@ the safety boundary as before. 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 (bind port 0) in a child with the persisted - config; assert the Grok fence stays absent and the startup log/result reports - the desired-state skip. + 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 @@ -80,7 +80,14 @@ the safety boundary as before. 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; sentinel-based synchronization +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 From def3ac9e22fe39619eec022a610a7b8eebaa049a Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 14:21:37 +0900 Subject: [PATCH 34/50] fix(codex): freshen sync admission at HTTP boundary Re-read persisted integration intent and runtime port for POST /api/sync, and refuse foreign service-home convergence before catalog/cache writes. --- src/codex/sync.ts | 28 ++++++++++++++++++++++++-- src/server/management/config-routes.ts | 8 ++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/codex/sync.ts b/src/codex/sync.ts index b63ad39aa..1ad3d1971 100644 --- a/src/codex/sync.ts +++ b/src/codex/sync.ts @@ -6,12 +6,15 @@ import type { OcxConfig } from "../types"; import { collectOrcaCodexHomeDiagnostic } from "./home"; import { summarizeComboCatalogOmissions, type ComboCatalogOmission } from "./catalog/aggregation"; import { shouldSyncCodexOnStart } from "./desired-state"; +import { admitCodexWrite } from "./admission"; export interface CodexSyncResult { /** `skipped` is policy truth, never evidence that Codex was written. */ - status: "applied" | "skipped"; + 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; @@ -56,7 +59,11 @@ export async function syncModelsToCodex( log: Pick | null = console, deps: CodexSyncDeps = defaultDeps, ): Promise { - if (!shouldSyncCodexOnStart(config)) { + // `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", @@ -69,6 +76,23 @@ export async function syncModelsToCodex( 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 = 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) { diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 77f06ea31..624836336 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -261,8 +261,12 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise Date: Thu, 6 Aug 2026 14:25:17 +0900 Subject: [PATCH 35/50] fix(codex): keep disabled paths artifact-free Skip clean repeated restores and cache serialization while Codex is disabled, and avoid native-main startup leases when the proxy starts with Codex OFF. --- src/cli/index.ts | 16 +++++++++++++++- src/server/index.ts | 14 +++++++++++++- .../management/native-integration-routes.ts | 9 +++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 49c5bc42f..c46c824a4 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -41,7 +41,7 @@ import { maybeShowStarPrompt } from "./star-prompt"; import { scheduleCatalogPrewarm } from "./catalog-prewarm"; import { maybeShowUpdatePrompt } from "../update/notify"; import { syncModelsToCodex } from "../codex/sync"; -import { setIntegrationEnabled, 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"; @@ -814,6 +814,16 @@ switch (command) { 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") { + console.log("Codex integration is already OFF and native; no Codex files changed."); + break; + } + } let r: { success: boolean; message: string }; try { r = await restoreNativeCodexAsync({ revalidateDesiredState: true }); @@ -901,6 +911,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/server/index.ts b/src/server/index.ts index 69b2d8435..8faa75ffb 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -22,6 +22,7 @@ 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 { registerCodexCooldownRecoveryProbeWorker } from "../codex/auth-api"; import { startMemoryWatchdog } from "./memory-watchdog"; import { @@ -162,6 +163,7 @@ import { releaseNativeMainStartupLifecycle, startNativeMainStartupLifecycle, type NativeMainStartupGateDeps, + type NativeMainStartupLifecycle, } from "../codex/native-profile-startup"; import { handleImages } from "./images"; import { handleLive, logLiveSidebandFrame, parseLiveSidebandTarget, resolveLiveSidebandUpgrade } from "./live"; @@ -514,7 +516,17 @@ export function startServer(port?: number, deps: StartServerDeps = {}) { return response; } - const nativeMainLifecycle = startNativeMainStartupLifecycle(deps.nativeMainStartup); + // Native-main startup ownership creates several SQLite coordination files in + // CODEX_HOME. When the user has disabled the Codex integration, starting the + // proxy must not manufacture those Codex artifacts merely to serve other + // clients; no Codex request can use this lifecycle in that state. + const nativeMainLifecycle: NativeMainStartupLifecycle = shouldSyncCodexOnStart(config) + ? startNativeMainStartupLifecycle(deps.nativeMainStartup) + : { + homeId: null, + settled: Promise.resolve({ status: "ready", homeId: null }), + release: async () => {}, + }; let server: Server; try { server = Bun.serve({ diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index 6055ce6fe..894c00960 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -350,6 +350,15 @@ 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({ revalidateDesiredState: true }); return jsonResponse({ From 025d90a89bdf6f05cdeac4cdcb8ae2e8d08596b0 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 14:26:24 +0900 Subject: [PATCH 36/50] fix(codex): disclose contended lock identity Expose the canonical lock id on typed busy outcomes so callers can prove which same-home coordinator is contended. --- src/codex/codex-write-lock.ts | 8 ++++---- tests/helpers/codex-write-lock-child.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/codex/codex-write-lock.ts b/src/codex/codex-write-lock.ts index 879497955..994920894 100644 --- a/src/codex/codex-write-lock.ts +++ b/src/codex/codex-write-lock.ts @@ -67,7 +67,7 @@ export type CodexWriteLockRefusalReason = export type CodexWriteLockResult = | { status: "acquired"; value: T; waitedMs: number; lockId: string } | { status: "skipped"; reason: "desired_disabled" | "desired_enabled"; waitedMs: number } - | { status: "busy"; reason: "deadline" | "cancelled"; retryable: true; waitedMs: number } + | { status: "busy"; reason: "deadline" | "cancelled"; retryable: true; waitedMs: number; lockId: string } | { status: "refused"; reason: CodexWriteLockRefusalReason; @@ -300,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 { @@ -315,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; @@ -363,7 +363,7 @@ export async function withCodexWriteLock( } 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/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 } : {}), })); From 1acf7bc4269d57f2910b5a108fe2c386dc9d6969 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 14:27:49 +0900 Subject: [PATCH 37/50] feat(cli): add machine-readable restore result Expose the native restore artifact envelope through `ocx restore --json` so child-process callers can distinguish history contention from success. --- src/cli/index.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/cli/index.ts b/src/cli/index.ts index c46c824a4..de2d24ecd 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -778,6 +778,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; @@ -830,6 +831,14 @@ switch (command) { } 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}`); From ae961e9ea0e4cfdf6bfc9c1afd41afe6878c5a02 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 14:32:03 +0900 Subject: [PATCH 38/50] fix(codex): refuse foreign-home restore before native locks Do not start native-main ownership or run explicit restore when a recorded foreign service home owns the Codex artifacts. --- src/codex/inject.ts | 23 +++++++++++++++++++++++ src/server/index.ts | 3 +++ 2 files changed, 26 insertions(+) diff --git a/src/codex/inject.ts b/src/codex/inject.ts index e62f9e1c6..fb82d08ad 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -23,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, @@ -1261,6 +1262,19 @@ function externalProviderRestoreResult(activeProvider: string): CodexNativeResto }; } +/** 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 { @@ -1343,6 +1357,15 @@ export async function restoreNativeCodexAsync( 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()), diff --git a/src/server/index.ts b/src/server/index.ts index 8faa75ffb..452abf32f 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -23,6 +23,7 @@ import { withCatalogWriteSerialization } from "../codex/catalog-write-serializat 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 { @@ -520,7 +521,9 @@ export function startServer(port?: number, deps: StartServerDeps = {}) { // CODEX_HOME. When the user has disabled the Codex integration, starting the // proxy must not manufacture those Codex artifacts merely to serve other // clients; no Codex request can use this lifecycle in that state. + const nativeOwnership = inspectNativeCodexOwnership(); const nativeMainLifecycle: NativeMainStartupLifecycle = shouldSyncCodexOnStart(config) + && nativeOwnership.ownership !== "foreign" ? startNativeMainStartupLifecycle(deps.nativeMainStartup) : { homeId: null, From d5a38458bec02a2643a5d6e3ea2e93d9c1b83002 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 14:33:40 +0900 Subject: [PATCH 39/50] test(codex): compose native toggle entry-point acceptance Exercise real CLI children, management HTTP, persisted desired state, foreign-home refusal, cross-home lock contention, Grok startup, and busy history restore recovery in one isolated workstation suite. --- tests/codex-composed-acceptance.test.ts | 481 ++++++++++++++++++++++++ 1 file changed, 481 insertions(+) create mode 100644 tests/codex-composed-acceptance.test.ts diff --git a/tests/codex-composed-acceptance.test.ts b/tests/codex-composed-acceptance.test.ts new file mode 100644 index 000000000..bbe8c559f --- /dev/null +++ b/tests/codex-composed-acceptance.test.ts @@ -0,0 +1,481 @@ +/** + * 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"; + +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 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'); + } + + 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", + }; + } + + 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"]); + // This workstation has a real launchd claim outside the fixture; P08 + // must therefore refuse rather than bypass the production ownership + // preflight. The child still exercised the actual entry point. + expect(back.exitCode).toBe(1); + 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 { + 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" }); + const server = await fx.start(); + try { + hold = true; + const stale = fx.request(server.runtime, "/api/sync", { method: "POST" }); + await enteredGather; + 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); +}); From 1700fee839722bf328d4a7ff6b1913623a85ae8d Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 14:56:34 +0900 Subject: [PATCH 40/50] fix(test): unstick the held-provider race and let the fixture own its service claim B-reduced held the startup discovery flight instead of its own request; enable discovery only after the server is up and race the sentinel against completion. The fixture now records itself as the active install so P08 exercises the admit path rather than refusing on the workstation claim. --- tests/codex-composed-acceptance.test.ts | 47 ++++++++++++++++++++----- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/tests/codex-composed-acceptance.test.ts b/tests/codex-composed-acceptance.test.ts index bbe8c559f..39bb0c580 100644 --- a/tests/codex-composed-acceptance.test.ts +++ b/tests/codex-composed-acceptance.test.ts @@ -96,6 +96,25 @@ class Fixture { if (existsSync(path)) throw new Error(`lock preflight found pre-existing case path: ${path}`); } writeFileSync(join(this.codex, "config.toml"), 'model = "gpt-5"\n'); + // The host may have a real service claim. Record this fixture as the active + // install so an acceptance writer never consults that ambient identity. + writeFileSync(join(this.ocx, "service-state.json"), JSON.stringify({ + version: 2, + codexHome: this.codex, + opencodexHome: this.ocx, + backend: "scheduler", + })); + if (process.platform === "darwin") { + const launchAgents = join(this.homeA, "Library", "LaunchAgents"); + mkdirSync(launchAgents, { recursive: true, mode: 0o700 }); + writeFileSync(join(launchAgents, "com.opencodex.proxy.plist"), [ + "", + "EnvironmentVariables", + `CODEX_HOME${this.codex}`, + `OPENCODEX_HOME${this.ocx}`, + "", + ].join("\n")); + } } env(home = this.homeA, userprofile = this.userprofileA): Record { @@ -271,10 +290,10 @@ describe("WP13 composed toggle acceptance", () => { }); expect(enabled.status).toBe(200); const back = await fx.runCli(["restore", "back"]); - // This workstation has a real launchd claim outside the fixture; P08 - // must therefore refuse rather than bypass the production ownership - // preflight. The child still exercised the actual entry point. - expect(back.exitCode).toBe(1); + // 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 }); @@ -305,15 +324,25 @@ describe("WP13 composed toggle acceptance", () => { }, }); try { - 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" }); + // 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 enteredGather; + 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 }), }); From 792b238cfc8e113a3b345c7e0e39467da5640d01 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 15:18:30 +0900 Subject: [PATCH 41/50] test(codex): isolate toggle regression fixtures Persist fresh intent inputs and explicitly claim temp service homes so tests exercise their intended contracts without ambient service state. --- src/codex/sync.ts | 8 +++- tests/cli-restore-back.test.ts | 43 ++++++++++++++--- tests/codex-sync-api.test.ts | 84 +++++++++++++++++++++++++++++---- tests/shutdown-launcher.test.ts | 36 +++++++++++++- 4 files changed, 152 insertions(+), 19 deletions(-) diff --git a/src/codex/sync.ts b/src/codex/sync.ts index 1ad3d1971..955db3804 100644 --- a/src/codex/sync.ts +++ b/src/codex/sync.ts @@ -6,7 +6,7 @@ import type { OcxConfig } from "../types"; import { collectOrcaCodexHomeDiagnostic } from "./home"; import { summarizeComboCatalogOmissions, type ComboCatalogOmission } from "./catalog/aggregation"; import { shouldSyncCodexOnStart } from "./desired-state"; -import { admitCodexWrite } from "./admission"; +import { admitCodexWrite, type CodexAdmission } from "./admission"; export interface CodexSyncResult { /** `skipped` is policy truth, never evidence that Codex was written. */ @@ -28,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; } @@ -79,7 +83,7 @@ export async function syncModelsToCodex( // 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 = admitCodexWrite(); + const admission = (deps.admitCodexWrite ?? admitCodexWrite)(); if (admission.kind === "refused" && admission.authority === "service-home") { return { status: "refused", diff --git a/tests/cli-restore-back.test.ts b/tests/cli-restore-back.test.ts index 2cb92850a..e27dce707 100644 --- a/tests/cli-restore-back.test.ts +++ b/tests/cli-restore-back.test.ts @@ -1,11 +1,34 @@ import { describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; const repoRoot = join(import.meta.dir, ".."); +function ownedEnvironment(codexHome: string, ocxHome: string): Record { + const home = join(ocxHome, "home"); + mkdirSync(home, { recursive: true }); + writeFileSync(join(ocxHome, "service-state.json"), JSON.stringify({ + version: 2, + codexHome, + opencodexHome: ocxHome, + backend: "scheduler", + })); + if (process.platform === "darwin") { + const launchAgents = join(home, "Library", "LaunchAgents"); + mkdirSync(launchAgents, { recursive: true }); + writeFileSync(join(launchAgents, "com.opencodex.proxy.plist"), [ + "", + "EnvironmentVariables", + `CODEX_HOME${codexHome}`, + `OPENCODEX_HOME${ocxHome}`, + "", + ].join("\n")); + } + return { HOME: home, USERPROFILE: home }; +} + describe("ocx restore back", () => { test("restore durably disables Codex in an isolated home", () => { const codexHome = mkdtempSync(join(tmpdir(), "ocx-cli-restore-codex-")); @@ -15,7 +38,7 @@ describe("ocx restore back", () => { 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, CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome, CI: "1" }, + env: { ...process.env, ...ownedEnvironment(codexHome, ocxHome), CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome, CI: "1" }, encoding: "utf8", }); expect(result.status).toBe(0); @@ -37,7 +60,7 @@ describe("ocx restore back", () => { const before = statSync(configPath).mtimeMs; 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", }); expect(result.status).toBe(0); @@ -62,14 +85,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", }); diff --git a/tests/codex-sync-api.test.ts b/tests/codex-sync-api.test.ts index 796133307..d6508ef79 100644 --- a/tests/codex-sync-api.test.ts +++ b/tests/codex-sync-api.test.ts @@ -10,15 +10,50 @@ import type { OrcaCodexHomeDiagnostic } from "../src/codex/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 { + writeFileSync(join(ocxHome, "service-state.json"), JSON.stringify({ + version: 2, + codexHome, + opencodexHome: ocxHome, + backend: "scheduler", + })); + if (process.platform === "darwin") { + const launchAgents = join(home, "Library", "LaunchAgents"); + mkdirSync(launchAgents, { recursive: true }); + writeFileSync(join(launchAgents, "com.opencodex.proxy.plist"), [ + "", + "EnvironmentVariables", + `CODEX_HOME${codexHome}`, + `OPENCODEX_HOME${ocxHome}`, + "", + ].join("\n")); + } +} + +const admittedSync = () => ({ kind: "admitted" as const }); + function homeDiagnostic(overrides: Partial = {}): OrcaCodexHomeDiagnostic { return { applicable: false, @@ -35,15 +70,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 () => { @@ -53,6 +104,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", @@ -89,10 +141,12 @@ describe("GUI/CLI Codex sync backend", () => { test("returns a policy skip without touching the catalog or config", async () => { let refreshed = false; let injected = false; - const result = await syncModelsToCodex(12345, { + writeFileSync(join(TEST_OCX_HOME, "config.json"), JSON.stringify({ ...config, clientIntegrations: { codex: false }, - }, null, { + })); + const result = await syncModelsToCodex(12345, config, null, { + admitCodexWrite: admittedSync, refreshCodexModelCatalog: async () => { refreshed = true; throw new Error("must not refresh"); @@ -122,13 +176,14 @@ describe("GUI/CLI Codex sync backend", () => { 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({ - providers: {}, defaultProvider: "openai", checkForUpdates: false, - })); + 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");', @@ -155,7 +210,13 @@ describe("GUI/CLI Codex sync backend", () => { const before = readFileSync(join(raceCodexHome, "config.toml"), "utf8"); const child = spawnSync(process.execPath, ["--eval", script], { cwd: repoRoot, - env: { ...process.env, CODEX_HOME: raceCodexHome, OPENCODEX_HOME: raceOcxHome }, + env: { + ...process.env, + HOME: raceHome, + USERPROFILE: raceHome, + CODEX_HOME: raceCodexHome, + OPENCODEX_HOME: raceOcxHome, + }, encoding: "utf8", }); expect(child.status).toBe(0); @@ -178,6 +239,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", @@ -207,6 +269,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", @@ -234,6 +297,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"); }, @@ -252,6 +316,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", @@ -323,6 +388,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"); diff --git a/tests/shutdown-launcher.test.ts b/tests/shutdown-launcher.test.ts index 2ba68a2d4..f11161dc1 100644 --- a/tests/shutdown-launcher.test.ts +++ b/tests/shutdown-launcher.test.ts @@ -1,6 +1,6 @@ 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"; @@ -25,6 +25,31 @@ const runnable = process.platform !== "win32" && nodeAvailable; const spawned: ChildProcess[] = []; const tmpHomes: string[] = []; +function claimTempHome(home: string): { homeDir: string; userProfile: string } { + const homeDir = join(home, "user-home"); + const userProfile = join(home, "user-profile"); + mkdirSync(homeDir, { recursive: true }); + mkdirSync(userProfile, { recursive: true }); + writeFileSync(join(home, "service-state.json"), JSON.stringify({ + version: 2, + codexHome: home, + opencodexHome: home, + backend: "scheduler", + })); + if (process.platform === "darwin") { + const launchAgents = join(homeDir, "Library", "LaunchAgents"); + mkdirSync(launchAgents, { recursive: true }); + writeFileSync(join(launchAgents, "com.opencodex.proxy.plist"), [ + "", + "EnvironmentVariables", + `CODEX_HOME${home}`, + `OPENCODEX_HOME${home}`, + "", + ].join("\n")); + } + return { homeDir, userProfile }; +} + afterAll(() => { for (const c of spawned) { try { c.kill("SIGKILL"); } catch { /* already gone */ } @@ -74,6 +99,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 +108,13 @@ 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, + }, }); spawned.push(child); From 9715fc73285d2500d154e86e072ac3758a525488 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 15:19:48 +0900 Subject: [PATCH 42/50] test(codex): persist retained-sync race inputs Drive POST /api/sync from a persisted, local held provider so the race remains valid after the route refreshes configuration at its boundary. --- .../codex-retained-root-serialization.test.ts | 117 +++++++++++------- 1 file changed, 74 insertions(+), 43 deletions(-) diff --git a/tests/codex-retained-root-serialization.test.ts b/tests/codex-retained-root-serialization.test.ts index 36c7fdb85..7e24bd31d 100644 --- a/tests/codex-retained-root-serialization.test.ts +++ b/tests/codex-retained-root-serialization.test.ts @@ -81,6 +81,23 @@ function makeSandbox(prefix: string): Sandbox { LOCALAPPDATA: join(home, "LocalAppData"), }, }; + 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")); + } sandboxes.push(sandbox); return sandbox; } @@ -225,8 +242,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 +272,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); } From 5c9c275ef2e2d58b1664f34518f2a726e9e748c5 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 15:32:46 +0900 Subject: [PATCH 43/50] fix(cli): restore --json emits an envelope on every outcome path Early exits (persist failure, already-OFF clean no-op) printed human text on a machine-readable invocation; each now emits one JSON envelope with skipped artifact states and never enters restore machinery. --- src/cli/index.ts | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index de2d24ecd..d3bc6aa2b 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -812,7 +812,21 @@ switch (command) { const desired = setIntegrationEnabled("codex", false); if (!desired.ok) { process.exitCode = desired.reason === "conflict" ? 2 : 1; - console.error(`Codex desired state was not saved (${desired.reason}).`); + if (restoreJson) { + // Machine-readable contract: every restore --json outcome emits one + // envelope on stdout, including failures before restore machinery runs. + console.log(JSON.stringify({ + success: false, + message: `Codex desired state was not saved (${desired.reason}).`, + artifacts: { + config: { state: "skipped" }, + catalog: { state: "skipped" }, + history: { state: "skipped" }, + }, + })); + } 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 @@ -821,7 +835,20 @@ switch (command) { if (desired.status === "unchanged") { const { classifyNativeRoutedResidue } = await import("../codex/native-residue"); if (classifyNativeRoutedResidue().kind === "clean") { - console.log("Codex integration is already OFF and native; no Codex files changed."); + const alreadyOff = "Codex integration is already OFF and native; no Codex files changed."; + if (restoreJson) { + console.log(JSON.stringify({ + success: true, + message: alreadyOff, + artifacts: { + config: { state: "skipped" }, + catalog: { state: "skipped" }, + history: { state: "skipped" }, + }, + })); + } else { + console.log(alreadyOff); + } break; } } From 65a55a78e62e7ec539196446af5c0c92806d7cea Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 15:35:44 +0900 Subject: [PATCH 44/50] fix(cli): restore --json early exits reuse the full envelope schema Early outcomes now flow through skippedRestoreEnvelope(), keeping every artifact member schema-complete, with a CLI test on the no-op path. --- src/cli/index.ts | 24 +++++------------------ src/codex/inject.ts | 11 ++++++++++- tests/cli-restore-back.test.ts | 35 ++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 20 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index d3bc6aa2b..808e81fda 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -814,16 +814,9 @@ switch (command) { process.exitCode = desired.reason === "conflict" ? 2 : 1; if (restoreJson) { // Machine-readable contract: every restore --json outcome emits one - // envelope on stdout, including failures before restore machinery runs. - console.log(JSON.stringify({ - success: false, - message: `Codex desired state was not saved (${desired.reason}).`, - artifacts: { - config: { state: "skipped" }, - catalog: { state: "skipped" }, - history: { state: "skipped" }, - }, - })); + // 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}).`); } @@ -837,15 +830,8 @@ switch (command) { if (classifyNativeRoutedResidue().kind === "clean") { const alreadyOff = "Codex integration is already OFF and native; no Codex files changed."; if (restoreJson) { - console.log(JSON.stringify({ - success: true, - message: alreadyOff, - artifacts: { - config: { state: "skipped" }, - catalog: { state: "skipped" }, - history: { state: "skipped" }, - }, - })); + const { skippedRestoreEnvelope } = await import("../codex/inject"); + console.log(JSON.stringify(skippedRestoreEnvelope(true, alreadyOff))); } else { console.log(alreadyOff); } diff --git a/src/codex/inject.ts b/src/codex/inject.ts index fb82d08ad..62ff9f220 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -1277,8 +1277,17 @@ function foreignOwnershipRestoreRefusal(message: string): CodexNativeRestoreResu function desiredEnabledRestoreSkip(): CodexNativeRestoreResult { const message = "Codex integration was re-enabled; native restore was skipped."; + return skippedRestoreEnvelope(true, message); +} + +/** + * 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: true, + success, message, artifacts: { config: { state: "skipped", changed: false, action: "owned-fields-stripped", message }, diff --git a/tests/cli-restore-back.test.ts b/tests/cli-restore-back.test.ts index e27dce707..6ce3183e2 100644 --- a/tests/cli-restore-back.test.ts +++ b/tests/cli-restore-back.test.ts @@ -50,6 +50,41 @@ describe("ocx restore back", () => { } }); + 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 }); + } + }); + 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-")); From 7a875c2eb628e753ba96448a521eeb5df8842925 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 15:55:07 +0900 Subject: [PATCH 45/50] =?UTF-8?q?docs(devlog):=20PR=20screenshot=20?= =?UTF-8?q?=E2=80=94=20integrations=20overview=20with=20the=20Desktop=20sw?= =?UTF-8?q?itch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assets/integrations-overview.png | Bin 0 -> 212584 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 devlog/_plan/260806_wp13_toggles_resume/assets/integrations-overview.png 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 0000000000000000000000000000000000000000..60bc1b841b127ed1a21d3c962edb8be2a5239907 GIT binary patch literal 212584 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 fC+n3PU3e(%r3eBa(uG64EKH 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
    $6uL>a3M@9`FR!&qHd$8a@7ykHE`rD?Ue_ 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

    VPBVUU$<58n#x;^a8y0Tz<=} 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 z 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 Date: Thu, 6 Aug 2026 16:15:44 +0900 Subject: [PATCH 46/50] test(fixtures): make Linux service ownership explicit --- tests/cli-restore-back.test.ts | 20 +----- tests/codex-composed-acceptance.test.ts | 23 ++----- .../codex-retained-root-serialization.test.ts | 20 +----- tests/codex-sync-api.test.ts | 19 +----- tests/helpers/owned-service-home.ts | 62 +++++++++++++++++++ tests/shutdown-launcher.test.ts | 23 ++----- 6 files changed, 77 insertions(+), 90 deletions(-) create mode 100644 tests/helpers/owned-service-home.ts diff --git a/tests/cli-restore-back.test.ts b/tests/cli-restore-back.test.ts index 6ce3183e2..a82700290 100644 --- a/tests/cli-restore-back.test.ts +++ b/tests/cli-restore-back.test.ts @@ -3,30 +3,14 @@ import { spawnSync } from "node:child_process"; 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 repoRoot = join(import.meta.dir, ".."); function ownedEnvironment(codexHome: string, ocxHome: string): Record { const home = join(ocxHome, "home"); mkdirSync(home, { recursive: true }); - writeFileSync(join(ocxHome, "service-state.json"), JSON.stringify({ - version: 2, - codexHome, - opencodexHome: ocxHome, - backend: "scheduler", - })); - if (process.platform === "darwin") { - const launchAgents = join(home, "Library", "LaunchAgents"); - mkdirSync(launchAgents, { recursive: true }); - writeFileSync(join(launchAgents, "com.opencodex.proxy.plist"), [ - "", - "EnvironmentVariables", - `CODEX_HOME${codexHome}`, - `OPENCODEX_HOME${ocxHome}`, - "", - ].join("\n")); - } - return { HOME: home, USERPROFILE: home }; + return { HOME: home, USERPROFILE: home, ...claimOwnedServiceHome(codexHome, ocxHome, home).env }; } describe("ocx restore back", () => { diff --git a/tests/codex-composed-acceptance.test.ts b/tests/codex-composed-acceptance.test.ts index 39bb0c580..315b32e83 100644 --- a/tests/codex-composed-acceptance.test.ts +++ b/tests/codex-composed-acceptance.test.ts @@ -31,6 +31,7 @@ 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"); @@ -84,6 +85,7 @@ class Fixture { readonly managementToken = "composed-admin-token"; readonly lockPath: string; readonly lockAllowlist: string[]; + readonly serviceManagerEnv: Record; readonly children: Array> = []; constructor() { @@ -96,25 +98,7 @@ class Fixture { if (existsSync(path)) throw new Error(`lock preflight found pre-existing case path: ${path}`); } writeFileSync(join(this.codex, "config.toml"), 'model = "gpt-5"\n'); - // The host may have a real service claim. Record this fixture as the active - // install so an acceptance writer never consults that ambient identity. - writeFileSync(join(this.ocx, "service-state.json"), JSON.stringify({ - version: 2, - codexHome: this.codex, - opencodexHome: this.ocx, - backend: "scheduler", - })); - if (process.platform === "darwin") { - const launchAgents = join(this.homeA, "Library", "LaunchAgents"); - mkdirSync(launchAgents, { recursive: true, mode: 0o700 }); - writeFileSync(join(launchAgents, "com.opencodex.proxy.plist"), [ - "", - "EnvironmentVariables", - `CODEX_HOME${this.codex}`, - `OPENCODEX_HOME${this.ocx}`, - "", - ].join("\n")); - } + this.serviceManagerEnv = claimOwnedServiceHome(this.codex, this.ocx, this.homeA).env; } env(home = this.homeA, userprofile = this.userprofileA): Record { @@ -131,6 +115,7 @@ class Fixture { // 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, }; } diff --git a/tests/codex-retained-root-serialization.test.ts b/tests/codex-retained-root-serialization.test.ts index 7e24bd31d..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,25 +81,9 @@ function makeSandbox(prefix: string): Sandbox { TMP: runtime, XDG_RUNTIME_DIR: runtime, LOCALAPPDATA: join(home, "LocalAppData"), + ...serviceManagerEnv, }, }; - 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")); - } sandboxes.push(sandbox); return sandbox; } diff --git a/tests/codex-sync-api.test.ts b/tests/codex-sync-api.test.ts index d6508ef79..1ec9f790a 100644 --- a/tests/codex-sync-api.test.ts +++ b/tests/codex-sync-api.test.ts @@ -7,6 +7,7 @@ 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"); @@ -33,23 +34,7 @@ const config = { } as OcxConfig; function claimTempHome(codexHome: string, ocxHome: string, home: string): void { - writeFileSync(join(ocxHome, "service-state.json"), JSON.stringify({ - version: 2, - codexHome, - opencodexHome: ocxHome, - backend: "scheduler", - })); - if (process.platform === "darwin") { - const launchAgents = join(home, "Library", "LaunchAgents"); - mkdirSync(launchAgents, { recursive: true }); - writeFileSync(join(launchAgents, "com.opencodex.proxy.plist"), [ - "", - "EnvironmentVariables", - `CODEX_HOME${codexHome}`, - `OPENCODEX_HOME${ocxHome}`, - "", - ].join("\n")); - } + claimOwnedServiceHome(codexHome, ocxHome, home); } const admittedSync = () => ({ kind: "admitted" as const }); 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/shutdown-launcher.test.ts b/tests/shutdown-launcher.test.ts index f11161dc1..08316ea58 100644 --- a/tests/shutdown-launcher.test.ts +++ b/tests/shutdown-launcher.test.ts @@ -4,6 +4,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync 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,29 +26,12 @@ const runnable = process.platform !== "win32" && nodeAvailable; const spawned: ChildProcess[] = []; const tmpHomes: string[] = []; -function claimTempHome(home: string): { homeDir: string; userProfile: 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 }); - writeFileSync(join(home, "service-state.json"), JSON.stringify({ - version: 2, - codexHome: home, - opencodexHome: home, - backend: "scheduler", - })); - if (process.platform === "darwin") { - const launchAgents = join(homeDir, "Library", "LaunchAgents"); - mkdirSync(launchAgents, { recursive: true }); - writeFileSync(join(launchAgents, "com.opencodex.proxy.plist"), [ - "", - "EnvironmentVariables", - `CODEX_HOME${home}`, - `OPENCODEX_HOME${home}`, - "", - ].join("\n")); - } - return { homeDir, userProfile }; + return { homeDir, userProfile, serviceManagerEnv: claimOwnedServiceHome(home, home, homeDir).env }; } afterAll(() => { @@ -114,6 +98,7 @@ describe.skipIf(!runnable)("ocx launcher graceful shutdown", () => { USERPROFILE: identity.userProfile, OPENCODEX_HOME: home, CODEX_HOME: home, + ...identity.serviceManagerEnv, }, }); spawned.push(child); From 39f452acc9d362a215c6ebf77ef2772601ed5054 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 16:20:24 +0900 Subject: [PATCH 47/50] test(ci): the Copilot migration swapped models:read for copilot-requests:write Pre-existing red on origin/dev (run 31078958589 and a clean dev worktree both fail it); the permissions gate now matches the migrated workflow. --- tests/ci-workflows.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index c5b635d65..90d3a7a1e 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -4141,8 +4141,10 @@ 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( - /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*models: read/, + /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/, ); const translateJob = workflow.split(/\n {2}translate:\n/)[1]!.split(/\n {2}[a-zA-Z]/)[0]!; expect(translateJob).not.toMatch(/actions:\s*write/); From bdd12a94fd9a7fd8028ea92343c7b6e7c0975905 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 21:41:12 +0900 Subject: [PATCH 48/50] fix(claude): stop POST /apply from cancelling its own enable The route persisted desired ON, then saved the whole long-lived server snapshot -- whose clientIntegrations still said OFF -- back over it, so its post-await guard refused the apply it had just been asked to perform. Persist only the desktopProfile field under the config-mutation lock, and route the writer through the deps seam its sibling path already uses. --- .../management/agent-settings-routes.ts | 41 ++++++++++++++++--- tests/native-claude-desktop-toggle.test.ts | 28 +++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 593d7463b..eb8a735cb 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,29 @@ let grokApplyTestHooks: { now?: () => number; run?: () => Promise } | n class GrokApplyBusyError extends Error {} +/** + * 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"], +): void { + // Keep the in-process snapshot coherent for the rest of this request. + config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile }; + mutatePersistedConfig(persisted => { + persisted.claudeCode = { ...(persisted.claudeCode ?? {}), desktopProfile }; + return { changed: true, value: true }; + }); +} + export function grokApplyFlightSnapshot(): { currentBytes: number; highWaterBytes: number; active: number } { return { currentBytes: grokApplyFlight?.bytes ?? 0, @@ -752,8 +776,12 @@ 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. + persistDesktopProfileField(config, state.profile); const { writeDesktop3pConfig } = await import("../../claude/desktop-3p"); const { desktopVisibleNativeSlugs } = await import("../../codex/catalog"); const routed = state.models @@ -775,7 +803,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise applied: false, }, 409); } - const result = writeDesktop3pConfig( + const result = (deps.writeDesktop3pConfig ?? writeDesktop3pConfig)( Number(url.port) || latest.port, [...desktopVisibleNativeSlugs(latest)], routed, @@ -786,8 +814,11 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise 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); + persistDesktopProfileField(config, { + ...state.profile, + appliedFingerprint: result.fingerprint, + appliedAt: new Date().toISOString(), + }); } return jsonResponse({ ok: true, saved: true, applied: true, path: result.path, fingerprint: result.fingerprint }); } catch (error) { diff --git a/tests/native-claude-desktop-toggle.test.ts b/tests/native-claude-desktop-toggle.test.ts index e20e5ea4d..b0daac6c3 100644 --- a/tests/native-claude-desktop-toggle.test.ts +++ b/tests/native-claude-desktop-toggle.test.ts @@ -185,3 +185,31 @@ test("explicit enable re-reads desired state after catalog fetch and skips a con 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(); +}); From d9d555437c3d421c79c8cec32ea6c28a5fc4ee44 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 21:45:56 +0900 Subject: [PATCH 49/50] fix(claude): keep the server snapshot coherent after a Desktop enable setIntegrationEnabled writes disk only, and the server reuses one config object per request, so the native GET reported OFF right after a successful apply and the profile PUT wrote that stale OFF back. Mirror the transition onto the snapshot, and surface an unavailable profile mutation instead of dropping it. --- .../management/agent-settings-routes.ts | 56 +++++++++++++++++-- tests/native-claude-desktop-toggle.test.ts | 31 ++++++++++ 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index eb8a735cb..17f7c00b3 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -71,6 +71,23 @@ 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. @@ -85,13 +102,16 @@ class GrokApplyBusyError extends Error {} function persistDesktopProfileField( config: OcxConfig, desktopProfile: NonNullable["desktopProfile"], -): void { - // Keep the in-process snapshot coherent for the rest of this request. - config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile }; - mutatePersistedConfig(persisted => { +): { 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 } { @@ -742,6 +762,11 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise 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"; @@ -781,7 +806,14 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise // 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. - persistDesktopProfileField(config, state.profile); + 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 @@ -814,11 +846,23 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise 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) { - persistDesktopProfileField(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) { diff --git a/tests/native-claude-desktop-toggle.test.ts b/tests/native-claude-desktop-toggle.test.ts index b0daac6c3..9b1844f01 100644 --- a/tests/native-claude-desktop-toggle.test.ts +++ b/tests/native-claude-desktop-toggle.test.ts @@ -213,3 +213,34 @@ test("POST /apply enables from a stale OFF server snapshot instead of cancelling // 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(); +}); From 38d383429268073c4722855581da1df1792d0def Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 21:51:45 +0900 Subject: [PATCH 50/50] fix(gui,cli): surface the partial-success apply instead of a clean success The route already reported saved:false with a warning when the applied marker failed to persist, but both front ends discarded it and announced a clean save+apply. GUI shows a warn-tone notice (six locales), CLI prints the warning after a successful apply. --- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/ClaudeDesktop.tsx | 19 +++++++++++++++---- gui/src/ui.tsx | 7 +++++-- src/cli/claude-desktop.ts | 17 ++++++++++++++--- 9 files changed, 40 insertions(+), 9 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index cc53158cb..7703f6c09 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1789,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.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 071e9a2df..9ae886124 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1823,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.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 8ff381ff4..d5f7a915b 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1670,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 プロファイルを保存しました。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 55360556e..e0ee1af6a 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1816,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 프로필을 저장했습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 782b3c90b..f707bb5f5 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1712,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 сохранён.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index fd3acdef2..14113c611 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1809,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 配置已保存。", diff --git a/gui/src/pages/ClaudeDesktop.tsx b/gui/src/pages/ClaudeDesktop.tsx index 785aeb768..f2cddba1d 100644 --- a/gui/src/pages/ClaudeDesktop.tsx +++ b/gui/src/pages/ClaudeDesktop.tsx @@ -162,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 @@ -321,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")); 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/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts index 18347fcb1..85cb2a876 100644 --- a/src/cli/claude-desktop.ts +++ b/src/cli/claude-desktop.ts @@ -42,7 +42,7 @@ 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); @@ -57,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. @@ -66,7 +66,14 @@ 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) }; } @@ -123,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) { @@ -187,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;