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/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/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/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/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/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/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/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/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/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/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/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 5f25447aa7..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; }; @@ -54,6 +55,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 +90,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/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 = 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 (see serviceRetryCommand). + const diag = diagnoseService(); + const retry = serviceRetryCommand(diag); 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 `ocx service install`.", + `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}\`.`, ); } @@ -1623,6 +1641,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. @@ -1635,7 +1656,9 @@ function installLaunchd(): void { `launchctl could not load ${p}: ${loaded.stderr || "load reported failure"}\n` + "A previous job may still be bootstrapped. Try:\n" + ` launchctl bootout ${launchdGuiDomain()}/${LABEL}\n` - + "then re-run 'ocx service install'.", + // 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 '${wasInstalled ? "ocx service repair" : "ocx service install"}'.`, ); } writeServiceInstallState(); @@ -2475,7 +2498,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` diff --git a/src/update/index.ts b/src/update/index.ts index e03f096f95..5c391c2888 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -353,7 +353,7 @@ export async function runUpdate(): 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/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/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 9d476a0c31..0ed31e33a2 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({ @@ -53,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" }); @@ -171,6 +214,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", }); 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"); + }); }); diff --git a/tests/service.test.ts b/tests/service.test.ts index 32d19bb809..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"; @@ -1116,6 +1116,22 @@ 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)", () => { + // 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"); + }); +}); + describe("service serving confirmation", () => { describe("launchdListenPort", () => { test("reads the port baked into the plist, not the current config", () => {