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 83586d533..e17fe1cfd 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -36,8 +36,12 @@ ocx start --port 8080 ### `ocx restart` -`stop` 다음에 `ensure`를 실행합니다. 즉, 프록시/서비스를 중지하고 기본 Codex를 복원한 뒤, -프록시를 백그라운드에서 다시 시작하고 살아 있는 포트를 Codex에 다시 동기화합니다. +현재 감독 방식을 유지하면서 프록시를 중지하고 다시 시작합니다. 설치된 백그라운드 서비스가 시작 +가능하면 해당 서비스를 다시 시작하여 로그인 및 충돌 복구 보호를 유지하고, 살아 있는 프록시와 +Codex 모델 동기화가 완료될 때까지 기다린 뒤 반환합니다. 설치된 서비스가 없으면 `ensure`를 통해 +독립 실행형 백그라운드 프록시를 시작합니다. 오래되었거나 충돌하는 서비스는 시작하지 않습니다. +이미 실행 중인 독립 실행형 프록시가 있으면 이를 유지하고 다시 동기화하며, 대체 프록시가 없으면 +복구 안내와 함께 안전하게 실패합니다. ### `ocx ensure` @@ -213,6 +217,12 @@ ocx service status ocx service uninstall ``` +`install`은 새 서비스 자산을 쓰고 로드하기 전에 기록된 기존 백엔드를 먼저 중지하고, Windows에서 +요청했거나 충돌하는 다른 백엔드가 있으면 그다음 중지한 뒤, 추적 중인 독립 실행형 프록시를 마지막으로 +중지합니다. 관리자 상태 확인, 관리자 중지 또는 독립 실행형 프록시 정리에 실패하면 설치를 중단합니다. +따라서 이전 리스너가 서비스 포트를 계속 점유한 채 새 관리자를 재시작 루프에 빠뜨리거나, 이전 리스너가 +성공적인 서비스 설치로 잘못 인식되는 일을 방지합니다. + Windows에서는 `ocx service status`가 Task Scheduler 등록 상태를 ID가 검증된 OpenCodex 프록시 도달 가능성과 별도로 보고합니다. 로컬라이즈된 `schtasks` 표는 출력하지 않으므로, 요약은 Windows 코드 페이지에서도 읽기 쉽습니다. diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index ef1f0f47a..42adbde4d 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -36,8 +36,12 @@ The same action is available from the web dashboard's **Stop** button (`POST /ap ### `ocx restart` -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. +Stop and restart the proxy while preserving its supervision mode. If a background service was +installed and is startable, `restart` starts that service again so login and crash protection remain +active, then waits for the live proxy and Codex model sync before returning. Without an installed +service, it starts the standalone background proxy through `ensure`. A stale or conflicting service +is never started: an already-live standalone proxy is preserved and resynced, while no live fallback +fails closed with a repair instruction. ### `ocx ensure` @@ -233,6 +237,12 @@ A non-zero exit here means *registered but not serving* — not *not installed*. service manager accepted the job; the proxy behind it never bound the port. Read the log named in the message, and use `ocx start` to serve in the foreground meanwhile. +Before `install` writes and loads new service assets, it stops the recorded installed backend first, +then any different requested or conflicting Windows backend, and finally any tracked standalone +proxy. Manager status, manager stop, and standalone cleanup failures abort the install. This prevents +an old listener from retaining the service port, forcing the newly installed manager into a restart +loop, and making the old listener look like a successful service install. + `ocx service status` reports the same three states rather than raw manager output: ``` 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 124a36c05..715cba7b8 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 @@ -28,7 +28,7 @@ ocx start --port 8080 ### `ocx restart` -执行 `stop` 然后执行 `ensure`:停止代理/服务,恢复原生 Codex,在后台启动代理,并将当前端口重新同步回 Codex。 +在保留当前监管方式的前提下停止并重新启动代理。如果已安装的后台服务可以安全启动,`restart` 会重新启动该服务以保留登录启动和崩溃恢复保护,并在返回前等待代理可用及 Codex 模型同步完成。如果未安装服务,则通过 `ensure` 启动独立后台代理。过期或冲突的服务不会被启动:如果已有独立代理正在运行,则保留并重新同步它;如果没有可用的代理,则按安全失败处理并提示修复服务。 ### `ocx ensure` @@ -168,6 +168,8 @@ ocx service status ocx service uninstall ``` +在 `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`。 diff --git a/src/cli/index.ts b/src/cli/index.ts index e9f3c22c3..940fbd0dd 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -119,6 +119,33 @@ async function waitForProxy(timeoutMs = 8_000): Promise { return null; } +async function syncLiveProxy( + live: LiveProxy, + config: ReturnType, + options: { requireCodexSync?: boolean } = {}, +): Promise { + let synced: Awaited> | null = null; + try { + synced = await syncModelsToCodex(live.port); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + if (options.requireCodexSync) throw new Error(`Codex model sync failed after service restart: ${detail}`); + console.error(`⚠️ Model sync skipped: ${detail}`); + } + if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native."); + // Ensure env file exists for already-running proxy (may have been deleted or pre-dates this feature). + await injectSystemEnv(live.port, config).catch(() => {}); + // Refresh the Grok Build fence too (same contract as start). live.hostname is the + // hostname the running proxy actually bound; config.hostname may have drifted. + try { + const { syncGrokConfig } = await import("../grok/sync"); + const g = await syncGrokConfig(live.port, config, live.hostname ? { hostname: live.hostname } : {}); + if (g.changed) console.log(" + Grok Build config updated (~/.grok/config.toml)"); + else if (!g.ok) console.error(`⚠️ ${g.message}`); + } catch (err) { console.error(`⚠️ ${grokSyncFailureMessage(err)}`); } + console.log(`✅ Proxy running on port ${live.port}`); +} + /** * A Grok fence sync that throws is best-effort by design — it must never block startup. * Reporting nothing, however, is what lets a STALE fence survive: `~/.grok/config.toml` @@ -411,24 +438,9 @@ async function handleEnsure() { } const live = await findLiveProxy(); if (live) { - const synced = await syncModelsToCodex(live.port).catch(e => { - console.error(`⚠️ Model sync skipped: ${e instanceof Error ? e.message : String(e)}`); - return null; - }); - if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native."); - // Ensure env file exists for already-running proxy (may have been deleted or pre-dates this feature). - await injectSystemEnv(live.port, config).catch(() => {}); - // Refresh the Grok Build fence too (same contract as start). live.hostname is the - // hostname the running proxy actually bound — config.hostname may have drifted. - try { - const { syncGrokConfig } = await import("../grok/sync"); - const g = await syncGrokConfig(live.port, config, live.hostname ? { hostname: live.hostname } : {}); - if (g.changed) console.log(" + Grok Build config updated (~/.grok/config.toml)"); - else if (!g.ok) console.error(`⚠️ ${g.message}`); - } catch (err) { console.error(`⚠️ ${grokSyncFailureMessage(err)}`); } - console.log(`✅ Proxy running on port ${live.port}`); - return; - } + await syncLiveProxy(live, config); + return; + } const pinPort = config.port ?? 10100; const child = spawn(process.execPath, startArgv(pinPort > 0 ? pinPort : undefined), { @@ -1099,9 +1111,45 @@ switch (command) { break; } case "restart": { + const serviceBeforeRestart = diagnoseService(); + if (serviceBeforeRestart.installed && !serviceBeforeRestart.startable) { + // Do not stop a working standalone proxy when the installed manager is stale, + // conflicting, or otherwise unsafe to start. Keep the live fallback and refresh + // its Codex integration; without one, fail closed and direct the user to repair. + if (await findLiveProxy()) { + console.warn(`⚠️ Installed service is not startable (${serviceBeforeRestart.summary}); preserving the live standalone proxy.`); + await handleEnsure(); + } else { + console.error(`❌ Installed service is not startable (${serviceBeforeRestart.summary}). Run 'ocx service repair' before restarting.`); + process.exitCode = 1; + } + break; + } // A failed stop must not be followed by a re-inject: with a foreign service still running // (ownership mismatch) we would rewrite shared config we just declined to touch. - if (await handleStop()) await handleEnsure(); + if (await handleStop()) { + // Preserve the installed supervision boundary. Starting via handleEnsure here + // creates a detached standalone proxy after handleStop unloads the service, + // so every routine restart silently loses login/crash protection. + if (serviceBeforeRestart.installed) { + await serviceCommand("start"); + if (process.exitCode && process.exitCode !== 0) break; + const live = await waitForProxy(); + if (!live) { + console.error("❌ Background service did not become ready after restart."); + process.exitCode = 1; + break; + } + try { + await syncLiveProxy(live, loadConfig(), { requireCodexSync: true }); + } catch (error) { + console.error(`❌ ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } + } else { + await handleEnsure(); + } + } else console.error("↩️ Restart aborted: the proxy was not stopped cleanly."); break; } diff --git a/src/service.ts b/src/service.ts index ce51dc72c..279392337 100644 --- a/src/service.ts +++ b/src/service.ts @@ -2177,6 +2177,11 @@ type ServiceOps = { status: () => string; uninstall: () => void; }; +type ServiceInstallCleanupOps = { + status: () => string | null; + stop: () => void; +}; + function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null { if (process.platform === "darwin") return { install: installLaunchd, start: startLaunchd, stop: stopLaunchd, status: statusLaunchd, uninstall: uninstallLaunchd }; @@ -2202,6 +2207,60 @@ function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null { return null; } +/** + * Install-only manager operations. Unlike the ordinary status/stop helpers, these + * distinguish confirmed absence from a failed manager query and propagate every + * non-benign stop failure. Installing new assets is unsafe while either answer is + * unknown because an old manager may still respawn a listener on the target port. + */ +function platformServiceInstallCleanupOps(backend: ServiceBackend): ServiceInstallCleanupOps | null { + if (process.platform === "darwin") { + return { + status: () => { + const listing = sh("launchctl list"); + return listing.split("\n").some(line => line.includes(LABEL)) ? listing : null; + }, + stop: () => { sh(`launchctl unload "${plistPath()}"`); }, + }; + } + if (process.platform === "win32") { + if (backend === "native") { + return { + status: () => { + const status = statusWinswRaw(); + if (status === "unknown") throw new Error("Native service status could not be verified."); + return status === "nonexistent" ? null : status; + }, + stop: stopWinswService, + }; + } + return { + status: () => { + const probe = probeWindowsSchedulerTask(TASK); + if (probe.status === "unknown") throw new Error(`Task Scheduler status could not be verified: ${probe.detail}`); + return probe.status === "present" ? "present" : null; + }, + stop: () => { + try { + schtasks(["/end", "/tn", TASK]); + } catch (error) { + if (!isWindowsSchedulerEndBenign(error)) throw error; + } + }, + }; + } + if (process.platform === "linux") { + return { + status: () => { + const listing = sh(`systemctl --user list-unit-files ${TASK} --no-legend`); + return listing || null; + }, + stop: () => { sh(`systemctl --user stop ${TASK}`); }, + }; + } + return null; +} + type TrackedProxyCleanupResult = "none" | "stale" | "stopped"; function verifiedKillTarget(pid: number | null | undefined): number | null { @@ -2297,6 +2356,60 @@ async function stopTrackedProxyForServiceCommand(): Promise ServiceDiagnostic; + managerOps?: (backend: ServiceBackend) => ServiceInstallCleanupOps | null; + stopTrackedProxy?: () => Promise; + platform?: NodeJS.Platform; +} + +/** + * Stop every manager that could own the install port, then stop the tracked + * standalone listener. Any unknown status or cleanup failure rejects, so callers + * cannot write assets or report success over a surviving old listener. + */ +export async function prepareServiceInstall( + requestedBackend: ServiceBackend, + deps: ServiceInstallPreparationDeps = {}, +): Promise { + const diagnostic = (deps.diagnose ?? diagnoseService)(); + const platform = deps.platform ?? process.platform; + const resolveOps = deps.managerOps ?? platformServiceInstallCleanupOps; + const backends: ServiceBackend[] = []; + const addBackend = (backend: ServiceBackend) => { + if (!backends.includes(backend)) backends.push(backend); + }; + + if (platform === "win32") { + // The recorded backend owns the old installation and must be stopped first. + // A conflicting diagnostic means both managers exist, so stop both even when + // the requested backend happens to match the recorded one. + if (diagnostic.backend === "scheduler" || diagnostic.backend === "native") { + addBackend(diagnostic.backend); + if (diagnostic.conflict) addBackend(diagnostic.backend === "scheduler" ? "native" : "scheduler"); + } + addBackend(requestedBackend); + } else { + addBackend(requestedBackend); + } + + for (const backend of backends) { + const manager = resolveOps(backend); + if (!manager) throw new Error(`Background service manager is unavailable for ${backend}.`); + if (manager.status() !== null) manager.stop(); + } + await (deps.stopTrackedProxy ?? stopTrackedProxyIfRunning)(); +} + +export async function installServiceSafely( + requestedBackend: ServiceBackend, + install: () => void | Promise, + deps: ServiceInstallPreparationDeps = {}, +): Promise { + await prepareServiceInstall(requestedBackend, deps); + await install(); +} + /** * If a service is installed, stop it so the process manager doesn't respawn after `ocx stop`. * Returns true if a service was found and stopped. @@ -2645,7 +2758,19 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise { test("ensure passes the observed bind host on the live branch and the configured host after spawning", () => { const ensureFn = sliceFn(CLI_SOURCE, "async function handleEnsure(", "async function handleTrayProxyStart("); - const liveBranch = ensureFn.slice(0, ensureFn.indexOf("const pinPort")); + const syncFn = sliceFn(CLI_SOURCE, "async function syncLiveProxy(", "function grokSyncFailureMessage("); const spawnBranch = ensureFn.slice(ensureFn.indexOf("const pinPort")); // live.hostname is what the proxy ACTUALLY bound; config.hostname may have drifted. - expect(liveBranch).toContain("live.hostname ? { hostname: live.hostname }"); + expect(syncFn).toContain("live.hostname ? { hostname: live.hostname }"); expect(spawnBranch).toContain("config.hostname ? { hostname: config.hostname }"); }); @@ -80,7 +80,7 @@ describe("Grok fence lifecycle wiring", () => { expect(stopFn.match(/if \(detail\) console\.error\(` \$\{detail\}`\);/g)).toHaveLength(2); }); - test("handleStop returns its outcome so restart and the tray can react", () => { + test("handleStop returns its outcome and restart preserves its supervision mode", () => { const stopFn = sliceFn(CLI_SOURCE, "async function handleStop(", "async function handleUninstall("); // process.exit() inside handleStop would strand runTrayProxyRestart's start() half. expect(stopFn).toContain("process.exitCode = 1"); @@ -88,7 +88,19 @@ describe("Grok fence lifecycle wiring", () => { expect(stopFn).not.toContain("process.exit(1)"); const restartCase = sliceFn(CLI_SOURCE, 'case "restart"', 'case "health"'); - expect(restartCase).toContain("if (await handleStop()) await handleEnsure()"); + const diagnoseAt = restartCase.indexOf("diagnoseService()"); + const stopAt = restartCase.indexOf("await handleStop()"); + const serviceStartAt = restartCase.indexOf('await serviceCommand("start")'); + const standaloneStartAt = restartCase.indexOf("await handleEnsure()"); + expect(diagnoseAt).toBeGreaterThan(-1); + expect(diagnoseAt).toBeLessThan(stopAt); + expect(serviceStartAt).toBeGreaterThan(stopAt); + expect(standaloneStartAt).toBeGreaterThan(-1); + expect(restartCase).toMatch( + /if \(serviceBeforeRestart\.installed\) \{[\s\S]*?await serviceCommand\("start"\);[\s\S]*?await syncLiveProxy\([\s\S]*?requireCodexSync: true[\s\S]*?\} else \{\s*await handleEnsure\(\);\s*\}/, + ); + expect(restartCase).toContain("serviceBeforeRestart.installed && !serviceBeforeRestart.startable"); + expect(restartCase).toContain("preserving the live standalone proxy"); }); test("handleStop treats an incomplete native Codex restore as a stop failure", () => { diff --git a/tests/service.test.ts b/tests/service.test.ts index 986756ad4..a60ec8f7b 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, serviceRetryCommand, serviceStatusSummary, systemdNeedsDaemonReload, windowsListenPort, winswListenPort, startLaunchd, windowsTaskRegistrationHealthy } from "../src/service"; +import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, confirmServiceServing, launchdListenPort, systemdListenPort, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsServiceScript, buildWindowsTaskXml, deriveWindowsServiceDiagnostic, installServiceSafely, launchctlLoadFailed, launchdJobMatchesPlist, normalizeServiceSubcommand, parseServiceInstallState, prepareServiceInstall, 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"; @@ -647,6 +647,60 @@ describe("launchd service plist", () => { }); describe("service lifecycle cleanup ordering", () => { + test("service install stops the recorded backend, requested backend, and standalone before loading assets", async () => { + const calls: string[] = []; + const managerOps = (backend: "scheduler" | "native") => ({ + status: () => { calls.push(`status:${backend}`); return "present"; }, + stop: () => { calls.push(`stop:${backend}`); }, + }); + await installServiceSafely("native", () => { calls.push("install:native"); }, { + platform: "win32", + diagnose: () => ({ supported: true, installed: true, enabled: true, running: true, viable: true, startable: true, stale: false, conflict: false, backend: "scheduler", summary: "test" }), + managerOps, + stopTrackedProxy: async () => { calls.push("stop:standalone"); }, + }); + expect(calls).toEqual([ + "status:scheduler", "stop:scheduler", + "status:native", "stop:native", + "stop:standalone", "install:native", + ]); + }); + + test("service install fails closed before install on manager or standalone cleanup errors", async () => { + for (const failure of ["status", "stop", "standalone"] as const) { + let installed = false; + const run = installServiceSafely("scheduler", () => { installed = true; }, { + platform: "win32", + diagnose: () => ({ supported: true, installed: true, enabled: true, running: true, viable: true, startable: true, stale: false, conflict: false, backend: "scheduler", summary: "test" }), + managerOps: () => ({ + status: () => { + if (failure === "status") throw new Error("status failed"); + return "present"; + }, + stop: () => { + if (failure === "stop") throw new Error("stop failed"); + }, + }), + stopTrackedProxy: async () => { + if (failure === "standalone") throw new Error("standalone failed"); + }, + }); + await expect(run).rejects.toThrow(`${failure} failed`); + expect(installed).toBe(false); + } + }); + + test("conflicting Windows install preparation stops both managers", async () => { + const stopped: string[] = []; + await prepareServiceInstall("scheduler", { + platform: "win32", + diagnose: () => ({ supported: true, installed: true, enabled: true, running: true, viable: false, startable: false, stale: false, conflict: true, backend: "scheduler", summary: "test" }), + managerOps: backend => ({ status: () => "present", stop: () => { stopped.push(backend); } }), + stopTrackedProxy: async () => {}, + }); + expect(stopped).toEqual(["scheduler", "native"]); + }); + test("direct service stop kills the tracked proxy before restoring native Codex", async () => { const service = await readText("src/service.ts"); const stopCase = service.slice(service.indexOf('case "stop":'), service.indexOf('case "status":'));