From 0153f0bf81355791e54f7034fe2ae98eb2e2c9a5 Mon Sep 17 00:00:00 2001 From: Stephen Drew <98895569+architecturesocial@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:24:07 +0100 Subject: [PATCH 1/4] fix(update): repair existing service without re-registration --- bin/ocx.mjs | 26 ++++------ src/service.ts | 17 +++++-- src/update/index.ts | 28 ++++------- src/update/job.ts | 86 ++++++++++++--------------------- tests/service.test.ts | 26 ++++++++++ tests/update-job.test.ts | 55 +++++++++++++++++++-- tests/update-stop-first.test.ts | 11 +++-- tests/winsw.test.ts | 8 +-- 8 files changed, 151 insertions(+), 106 deletions(-) diff --git a/bin/ocx.mjs b/bin/ocx.mjs index c4fd07680..1b358ec9b 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -134,19 +134,15 @@ function runNpmSelfUpdate() { } // Remember whether a background service manages the proxy BEFORE stopping — `ocx stop` - // unloads it permanently, so a successful update must reinstall it afterwards. + // unloads it, so a successful update must refresh and restart it afterwards. const serviceStatePath = join(configDir(), "service-state.json"); const serviceWasInstalled = existsSync(serviceStatePath); const trayBeforeUpdate = planWindowsTrayUpdate( process.platform === "win32" ? trayInstallState() : { installed: false, running: false }, ); - /** Read the backend from service-state.json so the update reinstalls the same one. */ + /** Refresh the existing backend without re-registering it from a non-elevated updater. */ function serviceReinstallArgs() { - try { - const state = JSON.parse(readFileSync(serviceStatePath, "utf8")); - if (state.backend === "native") return [launcher, "service", "install", "--native"]; - } catch { /* missing or corrupt — fall through to default */ } - return [launcher, "service", "install"]; + return [launcher, "service", "repair"]; } // Capture listen target before stop clears runtime-port.json (mirrors GUI/CLI update worker). @@ -241,10 +237,10 @@ function runNpmSelfUpdate() { if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start"); } } - // The stop above unloaded any managed service; reinstall via the freshly-installed - // launcher so the new files write the baked paths and the service restarts. + // The stop above unloaded any managed service; repair via the freshly-installed + // launcher so the new files write the baked paths and the existing manager restarts. if (serviceWasInstalled) { - console.log("Reinstalling the background service with the updated files..."); + console.log("Refreshing the background service with the updated files..."); const prevBake = process.env.OCX_BAKE_PORT; process.env.OCX_BAKE_PORT = String(bakePort); try { @@ -274,17 +270,15 @@ function runNpmSelfUpdate() { } } if (needDirectStart) { - // On Windows, schtasks /create requires elevation. The launcher inherits the - // user's (non-admin) token, so the service reinstall can fail with access - // denied — or exit 0 while leaving a non-viable manager. Fall back to a - // direct detached proxy start so the update never leaves the user without - // a running proxy. + // A repair can still fail, or exit 0 while leaving a non-viable manager. + // Fall back to a direct detached proxy start so the update never leaves the + // user without a running proxy. console.warn( svc.status === 0 ? "opencodex: service refresh left a non-viable manager — starting the proxy directly instead." : "opencodex: service refresh failed — starting the proxy directly instead.", ); - console.warn(" Run 'ocx service install' as administrator to refresh the background service."); + console.warn(" Run 'ocx service repair' to see why the background service could not restart."); const env = { ...process.env }; delete env.OCX_SERVICE; const child = spawn(process.execPath, [launcher, "start", "--port", String(bakePort)], { diff --git a/src/service.ts b/src/service.ts index 69ef03767..346b1b8d4 100644 --- a/src/service.ts +++ b/src/service.ts @@ -174,14 +174,23 @@ function readServiceInstallState(): ServiceInstallState | null { return null; } -/** Single accessor for update/reinstall code — v1/legacy state maps to scheduler. */ +/** Single accessor for backend-sensitive service code — v1/legacy state maps to scheduler. */ export function readServiceBackend(): ServiceBackend { return readServiceInstallState()?.backend === "native" ? "native" : "scheduler"; } -/** The `ocx` argv that reinstalls the currently-chosen service backend (update paths). */ +/** + * The `ocx` argv that refreshes an already-installed service after an update. + * + * `repair` discovers the installed backend itself. On Windows scheduler installs it + * rewrites the stable wrapper assets and restarts the existing task without + * `schtasks /create`, so a normal non-elevated update cannot stop the proxy and then + * fail solely because Task Scheduler registration requires UAC. + * + * Keep the historical export name for callers outside this module. + */ export function serviceReinstallArgs(): string[] { - return readServiceBackend() === "native" ? ["service", "install", "--native"] : ["service", "install"]; + return ["service", "repair"]; } /** @@ -439,7 +448,7 @@ export const SERVICE_INSTALL_HEALTH_MS = 20_000; * thing that answers the question the user is actually asking. * * Probes the BAKED target rather than resolving one. `findLiveProxy` resolves through - * pidfile -> runtime-port -> config.port, and a service reinstall has just invalidated + * pidfile -> runtime-port -> config.port, and a service repair has just invalidated * the first two while `resolveServiceListenPort` (OCX_BAKE_PORT precedence, config.port * === 0 normalization) can disagree with the third. * diff --git a/src/update/index.ts b/src/update/index.ts index 0c38fcbd8..bdb6006ba 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -186,7 +186,7 @@ export async function runUpdate(): Promise { } // Remember whether a background service manages the proxy BEFORE stopping — `ocx stop` - // unloads it permanently, so a successful update must reinstall/restart it afterwards. + // unloads it, so a successful update must repair/restart it afterwards. let serviceWasInstalled = false; try { const { isServiceInstalled } = await import("../service"); @@ -295,11 +295,11 @@ export async function runUpdate(): Promise { if (trayWasRunning) spawnSync(process.execPath, [process.argv[1], "tray", "start"], { stdio: "ignore", windowsHide: true }); } } - // The stop above unloaded any managed service; reinstall it with the NEW files + // The stop above unloaded any managed service; repair it with the NEW files // (spawn the fresh cli.ts so updated code writes the baked paths) so a // launchd/schtasks/systemd user isn't left with the background proxy down. if (serviceWasInstalled) { - console.log("🔁 Reinstalling the background service with the updated files..."); + console.log("🔁 Refreshing the background service with the updated files..."); const { serviceReinstallArgs } = await import("../service"); const { reclaimListenPort } = await import("../server/port-reclaim"); const freed = await reclaimListenPort(capturedListen.port, capturedListen.hostname, { @@ -310,7 +310,7 @@ export async function runUpdate(): Promise { onlyKillPids: capturedListen.oldPid != null ? [capturedListen.oldPid] : [], }); if (!freed) { - console.warn(`⚠️ Port ${capturedListen.port} still busy after 30s; reinstalling service with pinned --port ${capturedListen.port} anyway (refusing to hop).`); + console.warn(`⚠️ Port ${capturedListen.port} still busy after 30s; repairing service with pinned --port ${capturedListen.port} anyway (refusing to hop).`); } const prevBake = process.env.OCX_BAKE_PORT; process.env.OCX_BAKE_PORT = String(capturedListen.port); @@ -333,32 +333,24 @@ export async function runUpdate(): Promise { } } if (!serviceRefreshed || !serviceViable) { - // On Windows, schtasks /create requires elevation. The CLI inherits the - // user's (non-admin) token, so the service reinstall can fail with access - // denied — or exit 0 while leaving stale/missing assets that never start - // the proxy. Fall back to a direct detached proxy start so the update - // never leaves the user without a running proxy — but only when the port is free. + // A repair can still fail, or exit 0 while leaving stale/missing assets that + // never start the proxy. Fall back to a direct detached proxy start so the + // update never leaves the user without a running proxy — but only when the + // port is free. if (!freed) { console.warn( serviceRefreshed ? "⚠️ Service refresh left a non-viable manager and the captured port is still busy; not starting on another port." : "⚠️ Service refresh failed and the captured port is still busy; not starting on another port.", ); - console.warn(process.platform === "win32" - ? ` Run 'ocx service install' as administrator, then 'ocx start --port ${capturedListen.port}'.` - : ` Run 'ocx service install' to see the reason, then 'ocx start --port ${capturedListen.port}'.`); + console.warn(` Run 'ocx service repair' to see the reason, then 'ocx start --port ${capturedListen.port}'.`); } else { console.warn( serviceRefreshed ? "⚠️ Service refresh left a non-viable manager (stale or missing assets) — starting the proxy directly instead." : "⚠️ 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 - // the service actually serves). - console.warn(process.platform === "win32" - ? " Run 'ocx service install' as administrator to refresh the background service." - : " Run 'ocx service install' to refresh the background service and see why it failed."); + console.warn(" Run 'ocx service repair' to refresh the background service and see why it failed."); const env = { ...process.env }; delete env.OCX_SERVICE; const child = spawn(process.execPath, [process.argv[1], "start", "--port", String(capturedListen.port)], { diff --git a/src/update/job.ts b/src/update/job.ts index 6dc0466bb..5ac2b646f 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -300,7 +300,7 @@ export function restartCommand( const startArgs = pinPort ? [launcher, "start", "--port", String(Math.trunc(port))] : [launcher, "start"]; - const svcArgs = serviceInstalled ? [launcher, ...(serviceArgs ?? ["service", "install"])] : startArgs; + const svcArgs = serviceInstalled ? [launcher, ...(serviceArgs ?? ["service", "repair"])] : startArgs; if (installer === "npm") { const bin = nodeBin(); const args = svcArgs; @@ -626,8 +626,10 @@ export interface RestartIo { waitForPort?: typeof reclaimListenPort; spawnStart?: (job: UpdateJobState, installer: Installer, port?: number) => void; serviceInstalledFn?: () => boolean; + /** Stop the installed manager before port reclaim (injectable to keep tests off real services). */ + stopServiceWrappers?: () => void; /** - * After a service reinstall exits 0, only trust the service path when this is true. + * After a service repair exits 0, only trust the service path when this is true. * Defaults to {@link isServiceViable} — installed-but-stale assets must fall through * to a direct proxy start so dashboard updates never leave /healthz dead. */ @@ -648,7 +650,7 @@ export interface RestartIo { serviceHealthTimeoutMs?: number; sleepMs?: (ms: number) => Promise; now?: () => number; - /** Service-mode install/reinstall command (defaults to spawnSync via runLoggedCommand). */ + /** Service-mode repair command (defaults to spawnSync via runLoggedCommand). */ runService?: ( job: UpdateJobState, bin: string, @@ -683,14 +685,14 @@ export interface RestartIo { * window: being wrong here costs one extra start attempt; being wrong the other way * leaves the user with no proxy at all. * - * It runs AFTER the child's own 20s install probe (SERVICE_INSTALL_HEALTH_MS on - * macOS/Linux), so a reinstall that exits 0 but never serves spends up to 45s before + * It runs AFTER the child's own 20s repair probe (SERVICE_INSTALL_HEALTH_MS on + * macOS/Linux), so a repair that exits 0 but never serves spends up to 45s before * the fallback — inside RESTART_TIMEOUT_MS of 60s. That is why this is 25s, not more. */ export const SERVICE_RECOVERY_HEALTH_MS = 25_000; /** - * Whether the reinstalled service actually produced a listener on the captured target. + * Whether the repaired service actually produced a listener on the captured target. * * Not a duplicate of the child's own check: since WP2 the child asserts the port on * macOS/Linux, but Windows still reports success from registration alone, a flapping @@ -739,13 +741,14 @@ async function restartAfterUpdate( try { const { serviceReinstallArgs } = await import("../service"); svcArgs = serviceReinstallArgs(); - } catch { /* fallback to default service install */ } + } catch { /* fallback to default service repair */ } } const cmd = restartCommand(serviceInstalled, job.installer, packageLauncherPath(), port, svcArgs); const waitFn = io.waitForPort ?? reclaimListenPort; const listPids = io.listListenPidsFn ?? listListenPids; const verifyOcx = io.verifyOcxFn ?? verifyPidIdentity; const aliveFn = io.isAliveFn ?? isProcessAlive; + const stopServiceWrappers = io.stopServiceWrappers ?? stopWindowsServiceWrappersBestEffort; // Pre-update PID plus any ocx still LISTENing on the captured port. After a // stop-first npm self-update Windows often leaves a respawned bun/node child // that is not the captured PID; treating it as protected blocks reclaim and @@ -776,35 +779,20 @@ async function restartAfterUpdate( // schtasks /end often leaves the hidden cmd/wscript wrapper alive; its :loop // respawns `ocx start` a few seconds later and races port reclaim. End the // task again and best-effort kill those wrappers before we touch the socket. - stopWindowsServiceWrappersBestEffort(); + stopServiceWrappers(); // Stop-first update already unloaded the service; reclaim the socket, then - // reinstall wrappers that bake `--port`. + // repair the existing manager and rewrite wrappers that bake `--port`. const preServiceAllow = reclaimKillAllowlist(); const freed = await waitFn(port, hostname, reclaimOptsFor(preServiceAllow)); - let skipServiceInstall = false; - // Windows GUI update worker sets OCX_SERVICE=1 and is never elevated. - // `schtasks /create` will UAC-fail and can race the subsequent direct start. - // Keep systemd/launchd reinstall on non-Windows supervisors. - if (process.platform === "win32" && process.env.OCX_SERVICE === "1") { - updateJob(job, {}, "Skipping service reinstall from the non-elevated update worker; falling back to a direct proxy start."); - skipServiceInstall = true; - } - if (!freed && !skipServiceInstall) { + if (!freed) { updateJob( job, {}, - `Port ${port} still busy after ${Math.trunc(RESTART_PORT_RECLAIM_MS / 1000)}s; refusing to hop — reinstall may fail until the port is free.` + `Port ${port} still busy after ${Math.trunc(RESTART_PORT_RECLAIM_MS / 1000)}s; refusing to hop — service repair may fail until the port is free.` + ` ${formatPortHolders(port, listPids, verifyOcx, preServiceAllow)}`, ); - const liveAfter = listPids(port).filter(pid => pid !== process.pid && aliveFn(pid)); - if (liveAfter.length === 0) { - // Non-elevated `service install` will UAC-fail anyway; skip straight to - // the direct-start fallthrough instead of burning another minute on it. - updateJob(job, {}, "Skipping service reinstall after reclaim timeout with no live holders; falling back to a direct proxy start."); - skipServiceInstall = true; - } } - if (!skipServiceInstall) { + { const prevBake = process.env.OCX_BAKE_PORT; process.env.OCX_BAKE_PORT = String(Math.trunc(port)); let serviceOk = false; @@ -813,24 +801,11 @@ 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. 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 repair 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 { @@ -851,7 +826,7 @@ async function restartAfterUpdate( updateJob( job, {}, - `Service reinstall exited 0 and reported viable, but nothing answered on ${hostname}:${port} ` + `Service repair exited 0 and reported viable, but nothing answered on ${hostname}:${port} ` + `within ${Math.trunc((io.serviceHealthTimeoutMs ?? SERVICE_RECOVERY_HEALTH_MS) / 1000)}s; ` + "falling back to a direct proxy start.", ); @@ -859,13 +834,13 @@ async function restartAfterUpdate( updateJob( job, {}, - "Service reinstall exited 0 but the background service is not viable (stale or missing assets, disabled, or conflicting); falling back to a direct proxy start.", + "Service repair exited 0 but the background service is not viable (stale or missing assets, disabled, or conflicting); falling back to a direct proxy start.", ); } } } // Fall through to the direct proxy start below so the update never leaves the - // proxy stopped when the service reinstall could not run or did not leave a + // proxy stopped when the service repair could not run or did not leave a // viable supervisor. } @@ -879,7 +854,7 @@ async function restartAfterUpdate( // below are the path that repairs stuck Windows listeners. } } - if (serviceInstalled) stopWindowsServiceWrappersBestEffort(); + if (serviceInstalled) stopServiceWrappers(); // Reclaim the captured port before the pinned start. Spawning `--port` while the old // socket is still busy is how Windows updates used to fail health checks (or hop). // killAllOcxOnPort covers wrapper-respawned bun PIDs minted during the wait. @@ -1061,7 +1036,12 @@ export function restartAfterUpdateForTests( captured: { port: number; hostname: string; oldPid?: number }, io: RestartIo, ): Promise { - return restartAfterUpdate(job, captured, io); + return restartAfterUpdate(job, captured, { + // Unit tests can run on a developer machine that has the real service installed. + // Never let the test-only entry point stop it unless a test explicitly supplies a seam. + stopServiceWrappers: () => {}, + ...io, + }); } function restartFailureHint(port: number): string { @@ -1142,7 +1122,7 @@ async function confirmRestartedProxy( ): Promise { /* [Decision Log] - 목적과 의도: GUI update job이 detached restart 요청만 보고 성공 처리하지 않도록, 실제 프록시 복귀 여부를 확인한다. - - 기존 구현 및 제약 조건: update-job.json은 spawn/service reinstall 직후 `succeeded`로 끝났고, Windows npm/Bun 교체 실패처럼 몇 초 후 죽는 재시작을 잡지 못했다. + - 기존 구현 및 제약 조건: update-job.json은 spawn/service repair 직후 `succeeded`로 끝났고, Windows npm/Bun 교체 실패처럼 몇 초 후 죽는 재시작을 잡지 못했다. - 검토한 주요 대안: (1) 포트 점유만 확인 — 외부 프로세스/죽기 직전 프로세스를 성공으로 오인할 수 있다. (2) 무기한 /healthz 폴링 — UX가 느려지고 worker 종료 시점이 불명확하다. (3) 짧은 healthy 등장 + 안정성 창 확인 — 실제 복귀를 확인하면서도 대기 시간을 제한할 수 있다. - 선택한 방식: identity-aware /healthz probe가 일정 시간 안에 나타나고, 추가 안정성 창 동안 유지되는지 확인한다. - 다른 대안 대신 이 방식을 선택한 이유: GUI는 "업데이트가 설치됐지만 재시작은 실패"를 분리해 알려줘야 하며, 이 방식이 가장 적은 오탐으로 그 경계를 만든다. @@ -1238,11 +1218,9 @@ export function npmSelfUpdateRestartEvidence( /** * Post-install restart for the GUI worker. * - * npm installs run `node ocx.mjs update`, which already stops the proxy and reinstalls / - * starts the service (or falls back to a direct start). A second `service install` here - * calls `stopWindows()` on that healthy listener, then often fails elevation from the - * non-interactive worker — leaving the captured port (default 10100) dead until a manual - * restart. Prefer confirming the npm self-update's own restart first; only re-run restart + * npm installs run `node ocx.mjs update`, which already stops the proxy and repairs / + * starts the service (or falls back to a direct start). Prefer confirming that restart + * first so a healthy listener is not disrupted again; only run the explicit repair path * when that probe fails. Bun/source installs still always take the explicit restart path. * * Probe-first applies only to service-managed npm installs: without a service, `ocx.mjs` @@ -1253,7 +1231,7 @@ export function npmSelfUpdateRestartEvidence( * not enough when a no-op restart or failed port reclaim leaves the old proxy up. * * Browser-dashboard update recovery must not require a viable Background Service: when - * no service is installed (or reinstall leaves a non-viable/stale manager), the explicit + * no service is installed (or repair leaves a non-viable/stale manager), the explicit * path always falls through to a direct `ocx start --port` so /healthz can recover. */ export async function finishGuiUpdateRestart( diff --git a/tests/service.test.ts b/tests/service.test.ts index a2ebc5838..f0498441e 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -945,6 +945,32 @@ describe("service repair", () => { }); expect(calls).toEqual(["native", "native-state"]); }); + + test("macOS repair preserves the launchd reload path", async () => { + const calls: string[] = []; + await repairService({ + platform: "darwin", + diagnose: () => ({ ...baseDiag, backend: "launchd" }), + assertEnv: () => { calls.push("env"); }, + assertAuth: () => { calls.push("auth"); }, + repairLaunchd: () => { calls.push("launchd"); }, + repairSystemd: () => { calls.push("systemd"); }, + }); + expect(calls).toEqual(["env", "auth", "launchd"]); + }); + + test("Linux repair preserves the systemd reload path", async () => { + const calls: string[] = []; + await repairService({ + platform: "linux", + diagnose: () => ({ ...baseDiag, backend: "systemd" }), + assertEnv: () => { calls.push("env"); }, + assertAuth: () => { calls.push("auth"); }, + repairLaunchd: () => { calls.push("launchd"); }, + repairSystemd: () => { calls.push("systemd"); }, + }); + expect(calls).toEqual(["env", "auth", "systemd"]); + }); }); /** diff --git a/tests/update-job.test.ts b/tests/update-job.test.ts index d6492a083..d480f63b5 100644 --- a/tests/update-job.test.ts +++ b/tests/update-job.test.ts @@ -115,7 +115,7 @@ describe("GUI update execution decisions", () => { test("restart command separates service and direct proxy modes", () => { expect(restartCommand(true, "npm", "/pkg/bin/ocx.mjs")).toMatchObject({ mode: "service", - args: ["/pkg/bin/ocx.mjs", "service", "install"], + args: ["/pkg/bin/ocx.mjs", "service", "repair"], }); expect(restartCommand(false, "npm", "/pkg/bin/ocx.mjs")).toMatchObject({ mode: "proxy", @@ -128,9 +128,9 @@ describe("GUI update execution decisions", () => { expect(proxy.mode).toBe("proxy"); expect(proxy.args).toEqual(["/pkg/bin/ocx.mjs", "start", "--port", "10100"]); expect(proxy.display).toContain("start --port 10100"); - // Service reinstall stays install-only at the argv level; wrappers bake --port via OCX_BAKE_PORT. + // Service repair keeps the port at the environment/artifact layer via OCX_BAKE_PORT. expect(restartCommand(true, "npm", "/pkg/bin/ocx.mjs", 10100).args).toEqual([ - "/pkg/bin/ocx.mjs", "service", "install", + "/pkg/bin/ocx.mjs", "service", "repair", ]); }); @@ -349,7 +349,52 @@ describe("GUI update execution decisions", () => { } }); - test("service reinstall failure falls back to a direct proxy start", async () => { + test("non-elevated service worker repairs the existing manager instead of skipping it", async () => { + const commands: string[][] = []; + const spawned: number[] = []; + let wrapperStops = 0; + const job: UpdateJobState = { + id: "svc-worker-repair", + status: "restarting", + startedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + currentVersion: "2.10.0", + latestVersion: "2.10.1", + channel: "latest", + installer: "npm", + restart: true, + command: "", + log: [], + }; + writeFileSync(updateJobPath(job.id), JSON.stringify(job)); + const prevService = process.env.OCX_SERVICE; + process.env.OCX_SERVICE = "1"; + try { + await restartAfterUpdateForTests(job, { port: 19888, hostname: "127.0.0.1" }, { + serviceInstalledFn: () => true, + stopServiceWrappers: () => { wrapperStops += 1; }, + listListenPidsFn: () => [], + waitForPort: async () => true, + runService: (_job, _bin, args) => { + commands.push(args); + return { status: 0 }; + }, + serviceViableFn: () => true, + probeProxy: async () => true, + spawnStart: (_job, _installer, port) => { spawned.push(port ?? 0); }, + }); + expect(commands).toHaveLength(1); + expect(commands[0]?.slice(-2)).toEqual(["service", "repair"]); + expect(wrapperStops).toBe(1); + expect(spawned).toEqual([]); + expect(readUpdateJob(job.id)?.log.some(line => line.includes("Skipping service"))).toBe(false); + } finally { + if (prevService === undefined) delete process.env.OCX_SERVICE; + else process.env.OCX_SERVICE = prevService; + } + }); + + test("service repair failure falls back to a direct proxy start", async () => { const spawned: Array<{ port: number }> = []; const job: UpdateJobState = { id: "svc-fallback", @@ -385,7 +430,7 @@ describe("GUI update execution decisions", () => { } }); - test("service reinstall exit 0 with non-viable assets falls back to direct start", async () => { + test("service repair exit 0 with non-viable assets falls back to direct start", async () => { const spawned: Array<{ port: number }> = []; const job: UpdateJobState = { id: "svc-stale-fallback", diff --git a/tests/update-stop-first.test.ts b/tests/update-stop-first.test.ts index e1c902a08..ad6558aa8 100644 --- a/tests/update-stop-first.test.ts +++ b/tests/update-stop-first.test.ts @@ -55,14 +55,15 @@ describe("update stops the running proxy before replacing files", () => { expect(launcherSource).not.toContain('"npm.cmd"'); }); - test("both paths abort when the stop fails, and reinstall a managed service after success", () => { + test("both paths abort when the stop fails, and repair a managed service after success", () => { expect(updateSource).toContain("aborting the update"); - // The update path now uses serviceReinstallArgs() to preserve the chosen backend. + // The update path uses the shared repair argv so Task Scheduler is not re-registered. expect(updateSource).toContain("serviceReinstallArgs()"); expect(launcherSource).toContain("aborting the update"); - // The launcher reads service-state.json to preserve the backend choice on reinstall. + // The npm launcher mirrors the repair argv because it executes before Bun loads. expect(launcherSource).toContain("serviceReinstallArgs"); - // The launcher reads the state path for both service-installed detection and backend choice. + expect(launcherSource).toContain('[launcher, "service", "repair"]'); + // The launcher still reads service-state.json to decide whether a manager existed. expect(launcherSource).toContain('"service-state.json"'); expect(updateSource).toContain("OCX_BAKE_PORT"); expect(launcherSource).toContain("OCX_BAKE_PORT"); @@ -98,7 +99,7 @@ describe("update stops the running proxy before replacing files", () => { expect(updateSource).toContain("function updateChildStdio()"); expect(updateSource).toContain('process.env.OCX_SERVICE === "1"'); expect(updateSource).toContain('return "pipe"'); - // All three update children (stop, installer, service reinstall) go through it. + // All three update children (stop, installer, service repair) go through it. expect(updateSource).toContain("stdio: stopStdio"); expect(updateSource).toContain("stdio: installStdio"); expect(updateSource).toContain("stdio: svcStdio"); diff --git a/tests/winsw.test.ts b/tests/winsw.test.ts index 972460a6e..4d663419b 100644 --- a/tests/winsw.test.ts +++ b/tests/winsw.test.ts @@ -249,10 +249,10 @@ describe("service backend CLI parsing", () => { }); }); -describe("service reinstall args", () => { - test("defaults to the scheduler backend on this machine (no native state)", () => { - // On a dev machine without a native install-state the accessor maps to scheduler. - expect(serviceReinstallArgs()).toEqual(["service", "install"]); +describe("service update refresh args", () => { + test("repairs the already-installed backend without re-registration", () => { + // `service repair` diagnoses scheduler/native/launchd/systemd itself. + expect(serviceReinstallArgs()).toEqual(["service", "repair"]); }); }); From b15df8ffaf335454f93f15e639f9ec16c1082a0e Mon Sep 17 00:00:00 2001 From: Stephen Drew <98895569+architecturesocial@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:59:19 +0100 Subject: [PATCH 2/4] fix(startup): avoid permission-sensitive service reinstall --- gui/src/pages/startup-sections.tsx | 14 +++-- gui/src/pages/startup-shared.ts | 18 ++++++ gui/tests/startup-revisit-cache.test.tsx | 8 ++- gui/tests/startup-service-recovery.test.ts | 60 +++++++++++++++++++ gui/tests/startup-usage-loading-race.test.tsx | 8 ++- src/cli/doctor.ts | 20 ++++++- src/cli/help.ts | 9 ++- src/cli/index.ts | 3 + src/cli/status.ts | 21 ++++++- src/codex/autostart-health.ts | 14 ++++- src/server/startup-health-cache.ts | 22 ++++++- src/service.ts | 17 ++---- tests/autostart-health.test.ts | 40 +++++++++++++ tests/cli-help.test.ts | 21 +++++++ tests/cli-status-json.test.ts | 12 +++- tests/doctor.test.ts | 59 ++++++++++++++++-- tests/service.test.ts | 7 ++- 17 files changed, 318 insertions(+), 35 deletions(-) create mode 100644 gui/tests/startup-service-recovery.test.ts diff --git a/gui/src/pages/startup-sections.tsx b/gui/src/pages/startup-sections.tsx index 2a1b80ea3..ba8d050e8 100644 --- a/gui/src/pages/startup-sections.tsx +++ b/gui/src/pages/startup-sections.tsx @@ -10,6 +10,8 @@ import { PROTECTION_KEYS, STATUS_KEYS, SUMMARY_KEYS, + startupServiceNeedsRepair, + startupServiceRecoveryCommand, } from "./startup-shared"; function StartupStateBadge({ ok, yes, no }: { ok: boolean; yes: string; no: string }) { @@ -90,8 +92,9 @@ export function StartupDetailsSection({ onInstall: (action: StartupInstallAction, opts?: { repair?: boolean }) => void; }) { const { t } = useI18n(); - // Repair only rewrites stale assets — conflict/disabled need uninstall/reinstall, not repair. - const serviceNeedsRepair = data.serviceSupported && data.serviceInstalled && data.serviceStale && !data.serviceConflict; + // Repair refreshes assets and restarts an existing enabled manager without + // permission-sensitive re-registration. Conflict/disabled states still need reinstall. + const serviceNeedsRepair = startupServiceNeedsRepair(data); const shimNeedsRepair = data.shimInstalled && !data.shimHealthy; const actionsDisabled = installBusy !== null || failed || loading; @@ -224,6 +227,7 @@ export function StartupRecoverySection({ onCopy: (command: string) => void; }) { const { t } = useI18n(); + const serviceCommand = startupServiceRecoveryCommand(data); return (
@@ -237,10 +241,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 dd61a2469..24ab29d92 100644 --- a/gui/src/pages/startup-shared.ts +++ b/gui/src/pages/startup-shared.ts @@ -27,11 +27,29 @@ export interface StartupHealthData { diagnosticStale: boolean; commands: { installService: string; + startService: string; + repairService: string; installShim: string; restoreNative: string; }; } +export function startupServiceNeedsRepair(data: StartupHealthData): boolean { + return data.serviceSupported + && data.serviceInstalled + && data.serviceEnabled + && !data.serviceViable + && !data.serviceConflict; +} + +export function startupServiceRecoveryCommand(data: StartupHealthData): string { + if (!data.serviceInstalled) return data.commands.installService; + if (!data.serviceConflict && data.serviceStale) return data.commands.repairService; + if (!data.serviceConflict && data.serviceEnabled && !data.serviceRunning) return data.commands.startService; + if (!data.serviceConflict && data.serviceEnabled && !data.serviceViable) return data.commands.repairService; + return data.commands.installService; +} + export interface TrayStatusData { supported: boolean; installed: boolean; diff --git a/gui/tests/startup-revisit-cache.test.tsx b/gui/tests/startup-revisit-cache.test.tsx index 82b2c8b7a..a0f398d88 100644 --- a/gui/tests/startup-revisit-cache.test.tsx +++ b/gui/tests/startup-revisit-cache.test.tsx @@ -37,7 +37,13 @@ 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", + startService: "ocx service start", + repairService: "ocx service repair", + installShim: "ocx shim install", + restoreNative: "ocx restore", + }, }; } diff --git a/gui/tests/startup-service-recovery.test.ts b/gui/tests/startup-service-recovery.test.ts new file mode 100644 index 000000000..b431a8427 --- /dev/null +++ b/gui/tests/startup-service-recovery.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { + startupServiceNeedsRepair, + startupServiceRecoveryCommand, + type StartupHealthData, +} from "../src/pages/startup-shared"; + +const base: StartupHealthData = { + status: "at-risk", + routingKind: "opencodex-local", + routingInjected: true, + localRoutingDependency: true, + autostartEnabled: true, + rebootSafe: false, + protection: "none", + serviceInstalled: true, + serviceViable: false, + serviceEnabled: true, + serviceRunning: false, + serviceStale: false, + serviceConflict: false, + serviceSupported: true, + shimInstalled: false, + shimHealthy: false, + shimCoverage: "none", + platform: "win32", + recommendedCommand: "ocx service start", + diagnosticStale: false, + commands: { + installService: "ocx service install", + startService: "ocx service start", + repairService: "ocx service repair", + installShim: "ocx codex-shim install", + restoreNative: "ocx restore", + }, +}; + +describe("startup service recovery controls", () => { + test("offers one-click repair while recommending start for an enabled stopped service", () => { + expect(startupServiceNeedsRepair(base)).toBe(true); + expect(startupServiceRecoveryCommand(base)).toBe("ocx service start"); + }); + + test("uses repair for a stale installed service", () => { + const stale = { ...base, serviceRunning: true, serviceStale: true }; + expect(startupServiceNeedsRepair(stale)).toBe(true); + expect(startupServiceRecoveryCommand(stale)).toBe("ocx service repair"); + }); + + test("keeps install for a genuinely missing service", () => { + const missing = { ...base, serviceInstalled: false, serviceEnabled: false }; + expect(startupServiceNeedsRepair(missing)).toBe(false); + expect(startupServiceRecoveryCommand(missing)).toBe("ocx service install"); + }); + + test("does not offer repair for disabled or conflicting registrations", () => { + expect(startupServiceNeedsRepair({ ...base, serviceEnabled: false })).toBe(false); + expect(startupServiceNeedsRepair({ ...base, serviceConflict: true })).toBe(false); + }); +}); diff --git a/gui/tests/startup-usage-loading-race.test.tsx b/gui/tests/startup-usage-loading-race.test.tsx index 9c8d1564c..a5f180843 100644 --- a/gui/tests/startup-usage-loading-race.test.tsx +++ b/gui/tests/startup-usage-loading-race.test.tsx @@ -96,7 +96,13 @@ 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", + startService: "ocx service start", + 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 1be5c5dc8..7e0e4fd60 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -729,12 +729,21 @@ export function formatServiceMemoryLines(report: ServiceMemoryReport): string[] export function proxyDownRestartHint(input: { proxyRunning: boolean; port: number; + serviceInstalled: boolean; + serviceEnabled: boolean; + serviceRunning: boolean; + serviceStale: boolean; + serviceConflict: boolean; serviceViable: boolean; }): string | null { if (input.proxyRunning) return null; - 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'."; + const restart = !input.serviceInstalled + ? "Restart it with 'ocx start', or install the persistent service: 'ocx service install'." + : !input.serviceEnabled || input.serviceConflict + ? "The service needs re-registration. Use Install on the Startup page (Windows administrator approval is required), or run 'ocx service install' from an Administrator PowerShell. Meanwhile, use 'ocx start'." + : input.serviceStale || (input.serviceRunning && !input.serviceViable) + ? "Repair and restart the installed service with 'ocx service repair', or use 'ocx start' in the foreground." + : "Restart it with 'ocx service start' (service installed) or 'ocx start'."; 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}`; } @@ -949,6 +958,11 @@ export async function runDoctor(args: string[] = []): Promise { const proxyDown = proxyDownRestartHint({ proxyRunning: Boolean(live), port: live?.port ?? doctorConfig.port ?? 10100, + serviceInstalled: startup.serviceInstalled, + serviceEnabled: startup.serviceEnabled, + serviceRunning: startup.serviceRunning, + serviceStale: startup.serviceStale, + serviceConflict: startup.serviceConflict, serviceViable: startup.serviceViable, }); if (proxyDown) hints.push(proxyDown); diff --git a/src/cli/help.ts b/src/cli/help.ts index c74ba6475..0fe9bd52e 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -14,6 +14,11 @@ const helpEntries: Record = { init: { usage: "ocx init", summary: "Interactive setup for providers and Codex config injection." }, setup: { usage: "ocx setup", summary: "Interactive setup for providers and Codex config injection (alias of init)." }, start: { usage: "ocx start [--port ]", summary: "Start the proxy server and sync models to Codex." }, + repair: { + usage: "ocx repair", + summary: "Repair and restart the installed background service without re-registering it.", + details: ["Alias of: ocx service repair"], + }, stop: { usage: "ocx stop", summary: "Stop the proxy and restore native Codex config." }, restore: { usage: "ocx restore [back]", @@ -44,10 +49,11 @@ const helpEntries: Record = { ], }, service: { - usage: "ocx service [install|start|stop|status|uninstall|remove]", + usage: "ocx service [install|repair|start|stop|status|uninstall|remove]", summary: "Run as a background service.", details: [ "With no subcommand, installs/updates and starts the background service.", + "Use `ocx service repair` to refresh and restart an installed service without re-registering it.", "Use `ocx service status` to see diagnostics and log paths.", ], }, @@ -267,6 +273,7 @@ export function printUsage(): void { Usage: ocx setup Interactive setup (alias: init) ocx start [--port ] Start the proxy server (auto-syncs models to Codex) + ocx repair Repair/restart the installed service without re-registering it ocx stop Stop the proxy AND restore native Codex (plain codex works again) ocx restore Restore native Codex without stopping (alias: eject) ocx restore back Re-point codex at the running proxy (undo restore) diff --git a/src/cli/index.ts b/src/cli/index.ts index 54e3ff822..0599f8f40 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -734,6 +734,9 @@ switch (command) { case "start": await handleStart(); break; + case "repair": + await serviceCommand("repair"); + break; case "stop": { // Downtime warning lives HERE, not in handleStop: `restart`/tray-restart callers // re-start the proxy immediately, so warning there would contradict the next line. diff --git a/src/cli/status.ts b/src/cli/status.ts index 7e0ba0f8e..7fc7af9f0 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -3,7 +3,7 @@ import { codexAutoStartEnabled, getConfigPath, getPidPath, readConfigDiagnostics import { diagnoseCodexBundledPlugins, type CodexPluginsDiagnostic } from "../codex/plugins-doctor"; import { findLiveProxy, isOpencodexHealthz, probeHostname } from "../server/proxy-liveness"; import type { OcxConfig } from "../types"; -import { diagnoseService, serviceLogPath } from "../service"; +import { diagnoseService, serviceLogPath, type ServiceDiagnostic } from "../service"; import { collectStartupHealth, type StartupHealth } from "../codex/autostart-health"; import { getCodexRoutingKind } from "../codex/inject"; import { diagnoseCodexShim } from "../codex/shim"; @@ -85,6 +85,23 @@ export type ListenTarget = { dashboardUrl: string; }; +export function registeredServiceRecoveryCommand( + service: Pick, +): "ocx service install" | "ocx service start" | "ocx service repair" { + if (!service.enabled || service.conflict) return "ocx service install"; + if (service.stale || (service.running && !service.viable)) return "ocx service repair"; + return "ocx service start"; +} + +export function registeredServiceRecoveryInstruction( + service: Pick, +): string { + const command = registeredServiceRecoveryCommand(service); + return command === "ocx service install" + ? "use Install on the Startup page (administrator approval required), or run 'ocx service install' from an Administrator PowerShell" + : `run '${command}'`; +} + export function selectListenTarget( config: Pick, pid: number | null, @@ -173,7 +190,7 @@ export async function collectStatus(): Promise { // either way. `live` was already identity-probed a few lines above, so cross-check // rather than print registration as if it were service. const serviceSummary = service.installed && !live - ? `${service.summary} — registered but NOT serving; see ${serviceLogPath()} and re-run 'ocx service install'` + ? `${service.summary} — registered but NOT serving; see ${serviceLogPath()} and ${registeredServiceRecoveryInstruction(service)}` : service.summary; const codexShim = diagnoseCodexShim(); const codexShimSummary = codexShim.summary; diff --git a/src/codex/autostart-health.ts b/src/codex/autostart-health.ts index 5f25447aa..cede20033 100644 --- a/src/codex/autostart-health.ts +++ b/src/codex/autostart-health.ts @@ -47,6 +47,8 @@ export interface StartupHealth { recommendedCommand: string | null; commands: { installService: string; + startService: string; + repairService: string; installShim: string; restoreNative: string; }; @@ -54,6 +56,8 @@ export interface StartupHealth { const COMMANDS = { installService: "ocx service install", + startService: "ocx service start", + repairService: "ocx service repair", installShim: "ocx codex-shim install", restoreNative: "ocx restore", } as const; @@ -88,7 +92,15 @@ export function deriveStartupHealth(inputs: StartupHealthInputs): StartupHealth : inputs.routingKind === "custom-local" || inputs.routingKind === "unknown" ? COMMANDS.restoreNative : inputs.serviceSupported - ? COMMANDS.installService + ? inputs.serviceInstalled && !inputs.serviceConflict + ? !inputs.serviceEnabled + ? COMMANDS.installService + : inputs.serviceStale || (inputs.serviceRunning && !inputs.serviceViable) + ? COMMANDS.repairService + : !inputs.serviceRunning + ? COMMANDS.startService + : COMMANDS.installService + : COMMANDS.installService : COMMANDS.restoreNative; return { ...inputs, diff --git a/src/server/startup-health-cache.ts b/src/server/startup-health-cache.ts index fc83023f7..f4aad5c69 100644 --- a/src/server/startup-health-cache.ts +++ b/src/server/startup-health-cache.ts @@ -14,6 +14,10 @@ const MAX_DIAGNOSTIC_VALUE_BYTES = 8 * 1024; let cached: { timestamp: number; value: StartupHealth } | null = null; let inflight: Promise | null = null; let generation = 0; +// A single probe blip (spawn hiccup, busy machine, Defender scan) must not flip +// the GUI to its error state for a whole cache window. Only consecutive +// failures downgrade the last known-good result. +let consecutiveProbeFailures = 0; export function markStartupHealthDiagnosticStale(value: StartupHealth): StartupHealth { if (!value.localRoutingDependency) return { ...value, diagnosticStale: true }; @@ -25,7 +29,10 @@ export function markStartupHealthDiagnosticStale(value: StartupHealth): StartupH diagnosticStale: true, recommendedCommand: value.routingKind === "custom-local" || value.routingKind === "unknown" ? value.commands.restoreNative - : value.commands.installService, + : value.recommendedCommand + ?? (value.serviceInstalled && !value.serviceConflict + ? value.commands.repairService + : value.commands.installService), }; } @@ -65,6 +72,7 @@ function runProbe(config: Pick): Promise): Promise): Promise): void { export async function getCachedStartupHealth(config: Pick): Promise { if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) return cached.value; refreshInBackground(config); - return cached ? markStartupHealthDiagnosticStale(cached.value) : conservativeFallback(config); + // A routine revalidation is in flight, so the last result remains the best + // available answer. runProbe marks a real repeated failure as stale. + return cached ? cached.value : conservativeFallback(config); } export function invalidateStartupHealthCache(): void { generation += 1; cached = null; inflight = null; + consecutiveProbeFailures = 0; } diff --git a/src/service.ts b/src/service.ts index 346b1b8d4..705a10b5b 100644 --- a/src/service.ts +++ b/src/service.ts @@ -506,18 +506,9 @@ async function reportServiceServing( process.exitCode = 1; } -/** - * The reinstall command for the CURRENTLY INSTALLED backend. - * - * Plain `ocx service install` on a native/WinSW install runs installWindows's - * transactional backend switch, which tears down WinSW and replaces it with the Task - * Scheduler backend. Advising it in a repair hint would silently change the user's - * backend, so the hint has to carry `--native` when that is what is installed. - */ +/** Repair the currently installed backend without re-registering or switching it. */ function serviceRepairCommand(): string { - return process.platform === "win32" && readServiceBackend() === "native" - ? "ocx service install --native" - : "ocx service install"; + return "ocx service repair"; } function systemdQuote(value: string): string { @@ -1933,7 +1924,7 @@ export function bakedServicePathsDiagnostic(): string | null { if (!state?.bunPath || !state?.cliPath) return null; const missing = [state.bunPath, state.cliPath].filter(path => !existsSync(path)); if (missing.length === 0) return null; - return `STALE baked paths (missing: ${missing.join(", ")}) — run 'ocx service install' to re-bake`; + return `STALE baked paths (missing: ${missing.join(", ")}) — run 'ocx service repair' to re-bake`; } function serviceDiagnosticsSummary(): string { @@ -2350,7 +2341,7 @@ export function deriveWindowsServiceDiagnostic(inputs: WindowsServiceDiagnosticI const detail = conflict ? "CONFLICT: Task Scheduler and native WinSW are both present — run 'ocx service uninstall' then reinstall one" : stale - ? "stale or missing service assets — run 'ocx service install' to repair" + ? "stale or missing service assets — run 'ocx service repair'" : schedulerInstalled ? schedulerEnabled ? "Task Scheduler enabled" : "Task Scheduler disabled" : nativeInstalled diff --git a/tests/autostart-health.test.ts b/tests/autostart-health.test.ts index 9d476a0c3..b6d8424aa 100644 --- a/tests/autostart-health.test.ts +++ b/tests/autostart-health.test.ts @@ -43,6 +43,43 @@ describe("Codex startup health", () => { }); }); + test("starts an existing stopped service without re-registering it", () => { + const health = deriveStartupHealth({ + ...base, + serviceInstalled: true, + serviceEnabled: true, + serviceRunning: false, + }); + expect(health).toMatchObject({ + status: "at-risk", + recommendedCommand: "ocx service start", + }); + expect(startupHealthSummary(health)).toContain("ocx service start"); + }); + + test("repairs stale or running-unhealthy services but installs a missing service", () => { + expect(deriveStartupHealth({ + ...base, + serviceInstalled: true, + serviceEnabled: true, + serviceRunning: true, + serviceStale: true, + }).recommendedCommand).toBe("ocx service repair"); + expect(deriveStartupHealth({ + ...base, + serviceInstalled: true, + serviceEnabled: true, + serviceRunning: true, + }).recommendedCommand).toBe("ocx service repair"); + expect(deriveStartupHealth({ + ...base, + serviceInstalled: true, + serviceEnabled: false, + serviceStale: true, + }).recommendedCommand).toBe("ocx service install"); + expect(deriveStartupHealth(base).recommendedCommand).toBe("ocx service install"); + }); + test("never preserves a green local-routing claim when diagnostics are stale", () => { const protectedHealth = deriveStartupHealth({ ...base, serviceInstalled: true, serviceViable: true, serviceEnabled: true, serviceRunning: true }); expect(markStartupHealthDiagnosticStale(protectedHealth)).toMatchObject({ @@ -50,6 +87,7 @@ describe("Codex startup health", () => { rebootSafe: false, protection: "none", diagnosticStale: true, + recommendedCommand: "ocx service repair", }); }); @@ -171,6 +209,8 @@ describe("Codex startup health", () => { expect(typeof body.routingInjected).toBe("boolean"); expect(body.commands).toEqual({ installService: "ocx service install", + startService: "ocx service start", + repairService: "ocx service repair", installShim: "ocx codex-shim install", restoreNative: "ocx restore", }); diff --git a/tests/cli-help.test.ts b/tests/cli-help.test.ts index c64d2b6f6..36d6b1c2b 100644 --- a/tests/cli-help.test.ts +++ b/tests/cli-help.test.ts @@ -87,6 +87,27 @@ describe("CLI subcommand help", () => { expect(result.stdout).toContain("--no-start"); }); + test("service help includes the non-registering repair route", () => { + const result = runCli(["help", "service"]); + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("Usage: ocx service [install|repair|start|stop|status|uninstall|remove]"); + expect(result.stdout).toContain("ocx service repair"); + expect(result.stdout).toContain("without re-registering"); + }); + + test("top-level repair is a documented alias of service repair", () => { + const result = runCli(["help", "repair"]); + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("Usage: ocx repair"); + expect(result.stdout).toContain("Alias of: ocx service repair"); + + const source = readFileSync(cliPath, "utf8"); + expect(source).toContain('case "repair":'); + expect(source).toContain('await serviceCommand("repair");'); + }); + test("unknown command with help flag remains an error", () => { const result = runCli(["foobar", "--help"]); expect(result.status).toBe(1); diff --git a/tests/cli-status-json.test.ts b/tests/cli-status-json.test.ts index e8a8848d0..2e5154fba 100644 --- a/tests/cli-status-json.test.ts +++ b/tests/cli-status-json.test.ts @@ -4,7 +4,7 @@ import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync, mkdirSync import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { resolveStatusPid, selectListenTarget } from "../src/cli/status"; +import { registeredServiceRecoveryCommand, registeredServiceRecoveryInstruction, resolveStatusPid, selectListenTarget } from "../src/cli/status"; const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); const cliPath = join(repoRoot, "src", "cli", "index.ts"); @@ -18,6 +18,16 @@ function runStatusJson(opencodexHome: string) { } describe("CLI status JSON", () => { + test("an existing non-serving service uses start, repair, or re-registration according to its state", () => { + const healthyStopped = { enabled: true, running: false, viable: false, stale: false, conflict: false }; + expect(registeredServiceRecoveryCommand(healthyStopped)).toBe("ocx service start"); + expect(registeredServiceRecoveryCommand({ ...healthyStopped, running: true })).toBe("ocx service repair"); + expect(registeredServiceRecoveryCommand({ ...healthyStopped, stale: true })).toBe("ocx service repair"); + expect(registeredServiceRecoveryCommand({ ...healthyStopped, enabled: false })).toBe("ocx service install"); + expect(registeredServiceRecoveryCommand({ ...healthyStopped, conflict: true })).toBe("ocx service install"); + expect(registeredServiceRecoveryInstruction({ ...healthyStopped, enabled: false })).toContain("Administrator PowerShell"); + }); + test("status --json prints valid read-only diagnostics without secrets", () => { const opencodexHome = mkdtempSync(join(tmpdir(), "ocx-status-json-")); try { diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index e8af480df..f53416811 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -511,12 +511,22 @@ describe("service memory section (#314 WP4)", () => { }); test("proxyDownRestartHint is null while a live proxy exists", () => { - expect(proxyDownRestartHint({ proxyRunning: true, port: 10100, serviceViable: false })).toBeNull(); - expect(proxyDownRestartHint({ proxyRunning: true, port: 10100, serviceViable: true })).toBeNull(); + const stopped = { serviceEnabled: true, serviceRunning: false, serviceStale: false, serviceConflict: false }; + expect(proxyDownRestartHint({ proxyRunning: true, port: 10100, serviceInstalled: false, serviceViable: false, ...stopped })).toBeNull(); + expect(proxyDownRestartHint({ proxyRunning: true, port: 10100, serviceInstalled: true, serviceViable: true, ...stopped })).toBeNull(); }); test("proxyDownRestartHint names the symptom and both restart paths", () => { - const hint = proxyDownRestartHint({ proxyRunning: false, port: 10100, serviceViable: false }); + const hint = proxyDownRestartHint({ + proxyRunning: false, + port: 10100, + serviceInstalled: false, + serviceEnabled: false, + serviceRunning: false, + serviceStale: false, + serviceConflict: false, + serviceViable: false, + }); expect(hint).toContain("error sending request for url"); expect(hint).toContain("127.0.0.1:10100"); expect(hint).toContain("ocx start"); @@ -524,9 +534,50 @@ describe("service memory section (#314 WP4)", () => { }); test("proxyDownRestartHint prefers 'ocx service start' when a service is installed", () => { - const hint = proxyDownRestartHint({ proxyRunning: false, port: 12000, serviceViable: true }); + const hint = proxyDownRestartHint({ + proxyRunning: false, + port: 12000, + serviceInstalled: true, + serviceEnabled: true, + serviceRunning: false, + serviceStale: false, + serviceConflict: false, + serviceViable: false, + }); expect(hint).toContain("ocx service start"); expect(hint).toContain("127.0.0.1:12000"); expect(hint).not.toContain("ocx service install"); }); + + test("proxyDownRestartHint repairs an installed unhealthy service without re-registering", () => { + const hint = proxyDownRestartHint({ + proxyRunning: false, + port: 10100, + serviceInstalled: true, + serviceEnabled: true, + serviceRunning: true, + serviceStale: false, + serviceConflict: false, + serviceViable: false, + }); + expect(hint).toContain("ocx service repair"); + expect(hint).toContain("ocx start"); + expect(hint).not.toContain("ocx service install"); + }); + + test("proxyDownRestartHint explains administrator approval when re-registration is genuinely required", () => { + const hint = proxyDownRestartHint({ + proxyRunning: false, + port: 10100, + serviceInstalled: true, + serviceEnabled: false, + serviceRunning: false, + serviceStale: false, + serviceConflict: false, + serviceViable: false, + }); + expect(hint).toContain("administrator approval is required"); + expect(hint).toContain("Administrator PowerShell"); + expect(hint).toContain("ocx service install"); + }); }); diff --git a/tests/service.test.ts b/tests/service.test.ts index f0498441e..f8199b69f 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -795,6 +795,8 @@ describe("service diagnostics", () => { }); expect(missingAssets).toMatchObject({ installed: true, viable: false, stale: true, startable: false }); expect(missingAssets.summary).toContain("stale or missing service assets"); + expect(missingAssets.summary).toContain("ocx service repair"); + expect(missingAssets.summary).not.toContain("ocx service install"); }); test("a stopped healthy WinSW service remains startable from the tray", () => { @@ -852,6 +854,8 @@ describe("service diagnostics", () => { const diagnostic = bakedServicePathsDiagnostic(); expect(diagnostic).toContain("STALE baked paths"); expect(diagnostic).toContain(missing); + expect(diagnostic).toContain("ocx service repair"); + expect(diagnostic).not.toContain("ocx service install"); writeFileSync(statePath, JSON.stringify({ version: 1, @@ -1273,7 +1277,8 @@ describe("service serving confirmation", () => { matchesPlist: () => ({ loaded: true, matchesPlist: true }), }); expect(out).toContain("no proxy is answering on port 10100"); - expect(out).toContain("ocx service install"); + expect(out).toContain("ocx service repair"); + expect(out).not.toContain("ocx service install"); expect(out).toContain("ocx start"); }); From 3730bad7f3040e7deaa441d1eb31184f20a6700e Mon Sep 17 00:00:00 2001 From: Stephen Drew <98895569+architecturesocial@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:56:42 +0100 Subject: [PATCH 3/4] docs: explain permission-safe service recovery --- .../src/content/docs/guides/web-dashboard.md | 2 +- .../docs/ja/reference/cli/lifecycle.md | 8 +++++-- .../docs/ko/reference/cli/lifecycle.md | 11 +++++++--- .../content/docs/reference/cli/lifecycle.md | 22 +++++++++++++++---- .../docs/ru/reference/cli/lifecycle.md | 11 +++++++--- .../docs/zh-cn/reference/cli/lifecycle.md | 8 +++++-- 6 files changed, 47 insertions(+), 15 deletions(-) diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 660fbcfda..7754bb1ea 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -42,7 +42,7 @@ the browser or password manager's decision. | **Sub-agent delegation** | Choose a native or routed model and optional reasoning effort shared by OpenCodex delegation guidance and the separate native-default opt-in. This is not a proxy-side per-spawn router; see below. | | **Sidecars** | Choose the web-search model and effort plus the vision-description model. Changes apply on the next request. | | **Maintenance** | Resync the Codex model catalog, inspect project-local config bypass warnings, check the latest or preview release, and run an update with optional proxy restart. | -| **Startup safety** | Show whether injected Codex routing survives a restart, with separate service and launcher-shim health plus exact repair commands. | +| **Startup safety** | Show whether injected Codex routing survives a restart, with separate service and launcher-shim health plus state-aware commands: Start for a clean stopped service, Repair for an installed unhealthy service, and Install only when registration is missing or must be replaced. | | **Windows tray** | Install a per-user login tray for one-click proxy start, stop, restart, dashboard access, and status. The tray is a controller, not a proxy restart service. | | **Codex autostart** | Allow an already-installed Codex launcher shim to run `ocx ensure`. This toggle does not install a shim or background service. | | **Providers** | Add, edit, set the default (enabled providers only), enable/disable, and remove providers; manage OAuth account pools and API-key pools where supported. Removing the current default switches to the first remaining enabled provider when one exists; otherwise deletion is refused and the current default is kept. Provider Settings can disable live model discovery for endpoints with missing, slow, or oversized `/models` catalogs. For Claude (Anthropic) OAuth pools, each logged-in account shows its own 5-hour and weekly rate-limit bars (usage is per credential); a failed probe keeps the last-known bars and marks them unavailable until the next successful refresh. | 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 054b165fc..d1347ab5b 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,10 +153,13 @@ opencodex を、ログイン時に自動起動し、クラッシュ時に自動 ```bash ocx service ocx service install +ocx service repair ocx service status ocx service uninstall ``` +`ocx repair` は `ocx service repair` の別名です。既存の有効なサービスには、正常だが停止している場合は `start`、生成されたアセットが古い、または異常な場合は `repair` を使用します。`install` は新規登録または明示的な再登録にのみ使用し、Windows では管理者の承認が必要になる場合があります。 + Windows では、`ocx service status` は、ID 検証済みの OpenCodex プロキシの到達可能性とは別に、タスク スケジューラの登録を報告します。ローカライズされた `schtasks` テーブルは出力されないため、概要は Windows コード ページ間で読み取れるままです。 Windows では、タスク スケジューラ エントリを作成するには昇格が必要です。認識されたローカライズされたアクセス拒否テキストは、既存のガイダンス パスを維持します。そのテキストが判読できない場合、フォールバックには、所有されているコマンド形状 `/create /tn opencodex-proxy /xml /f`、ステータス 1、および確認済みの非昇格トークンが必要です。ダッシュボードのスタートアップ セーフティ アクションは、UAC を自動的に要求できるようになります。そのフォールバックがトークンの状態を判断できない場合、元のスケジューラ エラーが保持されます。外部タスクおよび操作は、自動昇格マーカーを発行することはできません。ダッシュボードの UAC プロンプトを承認するか、管理者特権の PowerShell ウィンドウで `ocx service install` を再実行します。 @@ -197,7 +201,7 @@ Windows ステータス トレイ アイコンをインストールして制御 ### `ocx update [--tag latest|preview]` -npm から opencodex を自己更新します。安定したインストールでは `@latest` を使用します。 `--tag latest|preview` を渡さない限り、プレビュー インストールは `@preview` に残ります。ソース チェックアウトを検出し、代わりに `git pull && bun install` を使用するように指示しますが、そのタグの最新バージョンをすでに使用している場合は何もしません。実行中のプロキシは、ファイルが置き換えられる前に停止されます。インストールされたサービスは再構築されて自動的に開始されますが、フォアグラウンド インストールでは次のステップとして `ocx start` が出力されます。 +npm から opencodex を自己更新します。安定したインストールでは `@latest` を使用します。 `--tag latest|preview` を渡さない限り、プレビュー インストールは `@preview` に残ります。ソース チェックアウトを検出し、代わりに `git pull && bun install` を使用するように指示しますが、そのタグの最新バージョンをすでに使用している場合は何もしません。実行中のプロキシは、ファイルが置き換えられる前に停止されます。既存のサービスは再登録しない repair パスで更新され、自動的に開始されます。サービスが実際に存在しない場合のみ通常の install パスを使用し、フォアグラウンド インストールでは次のステップとして `ocx start` が出力されます。 ```bash ocx update 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 e3b7fda42..11961516e 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,10 +196,13 @@ Codex의 로컬 모델 선택기 캐시를 무효화하여, 활성 opencodex 카 ```bash ocx service ocx service install +ocx service repair ocx service status ocx service uninstall ``` +`ocx repair`는 `ocx service repair`의 별칭입니다. 기존의 활성 서비스가 정상적으로 중지된 상태라면 `start`를, 생성된 자산이 오래되었거나 서비스가 비정상이라면 `repair`를 사용하세요. `install`은 새 등록이나 명시적인 재등록에만 사용하며, Windows에서는 관리자 승인이 필요할 수 있습니다. + Windows에서는 `ocx service status`가 Task Scheduler 등록 상태를 ID가 검증된 OpenCodex 프록시 도달 가능성과 별도로 보고합니다. 로컬라이즈된 `schtasks` 표는 출력하지 않으므로, 요약은 Windows 코드 페이지에서도 읽기 쉽습니다. @@ -260,8 +264,9 @@ Windows 상태 트레이 아이콘을 설치하고 제어합니다. Windows 로 npm에서 opencodex를 자체 업데이트합니다. 안정판 설치는 `@latest`를 사용하고, 미리보기 설치는 `--tag latest|preview`를 주지 않으면 `@preview`를 유지합니다. 소스 체크아웃을 감지하면 대신 `git pull && bun install`을 실행하라고 안내하고, 해당 태그에서 이미 최신 버전이면 아무 동작도 하지 -않습니다. 실행 중인 프록시가 있으면 파일을 교체하기 전에 중지합니다. 설치된 서비스는 자동으로 다시 -빌드해 시작하며, 포그라운드 설치에서는 다음 단계로 `ocx start`를 출력합니다. +않습니다. 실행 중인 프록시가 있으면 파일을 교체하기 전에 중지합니다. 기존 서비스는 다시 등록하지 +않는 repair 경로로 새로 고친 뒤 자동으로 시작합니다. 서비스가 실제로 없을 때만 일반 install 경로를 +사용하며, 포그라운드 설치에서는 다음 단계로 `ocx start`를 출력합니다. ```bash ocx update diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 02698c109..7387386d0 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -39,6 +39,12 @@ The same action is available from the web dashboard's **Stop** button (`POST /ap Run `stop` followed by `ensure`: stop the proxy/service, restore native Codex, start the proxy in the background, and sync the live port back into Codex. +### `ocx repair` + +Alias of `ocx service repair`. Refresh and restart an installed background service without +re-registering it with launchd, systemd, or Windows Task Scheduler. Use this when the service +already exists but its generated launcher assets are stale or it is registered but not serving. + ### `ocx ensure` Idempotently ensure a background proxy is running, then sync its live model catalog. If @@ -175,7 +181,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 @@ -185,6 +191,7 @@ Windows **Task Scheduler**) that auto-starts on login and auto-restarts on crash | --- | --- | | none | Create/update and start the service. | | `install` | Create and start the service. | +| `repair` | Refresh and restart an installed service 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,10 +201,16 @@ 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 ``` +Use `install` only to create or deliberately re-register a service. On Windows that registration +may require administrator approval. For an existing enabled service, use `start` when it is clean +but stopped, or `repair` when its generated assets are stale or it is registered but unhealthy. +The shorter `ocx repair` command is an alias of `ocx service repair`. + `install`, `start`, and `repair` confirm that a proxy actually answers on the port baked into the installed service before reporting success — on all three platforms. They wait up to 20 seconds and then print the serving port: @@ -232,7 +245,7 @@ log named in the message, and use `ocx start` to serve in the foreground meanwhi launchd is running an OLDER plist than the one on disk. Fix: launchctl bootout gui/$(id -u)/com.opencodex.proxy && ocx service install Log: ~/.opencodex/service.log - Repair: ocx service install + Repair: ocx service repair Meanwhile: ocx start (serves in the foreground) ``` @@ -311,8 +324,9 @@ if it is not running. Self-update opencodex from npm. Stable installs use `@latest`; preview installs stay on `@preview` unless you pass `--tag latest|preview`. It detects a source checkout and tells you to `git pull && bun install` instead, and is a no-op if you are already on the newest version for that -tag. A running proxy is stopped before files are replaced; an installed service is rebuilt and -started automatically, while a foreground installation prints `ocx start` as the next step. +tag. A running proxy is stopped before files are replaced; an existing service is refreshed through +the non-registering repair path and started automatically, while a foreground installation prints +`ocx start` as the next step. A genuinely missing service still uses the normal install path. ```bash ocx update 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 e553f5d1b..0fff464cb 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,10 +212,13 @@ unit**, Windows **Task Scheduler**), которая автоматически ```bash ocx service ocx service install +ocx service repair ocx service status ocx service uninstall ``` +`ocx repair` — alias команды `ocx service repair`. Для существующей включённой службы используйте `start`, если она исправна, но остановлена, и `repair`, если сгенерированные файлы устарели или служба работает некорректно. `install` предназначен только для новой или намеренной повторной регистрации; в Windows для этого может потребоваться одобрение администратора. + На Windows `ocx service status` отдельно показывает регистрацию в Task Scheduler и identity-проверенную достижимость прокси OpenCodex. Он не печатает локализованную таблицу `schtasks`, чтобы сводка оставалась читаемой на любых code page Windows. @@ -281,8 +285,9 @@ one-click управление прокси. `start` и `stop` управляю остаются на `@preview`, если только вы не передадите `--tag latest|preview`. Команда распознаёт source checkout и предлагает вместо этого `git pull && bun install`, а если у вас уже новейшая версия для выбранного тега, становится no-op. Перед заменой файлов работающий прокси -останавливается; установленная служба автоматически пересобирается и запускается заново, а для -foreground-установки печатается подсказка `ocx start`. +останавливается; существующая служба обновляется через repair без повторной регистрации и +автоматически запускается. Обычный install используется только если служба действительно +отсутствует, а для foreground-установки печатается подсказка `ocx start`. ```bash ocx update 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 1755a740b..fa10ee5a2 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,10 +153,13 @@ ocx status --json ```bash ocx service ocx service install +ocx service repair ocx service status ocx service uninstall ``` +`ocx repair` 是 `ocx service repair` 的别名。对于现有且已启用的服务,若服务正常但已停止,请使用 `start`;若生成的资源已过期或服务状态异常,请使用 `repair`。`install` 仅用于新注册或明确需要重新注册的情况,在 Windows 上可能需要管理员批准。 + 在 Windows 上,`ocx service status` 会单独报告 Task Scheduler 注册状态和已身份验证的 OpenCodex 代理可达性。它不会打印本地化的 `schtasks` 表格,因此在不同 Windows 代码页下摘要仍然可读。 在 Windows 上,创建 Task Scheduler 条目需要提升权限。识别到本地化的访问被拒绝文本时,会沿用现有的指导路径。如果该文本不可读,则回退要求命令形态为 `/create /tn opencodex-proxy /xml /f`,状态为 1,并且令牌明确为非提升权限;这时仪表盘的 Startup Safety 操作可以自动请求 UAC。如果该回退无法判断令牌状态,它会保留原始调度器错误。外部任务和操作绝不会发出自动提升标记。请批准仪表盘的 UAC 提示,或在提升权限的 PowerShell 窗口中重新运行 `ocx service install`。 @@ -197,7 +201,7 @@ ocx codex-shim uninstall ### `ocx update [--tag latest|preview]` -从 npm 自更新 opencodex。稳定版安装使用 `@latest`;预览版安装保持在 `@preview`,除非你传入 `--tag latest|preview`。它会检测源码检出,并提示你改为运行 `git pull && bun install`;如果你已经是该标签的最新版本,则不会执行任何操作。在替换文件之前会先停止正在运行的代理;已安装的服务会自动重建并启动,而前台安装则会打印 `ocx start` 作为下一步。 +从 npm 自更新 opencodex。稳定版安装使用 `@latest`;预览版安装保持在 `@preview`,除非你传入 `--tag latest|preview`。它会检测源码检出,并提示你改为运行 `git pull && bun install`;如果你已经是该标签的最新版本,则不会执行任何操作。在替换文件之前会先停止正在运行的代理;现有服务会通过无需重新注册的 repair 路径刷新并自动启动。只有服务确实不存在时才使用常规 install 路径,而前台安装则会打印 `ocx start` 作为下一步。 ```bash ocx update From 6135abf048c98674e31d1ccbc40150e0ef908fdb Mon Sep 17 00:00:00 2001 From: Stephen Drew <98895569+architecturesocial@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:46:05 +0100 Subject: [PATCH 4/4] test(update): align deploy contract with service repair --- tests/windows-deploy-close-regressions.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/windows-deploy-close-regressions.test.ts b/tests/windows-deploy-close-regressions.test.ts index b914562ef..bc07f984b 100644 --- a/tests/windows-deploy-close-regressions.test.ts +++ b/tests/windows-deploy-close-regressions.test.ts @@ -17,7 +17,7 @@ describe("update-job restart avoids the shell-less .cmd EINVAL (Windows, bun/sou }); test("bun/source restart uses the runtime executable + launcher (a real .exe, no shell)", () => { // restartCommand's non-npm branch resolves to process.execPath + the package launcher. - // Proxy mode may pin --port via startArgs; service mode stays install-only. + // Proxy mode may pin --port via startArgs; service mode stays repair-only. // Service mode now uses svcArgs (which accepts a serviceArgs parameter to preserve the backend). expect(src).toMatch(/const bin = process\.execPath;\s*\n\s*const args = svcArgs;/); expect(src).toContain('? [launcher, "start", "--port", String(Math.trunc(port))]'); @@ -25,7 +25,7 @@ describe("update-job restart avoids the shell-less .cmd EINVAL (Windows, bun/sou }); test("service update restart bakes OCX_BAKE_PORT so wrappers hard-pin the captured port", () => { expect(src).toContain("OCX_BAKE_PORT"); - // Service reinstall still runs (with bake) even when reclaim warns; direct start refuses to hop. + // Service repair still runs (with bake) even when reclaim warns; direct start refuses to hop. expect(src).toContain("refusing to hop"); expect(src).toContain("runtimeTrusted"); expect(read("src/cli/index.ts")).toContain("allowEphemeralFallback: !hardPin"); @@ -42,7 +42,10 @@ describe("update-job restart avoids the shell-less .cmd EINVAL (Windows, bun/sou expect(src).toContain("spawnWorkerFn: spawnGuiUpdateWorker"); // Foreign listeners must stay fail-closed; npm rename is covered by ocx identity. expect(src).not.toContain("killAnyListenPidOnPort"); - expect(src).toContain('process.platform === "win32" && process.env.OCX_SERVICE === "1"'); + // Existing service registrations use the permission-safe repair route. Fresh installation + // remains a separate, explicitly elevated operation. + expect(src).toContain('serviceArgs ?? ["service", "repair"]'); + expect(src).not.toContain('serviceArgs ?? ["service", "install"]'); // Native WinSW installs must stop via stopWinswService, not Task Scheduler /end only. expect(src).toContain("readServiceBackend"); expect(src).toContain("stopWinswService");