diff --git a/src/service.ts b/src/service.ts index 85c5b3abc56..92cedd7e226 100644 --- a/src/service.ts +++ b/src/service.ts @@ -5,5554 +5,26 @@ * Codex on a service-managed restart (the restarted instance re-injects); explicit stop/uninstall * restore it via the command. */ -import { execFileSync, execSync, spawnSync } from "node:child_process"; -import { findLiveProxy, proxyIdentityAt, SERVICE_STOP_LIVENESS } from "./server/proxy-liveness"; -import { accessSync, chmodSync, constants as fsConstants, existsSync, mkdirSync, mkdtempSync, readFileSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs"; -import { homedir, tmpdir } from "node:os"; -import { delimiter, dirname, isAbsolute, join, posix, resolve, win32 } from "node:path"; -import { expandUserPath, getConfigDir, loadConfig } from "./config"; -import { readPid, removePid, removeRuntimePort, verifyPidIdentity } from "./config/process-state"; -import { restoreNativeCodex, restoreNativeCodexAsync } from "./codex/inject"; -import { stripGrokConfig } from "./grok/inject"; -import { isWslRuntime, resolveCodexHomeDir, type CodexHomeDeps } from "./codex/home"; -import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from "./lib/bun-runtime"; -/** - * Written only by the launchd plist and the systemd unit. `OCX_SERVICE=1` cannot stand in - * for it: `ocx claude` and `ocx opencode` set that on the proxies they spawn to borrow its - * routing-preservation meaning, so a proxy carrying it is not necessarily the managed job. - */ -export const SERVICE_MANAGED_ENV = "OCX_SERVICE_MANAGED"; -import type { BunRuntimeSource, DurableBunRuntime } from "./lib/bun-runtime"; -import { isProcessAlive, stopProxy } from "./lib/process-control"; -import { readServiceApiTokenState, serviceApiTokenFilePath } from "./lib/service-secrets"; -import { tokenCollidesWithAdmin } from "./lib/admin-secrets"; -import { PROXY_ENV_KEYS } from "./lib/proxy-env"; -import { randomBytes, randomUUID } from "node:crypto"; -import { - ELEVATION_REQUEST_TIMEOUT_MS, - OCX_ELEVATED_PROTOCOL_FAILED, - raceWithTimeout, - resolveTrustedWindowsPowerShellExe, - resolveTrustedWindowsSchtasksExe, - startElevatedSchtasksCreateAndRun, - runWindowsElevated, - runWindowsElevatedScheduledTaskRegistration, - toWindowsSchtasksError, - WindowsElevationError, - WindowsSchtasksError, - type ElevatedSchedulerOutcome, - type ElevatedSchtasksCreateAndRunExecution, - type ElevatedSchtasksCreateAndRunResult, -} from "./lib/windows-elevation"; -import { defaultWinswEntry, installWinswService, startWinswService, stopWinswService, statusWinswRaw, uninstallWinswService, winswStatusSummary, winswXmlPath, WINSW_SERVICE_ID, WINSW_SHA256, WINSW_VERSION, type WinswStatus } from "./lib/winsw"; -import { - forgetEphemeralSecretDir, - forgetEphemeralSecretPath, - hardenSecretDir, - hardenSecretPath, -} from "./lib/windows-secret-acl"; -import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "./lib/win-paths"; -import { - cachedCurrentWindowsIdentity, - resolveCurrentWindowsPrincipal, - WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS, -} from "./lib/windows-user-principal"; -import { recordOwnedConfigPath } from "./lib/config-ownership"; -import { killWindowsSchedulerWrappers } from "./lib/windows-service-wrappers"; -import { withWindowsServiceMutationLock } from "./lib/windows-service-mutation-lock"; -import { maybeShowStarPrompt } from "./cli/star-prompt"; -import { systemdProperty } from "./service-manager-probe"; -import { assertNotRealLaunchAgentsUnderTest, isProtectedHomeUnderTest, isTestHomeGuardArmed } from "./lib/test-home-guard"; - -const LABEL = "com.opencodex.proxy"; -const TASK = "opencodex-proxy"; - -export type ServiceBackend = "scheduler" | "native"; - -function cliEntry(runtime: DurableBunRuntime = durableBunRuntime()): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: string } { - // Bake the bundled Bun (manager-owned global package directory, survives `ocx update`) rather than - // a transient system Bun, so launchd/systemd/schtasks keep resolving even if a - // standalone Bun is later removed. The CLI entry lives at src/cli/index.ts. - // - // Path and provenance come from ONE resolution so the marker can never describe a - // different binary than the one actually baked. - return { bun: runtime.path, bunRuntimeSource: runtime.source, cli: join(import.meta.dir, "cli", "index.ts") }; -} - -/** - * The stable `ocx` launcher to bake into a systemd unit, or null to fall back to the - * Bun + CLI pair. - * - * `cliEntry()` resolves both of its paths from `import.meta.dir`, so they point INSIDE - * the installed package tree. Under a version manager that tree is a versioned directory: - * `~/.local/share/mise/installs/npm-opencodex/2.35.0/...`. An upgrade installs 2.36.0 and - * deletes 2.35.0, after which the unit's `exec ` cannot resolve, and - * `Restart=on-failure` turns that into a restart loop (#2898). The shim in - * `~/.local/share/mise/shims/ocx` survives the upgrade and dispatches to whatever version - * is current, so it is the durable thing to name. - * - * Deliberately LEXICAL. Resolving the symlink would write the versioned target back into - * the unit and reintroduce the bug — the indirection is the entire point. - * - * Only an absolute path is accepted. A bare `ocx` would be re-resolved through `PATH` on - * every restart, which turns a service definition into a PATH-hijacking surface; naming - * one validated absolute file keeps the target fixed at install time. - * - * The RECORDED launcher wins over a fresh PATH walk. `ocx service repair` runs from - * whatever shell the operator (or `ocx update`, or a tray helper) happened to have, and a - * context without `ocx` on `PATH` used to resolve null here — rewriting a working - * launcher-form plist into the version-pinned Bun + CLI pair and then booting the healthy - * job out to load it (#4236, defect 1g). A launcher that is still an executable file is - * the thing the installed service already runs, so repair must keep naming it; only a - * recorded launcher that has disappeared falls through to discovery. - * - * That preference is NOT macOS-only: `installSystemd` resolves this same function, so a - * Linux `ocx service repair` from a PATH-less context keeps the `ExecStart` the unit - * already has instead of rewriting it to the version-pinned pair — the #2898 shape this - * function exists to avoid. The failure mode it prevents is milder there (systemd - * `daemon-reload` + `restart` does not evict-then-maybe-nothing the way launchd did), but - * the rewrite was the same, so the behavior is deliberately shared rather than branched. - */ -export function stableLauncherEntry(deps: { - env?: NodeJS.ProcessEnv; - isExecutableFile?: (path: string) => boolean; - pathDelimiter?: string; - state?: ServiceInstallState | null; -} = {}): string | null { - const env = deps.env ?? process.env; - const isExecutableFile = deps.isExecutableFile ?? ((path: string): boolean => { - try { - if (!statSync(path).isFile()) return false; - accessSync(path, fsConstants.X_OK); - return true; - } catch { - return false; - } - }); - const recorded = (deps.state === undefined ? readServiceInstallState() : deps.state)?.launcherPath; - if (recorded && isAbsolute(recorded) && isExecutableFile(recorded)) return recorded; - const entries = (env.PATH ?? "").split(deps.pathDelimiter ?? delimiter); - for (const entry of entries) { - if (!entry || !isAbsolute(entry)) continue; - const candidate = join(entry, "ocx"); - if (isExecutableFile(candidate)) return candidate; - } - return null; -} - -function plistPath(): string { - return join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`); -} - -function logPath(): string { - return join(getConfigDir(), "service.log"); -} - -export function serviceLogPath(): string { - return logPath(); -} - -function windowsServiceScriptPath(): string { - return join(getConfigDir(), "opencodex-service.cmd"); -} - -function windowsLauncherVbsPath(): string { - return join(getConfigDir(), "opencodex-service-launcher.vbs"); -} - -function windowsTaskXmlPath(): string { - return join(getConfigDir(), "opencodex-service-task.xml"); -} - -function serviceStatePath(): string { - return join(getConfigDir(), "service-state.json"); -} - -function defaultOpenCodexHome(): string { - return resolve(join(homedir(), ".opencodex")); -} - -export function serviceStatePathsForOpenCodexHome(opencodexHome: string): string[] { - const paths = [join(opencodexHome, "service-state.json")]; - const defaultPath = join(defaultOpenCodexHome(), "service-state.json"); - if (normalizePathForCompare(defaultPath) !== normalizePathForCompare(paths[0])) paths.push(defaultPath); - return paths; -} - -function serviceStatePaths(): string[] { - const paths = serviceStatePathsForOpenCodexHome(currentOpenCodexHome()); - if (!isTestHomeGuardArmed()) return paths; - /* - * Under an armed test process the legacy default-home entry IS the developer's real - * `~/.opencodex/service-state.json`. It is there so an install made before - * OPENCODEX_HOME was set can still be found, but it means a test whose OPENCODEX_HOME - * points at a sandbox still writes their live install state — observed while building - * the launchd repair coverage: one case replaced the real record's codexHome and - * opencodexHome with temp-directory paths. Drop it rather than deny the write, so the - * sandbox path keeps working and the real one is simply not in the list. - * - * The predicate is the guard's own, not a local `resolve()` compare: the guard - * canonicalizes through `realpath`, and on macOS a sandbox under `/var/folders/...` - * resolves to `/private/var/folders/...`, so two spellings of one directory must not - * decide this. - */ - return paths.filter(path => !isProtectedHomeUnderTest(dirname(path))); -} - -/** - * The state paths a WRITE may use. Same list, but an empty one is an error instead of a - * silent no-op. - * - * With OPENCODEX_HOME unset under an armed test process, `currentOpenCodexHome()` falls - * back to the real `~/.opencodex` (`os.homedir()` ignores `$HOME`), the filter above then - * removes every candidate, and `writeServiceInstallState` wrote NOTHING while reporting - * success — a test asserting on install state would read the previous run's record, or - * none. Fail the way `assertNotRealHomeUnderTest` does, naming the fix. - */ -function serviceStateWritePaths(): string[] { - const paths = serviceStatePaths(); - if (paths.length > 0) return paths; - throw new Error( - "refusing to write service install state with no writable state path: every candidate " - + "resolved to the real OpenCodex home and was filtered out. Point OPENCODEX_HOME at a " - + "temp directory for this test (the preload does it for every invocation; something " - + "deleted the variable without restoring it).", - ); -} - -function currentCodexHome(deps: CodexHomeDeps = {}): string { - // Service ownership must identify the same home as the runtime. In WSL an - // unset CODEX_HOME can resolve to the single Windows Desktop home rather than - // Linux ~/.codex; recording the fallback here creates a false foreign owner. - return resolveCodexHomeDir(deps); -} - -function currentCodexSqliteHomeAbsolute(target: "native" | "windows" = "native"): string | undefined { - const raw = process.env.CODEX_SQLITE_HOME?.trim(); - if (!raw) return undefined; - const expanded = expandUserPath(raw); - // Service artifacts can be rendered by cross-platform tests and repair tooling, so an - // already-absolute path for the TARGET platform is preserved rather than re-anchored - // against the writing host. `resolve()` is host-relative in both directions: on a POSIX - // host it turns `C:\data` into `/C:\data`, and on a Windows host it turns `/tmp/x` - // into `D:\tmp\x` — neither is a path the target can use. A relative value still resolves, - // because a service unit has no meaningful working directory. - // - // CODEX_HOME and OPENCODEX_HOME are carried through literally, so without this the same - // generated file disagreed with itself about two variables holding the same kind of value. - if (target === "windows") { - return win32.isAbsolute(expanded) ? win32.normalize(expanded) : resolve(expanded); - } - return posix.isAbsolute(expanded) ? posix.normalize(expanded) : resolve(expanded); -} - -function currentOpenCodexHome(): string { - // getConfigDir() already resolves OPENCODEX_HOME with ~ expansion; keep the - // install-state comparison on the same normalization or `~/...` values falsely - // fail the environment-match check depending on cwd. - return getConfigDir(); -} - -function normalizePathForCompare(path: string): string { - const resolved = resolve(path); - return process.platform === "win32" ? resolved.toLowerCase() : resolved; -} - -export interface ServiceInstallState { - version: 1 | 2; - codexHome: string; - opencodexHome: string; - /** Baked at install; lets status flag paths gone stale after npm prefix/nvm moves. */ - bunPath?: string; - cliPath?: string; - /** - * launchd and systemd. The stable `ocx` launcher the service definition actually invokes, - * when one was found. Present means `bunPath`/`cliPath` are provenance for the install, - * NOT what the service runs — so staleness must be judged against THIS path instead. A version-manager - * upgrade replaces the directory those two point into while the launcher survives, and - * checking the old pair would report a stale service that is in fact healthy. - */ - launcherPath?: string; - /** v2: which Windows backend was chosen at install; absent (v1/legacy) means scheduler. */ - backend?: ServiceBackend; - winswVersion?: string; - winswSha256?: string; -} - -export function parseServiceInstallState(value: unknown): ServiceInstallState | null { - if (!value || typeof value !== "object" || Array.isArray(value)) return null; - const state = value as Record; - if (state.version !== 1 && state.version !== 2) return null; - if (typeof state.codexHome !== "string" || state.codexHome.length === 0) return null; - if (typeof state.opencodexHome !== "string" || state.opencodexHome.length === 0) return null; - for (const key of ["bunPath", "cliPath", "launcherPath", "winswVersion", "winswSha256"] as const) { - if (state[key] !== undefined && (typeof state[key] !== "string" || state[key].length === 0)) return null; - } - if (state.version === 1) { - if (state.backend !== undefined) return null; - } else if (state.backend !== "scheduler" && state.backend !== "native") { - return null; - } - return state as unknown as ServiceInstallState; -} - -function writeServiceInstallState(backend: ServiceBackend = "scheduler", launcherPath?: string | null): void { - const { bun, cli } = cliEntry(); - const state: ServiceInstallState = { - version: 2, - codexHome: currentCodexHome(), - opencodexHome: currentOpenCodexHome(), - bunPath: bun, - cliPath: cli, - ...(launcherPath ? { launcherPath } : {}), - backend, - ...(backend === "native" ? { winswVersion: WINSW_VERSION, winswSha256: WINSW_SHA256 } : {}), - }; - for (const path of serviceStateWritePaths()) { - const dir = dirname(path); - recordOwnedConfigPath(getConfigDir(), path); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); - writeFileSync(path, JSON.stringify(state, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); - try { chmodSync(path, 0o600); } catch { /* best-effort */ } - if (process.platform === "win32") hardenSecretPath(path, { required: true }); - } -} - -function readServiceInstallState(): ServiceInstallState | null { - for (const path of serviceStatePaths()) { - try { - const parsed = parseServiceInstallState(JSON.parse(readFileSync(path, "utf8"))); - if (parsed) return parsed; - } catch { - /* try the next known state path */ - } - } - return null; -} - -/** What ONE state path said. Absent, unreadable and invalid are different answers. */ -export type ServiceStateEvidence = - | { readonly path: string; readonly kind: "absent" } - | { readonly path: string; readonly kind: "unreadable"; readonly reason: string } - | { readonly path: string; readonly kind: "invalid" } - | { readonly path: string; readonly kind: "valid"; readonly state: ServiceInstallState }; - -/** - * Every state path, with what each one said. - * - * `readServiceInstallState` returns the FIRST path that parsed and discards the - * rest, so a valid mirror beside a corrupt one reads as clean. That is the right - * behavior for callers that just need the install state; it is the wrong input - * for deciding ownership, where a disagreement between mirrors is exactly the - * evidence that matters. - */ -export function inspectServiceStateEvidence( - paths: readonly string[] = serviceStatePaths(), -): readonly ServiceStateEvidence[] { - return paths.map((path): ServiceStateEvidence => { - let raw: string; - try { - raw = readFileSync(path, "utf8"); - } catch (error) { - const code = error && typeof error === "object" && "code" in error - ? String((error as { code?: unknown }).code) - : ""; - // ENOENT is an answer. EACCES, ENOTDIR and the rest are a failure to ask, - // and collapsing them into absence is how a locked-down state file would - // become permission to write. - if (code === "ENOENT") return { path, kind: "absent" }; - return { path, kind: "unreadable", reason: code || String(error) }; - } - let parsed: ServiceInstallState | null; - try { - parsed = parseServiceInstallState(JSON.parse(raw)); - } catch { - return { path, kind: "invalid" }; - } - return parsed ? { path, kind: "valid", state: parsed } : { path, kind: "invalid" }; - }); -} - -/** The homes this process is actually using, for comparison against a claim. */ -export function currentServiceHomes(deps: CodexHomeDeps = {}): { codexHome: string; opencodexHome: string } { - return { codexHome: currentCodexHome(deps), opencodexHome: currentOpenCodexHome() }; -} - -export function serviceHomeMatches(a: string, b: string): boolean { - return normalizePathForCompare(a) === normalizePathForCompare(b); -} - -/** 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 refreshes an already-installed service after an update. - * - * `repair` discovers the installed backend itself. A healthy Windows scheduler task only - * gets refreshed assets plus a restart; a stale live definition is re-registered and may - * require elevation. `install` always reaches `/create`, so using repair here avoids an - * unnecessary admin prompt for the common healthy update path. - * - * The historical export name is kept for callers outside this module. - */ -export function serviceReinstallArgs(): string[] { - return ["service", "repair"]; -} - -/** The `ocx` argv that registers a service from scratch, preserving the chosen backend. */ -export function serviceInstallArgs(): string[] { - return readServiceBackend() === "native" ? ["service", "install", "--native"] : ["service", "install"]; -} - -/** - * The service was installed under a different CODEX_HOME/OPENCODEX_HOME, so this process may not - * touch it. Distinct from "stop failed": the manager was never even contacted, which means the - * installed service is still live and shared state (native Codex config, the Grok fence) must be - * left alone — tearing it down would strip config out from under a running service. - */ -export class ServiceOwnershipError extends Error { - readonly code = "service-ownership-mismatch" as const; -} - -export function isServiceOwnershipError(err: unknown): err is ServiceOwnershipError { - return err instanceof ServiceOwnershipError; -} - -/** - * True when no installed service exists, or the installed one belongs to THIS - * CODEX_HOME/OPENCODEX_HOME. Callers use it to decide whether they may tear down shared state - * (native Codex config, the Grok fence) that a foreign service would still be relying on. - */ -export function serviceEnvironmentOwnedHere(): boolean { - try { - assertServiceEnvironmentMatchesInstall(); - return true; - } catch (err) { - if (isServiceOwnershipError(err)) return false; - return true; // unrelated failure: fall back to the previous behavior rather than wedging - } -} - -export function assertServiceEnvironmentMatchesInstall(): void { - const state = readServiceInstallState(); - if (!state) return; - const actualCodexHome = currentCodexHome(); - const expected = normalizePathForCompare(state.codexHome); - const actual = normalizePathForCompare(actualCodexHome); - if (expected !== actual) { - throw new ServiceOwnershipError( - `Service was installed with CODEX_HOME=${state.codexHome}, but current CODEX_HOME=${actualCodexHome}. ` + - "Run the service command from the same Codex home so native Codex restore updates the correct config.", - ); - } - const expectedOpenCodexHome = normalizePathForCompare(state.opencodexHome); - const actualOpenCodexHome = normalizePathForCompare(currentOpenCodexHome()); - if (expectedOpenCodexHome !== actualOpenCodexHome) { - throw new ServiceOwnershipError( - `Service was installed with OPENCODEX_HOME=${state.opencodexHome}, but current OPENCODEX_HOME=${currentOpenCodexHome()}. ` + - "Run the service command from the same OpenCodex home so service state and secrets match.", - ); - } -} - - -function plistString(value: string): string { - return value - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -function isLoopbackHostname(hostname: string | undefined): boolean { - const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase(); - return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]"; -} - -/** - * The `ocx` command a user should rerun for the service state they actually have. - * - * `installed` alone is not enough: `repairService()` refuses a Task-Scheduler-plus-WinSW - * conflict outright, so recommending repair there names a command guaranteed to fail. - * Install IS the valid conflict recovery, because `installWindows` removes the native - * backend first. Exported so the guard tests the real selector rather than a copy of it. - */ -export function serviceRetryCommand( - diag: Pick = diagnoseService(), -): string { - return diag.installed && !diag.conflict ? "ocx service repair" : "ocx service install"; -} - -/** - * Refuse a management (admin) token as the data-plane secret. - * - * The service exports the contents of the service token file as - * `OPENCODEX_API_AUTH_TOKEN` before starting the proxy. When that value is the admin - * token, the server treats the management credential as a data-plane admission secret - * and fails the ENTIRE management plane closed at boot, so every `/api/*` request - * returns 503 — even on a loopback install that never needed a data-plane secret. - * Exporting the admin token in the CLI cannot recover it, because the fence is decided - * server-side at startup (#2696). - * - * Nothing in this codebase puts an admin token in that env var; it arrives from the - * installing shell. This function is the chokepoint that should refuse it rather than - * writing a file that produces a broken service. Comparison is the same helper doctor - * uses: minted `ocx_admin_…` prefix, or byte-equal to configuredAdminToken (env or file). - * - * `source` selects the remedy, not the rule. The token can also arrive from an EXISTING - * `service-api-token` that install/repair reuses, and there `unset` is meaningless advice — - * the fix is to delete the file so a data-plane token is generated. - */ -export function assertNotAdminToken( - token: string, - env: NodeJS.ProcessEnv = process.env, - source: "env" | "file" = "env", -): void { - if (!tokenCollidesWithAdmin(token, env)) return; - if (source === "file") { - // The file branch of `writeServiceApiTokenFile` used to skip this check entirely, so a - // hand-pasted admin token already on disk (pre-#2696, or the exact #4236 incident) was - // silently reused: `ocx status` said `present (file)` and the hub crash-looped at boot. - // The remedy is NOT `unset` -- there is nothing in the environment to unset. - throw new Error( - `${serviceApiTokenFilePath()} holds a management (admin) token, not a data-plane token. ` - + "The service exports that file as the data-plane secret, which fences the whole management " - + "API closed and makes every ocx management command fail with 503, so the hub crash-loops at " - + `boot. Delete the file (rm ${serviceApiTokenFilePath()}), then rerun \`ocx service repair\` ` - + "(or `ocx service install` when the service is not installed yet): a fresh owner-only " - + "data-plane token is generated and nothing needs to be exported by hand.", - ); - } - throw new Error( - "OPENCODEX_API_AUTH_TOKEN holds a management (admin) token. The service exports it " - + "as the data-plane secret, which fences the whole management API closed and makes " - + "every ocx management command fail with 503. Run `unset OPENCODEX_API_AUTH_TOKEN` " - + "and rerun: nothing needs to be exported by hand, because the service provisions " - + `its own owner-only data-plane token at ${serviceApiTokenFilePath()}.`, - ); -} - -/** - * Preflight for `service install` / `service repair` on the data-plane credential. - * - * It used to DEMAND `OPENCODEX_API_AUTH_TOKEN` for a non-loopback hostname, and it threw - * even when `~/.opencodex/service-api-token` already held a perfectly good token. That is - * the defect behind the incident this unit exists to close (#4236): an operator exported the - * ADMIN token as OPENCODEX_API_AUTH_TOKEN because `install` asked for a token, the hub then - * crash-looped on `assertNotAdminToken`, and `service repair` asked for the same env var - * again — so the only remembered way to make the command proceed was the thing that broke it. - * - * Nobody should have to export a token by hand to run a hub. {@link writeServiceApiTokenFile} - * provisions one, so the only conditions left that install cannot fix are an admin-token - * collision in the environment and a token file that exists but cannot be used. - */ -export function assertServiceAuthEnvironment(): void { - const config = loadConfig(); - // Both collision checks come BEFORE the loopback short-circuit, because the launch wrapper - // exports the token file unconditionally (`buildServiceShellCommand` cats it whenever it - // exists, whatever the hostname): a management token in either source fences the whole - // management plane closed at boot, even on a loopback install that needs no admission - // secret. Returning early is what let that broken state through. - const present = process.env.OPENCODEX_API_AUTH_TOKEN?.trim(); - if (present) assertNotAdminToken(present); - const state = readServiceApiTokenState(); - // An existing FILE holding the admin token is the incident shape itself, and the first round - // only checked the env var — so install/repair reused it and the hub crash-looped at boot. - // On a machine connected to a hub this same file holds that hub's issued client key, which - // is never a management token, so the check is a no-op there. - if (state.kind === "present") assertNotAdminToken(state.token, process.env, "file"); - if (isLoopbackHostname(config.hostname)) return; - if (present) return; - // Absent is fine — install/repair generates one below. `unsafe` is not: the writer refuses - // to replace a path it cannot vouch for, so say so here, where the operator can still act, - // instead of failing mid-install. Reached from `service repair` as well as `install`, so - // name a command that can actually succeed (see serviceRetryCommand). - if (state.kind !== "unsafe") return; - const diag = diagnoseService(); - throw new Error( - `The data-plane token file cannot be used (${state.reason}): ${serviceApiTokenFilePath()}. ` - + `Move it aside, then rerun \`${serviceRetryCommand(diag)}\`; the service provisions a ` - + "fresh owner-only token and needs nothing from the environment.", - ); -} - -/** How the data-plane token the service will export was obtained. */ -export type ServiceApiTokenOrigin = "env" | "file" | "generated"; - -export interface ProvisionedServiceApiToken { - path: string; - origin: ServiceApiTokenOrigin; -} - -function persistServiceApiToken(token: string): string { - const path = serviceApiTokenFilePath(); - const dir = getConfigDir(); - recordOwnedConfigPath(dir, path); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); - if (process.platform === "win32") hardenSecretDir(dir, { required: true }); - writeFileSync(path, `${token}\n`, { encoding: "utf8", mode: 0o600 }); - try { chmodSync(path, 0o600); } catch { /* best-effort */ } - if (process.platform === "win32") hardenSecretPath(path, { required: true }); - return path; -} - -/** - * Put a usable data-plane token on disk for the service to read at launch, and say where. - * - * EVERY backend funnels through here — launchd, systemd, the Windows scheduler wrapper and - * WinSW native — because the launch wrapper's only source of the secret is this file - * (`buildServiceShellCommand` cats it into the environment; WinSW reads it through - * `OCX_API_TOKEN_FILE`). One chokepoint is also what makes the admin-token refusal - * unskippable (#2696). - * - * Precedence, in order: - * 1. `OPENCODEX_API_AUTH_TOKEN` from the installing shell — still refused outright when it is - * an admin token. An operator who deliberately exports a key keeps full control of it. - * 2. An existing owner-only `service-api-token`. Reusing it is what makes `repair`, a - * reinstall and a restart idempotent; regenerating would silently invalidate every client - * key-exchange already performed against the old value. - * 3. 32 fresh random bytes, hex. This is the branch that removes the manual step: a hub - * install on a non-loopback hostname provisions its own secret. - * - * A loopback install with no env token gets nothing: admission is not required there, so - * creating a credential would be inventing a secret nobody asked for — and on a machine - * connected to a hub the same file holds that hub's issued client key, which must not be - * overwritten by a local install. - * - * The PATH is logged; the value never is, and never reaches argv, a unit file or a plist. - */ -export function writeServiceApiTokenFile(): ProvisionedServiceApiToken | null { - const token = process.env.OPENCODEX_API_AUTH_TOKEN?.trim(); - if (token) { - // Last line of defence: every install/repair path funnels through here, so a - // collision cannot reach disk regardless of which caller ran (#2696). - assertNotAdminToken(token); - const path = persistServiceApiToken(token); - console.log(`🔐 Data-plane token taken from OPENCODEX_API_AUTH_TOKEN and stored at ${path} (owner-only).`); - return { path, origin: "env" }; - } - if (isLoopbackHostname(loadConfig().hostname)) return null; - const existing = readServiceApiTokenState(); - if (existing.kind === "present") { - // The collision check is NOT only for the env branch. A file that already holds the admin - // token -- hand-pasted before #2696, or written by the very incident this unit closes -- - // was silently accepted here, so `ocx status` reported `present (file)` and the hub - // crash-looped at boot with no command pointing at the cause. - const path = serviceApiTokenFilePath(); - assertNotAdminToken(existing.token, process.env, "file"); - // `readServiceApiTokenState` accepts any bounded regular file, so a reused token may well - // be group- or world-readable. Tighten it on the way through rather than claiming - // "owner-only" about a mode nobody checked; best-effort, since a non-owner cannot chmod - // and failing the install over it would be worse than the loose mode. - try { chmodSync(path, 0o600); } catch { /* best-effort */ } - if (process.platform === "win32") hardenSecretPath(path, { required: false }); - // No log line: repair/restart hit this on every run and an unconditional notice about a - // credential file trains operators to ignore the one that matters. - return { path, origin: "file" }; - } - if (existing.kind === "unsafe") throw new Error(`${existing.reason}: ${serviceApiTokenFilePath()}`); - const path = persistServiceApiToken(randomBytes(32).toString("hex")); - console.log(`🔐 Provisioned an owner-only data-plane token at ${path}; nothing needs to be exported by hand.`); - console.log(" Remote machines get their own per-client key — run 'ocx hub invite' instead of copying this file."); - return { path, origin: "generated" }; -} - -/** - * Render the launchd plist. Mirrors `buildUnit`: when `deps.launcher` names a stable `ocx` - * executable, the job execs that launcher instead of the package-local Bun + CLI pair, so a - * version-manager upgrade (mise, asdf, nvm) that replaces the package directory is picked up - * on the next launchd start instead of leaving the old build serving (#3464 — the macOS - * counterpart of #2898). Discovery belongs to `installLaunchd()`; the default here is the - * legacy pair so callers and tests stay hermetic. - */ -export function buildPlist( - proxyEnv: { name: string; value: string }[] = resolvedProxyEnv(), - deps: { launcher?: string | null; runtime?: DurableBunRuntime } = {}, -): string { - const runtime = deps.runtime ?? durableBunRuntime(); - const { bun, bunRuntimeSource, cli } = cliEntry(runtime); - const launcher = deps.launcher ?? null; - const log = logPath(); - const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"; - const codexHome = process.env.CODEX_HOME?.trim(); - const codexSqliteHome = currentCodexSqliteHomeAbsolute(); - const opencodexHome = process.env.OPENCODEX_HOME?.trim(); - const envLines = [ - ` OCX_SERVICE1`, - // OCX_SERVICE alone cannot identify the managed job: `ocx claude` and `ocx opencode` - // also set it on the proxies they spawn, to borrow its routing-preservation meaning - // (src/cli/index.ts preserveRouting). Only the wrapper writes this second marker, so - // the dashboard-stop refusal below can tell a real launchd job from an ordinary child. - ` ${SERVICE_MANAGED_ENV}1`, - ...(launcher ? [] : [ - ` ${BUN_RUNTIME_SOURCE_ENV}${bunRuntimeSource}`, - ` ${BUN_RUNTIME_PATH_ENV}${plistString(bun)}`, - ]), - // A launcher resolves the current package's bundled Bun after every upgrade. Preserve - // only a proof-bound shell override; baking a package-local path here would recreate - // the version-manager pin that launcher mode exists to remove (same rule as buildUnit). - launcher && runtime.source === "override" - ? ` ${runtime.overrideEnv}${plistString(runtime.path)}` - : null, - ` PATH${plistString(path)}`, - codexHome ? ` CODEX_HOME${plistString(codexHome)}` : null, - codexSqliteHome ? ` CODEX_SQLITE_HOME${plistString(codexSqliteHome)}` : null, - opencodexHome ? ` OPENCODEX_HOME${plistString(opencodexHome)}` : null, - ...proxyEnv.map(({ name, value }) => - ` ${name}${plistString(value)}`), - ].filter((line): line is string => Boolean(line)).join("\n"); - const command = launchdServiceCommand(launcher, runtime); - return ` - - - - Label${LABEL} - ProgramArguments - - /bin/sh - -lc - ${plistString(command)} - - RunAtLoad - KeepAlive - EnvironmentVariables - -${envLines} - - StandardOutPath${plistString(log)} - StandardErrorPath${plistString(log)} - - -`; -} - -/** - * The single `EnvironmentVariables` line {@link buildPlist} fills from the env of whatever - * process happens to be repairing. - */ -const PLIST_PATH_ENTRY = /^(\s*PATH<\/key>)([^\n]*)(<\/string>)$/m; - -/** - * The rendered plist with the PREVIOUS definition's `PATH` put back — or null when `PATH` - * is not the only difference. - * - * `buildPlist` bakes `process.env.PATH`, and `ocx service repair` is run by whatever has a - * shell: a tray helper, `ocx update`'s child, an ssh session, a cron job. Each of those - * carries a DIFFERENT PATH from the login shell that installed the service, so comparing - * whole-file bytes made the "nothing to repair" pre-check miss almost every time it - * mattered: a healthy hub was evicted, and its PATH rewritten to the narrower one, purely - * because of who asked (#4236, review finding 2). - * - * Reuse rather than ignore. A plist that differs only in PATH is not "equal" — dropping the - * difference silently would let a repair report a no-op while launchd keeps a PATH the - * operator has changed on purpose. Putting the previous value back makes the two files - * genuinely identical, so the caller's ordinary byte comparison decides, and the PATH the - * service already runs with is the one that survives. - * - * The caller applies this only when the live job is loaded from exactly the exec line this - * install baked: that is the evidence that the running definition is the one on disk, which - * is what makes keeping its PATH correct rather than a guess. Whenever anything ELSE about - * the definition changed the plist is rewritten in full, PATH included, so a real - * re-install still updates it. - */ -export function reusePreviousPlistPathVariable(previous: string, rendered: string): string | null { - const prev = PLIST_PATH_ENTRY.exec(previous); - const next = PLIST_PATH_ENTRY.exec(rendered); - if (!prev || !next || prev[2] === next[2]) return null; - // A function replacer, not a `$1` template: a PATH entry containing `$&` or `$1` would - // otherwise be re-expanded into the file. - const adopted = rendered.replace(PLIST_PATH_ENTRY, (_match, open: string, _value: string, close: string) => - `${open}${prev[2] ?? ""}${close}`); - return adopted === previous ? adopted : null; -} - -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'`; -} - -/** - * Listen port baked into service wrappers / WinSW XML. - * Priority: explicit override → OCX_BAKE_PORT (update restart) → config.port → 10100. - * `config.port === 0` means ephemeral for interactive start; services need a stable pin, - * so treat 0 / invalid like unset (default 10100) instead of baking `--port 0`. - */ -export function resolveServiceListenPort(override?: number): number { - if (typeof override === "number" && Number.isFinite(override) && override > 0 && override <= 65535) { - return Math.trunc(override); - } - const baked = process.env.OCX_BAKE_PORT?.trim(); - if (baked && /^\d+$/.test(baked)) { - const n = Number(baked); - if (n > 0 && n <= 65535) return n; - } - const configured = loadConfig().port; - if (typeof configured === "number" && configured > 0 && configured <= 65535) return configured; - return 10100; -} - -function buildServiceShellCommand(bun: string, cli: string, port = resolveServiceListenPort()): string { - const tokenFile = serviceApiTokenFilePath(); - return `if [ -f ${shellQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shellQuote(tokenFile)})"; export OPENCODEX_API_AUTH_TOKEN; fi; exec ${shellQuote(bun)} ${shellQuote(cli)} start --port ${port}`; -} - -/** - * The same command shape, launched through a stable `ocx` executable instead of an - * explicit Bun + CLI pair. The token-file preamble is identical and deliberately shared - * in form: the service still reads the token from disk at start and never carries it in - * the unit. - */ -function buildServiceLauncherShellCommand(launcher: string, port = resolveServiceListenPort()): string { - const tokenFile = serviceApiTokenFilePath(); - return `if [ -f ${shellQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shellQuote(tokenFile)})"; export OPENCODEX_API_AUTH_TOKEN; fi; exec ${shellQuote(launcher)} start --port ${port}`; -} - -/** - * The exec line {@link buildPlist} bakes, for the launcher and runtime a single install - * already resolved. Shared so `installLaunchd` can verify the live job against the exact - * string it just wrote instead of re-deriving it from install state that has not been - * written yet (a fresh install has no state, so `expectedLaunchdCommand` would hand back - * the Bun + CLI pair and call a correctly loaded launcher job stale). - */ -function launchdServiceCommand( - launcher: string | null, - runtime: DurableBunRuntime = durableBunRuntime(), - port: number = resolveServiceListenPort(), -): string { - if (launcher) return buildServiceLauncherShellCommand(launcher, port); - const { bun, cli } = cliEntry(runtime); - return buildServiceShellCommand(bun, cli, port); -} - -/** - * The exec line the installed launchd plist is expected to carry, derived from the recorded - * install state rather than rediscovered: a launcher install runs the launcher, a legacy or - * stateless install runs the Bun + CLI pair. `start` and `status` compare the live job - * against this, so both must follow the launcher or a healthy launcher-backed job reads as - * "an OLDER plist" (#3464). PATH is deliberately NOT re-walked here. - */ -export function expectedLaunchdCommand( - port: number, - deps: { state?: ServiceInstallState | null; entry?: { bun: string; cli: string } } = {}, -): string { - const state = deps.state === undefined ? readServiceInstallState() : deps.state; - if (state?.launcherPath) return buildServiceLauncherShellCommand(state.launcherPath, port); - const entry = deps.entry ?? cliEntry(); - return buildServiceShellCommand(entry.bun, entry.cli, port); -} - -/** - * The `--port ` actually baked into the installed launchd plist, or null when it - * cannot be read. macOS only — named for launchd rather than "service" so no caller - * assumes it covers systemd or the Windows wrapper. - * - * `start` needs this because it does NOT rewrite the plist: an install made under - * OCX_BAKE_PORT, or any later config.port edit, would otherwise leave launchd serving - * one port while the confirmation probes another, failing a healthy service. - * - * Anchored on the closing tag and matched LAST: the command also carries the Bun and - * CLI paths, and a path containing the literal `start --port 9999` must not shadow - * the real argument. buildPlist emits the command as the final ProgramArguments - * string, and buildServiceShellCommand puts the port at the very end of it. - */ -export function launchdListenPort(deps: { readPlist?: () => string } = {}): number | null { - try { - const text = (deps.readPlist ?? (() => readFileSync(plistPath(), "utf8")))(); - const last = [...text.matchAll(/start --port (\d{1,5})\s*<\/string>/g)].at(-1); - if (!last) return null; - const n = Number(last[1]); - return n > 0 && n <= 65535 ? n : null; - } catch { - return null; - } -} - -/** The `--port ` baked into the installed systemd user unit. Linux only. */ -export function systemdListenPort(deps: { readUnit?: () => string } = {}): number | null { - try { - const text = (deps.readUnit ?? (() => readFileSync(unitPath(), "utf8")))(); - const last = [...text.matchAll(/start --port (\d{1,5})(?:\s|"|$)/gm)].at(-1); - if (!last) return null; - const n = Number(last[1]); - return n > 0 && n <= 65535 ? n : null; - } catch { - return null; - } -} - -/** - * Shared tail parser for the baked `--port `. - * - * Terminators cover all three artifact shapes: whitespace (batch wrapper, systemd - * unit), `"` (systemd's quoted ExecStart), `<` (WinSW's ``), and `&` (an - * XML-escaped quote). Matched LAST because every artifact carries the Bun and CLI - * paths ahead of the argument, and a path containing the literal must not shadow it. - */ -function parseBakedListenPort(read: () => string): number | null { - try { - const last = [...read().matchAll(/start --port (\d{1,5})(?:\s|"|&|<|$)/gm)].at(-1); - if (!last) return null; - const n = Number(last[1]); - return n > 0 && n <= 65535 ? n : null; - } catch { - return null; - } -} - -/** The `--port ` baked into the Task Scheduler wrapper. Windows scheduler backend. */ -export function windowsListenPort(deps: { readScript?: () => string } = {}): number | null { - return parseBakedListenPort(deps.readScript ?? (() => readFileSync(windowsServiceScriptPath(), "utf8"))); -} - -/** - * The `--port ` baked into the WinSW XML's ``. Windows native backend. - * - * Separate from {@link windowsListenPort} rather than one function branching on - * `readServiceBackend()`: the recorded backend can disagree with what is actually on - * disk (the `stale` / `backendStateMismatch` cases `deriveWindowsServiceDiagnostic` - * exists to catch), and a reader that trusted it would then read the wrong file. - * Each returns null when its own artifact is absent, so the chain needs no branch. - */ -export function winswListenPort(deps: { readXml?: () => string } = {}): number | null { - return parseBakedListenPort(deps.readXml ?? (() => readFileSync(winswXmlPath(), "utf8"))); -} - -/** - * The listen port of the INSTALLED service artifact, falling back to the configured - * one. Each reader returns null off its own platform, so the chain needs no platform - * branch — and on Windows both return null, preserving today's behavior. - */ -export function installedServiceListenPort(): number { - return launchdListenPort() - ?? systemdListenPort() - ?? windowsListenPort() - ?? winswListenPort() - ?? resolveServiceListenPort(); -} - -export const SERVICE_INSTALL_HEALTH_MS = 20_000; - -/** - * Windows gets a longer budget because its cold start does more before the - * listener exists: NTFS ACL hardening and previous-session journal recovery - * both run first, and #3009 recorded a service that bound a few seconds past - * the 20s deadline and then stayed healthy. Reporting that as a terminal - * repair failure is worse than waiting — the caller's fallback is to start a - * second proxy against a port that is about to be taken. - */ -export const SERVICE_INSTALL_HEALTH_WINDOWS_MS = 45_000; - -/** The health budget for the platform this is running on. */ -export function serviceInstallHealthMs( - platform: NodeJS.Platform = process.platform, -): number { - return platform === "win32" ? SERVICE_INSTALL_HEALTH_WINDOWS_MS : SERVICE_INSTALL_HEALTH_MS; -} - -/** - * Whether a proxy actually answers on the port this install/start just produced. - * - * Registration is not service: `launchctl list` reports a job that never bound, and - * `systemctl is-active` reports a process that bound nothing. Probing is the only - * 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 - * the first two while `resolveServiceListenPort` (OCX_BAKE_PORT precedence, config.port - * === 0 normalization) can disagree with the third. - * - * Soft: returns the outcome, never throws; the caller chooses between a checkmark and - * an actionable warning. - */ -export async function confirmServiceServing( - deps: { - port?: number; - hostname?: string; - probe?: (port: number, hostname: string) => Promise; - sleep?: (ms: number) => Promise; - now?: () => number; - timeoutMs?: number; - } = {}, -): Promise<{ ok: true; port: number } | { ok: false; port: number }> { - const port = deps.port ?? installedServiceListenPort(); - const hostname = deps.hostname ?? loadConfig().hostname ?? "127.0.0.1"; - const now = deps.now ?? Date.now; - const sleep = deps.sleep ?? ((ms: number) => new Promise(r => setTimeout(r, ms))); - const probe = deps.probe ?? (async (p, h) => !!(await proxyIdentityAt(p, { hostname: h }))); - const deadline = now() + (deps.timeoutMs ?? serviceInstallHealthMs()); - let waited = false; - for (;;) { - if (await probe(port, hostname)) return { ok: true, port }; - if (now() >= deadline) break; - await sleep(500); - waited = true; - } - // The probe that ran last started before the deadline, so a service that binds - // during it is reported as dead (#3009). Knock once more after a short grace - // before calling it a failure. A zero budget means the caller asked not to - // wait, so it gets exactly the single probe it asked for and nothing more. - if (waited) { - await sleep(500); - if (await probe(port, hostname)) return { ok: true, port }; - } - return { ok: false, port }; -} - -/** - * Print the outcome of `install` / `start` / `repair` in terms of what the user cares - * about — is it serving? — instead of whether the manager accepted the registration. - * - * Sets `process.exitCode = 1` when nothing answers. That is deliberate: the GUI update - * worker reads the child's exit status, so a registered-but-silent service now makes it - * fall back to a direct proxy start rather than reporting a successful update over a - * dead port. - */ -export async function reportServiceServing( - verb: "installed" | "started" | "repaired" | "restarted", - deps: Parameters[0] = {}, -): Promise { - const healthBudgetMs = deps.timeoutMs ?? serviceInstallHealthMs(); - // Timed here rather than reported from the budget. confirmServiceServing knocks once - // more after a grace sleep whenever it waited at all, so the real wait is the budget - // plus that grace — and printing the budget states a number the run did not spend. - // What the reader is deciding is whether the service was still coming up, which is a - // judgement about elapsed time (#3009). - const now = deps.now ?? Date.now; - const startedAt = now(); - const serving = await confirmServiceServing({ ...deps, timeoutMs: healthBudgetMs }); - const waitedMs = Math.max(0, now() - startedAt); - if (serving.ok) { - console.log(`✅ opencodex service ${verb} and serving on port ${serving.port}.`); - return; - } - console.error( - `⚠️ Service ${verb}, but no proxy answered on port ${serving.port} after ` - + `${Math.round(waitedMs / 1000)}s.\n` - + ` The manager registered the job; that is not the same as serving.\n` - + ` Log: ${serviceLogPath()}\n` - + ` Meanwhile: ocx start (serves in the foreground)`, - ); - process.exitCode = 1; -} - -/** - * The command that repairs the CURRENTLY INSTALLED backend without switching it. - * - * `ocx service repair` reads the recorded backend itself, so it cannot silently switch a - * WinSW install to Task Scheduler the way a plain `ocx service install` would. A healthy - * scheduler definition needs no elevation; a stale definition can be re-registered and prompt. - */ -function serviceRepairCommand(): string { - return "ocx service repair"; -} - -function systemdQuote(value: string): string { - return `"${value - .replace(/\\/g, "\\\\") - .replace(/"/g, "\\\"") - .replace(/%/g, "%%") - .replace(/\n/g, "\\n")}"`; -} - -function systemdEnvironmentAssignment(name: string, value: string | undefined): string | null { - if (!value) return null; - return `Environment=${systemdQuote(`${name}=${value}`)}`; -} - -/** - * Outbound proxy settings the installing shell had, resolved for baking into a service - * definition. - * - * A service manager does not inherit the environment of the shell that installed it, and - * `ExecStart=/bin/sh -lc` is dash on Ubuntu/WSL — login dash reads `.profile`, not - * `.bashrc`, which is where proxy exports usually live. So a user who needs a proxy to - * reach the upstream got a service that dialed direct: the socket was reset, the retry - * budget drained, and the request surfaced as `502 Provider unreachable` (#2107). The - * same install driven through `ocx codex-shim` worked, because that path spawns with - * `{ ...process.env }`. - * - * Lower-case variants are honored because curl-style tooling sets them and the runtime's - * own `applyProxyEnv` already treats both cases as equivalent. Only the canonical - * upper-case name is baked, so a definition never carries two spellings of one setting. - */ -export function resolvedProxyEnv(env: NodeJS.ProcessEnv = process.env): { name: string; value: string }[] { - const resolved: { name: string; value: string }[] = []; - for (const key of PROXY_ENV_KEYS) { - const value = env[key]?.trim() || env[key.toLowerCase()]?.trim(); - if (value) resolved.push({ name: key, value }); - } - return resolved; -} - -function sh(cmd: string): string { - assertLiveServiceManagerAllowed(cmd); - return execSync(cmd, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim(); -} - -/** - * Service-manager invocations that only observe. Everything else changes a job that - * launchd or the systemd user manager is running right now. - */ -const READ_ONLY_SERVICE_MANAGER = new RegExp( - "^(?:launchctl\\s+(?:list|print|print-disabled|blame|managerpid|manageruid)\\b" - + "|systemctl\\s+(?:--user\\s+)?(?:show|show-environment|status|is-active|is-enabled|is-failed|cat|list-units|list-unit-files|--version)\\b)", -); - -const SERVICE_MANAGER_COMMAND = /^(?:launchctl|systemctl)\b/; - -/** - * Refuse to mutate a live service manager from an armed test process. - * - * The test preload isolates HOME, OPENCODEX_HOME and CODEX_HOME, and that is enough for - * anything addressed by path. It is not enough here. `systemctl --user stop - * opencodex-proxy.service` is addressed by job NAME and talks to the user manager that is - * already running, so it stops the proxy the developer is actually using no matter what - * HOME says. `launchctl bootout gui//com.opencodex.proxy` has the same shape. - * - * Windows already had this guard: `querySchtasks` refuses every non-query call while the - * test-home guard is armed, after a partially-faked test replaced a real scheduled task - * with a launcher inside a temporary test home. macOS and Linux were left without the - * equivalent, which means the person most likely to run this suite - someone running - * opencodex on the machine they are developing it on - is the person it can disrupt. - * - * Read-only verbs stay allowed: probing what the manager reports is the whole point of - * the diagnostics, and observation cannot take a service down. - */ -export function assertLiveServiceManagerAllowed(command: string): void { - if (!isTestHomeGuardArmed()) return; - const trimmed = command.trim(); - if (!SERVICE_MANAGER_COMMAND.test(trimmed)) return; - if (READ_ONLY_SERVICE_MANAGER.test(trimmed)) return; - throw new Error( - `refusing to run \`${trimmed}\` from an armed test process: launchd and the systemd user ` - + "manager address a job by name, not by HOME, so this reaches the service the developer is " - + "actually running. Inject the service operation instead of calling the live manager.", - ); -} - -/** - * Run `launchctl` and report BOTH streams regardless of exit status. - * - * `launchctl load` writes "Load failed: : " to stderr and exits 0 for - * every already-bootstrapped job. `sh()` above is execSync, which throws only on a - * non-zero exit, so install and start both reported success for a load that did - * nothing — leaving launchd running the PREVIOUS plist while a freshly written one - * sat unused on disk. That is the 2026-08-02 report: `ocx service` prints a - * checkmark, `launchctl list` shows the job, and the port answers nothing. - * - * spawnSync, NOT execFileSync: execFileSync discards stderr when the child exits 0, - * which is precisely this case — a runner built on it returns an empty stderr and - * the guard below can never fire. Measured on macOS 27.0. - */ -export function runLaunchctl( - args: string[], - deps: { run?: typeof spawnSync } = {}, -): { ok: boolean; stdout: string; stderr: string; status: number | null } { - const run = deps.run ?? spawnSync; - // Only the real runner is guarded. Tests that inject a spawnSync stand-in are - // exercising the parsing, not reaching launchd, and must keep working. - if (run === spawnSync) assertLiveServiceManagerAllowed(`launchctl ${args.join(" ")}`); - const result = run("/bin/launchctl", args, { encoding: "utf8", windowsHide: true }); - // `error` is set when the spawn itself failed (ENOENT off macOS) and `status` is - // null for a signalled child; neither may be reported as success. - if (result.error) { - return { ok: false, stdout: "", stderr: String(result.error.message ?? ""), status: null }; - } - return { - ok: result.status === 0, - stdout: String(result.stdout ?? "").trim(), - stderr: String(result.stderr ?? "").trim(), - /* - * The NUMBER, not just its zero-ness. - * - * `launchctl print` distinguishes "that domain does not exist" (112) from - * "the domain answered and has no such service" (113), and an ownership - * probe needs that difference: the second proves absence, the first only - * proves we could not look. Collapsing both into `ok: false` forced callers - * to parse stderr, which Apple does not treat as a stable interface. - */ - status: result.status ?? null, - }; -} - -/** - * Whether launchctl output indicates the operation did not take. Needed because - * `ok` alone is insufficient for the legacy `load`/`unload` subcommands, which - * report failure on stderr while exiting 0. `bootstrap` exits 5, so for that path - * this is belt-and-braces rather than the only signal. - */ -export function launchctlLoadFailed(stderr: string): boolean { - return /\b(?:Load|Bootstrap) failed\b/i.test(stderr); -} - -/** launchd domain target for the current user's GUI session. */ -function launchdGuiDomain(): string { - return `gui/${process.getuid?.() ?? 0}`; -} - -/** - * Whether launchd is running the job from the CURRENT plist. `launchctl list` only - * proves domain membership — a job bootstrapped from an older plist stays listed - * forever. `launchctl print` exposes the live `arguments`, which is the only way to - * catch a load that silently no-op'd. - */ -export function launchdJobMatchesPlist( - expectedCommand: string, - deps: { run?: typeof runLaunchctl } = {}, -): { loaded: boolean; matchesPlist: boolean } { - const run = deps.run ?? runLaunchctl; - const printed = run(["print", `${launchdGuiDomain()}/${LABEL}`]); - if (!printed.ok) return { loaded: false, matchesPlist: false }; - // `print` writes the arguments block to stdout for a live job. Search both streams - // anyway so a future launchctl that moves diagnostics between them cannot turn this - // into a false negative — a false "stale" verdict would send users to `bootout` for - // nothing. - const printedText = `${printed.stdout}\n${printed.stderr}`; - return { loaded: true, matchesPlist: printedText.includes(expectedCommand) }; -} - -/** `launchctl print`: the domain answered and holds no such service. */ -const LAUNCHCTL_NO_SUCH_SERVICE = 113; -/** `launchctl print`: that domain does not exist at all (label-independent). */ -const LAUNCHCTL_NO_SUCH_DOMAIN = 112; -/** `launchctl bootstrap`: something is already bootstrapped under that label. */ -const LAUNCHCTL_BOOTSTRAP_BUSY = 5; -/** `launchctl bootout`: nothing was loaded under that label, i.e. already stopped. */ -const LAUNCHCTL_BOOTOUT_NO_SUCH_PROCESS = 3; - -/** - * Every domain target an eviction has to cover. - * - * {@link probeLaunchdLoadState} asks `gui/` AND `user/` because the two domains - * are independent and hold separate service sets, while every MUTATING verb in this file - * addressed `gui/` alone. So a `user/`-domain registration of our Label used to: - * survive `ocx service stop` (`bootout gui//