From ba68e9037b4c3b01397f6be0c92d458d18913efe Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 4 Aug 2026 17:49:01 +0900 Subject: [PATCH 1/6] docs(service): document the repair subcommand and fix the stale launchd hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #980, found by an adversarial audit of the merged dev state. `ocx service repair` ships and is what the update path now runs, but the CLI reference never documented it: the subcommand heading, the table and the example block all listed install/start/stop/status/uninstall only, across all five locales. Worse, the English status example literally read `Repair: ocx service install`. That advice is wrong in a way that costs the user something. `repair` refreshes the installed backend in place; plain `install` re-registers, which needs elevation on Windows and can switch a WinSW install to Task Scheduler (src/service.ts:519, :1760). A user hand-recovering a service after an update would hit a UAC prompt and possibly a backend switch, both avoidable. src/service.ts:2478 had the same stale text in the launchd older-plist hint — the sibling at :1676 was updated in #980 and this one was missed. Both are installed-service recovery paths, so both say repair now. First-install, absent-service and backend-switch messages still say install, which is correct: repair refuses a service that is not installed. --- .../src/content/docs/ja/reference/cli/lifecycle.md | 4 +++- .../src/content/docs/ko/reference/cli/lifecycle.md | 4 +++- docs-site/src/content/docs/reference/cli/lifecycle.md | 10 ++++++---- .../src/content/docs/ru/reference/cli/lifecycle.md | 4 +++- .../src/content/docs/zh-cn/reference/cli/lifecycle.md | 4 +++- src/service.ts | 2 +- 6 files changed, 19 insertions(+), 9 deletions(-) diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 054b165fc7..92b8fcfb2b 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -135,7 +135,7 @@ Codex のローカル モデル ピッカー キャッシュを無効にし、 ## バックグラウンドサービス -### `ocx service [install|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|start|stop|status|uninstall|remove]` opencodex を、ログイン時に自動起動し、クラッシュ時に自動再起動するログイン管理バックグラウンド サービス (macOS **launchd**、Linux **systemd ユーザー ユニット**、Windows **タスク スケジューラ**) として実行します。サービスは `OCX_SERVICE=1` を設定して実行されるため、再起動によって Codex 設定が変更されることはありません。 @@ -143,6 +143,7 @@ opencodex を、ログイン時に自動起動し、クラッシュ時に自動 | --- | --- | |なし |サービスを作成/更新して開始します。 | | `install` |サービスを作成して開始します。 | +| `repair` | 既存のサービスを再登録せずに更新して再起動します。 | | `start` |インストールされているサービスを開始します。 | | `stop` |サービスを停止し、ネイティブ Codex を復元します。 | | `status` |サービスとプロキシの診断とログ パスをレポートします。 | @@ -152,6 +153,7 @@ opencodex を、ログイン時に自動起動し、クラッシュ時に自動 ```bash ocx service ocx service install +ocx service repair ocx service status ocx service uninstall ``` diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index e3b7fda421..3d1ee4023d 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -176,7 +176,7 @@ Codex의 로컬 모델 선택기 캐시를 무효화하여, 활성 opencodex 카 ## 백그라운드 서비스 -### `ocx service [install|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|start|stop|status|uninstall|remove]` 로그인 관리형 백그라운드 서비스로 opencodex를 실행합니다(macOS **launchd**, Linux **systemd** 사용자 유닛, Windows **Task Scheduler**). 로그인 시 자동 시작하고 충돌 시 자동 재시작합니다. 서비스 실행은 @@ -186,6 +186,7 @@ Codex의 로컬 모델 선택기 캐시를 무효화하여, 활성 opencodex 카 | --- | --- | | 없음 | 서비스를 생성/업데이트하고 시작합니다. | | `install` | 서비스를 생성하고 시작합니다. | +| `repair` | 설치된 서비스를 다시 등록하지 않고 제자리에서 새로 고친 뒤 재시작합니다. | | `start` | 설치된 서비스를 시작합니다. | | `stop` | 서비스를 중지하고 기본 Codex를 복원합니다. | | `status` | 서비스와 프록시 진단, 로그 경로를 보고합니다. | @@ -195,6 +196,7 @@ Codex의 로컬 모델 선택기 캐시를 무효화하여, 활성 opencodex 카 ```bash ocx service ocx service install +ocx service repair ocx service status ocx service uninstall ``` diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 02698c1092..0fab5b4d43 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -175,7 +175,7 @@ same stale-`app-server` warning and optional `--restart-codex` behavior as `ocx ## Background service -### `ocx service [install|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|start|stop|status|uninstall|remove]` Run opencodex as a login-managed background service (macOS **launchd**, Linux **systemd user unit**, Windows **Task Scheduler**) that auto-starts on login and auto-restarts on crash. Service runs set @@ -184,7 +184,8 @@ Windows **Task Scheduler**) that auto-starts on login and auto-restarts on crash | Subcommand | Action | | --- | --- | | none | Create/update and start the service. | -| `install` | Create and start the service. | +| `install` | Create and start the service. Registers it, which on Windows needs elevation. | +| `repair` | Refresh an installed service in place and restart it, without re-registering it. | | `start` | Start an installed service. | | `stop` | Stop the service and restore native Codex. | | `status` | Report service and proxy diagnostics plus log paths. | @@ -194,6 +195,7 @@ Windows **Task Scheduler**) that auto-starts on login and auto-restarts on crash ```bash ocx service ocx service install +ocx service repair ocx service status ocx service uninstall ``` @@ -230,9 +232,9 @@ log named in the message, and use `ocx start` to serve in the foreground meanwhi ⚠️ installed and loaded (launchd; logs: …) Registered, but no proxy is answering on port 10100. launchd is running an OLDER plist than the one on disk. - Fix: launchctl bootout gui/$(id -u)/com.opencodex.proxy && ocx service install + Fix: launchctl bootout gui/$(id -u)/com.opencodex.proxy && ocx service repair Log: ~/.opencodex/service.log - Repair: ocx service install + Repair: ocx service repair Meanwhile: ocx start (serves in the foreground) ``` diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index e553f5d1b7..e082593874 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -191,7 +191,7 @@ opencodex. Предупреждение о stale-`app-server` и optional `--res ## Фоновая служба -### `ocx service [install|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|start|stop|status|uninstall|remove]` Запустить opencodex как login-managed background service (macOS **launchd**, Linux **systemd user unit**, Windows **Task Scheduler**), которая автоматически стартует при логине и сама @@ -202,6 +202,7 @@ unit**, Windows **Task Scheduler**), которая автоматически | --- | --- | | none | Создать/обновить и запустить службу. | | `install` | Создать и запустить службу. | +| `repair` | Обновить установленную службу на месте и перезапустить её без повторной регистрации. | | `start` | Запустить уже установленную службу. | | `stop` | Остановить службу и восстановить native Codex. | | `status` | Показать диагностику службы и прокси, а также пути к логам. | @@ -211,6 +212,7 @@ unit**, Windows **Task Scheduler**), которая автоматически ```bash ocx service ocx service install +ocx service repair ocx service status ocx service uninstall ``` diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index 1755a740bd..1800d12a71 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -135,7 +135,7 @@ ocx status --json ## 后台服务 -### `ocx service [install|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|start|stop|status|uninstall|remove]` 将 opencodex 作为登录管理的后台服务运行(macOS **launchd**、Linux **systemd user unit**、Windows **Task Scheduler**),在登录时自动启动,在崩溃时自动重启。服务运行会设置 `OCX_SERVICE=1`,因此重启时不会反复改动 Codex 配置。 @@ -143,6 +143,7 @@ ocx status --json | --- | --- | | none | 创建/更新并启动服务。 | | `install` | 创建并启动服务。 | +| `repair` | 就地刷新已安装的服务并重启,不重新注册。 | | `start` | 启动已安装的服务。 | | `stop` | 停止服务并恢复原生 Codex。 | | `status` | 报告服务和代理诊断信息及日志路径。 | @@ -152,6 +153,7 @@ ocx status --json ```bash ocx service ocx service install +ocx service repair ocx service status ocx service uninstall ``` diff --git a/src/service.ts b/src/service.ts index 2c4cd16080..68ff05e0f8 100644 --- a/src/service.ts +++ b/src/service.ts @@ -2475,7 +2475,7 @@ export async function serviceStatusReport( : null); const staleLine = stalePlist && stalePlist.loaded && !stalePlist.matchesPlist ? " launchd is running an OLDER plist than the one on disk.\n" - + ` Fix: launchctl bootout gui/$(id -u)/${LABEL} && ocx service install\n` + + ` Fix: launchctl bootout gui/$(id -u)/${LABEL} && ocx service repair\n` : ""; return `⚠️ ${diag.summary}\n` From bee1cc75e7db913c2246061de901f5fe17cbf595 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 4 Aug 2026 18:15:01 +0900 Subject: [PATCH 2/6] fix(startup): recommend repair for an installed service, not re-registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second instance of the same defect the docs commit fixed, found while checking whether that fix was complete. startupHealthSummary() renders 'background service files are stale' and 'installed service is disabled, stopped, or unhealthy' — both cases where a service is already REGISTERED — and then told the user to run 'ocx service install'. The dashboard recovery panel hardcoded the same command in its copy button. install re-registers: it needs elevation on Windows (schtasks /create) and its transactional backend switch can replace a WinSW install with Task Scheduler. So the advice handed a UAC prompt and a possible backend change to someone whose only problem was stale assets. repair rewrites those assets and restarts the existing registration in place. recommendedCommand now picks repair when serviceInstalled and not conflicting; a genuinely absent service still gets install, and a conflict still gets install because repairService() refuses a two-manager conflict outright and the user must uninstall first. The GUI derives its command the same way instead of hardcoding. Red-green: restoring the unconditional installService fails the stale/disabled/ stopped guard. The DTO contract test was updated for the new commands entry, and eslint caught the constant landing in the tray section rather than the recovery section it renders in. 11 pass / 0 fail autostart-health, 583 pass / 0 fail gui. --- gui/src/pages/startup-sections.tsx | 14 +++++++++++--- gui/src/pages/startup-shared.ts | 1 + src/codex/autostart-health.ts | 8 +++++++- tests/autostart-health.test.ts | 30 ++++++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/gui/src/pages/startup-sections.tsx b/gui/src/pages/startup-sections.tsx index 2a1b80ea3a..d35ba18865 100644 --- a/gui/src/pages/startup-sections.tsx +++ b/gui/src/pages/startup-sections.tsx @@ -225,6 +225,14 @@ export function StartupRecoverySection({ }) { const { t } = useI18n(); + // An already-registered service is refreshed in place. `install` re-registers, which + // needs elevation on Windows and can switch a WinSW backend to Task Scheduler, so + // handing that command to someone who already has a service costs them a UAC prompt + // they do not need. A conflict still needs uninstall-then-install. + const serviceCommand = data.serviceInstalled && !data.serviceConflict + ? data.commands.repairService + : data.commands.installService; + return (
@@ -237,10 +245,10 @@ export function StartupRecoverySection({
{t("startup.command.service")} - {data.commands.installService} + {serviceCommand}
-
)} diff --git a/gui/src/pages/startup-shared.ts b/gui/src/pages/startup-shared.ts index dd61a24695..b62d7b058e 100644 --- a/gui/src/pages/startup-shared.ts +++ b/gui/src/pages/startup-shared.ts @@ -27,6 +27,7 @@ export interface StartupHealthData { diagnosticStale: boolean; commands: { installService: string; + repairService: string; installShim: string; restoreNative: string; }; diff --git a/src/codex/autostart-health.ts b/src/codex/autostart-health.ts index 5f25447aa7..a03263a56f 100644 --- a/src/codex/autostart-health.ts +++ b/src/codex/autostart-health.ts @@ -54,6 +54,7 @@ export interface StartupHealth { const COMMANDS = { installService: "ocx service install", + repairService: "ocx service repair", installShim: "ocx codex-shim install", restoreNative: "ocx restore", } as const; @@ -88,7 +89,12 @@ export function deriveStartupHealth(inputs: StartupHealthInputs): StartupHealth : inputs.routingKind === "custom-local" || inputs.routingKind === "unknown" ? COMMANDS.restoreNative : inputs.serviceSupported - ? COMMANDS.installService + // An already-registered service is refreshed in place: `repair` rewrites its assets + // and restarts it without re-registering, so it needs no elevation on Windows and + // cannot switch a WinSW install to Task Scheduler the way `install` would. Only a + // genuinely absent (or conflicting, which needs uninstall-then-install) service + // gets the registering command. + ? (inputs.serviceInstalled && !inputs.serviceConflict ? COMMANDS.repairService : COMMANDS.installService) : COMMANDS.restoreNative; return { ...inputs, diff --git a/tests/autostart-health.test.ts b/tests/autostart-health.test.ts index 9d476a0c31..c17e97bd09 100644 --- a/tests/autostart-health.test.ts +++ b/tests/autostart-health.test.ts @@ -33,6 +33,35 @@ describe("Codex startup health", () => { expect(startupHealthSummary(health)).toContain("ocx service install"); }); + // 260804 #970 follow-up: an already-REGISTERED service is refreshed in place. `install` + // re-registers, which needs elevation on Windows and can switch a WinSW backend to Task + // Scheduler, so recommending it to someone who already has a service costs them a UAC + // prompt they do not need. Ablate by restoring the unconditional installService and the + // stale/unhealthy cases below go red. + test("an installed but unhealthy service is repaired, not re-registered", () => { + for (const broken of [ + { serviceInstalled: true, serviceStale: true }, + { serviceInstalled: true, serviceEnabled: false }, + { serviceInstalled: true, serviceRunning: false }, + ]) { + const health = deriveStartupHealth({ ...base, ...broken }); + expect(health.status).toBe("at-risk"); + expect(health.recommendedCommand).toBe("ocx service repair"); + expect(startupHealthSummary(health)).toContain("ocx service repair"); + } + }); + + test("a genuinely absent service still gets the registering command", () => { + const health = deriveStartupHealth({ ...base, serviceInstalled: false }); + expect(health.recommendedCommand).toBe("ocx service install"); + }); + + test("a conflicting service needs uninstall-then-install, not repair", () => { + // repairService() refuses a conflict outright — two managers must be torn down first. + const health = deriveStartupHealth({ ...base, serviceInstalled: true, serviceConflict: true }); + expect(health.recommendedCommand).toBe("ocx service install"); + }); + test("treats a background service as restart protection", () => { const health = deriveStartupHealth({ ...base, serviceInstalled: true, serviceViable: true, serviceEnabled: true, serviceRunning: true }); expect(health).toMatchObject({ @@ -171,6 +200,7 @@ describe("Codex startup health", () => { expect(typeof body.routingInjected).toBe("boolean"); expect(body.commands).toEqual({ installService: "ocx service install", + repairService: "ocx service repair", installShim: "ocx codex-shim install", restoreNative: "ocx restore", }); From 0662e3430d97556aeadee188cce1b3d6e1c00ce1 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 4 Aug 2026 18:17:38 +0900 Subject: [PATCH 3/6] fix(service): finish the repair migration across update, doctor, and troubleshooting docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third round of the same audit. The reviewer re-checked my first fix and found three more surfaces still advising re-registration for an installed service. update/job.ts: the refresh it runs is 'service repair' (serviceReinstallArgs), but the failure message told Windows users to run 'ocx service install' as administrator. That is the exact post-update path #980 changed — advising install there sends the user to a UAC prompt and a possible WinSW-to-scheduler switch to fix a service that is already registered. It now names the command that actually failed, on every platform, so its output explains why. doctor.ts proxyDownRestartHint(): took only serviceViable, which conflates 'no service at all' with 'registered but stale or stopped'. Only the first wants install. It now takes serviceInstalled/serviceConflict and points an installed service at repair; a conflict still gets install because repairService() refuses a two-manager conflict and the user must uninstall first. docs-site troubleshooting/windows-memory.md in all five locales: the paragraph explicitly discusses 'an already-installed service' and then said to re-run 'ocx service install' to re-bake OPENCODEX_BUN_PATH. repairService() rewrites exactly those scheduler/WinSW assets in place, so repair is both correct and cheaper. The reviewer also flagged autostart-health, which bee1cc75e already fixed — it audited the earlier commit. Its note that the existing stale-service test was vacuous (asserting status but never the resulting command) was accurate, and the guard added in that commit closes it. 196 pass / 0 fail across doctor, autostart-health, update-job, service, and update-stop-first. --- .../docs/ja/troubleshooting/windows-memory.md | 2 +- .../docs/ko/troubleshooting/windows-memory.md | 2 +- .../docs/ru/troubleshooting/windows-memory.md | 2 +- .../docs/troubleshooting/windows-memory.md | 2 +- .../zh-cn/troubleshooting/windows-memory.md | 2 +- src/cli/doctor.ts | 14 ++++++++++- src/update/job.ts | 23 +++++++------------ tests/doctor.test.ts | 16 +++++++++++++ 8 files changed, 42 insertions(+), 21 deletions(-) diff --git a/docs-site/src/content/docs/ja/troubleshooting/windows-memory.md b/docs-site/src/content/docs/ja/troubleshooting/windows-memory.md index 37dcc60fcd..eca5b46fd1 100644 --- a/docs-site/src/content/docs/ja/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/ja/troubleshooting/windows-memory.md @@ -40,7 +40,7 @@ Windows では、#32111 クラッシュを回避するために、opencodex は 修正が適用され、opencodex はバンドルされたランタイムを強化し、より安全なストリーム パスが Windows 上で自動的にオンになります (macOS では引き続き以下の明示的なオプトインが必要です)。 2. **`OPENCODEX_BUN_PATH` を使用して信頼できる Bun ランタイムを実行します。** これは -未検証の領域 — 私たちがテストしていないランタイムで opencodex を実行しています。自己責任で。サービスのインストールにとって重要: オーバーライドは、サービスの開始時ではなく、**サービス アーティファクトの生成時に**読み込まれます。環境変数を設定し、同じシェルから `ocx service install` を再実行すると、パスが永続サービス定義に組み込まれます。 env を設定するだけでは、すでにインストールされているサービスには何も影響しません。 +未検証の領域 — 私たちがテストしていないランタイムで opencodex を実行しています。自己責任で。サービスのインストールにとって重要: オーバーライドは、サービスの開始時ではなく、**サービス アーティファクトの生成時に**読み込まれます。環境変数を設定し、同じシェルから `ocx service repair` を再実行すると、パスが永続サービス定義に組み込まれます。 env を設定するだけでは、すでにインストールされているサービスには何も影響しません。 3. **`streamMode: "eager-relay"` を使用して有界リレーにオプトインします。** 2 つの方法: `config.json` を編集する (`"streamMode": "eager-relay"` を追加する) か、管理 API を呼び出します。`PUT /api/settings` と `{"streamMode":"eager-relay"}` は、再起動せずに新しいターンに適用されます。 **クラッシュのリスク警告:** Bun 1.3.14 では、#32111 の影響を受けるストリーム形状が使用されており、ストリームの途中でプロセスがクラッシュする可能性があります (Windows に限らず、どの OS でも)。サービス マネージャーはサービスを再起動しますが、実行中のリクエストは失敗します。 `"legacy-tee"` は現在のデフォルトを固定します。 Windows では、`"auto"` (デフォルト) によりランタイム ゲートが決定します。 macOS では、`"auto"` は常に T 上にあります。明示的な `"eager-relay"` はオプトインです。 diff --git a/docs-site/src/content/docs/ko/troubleshooting/windows-memory.md b/docs-site/src/content/docs/ko/troubleshooting/windows-memory.md index ecc25aba99..4e53e3d5f3 100644 --- a/docs-site/src/content/docs/ko/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/ko/troubleshooting/windows-memory.md @@ -34,7 +34,7 @@ Windows에서는 opencodex가 #32111 충돌을 피하기 위해 스트리밍 응 1. **번들된 런타임 업데이트를 기다립니다.** Bun 릴리스가 수정 사항을 실제로 포함함이 확인되면 opencodex가 번들 런타임을 올리고, Windows에서는 더 안전한 스트림 경로가 자동으로 켜집니다(macOS는 아래의 명시적 opt-in을 계속 요구합니다). -2. **`OPENCODEX_BUN_PATH`로 신뢰하는 Bun 런타임을 사용합니다.** 이 경로는 검증되지 않은 영역입니다. opencodex를 아직 테스트하지 않은 런타임에서 실행하는 것이므로, 위험은 사용자에게 있습니다. 서비스 설치에서 특히 중요한 점은 이 override가 서비스 시작 시가 아니라 **서비스 아티팩트를 생성할 때** 읽힌다는 것입니다. 환경 변수를 설정한 뒤, 같은 셸에서 `ocx service install`을 다시 실행해야 경로가 영구적인 서비스 정의에 반영됩니다. 환경 변수만 설정하면 이미 설치된 서비스에는 아무 영향이 없습니다. +2. **`OPENCODEX_BUN_PATH`로 신뢰하는 Bun 런타임을 사용합니다.** 이 경로는 검증되지 않은 영역입니다. opencodex를 아직 테스트하지 않은 런타임에서 실행하는 것이므로, 위험은 사용자에게 있습니다. 서비스 설치에서 특히 중요한 점은 이 override가 서비스 시작 시가 아니라 **서비스 아티팩트를 생성할 때** 읽힌다는 것입니다. 환경 변수를 설정한 뒤, 같은 셸에서 `ocx service repair`를 다시 실행해야 경로가 영구적인 서비스 정의에 반영됩니다. 환경 변수만 설정하면 이미 설치된 서비스에는 아무 영향이 없습니다. 3. **`streamMode: "eager-relay"`로 bounded relay를 opt-in합니다.** 방법은 두 가지입니다. `config.json`을 수정해 `"streamMode": "eager-relay"`를 추가하거나, 관리 API에 `PUT /api/settings`와 `{"streamMode":"eager-relay"}`를 보내 새 턴에 재시작 없이 적용합니다. **충돌 위험 경고:** Bun 1.3.14에서는 이 방식이 #32111의 영향을 받는 스트림 형태를 사용하므로, 어떤 OS에서든 스트림 중간에 프로세스가 충돌할 수 있습니다. 서비스 관리자가 다시 시작하겠지만, 진행 중이던 요청은 실패합니다. `"legacy-tee"`는 현재 기본값을 고정합니다. Windows에서는 `"auto"`(기본값)가 런타임 게이트 판단을 따르게 합니다. macOS에서는 `"auto"`가 항상 tee를 유지하고, 명시적 `"eager-relay"`만 opt-in입니다. diff --git a/docs-site/src/content/docs/ru/troubleshooting/windows-memory.md b/docs-site/src/content/docs/ru/troubleshooting/windows-memory.md index 1015f67ea4..11fff3de88 100644 --- a/docs-site/src/content/docs/ru/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/ru/troubleshooting/windows-memory.md @@ -73,7 +73,7 @@ opencodex поставляет рантайм Bun (сейчас это **1.3.14* 2. **Запустить Bun, которому вы доверяете, через `OPENCODEX_BUN_PATH`.** Это непроверенная территория — вы запускаете opencodex на рантайме, который мы не тестировали, на свой риск. Важно для service-установок: override считывается **при генерации артефакта службы**, а не при - её старте. Задайте переменную окружения и заново выполните `ocx service install` из той же + её старте. Задайте переменную окружения и заново выполните `ocx service repair` из той же оболочки, чтобы путь оказался зашит в долговременное определение службы. Одной только переменной для уже установленной службы недостаточно. diff --git a/docs-site/src/content/docs/troubleshooting/windows-memory.md b/docs-site/src/content/docs/troubleshooting/windows-memory.md index 149db672f7..54a9492d21 100644 --- a/docs-site/src/content/docs/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/troubleshooting/windows-memory.md @@ -81,7 +81,7 @@ restart it. unvalidated territory — you are running opencodex on a runtime we have not tested; at your own risk. Important for service installs: the override is read **when the service artifact is generated**, not at service start. Set - the environment variable, then re-run `ocx service install` from that same + the environment variable, then re-run `ocx service repair` from that same shell so the path is baked into the durable service definition. Setting the env alone does nothing for an already-installed service. diff --git a/docs-site/src/content/docs/zh-cn/troubleshooting/windows-memory.md b/docs-site/src/content/docs/zh-cn/troubleshooting/windows-memory.md index 73aa3d600d..8d3ae224d8 100644 --- a/docs-site/src/content/docs/zh-cn/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/zh-cn/troubleshooting/windows-memory.md @@ -34,7 +34,7 @@ opencodex 打包了 Bun 运行时(当前为 **1.3.14**)。这类内存增长 1. **等待捆绑运行时更新。** 一旦某个 Bun 版本可验证地包含这些修复,opencodex 就会升级捆绑运行时,并在 Windows 上自动启用更安全的流路径(macOS 仍然需要下面的显式启用)。 -2. **通过 `OPENCODEX_BUN_PATH` 运行你信任的 Bun 运行时。** 这属于未验证区域,你是在一个我们没有测试过的运行时上运行 opencodex,风险自负。对服务安装而言,这个覆盖值是在生成服务产物时读取的,而不是在服务启动时读取的。先设置环境变量,然后在同一个 shell 中重新运行 `ocx service install`,这样路径才会被写入持久化的服务定义。只设置环境变量对已经安装好的服务没有任何作用。 +2. **通过 `OPENCODEX_BUN_PATH` 运行你信任的 Bun 运行时。** 这属于未验证区域,你是在一个我们没有测试过的运行时上运行 opencodex,风险自负。对服务安装而言,这个覆盖值是在生成服务产物时读取的,而不是在服务启动时读取的。先设置环境变量,然后在同一个 shell 中重新运行 `ocx service repair`,这样路径才会被写入持久化的服务定义。只设置环境变量对已经安装好的服务没有任何作用。 3. **通过 `streamMode: "eager-relay"` 显式启用有界中继。** 有两种方式:编辑 `config.json`(添加 `"streamMode": "eager-relay"`),或调用管理 API - `PUT /api/settings` 携带 `{"streamMode":"eager-relay"}`,即可对新轮次生效,无需重启。**崩溃风险警告:** 在 Bun 1.3.14 上,这会使用受 #32111 影响的流形态,可能在流中途使进程崩溃(任何操作系统都会受影响,不只是 Windows)。服务管理器会把它重启,但正在进行的请求会失败。`"legacy-tee"` 会固定在当前默认路径。Windows 上,`"auto"`(默认值)会交给运行时门控决定。macOS 上,`"auto"` 始终保持 tee;显式 `"eager-relay"` 才是显式启用选项。 diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 1be5c5dc89..29b8012a71 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -730,11 +730,21 @@ export function proxyDownRestartHint(input: { proxyRunning: boolean; port: number; serviceViable: boolean; + /** Absent means "unknown"; the hint then keeps its pre-repair wording. */ + serviceInstalled?: boolean; + serviceConflict?: boolean; }): string | null { if (input.proxyRunning) return null; + // `serviceViable` alone conflates "no service at all" with "registered but stale or + // stopped". Only the first wants `install`: re-registering an existing service costs a + // UAC prompt on Windows and can switch a WinSW backend to Task Scheduler. A conflict + // still needs uninstall-then-install, which repairService() refuses outright. + const installedButBroken = input.serviceInstalled === true && input.serviceConflict !== true; const restart = input.serviceViable ? "Restart it with 'ocx service start' (service installed) or 'ocx start'." - : "Restart it with 'ocx start', or install the persistent service: 'ocx service install'."; + : installedButBroken + ? "Restart it with 'ocx start', or refresh the installed service: 'ocx service repair'." + : "Restart it with 'ocx start', or install the persistent service: 'ocx service install'."; return `The ocx proxy is not running. Codex/Claude clients pinned to 127.0.0.1:${input.port} fail with errors like "error sending request for url (http://127.0.0.1:${input.port}/v1/responses)". ${restart}`; } @@ -950,6 +960,8 @@ export async function runDoctor(args: string[] = []): Promise { proxyRunning: Boolean(live), port: live?.port ?? doctorConfig.port ?? 10100, serviceViable: startup.serviceViable, + serviceInstalled: startup.serviceInstalled, + serviceConflict: startup.serviceConflict, }); if (proxyDown) hints.push(proxyDown); for (const row of providerApiKeys) { diff --git a/src/update/job.ts b/src/update/job.ts index f0684cd199..3b9557f953 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -823,24 +823,17 @@ async function restartAfterUpdate( const result = run(job, cmd.bin, cmd.args); serviceOk = result.status === 0; if (!serviceOk) { - // On Windows, `schtasks /create` requires an elevated token. The update worker - // inherits the (non-admin) proxy's privileges, so a service-managed install - // updated from the GUI or a normal terminal fails here with access denied. - // Falling back to a direct proxy start keeps the update from leaving the proxy - // stopped; the stale service manager can be refreshed later with an admin - // `ocx service install`. - // - // That advice is Windows-only, and on macOS/Linux it now actively misleads: - // `ocx service install` gained a non-zero exit for a service that registers - // but does not serve, so this branch fires there for a reason elevation - // cannot fix. Point at the command that prints the real reason instead. + // The refresh that just failed was `ocx service repair` (serviceReinstallArgs), + // which needs no elevation because it never calls `schtasks /create`. Advising + // `install` here would send the user to re-registration — a UAC prompt on + // Windows and a possible WinSW-to-scheduler backend switch — to fix a service + // that is already registered. Point at the same command that failed so its + // output explains why, on every platform. updateJob( job, {}, - `Service reinstall failed (exit ${result.status ?? "?"}); falling back to a direct proxy start.` - + (process.platform === "win32" - ? " Run 'ocx service install' as administrator to refresh the background service manager." - : " Run 'ocx service install' by hand to see the reason, then 'ocx service status'."), + `Service refresh failed (exit ${result.status ?? "?"}); falling back to a direct proxy start.` + + " Run 'ocx service repair' by hand to see the reason, then 'ocx service status'.", ); } } finally { diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index e8af480df1..3a3ebca180 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -529,4 +529,20 @@ describe("service memory section (#314 WP4)", () => { expect(hint).toContain("127.0.0.1:12000"); expect(hint).not.toContain("ocx service install"); }); + + // 260804 #970 follow-up: serviceViable=false conflates "no service" with "registered + // but stale/stopped". Only the first wants install; re-registering an existing service + // costs a UAC prompt on Windows and can switch a WinSW backend to Task Scheduler. + test("an installed but unhealthy service is pointed at repair, not install", () => { + const broken = proxyDownRestartHint({ proxyRunning: false, port: 10100, serviceViable: false, serviceInstalled: true }); + expect(broken).toContain("ocx service repair"); + expect(broken).not.toContain("ocx service install"); + + const absent = proxyDownRestartHint({ proxyRunning: false, port: 10100, serviceViable: false, serviceInstalled: false }); + expect(absent).toContain("ocx service install"); + + // A two-manager conflict must be uninstalled first; repairService() refuses it. + const conflict = proxyDownRestartHint({ proxyRunning: false, port: 10100, serviceViable: false, serviceInstalled: true, serviceConflict: true }); + expect(conflict).toContain("ocx service install"); + }); }); From b3dfdc23bd712b6fa1e7d9f21ff006a271662d34 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 4 Aug 2026 18:30:02 +0900 Subject: [PATCH 4/6] fix(startup): keep the repair choice across the health cache and the remaining advice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 3 of the audit found two RUNTIME bugs I introduced in bee1cc75e, plus the last stale advice surfaces. Both runtime bugs were reproduced before fixing. The DTO type never declared repairService, so TypeScript could not catch either: markStartupHealthDiagnosticStale() re-derived recommendedCommand and hardcoded installService, silently undoing the repair choice on every stale-cache read — the exact path the dashboard hits while a probe revalidates: before: ocx service repair -> after stale marking: ocx service install the subprocess DTO rebuild copied installService/installShim/restoreNative and dropped repairService, so after a warm probe the GUI recovery row rendered an empty command and would copy undefined. StartupHealth.commands now declares repairService, which is what makes both sites type-checked rather than convention-checked. Remaining advice surfaces, all reachable with a service already installed: - src/cli/index.ts printed 'install the persistent service' unconditionally while the service summary three lines later told the same user to repair — one status report, two contradictory instructions. - assertServiceAuthEnvironment() and installLaunchd() are both reached THROUGH repair (macOS repair delegates straight to installLaunchd), so they now name the command the caller actually ran. - structure/05 and an update/index comment described the old command set. - Two GUI fixtures omitted repairService, which is what let an incomplete DTO look acceptable in those tests. Red-green: restoring the unconditional installService in the stale-cache path fails the new guard. The pre-existing stale-cache test asserted status and protection but never the resulting command, which is why this regression was invisible to it. 254 pass / 0 fail across service, startup, doctor, update and winsw suites; 583 pass / 0 fail gui. --- gui/tests/startup-revisit-cache.test.tsx | 2 +- gui/tests/startup-usage-loading-race.test.tsx | 2 +- src/cli/index.ts | 9 ++++++++- src/codex/autostart-health.ts | 1 + src/server/startup-health-cache.ts | 8 +++++++- src/service.ts | 10 ++++++++-- src/update/index.ts | 2 +- structure/05_gui-and-management-api.md | 2 +- tests/autostart-health.test.ts | 14 ++++++++++++++ 9 files changed, 42 insertions(+), 8 deletions(-) diff --git a/gui/tests/startup-revisit-cache.test.tsx b/gui/tests/startup-revisit-cache.test.tsx index 82b2c8b7a8..11a0ef7f6c 100644 --- a/gui/tests/startup-revisit-cache.test.tsx +++ b/gui/tests/startup-revisit-cache.test.tsx @@ -37,7 +37,7 @@ function atRiskHealth() { platform: "darwin", recommendedCommand: "ocx service install", diagnosticStale: false, - commands: { installService: "ocx service install", installShim: "ocx shim install", restoreNative: "ocx restore" }, + commands: { installService: "ocx service install", repairService: "ocx service repair", installShim: "ocx shim install", restoreNative: "ocx restore" }, }; } diff --git a/gui/tests/startup-usage-loading-race.test.tsx b/gui/tests/startup-usage-loading-race.test.tsx index 9c8d1564c7..f465af1280 100644 --- a/gui/tests/startup-usage-loading-race.test.tsx +++ b/gui/tests/startup-usage-loading-race.test.tsx @@ -96,7 +96,7 @@ test("an aborted Startup fetch must not clear loading while its replacement is i platform: "darwin", recommendedCommand, diagnosticStale: false, - commands: { installService: "ocx service install", installShim: "ocx shim install", restoreNative: "ocx restore" }, + commands: { installService: "ocx service install", repairService: "ocx service repair", installShim: "ocx shim install", restoreNative: "ocx restore" }, }); const STALE = health("stale-startup-marker"); const FRESH = health("fresh-startup-marker"); diff --git a/src/cli/index.ts b/src/cli/index.ts index 54e3ff8221..8557613596 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -660,7 +660,14 @@ async function handleStatus() { console.log(` Health: ${status.healthLabel}`); if (!(status.json.proxy.pid || status.json.proxy.health.ok)) { console.log(" ↳ Not running — Codex/Claude requests will fail with connection errors."); - console.log(" Restart with 'ocx start', or install the persistent service: 'ocx service install'."); + // The service summary a few lines below already tells a registered-but-not-serving + // user to repair. Printing "install the persistent service" unconditionally + // contradicted it in the same report, and install re-registers: UAC on Windows and a + // possible WinSW-to-scheduler switch for someone who already has a service. + const installed = status.json.startup.serviceInstalled && !status.json.startup.serviceConflict; + console.log(installed + ? " Restart with 'ocx start', or refresh the installed service: 'ocx service repair'." + : " Restart with 'ocx start', or install the persistent service: 'ocx service install'."); } console.log(` Dashboard: ${status.json.dashboard.url}`); console.log(` Config: ${status.json.paths.config}`); diff --git a/src/codex/autostart-health.ts b/src/codex/autostart-health.ts index a03263a56f..b3496696e1 100644 --- a/src/codex/autostart-health.ts +++ b/src/codex/autostart-health.ts @@ -47,6 +47,7 @@ export interface StartupHealth { recommendedCommand: string | null; commands: { installService: string; + repairService: string; installShim: string; restoreNative: string; }; diff --git a/src/server/startup-health-cache.ts b/src/server/startup-health-cache.ts index fc83023f76..d878be9ee4 100644 --- a/src/server/startup-health-cache.ts +++ b/src/server/startup-health-cache.ts @@ -23,9 +23,14 @@ export function markStartupHealthDiagnosticStale(value: StartupHealth): StartupH rebootSafe: false, protection: "none", diagnosticStale: true, + // Mirror deriveStartupHealth's choice: an already-registered service is refreshed in + // place. Hardcoding installService here silently undid that for every stale-cache + // read, which is the path the dashboard hits while a probe is revalidating. recommendedCommand: value.routingKind === "custom-local" || value.routingKind === "unknown" ? value.commands.restoreNative - : value.commands.installService, + : value.serviceInstalled && !value.serviceConflict + ? value.commands.repairService + : value.commands.installService, }; } @@ -72,6 +77,7 @@ function runProbe(config: Pick): Promise { : "⚠️ Service refresh failed — starting the proxy directly instead.", ); // Elevation is a Windows-only remedy; elsewhere the refresh fails for - // reasons `ocx service install` reports directly (since it now verifies + // reasons `ocx service repair` reports directly (since it now verifies // the service actually serves). console.warn(process.platform === "win32" ? " Run 'ocx service repair' to refresh the background service." diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index 3b9e155adc..0bb6fe7edb 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -164,7 +164,7 @@ sidebar entry: it is entered from the dashboard's startup-state row, which links current state needs remediation or merely reports how routing is protected. Its warning state is derived from active Codex routing plus the actual service and launcher-shim installation state; the `codexAutoStart` preference alone is never presented as proof of restart protection. The page shows -copyable repair commands (`ocx service install`, `ocx codex-shim install`, and `ocx restore`). On +copyable repair commands (`ocx service repair` for an installed service or `ocx service install` when none is registered, `ocx codex-shim install`, and `ocx restore`). On Windows it can also install an owned, per-user system tray. The resident tray owns only its icon, home-scoped singleton, and HKCU Run registration; fixed proxy actions delegate to the CLI so drain, service conflict handling, native restore, and PID identity remain centralized. Tray presence never diff --git a/tests/autostart-health.test.ts b/tests/autostart-health.test.ts index c17e97bd09..0ed31e33a2 100644 --- a/tests/autostart-health.test.ts +++ b/tests/autostart-health.test.ts @@ -82,6 +82,20 @@ describe("Codex startup health", () => { }); }); + // The stale-cache path re-derives recommendedCommand itself, so it can silently undo + // the repair choice deriveStartupHealth made — and the dashboard reads exactly this + // value while a probe is revalidating. Asserting status/protection alone missed it. + test("the stale-cache path keeps repair for an installed service", () => { + const installed = deriveStartupHealth({ ...base, serviceInstalled: true, serviceViable: true, serviceEnabled: true, serviceRunning: true }); + expect(markStartupHealthDiagnosticStale(installed).recommendedCommand).toBe("ocx service repair"); + + const absent = deriveStartupHealth({ ...base, serviceInstalled: false, serviceViable: true, serviceEnabled: true, serviceRunning: true }); + expect(markStartupHealthDiagnosticStale(absent).recommendedCommand).toBe("ocx service install"); + + const conflict = deriveStartupHealth({ ...base, serviceInstalled: true, serviceConflict: true, serviceViable: true, serviceEnabled: true, serviceRunning: true }); + expect(markStartupHealthDiagnosticStale(conflict).recommendedCommand).toBe("ocx service install"); + }); + test("classifies a healthy Windows shim as CLI-only rather than Desktop-safe", () => { const windowsShim = deriveStartupHealth({ ...base, shimInstalled: true, shimHealthy: true }); expect(windowsShim).toMatchObject({ protection: "shim", shimCoverage: "cli-only", status: "at-risk" }); From a632c878fbfc98e8f254d00fa316ac7330e5072f Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 4 Aug 2026 18:39:14 +0900 Subject: [PATCH 5/6] fix(service): the auth preflight must not recommend a command that refuses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 4 blocker. My preflight retry hint checked diagnoseService().installed but ignored conflict, unlike the other three sites. Concrete failure: Windows with both Task Scheduler and WinSW registered, non-loopback hostname, no OPENCODEX_API_AUTH_TOKEN. The preflight runs before repairService() (service.ts:2544), so the user was told to set the token and rerun 'ocx service repair' — a command repairService() then refuses outright (:1783), which sends them to uninstall-then-install. Two round trips to reach advice the first message could have given. Install IS the valid recovery for a conflict: installWindows removes the native backend before registering the scheduler one. So the predicate now matches the other three sites exactly: installed && !conflict ? repair : install. The message's first sentence also said the token is required 'before installing' even when reached from repair; it now says refreshing or installing to match. Also fixed while checking the reviewer's second question: installLaunchd() captured its installed-signal AFTER writeFileSync(plistPath()), so existsSync was unconditionally true and every fresh install would have been told to repair. Captured before the write instead. --- src/service.ts | 21 ++++++++++++++------- tests/service.test.ts | 14 ++++++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/service.ts b/src/service.ts index 04979c3ae6..34359e65b1 100644 --- a/src/service.ts +++ b/src/service.ts @@ -267,13 +267,17 @@ export function assertServiceAuthEnvironment(): void { const config = loadConfig(); if (isLoopbackHostname(config.hostname)) return; if (process.env.OPENCODEX_API_AUTH_TOKEN?.trim()) return; - // Reached from `service repair` as well as `install` (serviceCommand and - // repairService both assert it), so name the command the caller actually ran — - // telling an already-registered user to install re-registers for no reason. - const retry = diagnoseService().installed ? "ocx service repair" : "ocx service install"; + // Reached from `service repair` as well as `install`, so name a command that can + // actually succeed. `installed` alone is not enough: repairService() refuses a + // Task-Scheduler-plus-WinSW conflict outright, so recommending repair there sends the + // user to a command guaranteed to fail. Install IS the valid recovery for a conflict, + // because installWindows removes the native backend first. Same predicate as the other + // three sites (autostart-health, the health cache, doctor). + const diag = diagnoseService(); + const retry = diag.installed && !diag.conflict ? "ocx service repair" : "ocx service install"; throw new Error( - "OPENCODEX_API_AUTH_TOKEN is required before installing a service for non-loopback hostname. " + - `Set it in the same shell, then rerun \`${retry}\`.`, + `OPENCODEX_API_AUTH_TOKEN is required before ${diag.installed ? "refreshing" : "installing"} a service ` + + `for non-loopback hostname. Set it in the same shell, then rerun \`${retry}\`.`, ); } @@ -1627,6 +1631,9 @@ function installLaunchd(): void { if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true }); writeServiceApiTokenFile(); const p = plistPath(); + // Capture this BEFORE writing: the write below makes the plist exist unconditionally, + // so a post-write existsSync would call every fresh install an "installed" service. + const wasInstalled = existsSync(p); writeFileSync(p, buildPlist(), "utf8"); // Best-effort: an absent job is fine here, and a failed unload is caught by the // load verification below with a better message than a raw unload error. @@ -1641,7 +1648,7 @@ function installLaunchd(): void { + ` launchctl bootout ${launchdGuiDomain()}/${LABEL}\n` // macOS `service repair` delegates straight to installLaunchd, so this fires for // an already-installed service too; repair reloads it without re-registering. - + `then re-run '${existsSync(p) ? "ocx service repair" : "ocx service install"}'.`, + + `then re-run '${wasInstalled ? "ocx service repair" : "ocx service install"}'.`, ); } writeServiceInstallState(); diff --git a/tests/service.test.ts b/tests/service.test.ts index 32d19bb809..761d985173 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -1116,6 +1116,20 @@ describe("launchctl load verification", () => { * a port — so `install`/`start` printed a green checkmark for a service that never * served. These helpers answer the second question. */ +describe("auth preflight retry command (260804 #970 follow-up)", () => { + // The preflight runs before BOTH install and repair. Naming the wrong one costs the + // user a round trip: repair refuses a two-manager conflict outright, so recommending + // it there is a command guaranteed to fail. Install is the valid conflict recovery + // because installWindows removes the native backend first. + test("picks the command that can actually succeed", () => { + const pick = (installed: boolean, conflict: boolean) => + installed && !conflict ? "ocx service repair" : "ocx service install"; + expect(pick(true, false)).toBe("ocx service repair"); + expect(pick(false, false)).toBe("ocx service install"); + expect(pick(true, true)).toBe("ocx service install"); + }); +}); + describe("service serving confirmation", () => { describe("launchdListenPort", () => { test("reads the port baked into the plist, not the current config", () => { From 362771a2e95a75c0ced608873c7b3e6e61c5fef3 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 4 Aug 2026 18:49:42 +0900 Subject: [PATCH 6/6] test(service): make the preflight guard call the production selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-5 reviewer caught that my guard was vacuous: it defined a local pick() lambda duplicating the predicate and asserted against that, so reverting the production fix to the broken installed-only logic would have left it green. A guard that cannot fail is worse than no guard — it reads as coverage. Extracted serviceRetryCommand() from assertServiceAuthEnvironment() and the test now calls it with the three diagnostic shapes directly. It takes a Pick defaulting to diagnoseService(), so production behavior is unchanged and the test needs no environment mocking. Red-green: reverting the predicate to 'diag.installed ? repair : install' now fails the guard (97 pass / 1 fail), and restoring it returns 98 pass / 0 fail. This is the third vacuous or wrong-reason test caught in this session — the Windows GUI guard that ran on macOS, the stale-service test that asserted status but never the command, and now this one. --- src/service.ts | 22 ++++++++++++++++------ tests/service.test.ts | 24 +++++++++++++----------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/service.ts b/src/service.ts index 34359e65b1..c397eb1094 100644 --- a/src/service.ts +++ b/src/service.ts @@ -263,18 +263,28 @@ function isLoopbackHostname(hostname: string | undefined): boolean { return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]"; } +/** + * The `ocx` command a user should rerun for the service state they actually have. + * + * `installed` alone is not enough: `repairService()` refuses a Task-Scheduler-plus-WinSW + * conflict outright, so recommending repair there names a command guaranteed to fail. + * Install IS the valid conflict recovery, because `installWindows` removes the native + * backend first. Exported so the guard tests the real selector rather than a copy of it. + */ +export function serviceRetryCommand( + diag: Pick = diagnoseService(), +): string { + return diag.installed && !diag.conflict ? "ocx service repair" : "ocx service install"; +} + export function assertServiceAuthEnvironment(): void { const config = loadConfig(); if (isLoopbackHostname(config.hostname)) return; if (process.env.OPENCODEX_API_AUTH_TOKEN?.trim()) return; // Reached from `service repair` as well as `install`, so name a command that can - // actually succeed. `installed` alone is not enough: repairService() refuses a - // Task-Scheduler-plus-WinSW conflict outright, so recommending repair there sends the - // user to a command guaranteed to fail. Install IS the valid recovery for a conflict, - // because installWindows removes the native backend first. Same predicate as the other - // three sites (autostart-health, the health cache, doctor). + // actually succeed (see serviceRetryCommand). const diag = diagnoseService(); - const retry = diag.installed && !diag.conflict ? "ocx service repair" : "ocx service install"; + const retry = serviceRetryCommand(diag); throw new Error( `OPENCODEX_API_AUTH_TOKEN is required before ${diag.installed ? "refreshing" : "installing"} a service ` + `for non-loopback hostname. Set it in the same shell, then rerun \`${retry}\`.`, diff --git a/tests/service.test.ts b/tests/service.test.ts index 761d985173..eeb6af4e97 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -3,7 +3,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { saveConfig } from "../src/config"; import { windowsEnvIndirectBatchValue } from "../src/lib/win-paths"; -import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, confirmServiceServing, launchdListenPort, systemdListenPort, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsServiceScript, buildWindowsTaskXml, deriveWindowsServiceDiagnostic, launchctlLoadFailed, launchdJobMatchesPlist, normalizeServiceSubcommand, parseServiceInstallState, readWindowsSchedulerXmlState, repairService, resolveServiceListenPort, runLaunchctl, serviceLogPath, serviceStartableFromTray, serviceStatusReport, serviceStatusSummary, systemdNeedsDaemonReload, windowsListenPort, winswListenPort, startLaunchd, windowsTaskRegistrationHealthy } from "../src/service"; +import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, confirmServiceServing, launchdListenPort, systemdListenPort, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsServiceScript, buildWindowsTaskXml, deriveWindowsServiceDiagnostic, launchctlLoadFailed, launchdJobMatchesPlist, normalizeServiceSubcommand, parseServiceInstallState, readWindowsSchedulerXmlState, repairService, resolveServiceListenPort, runLaunchctl, serviceLogPath, serviceStartableFromTray, serviceStatusReport, serviceRetryCommand, serviceStatusSummary, systemdNeedsDaemonReload, windowsListenPort, winswListenPort, startLaunchd, windowsTaskRegistrationHealthy } from "../src/service"; import type { ServiceDiagnostic } from "../src/service"; import { buildWinswXml } from "../src/lib/winsw"; import { serviceApiTokenFilePath } from "../src/lib/service-secrets"; @@ -1117,16 +1117,18 @@ describe("launchctl load verification", () => { * served. These helpers answer the second question. */ describe("auth preflight retry command (260804 #970 follow-up)", () => { - // The preflight runs before BOTH install and repair. Naming the wrong one costs the - // user a round trip: repair refuses a two-manager conflict outright, so recommending - // it there is a command guaranteed to fail. Install is the valid conflict recovery - // because installWindows removes the native backend first. - test("picks the command that can actually succeed", () => { - const pick = (installed: boolean, conflict: boolean) => - installed && !conflict ? "ocx service repair" : "ocx service install"; - expect(pick(true, false)).toBe("ocx service repair"); - expect(pick(false, false)).toBe("ocx service install"); - expect(pick(true, true)).toBe("ocx service install"); + // Calls the PRODUCTION selector, not a copy of its logic. An earlier version of this + // test re-implemented the predicate as a local lambda and would have stayed green with + // the fix reverted — a guard that cannot fail is worse than no guard. + test("serviceRetryCommand picks the command that can actually succeed", () => { + // Registered and healthy enough to refresh in place: repair, no elevation needed. + expect(serviceRetryCommand({ installed: true, conflict: false })).toBe("ocx service repair"); + // Nothing registered: repairService() would refuse, so install is the only option. + expect(serviceRetryCommand({ installed: false, conflict: false })).toBe("ocx service install"); + // Task Scheduler AND WinSW both present: repairService() refuses this outright + // (see the conflict guard in repairService), and installWindows removes the native + // backend first, so install is the valid recovery. + expect(serviceRetryCommand({ installed: true, conflict: true })).toBe("ocx service install"); }); });