Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions docs-site/src/content/docs/ko/reference/cli/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,12 @@ ocx start --port 8080

### `ocx restart`

`stop` 다음에 `ensure`를 실행합니다. 즉, 프록시/서비스를 중지하고 기본 Codex를 복원한 뒤,
프록시를 백그라운드에서 다시 시작하고 살아 있는 포트를 Codex에 다시 동기화합니다.
현재 감독 방식을 유지하면서 프록시를 중지하고 다시 시작합니다. 설치된 백그라운드 서비스가 시작
가능하면 해당 서비스를 다시 시작하여 로그인 및 충돌 복구 보호를 유지하고, 살아 있는 프록시와
Codex 모델 동기화가 완료될 때까지 기다린 뒤 반환합니다. 설치된 서비스가 없으면 `ensure`를 통해
독립 실행형 백그라운드 프록시를 시작합니다. 오래되었거나 충돌하는 서비스는 시작하지 않습니다.
이미 실행 중인 독립 실행형 프록시가 있으면 이를 유지하고 다시 동기화하며, 대체 프록시가 없으면
복구 안내와 함께 안전하게 실패합니다.

### `ocx ensure`

Expand Down Expand Up @@ -213,6 +217,12 @@ ocx service status
ocx service uninstall
```

`install`은 새 서비스 자산을 쓰고 로드하기 전에 기록된 기존 백엔드를 먼저 중지하고, Windows에서
요청했거나 충돌하는 다른 백엔드가 있으면 그다음 중지한 뒤, 추적 중인 독립 실행형 프록시를 마지막으로
중지합니다. 관리자 상태 확인, 관리자 중지 또는 독립 실행형 프록시 정리에 실패하면 설치를 중단합니다.
따라서 이전 리스너가 서비스 포트를 계속 점유한 채 새 관리자를 재시작 루프에 빠뜨리거나, 이전 리스너가
성공적인 서비스 설치로 잘못 인식되는 일을 방지합니다.

Windows에서는 `ocx service status`가 Task Scheduler 등록 상태를 ID가 검증된 OpenCodex 프록시
도달 가능성과 별도로 보고합니다. 로컬라이즈된 `schtasks` 표는 출력하지 않으므로, 요약은 Windows
코드 페이지에서도 읽기 쉽습니다.
Expand Down
14 changes: 12 additions & 2 deletions docs-site/src/content/docs/reference/cli/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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:

```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ ocx start --port 8080

### `ocx restart`

执行 `stop` 然后执行 `ensure`:停止代理/服务,恢复原生 Codex,在后台启动代理,并将当前端口重新同步回 Codex
在保留当前监管方式的前提下停止并重新启动代理。如果已安装的后台服务可以安全启动,`restart` 会重新启动该服务以保留登录启动和崩溃恢复保护,并在返回前等待代理可用及 Codex 模型同步完成。如果未安装服务,则通过 `ensure` 启动独立后台代理。过期或冲突的服务不会被启动:如果已有独立代理正在运行,则保留并重新同步它;如果没有可用的代理,则按安全失败处理并提示修复服务

### `ocx ensure`

Expand Down Expand Up @@ -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 <non-empty-path> /f`,状态为 1,并且令牌明确为非提升权限;这时仪表盘的 Startup Safety 操作可以自动请求 UAC。如果该回退无法判断令牌状态,它会保留原始调度器错误。外部任务和操作绝不会发出自动提升标记。请批准仪表盘的 UAC 提示,或在提升权限的 PowerShell 窗口中重新运行 `ocx service install`。
Expand Down
86 changes: 67 additions & 19 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,33 @@ async function waitForProxy(timeoutMs = 8_000): Promise<LiveProxy | null> {
return null;
}

async function syncLiveProxy(
live: LiveProxy,
config: ReturnType<typeof loadConfig>,
options: { requireCodexSync?: boolean } = {},
): Promise<void> {
let synced: Awaited<ReturnType<typeof syncModelsToCodex>> | 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`
Expand Down Expand Up @@ -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), {
Expand Down Expand Up @@ -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;
}
Expand Down
127 changes: 126 additions & 1 deletion src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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 {
Expand Down Expand Up @@ -2297,6 +2356,60 @@ async function stopTrackedProxyForServiceCommand(): Promise<TrackedProxyCleanupR
}
}

export interface ServiceInstallPreparationDeps {
diagnose?: () => ServiceDiagnostic;
managerOps?: (backend: ServiceBackend) => ServiceInstallCleanupOps | null;
stopTrackedProxy?: () => Promise<unknown>;
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<void> {
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<void>,
deps: ServiceInstallPreparationDeps = {},
): Promise<void> {
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.
Expand Down Expand Up @@ -2645,7 +2758,19 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
case "install":
assertServiceEnvironmentMatchesInstall();
assertServiceAuthEnvironment();
await ops.install();
// A manually started proxy can still own the configured port while the service
// registration is absent or unloaded. Stop both the registered manager and any
// tracked standalone listener before loading the freshly written service assets.
// Otherwise launchd/Task Scheduler can register successfully while its child
// restart-loops on EADDRINUSE, and the old standalone process makes the install
// verification report a false success.
try {
await installServiceSafely(backend, ops.install);
} catch (error) {
console.error(`❌ Service install cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
break;
}
// The wrapper was written moments ago in this process, so the configured port
// and the baked one cannot have diverged yet — unlike `start`, which reads the
// installed artifact instead.
Expand Down
20 changes: 16 additions & 4 deletions tests/grok-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ describe("Grok fence lifecycle wiring", () => {

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 }");
});

Expand Down Expand Up @@ -80,15 +80,27 @@ 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");
expect(stopFn).toContain("return !stopFailed");
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", () => {
Expand Down
Loading
Loading