diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 97182382f9..203e35372c 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -231,18 +231,10 @@ interrupted package update removed either file, it logs one `installation is inc stops instead of retrying the same missing executable every five seconds. Reinstall opencodex, then run `ocx service repair` to refresh the task with the restored package paths. -On Linux, the systemd unit invokes the first regular, executable `ocx` file found on `PATH` at -install time rather than the Bun and CLI paths inside the installed package tree. Version managers such as -**mise** and **asdf** install into a versioned directory and delete the old one on upgrade, which -used to leave the unit pointing at files that no longer existed — systemd then restart-looped while -still reporting the service as installed. A shim path survives the upgrade, so the unit keeps -resolving. Source checkouts without an `ocx` launcher keep the previous direct Bun + CLI form. A -trusted `OPENCODEX_BUN_PATH` selected before Bun starts is preserved through the shim; package-local -bundled Bun paths are deliberately rediscovered after upgrades instead of being pinned in the unit. - -Units installed before this change still carry the old versioned paths and cannot migrate -themselves — once the old executable is deleted, no opencodex code runs to fix it. Run -`ocx service repair` once after upgrading; subsequent version changes need no action. +Linux systemd units likewise bake the Bun runtime and CLI entry selected by the installed package; +they do not select a service executable from `PATH`. If a version manager removes those paths during +an upgrade, run `ocx service repair` from the new installation to refresh the unit. + | Subcommand | Action | | --- | --- | diff --git a/src/service.ts b/src/service.ts index 4dfb0de6d7..2fd39b3ccd 100644 --- a/src/service.ts +++ b/src/service.ts @@ -7,9 +7,9 @@ */ 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 { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; -import { delimiter, dirname, isAbsolute, join, posix, resolve, win32 } from "node:path"; +import { dirname, 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"; @@ -66,48 +66,6 @@ function cliEntry(runtime: DurableBunRuntime = durableBunRuntime()): { bun: stri 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. - */ -export function stableLauncherEntry(deps: { - env?: NodeJS.ProcessEnv; - isExecutableFile?: (path: string) => boolean; - pathDelimiter?: string; -} = {}): 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 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`); @@ -197,14 +155,6 @@ export interface ServiceInstallState { /** Baked at install; lets status flag paths gone stale after npm prefix/nvm moves. */ bunPath?: string; cliPath?: string; - /** - * Linux only. The stable `ocx` launcher the unit actually invokes, when one was found. - * Present means `bunPath`/`cliPath` are provenance for the install, NOT what systemd - * 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; @@ -217,7 +167,7 @@ export function parseServiceInstallState(value: unknown): ServiceInstallState | 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) { + for (const key of ["bunPath", "cliPath", "winswVersion", "winswSha256"] as const) { if (state[key] !== undefined && (typeof state[key] !== "string" || state[key].length === 0)) return null; } if (state.version === 1) { @@ -228,7 +178,7 @@ export function parseServiceInstallState(value: unknown): ServiceInstallState | return state as unknown as ServiceInstallState; } -function writeServiceInstallState(backend: ServiceBackend = "scheduler", launcherPath?: string | null): void { +function writeServiceInstallState(backend: ServiceBackend = "scheduler"): void { const { bun, cli } = cliEntry(); const state: ServiceInstallState = { version: 2, @@ -236,7 +186,6 @@ function writeServiceInstallState(backend: ServiceBackend = "scheduler", launche opencodexHome: currentOpenCodexHome(), bunPath: bun, cliPath: cli, - ...(launcherPath ? { launcherPath } : {}), backend, ...(backend === "native" ? { winswVersion: WINSW_VERSION, winswSha256: WINSW_SHA256 } : {}), }; @@ -552,17 +501,6 @@ function buildServiceShellCommand(bun: string, cli: string, port = resolveServic 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 `--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 @@ -2569,14 +2507,6 @@ function uninstallWindows(): void { */ export function bakedServicePathsDiagnostic(): string | null { const state = readServiceInstallState(); - // A launcher install runs the launcher, not the baked pair, so the pair's existence says - // nothing about whether the service can start. Judging the recorded launcher is both - // necessary (a deleted launcher IS stale) and sufficient (a replaced version directory - // is not, which is exactly what #2898 made routine). - if (state?.launcherPath) { - if (existsSync(state.launcherPath)) return null; - return `STALE baked paths (missing: ${state.launcherPath}) — run 'ocx service repair' to re-bake`; - } if (!state?.bunPath || !state?.cliPath) return null; const missing = [state.bunPath, state.cliPath].filter(path => !existsSync(path)); if (missing.length === 0) return null; @@ -2597,16 +2527,8 @@ function unitPath(): string { return join(unitDir(), `${TASK}.service`); } -export function buildUnit( - proxyEnv: { name: string; value: string }[] = resolvedProxyEnv(), - deps: { launcher?: string | null; runtime?: DurableBunRuntime } = {}, -): string { - const runtime = deps.runtime ?? durableBunRuntime(); - const { bun, bunRuntimeSource, cli } = cliEntry(runtime); - // Discovery belongs to installSystemd(), which resolves once and passes the same value to - // both the unit and install state. Keeping this builder explicit makes tests and diagnostics - // independent of the host PATH. - const launcher = deps.launcher ?? null; +export function buildUnit(proxyEnv: { name: string; value: string }[] = resolvedProxyEnv()): string { + const { bun, bunRuntimeSource, cli } = cliEntry(); const log = logPath(); const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"; const codexHome = systemdEnvironmentAssignment("CODEX_HOME", process.env.CODEX_HOME?.trim()); @@ -2614,23 +2536,15 @@ export function buildUnit( const opencodexHome = systemdEnvironmentAssignment("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim()); const envLines = [ systemdEnvironmentAssignment("OCX_SERVICE", "1"), - ...(launcher ? [] : [ - systemdEnvironmentAssignment(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource), - systemdEnvironmentAssignment(BUN_RUNTIME_PATH_ENV, bun), - ]), - // A launcher normally resolves the current package's bundled Bun after every upgrade. - // Preserve only a proof-bound shell override; otherwise writing a package-local path here - // would recreate the version-manager pin that the launcher mode exists to remove. - launcher && runtime.source === "override" - ? systemdEnvironmentAssignment(runtime.overrideEnv, runtime.path) - : null, + systemdEnvironmentAssignment(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource), + systemdEnvironmentAssignment(BUN_RUNTIME_PATH_ENV, bun), systemdEnvironmentAssignment("PATH", path), codexHome, codexSqliteHome, opencodexHome, ...proxyEnv.map(({ name, value }) => systemdEnvironmentAssignment(name, value)), ].filter((line): line is string => Boolean(line)).join("\n"); - const command = `${launcher ? buildServiceLauncherShellCommand(launcher) : buildServiceShellCommand(bun, cli)} >> ${shellQuote(log)} 2>&1`; + const command = `${buildServiceShellCommand(bun, cli)} >> ${shellQuote(log)} 2>&1`; return `[Unit] Description=OpenCodex Proxy Server After=network-online.target @@ -2688,14 +2602,11 @@ function installSystemd(): void { recordOwnedConfigPath(getConfigDir(), serviceStatePath()); if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true }); writeServiceApiTokenFile(); - // Resolve ONCE and reuse: the unit and the install state must agree about what is - // launched, or the staleness check would validate a path the unit does not run. - const launcher = stableLauncherEntry(); - writeServiceDefinitionFile(unitPath(), buildUnit(resolvedProxyEnv(), { launcher }), "utf8"); + writeServiceDefinitionFile(unitPath(), buildUnit(), "utf8"); sh("systemctl --user daemon-reload"); sh(`systemctl --user enable ${TASK}`); sh(`systemctl --user restart ${TASK}`); - writeServiceInstallState("scheduler", launcher); + writeServiceInstallState(); } /** * Whether systemd's in-memory unit differs from the file on disk. diff --git a/tests/service.test.ts b/tests/service.test.ts index 3b48a76c43..6ccf1f801a 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -1,13 +1,11 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { execFileSync } from "node:child_process"; import { tmpdir } from "node:os"; -import { delimiter, isAbsolute, join, posix, win32 } from "node:path"; -import { pathToFileURL } from "node:url"; +import { isAbsolute, join, posix, win32 } from "node:path"; import * as serviceModule from "../src/service"; import { saveConfig } from "../src/config"; import { windowsEnvIndirectBatchValue } from "../src/lib/win-paths"; -import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, confirmServiceServing, launchdListenPort, systemdListenPort, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsSchtasksCreateArgsForXml, buildWindowsServiceScript, buildWindowsTaskXml, deriveWindowsServiceDiagnostic, installFreshWindowsSchedulerSafely, installServiceSafely, launchctlLoadFailed, launchdJobMatchesPlist, normalizeServiceSubcommand, parseServiceArgs, parseServiceInstallState, planServiceCommand, prepareServiceInstall, probeServiceInstallation, readWindowsSchedulerXmlState, registerFreshWindowsSchedulerTask, removeNativeWindowsServiceForScheduler, repairService, resolveServiceListenPort, runLaunchctl, selectServiceSubcommand, serviceLogPath, serviceStartableFromTray, serviceStatusReport, serviceRetryCommand, serviceStatusSummary, stableLauncherEntry, systemdNeedsDaemonReload, systemdServiceInstallCleanupOps, uninstallSystemd, windowsListenPort, winswListenPort, startLaunchd, windowsTaskRegistrationHealthy } from "../src/service"; +import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, confirmServiceServing, launchdListenPort, systemdListenPort, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsSchtasksCreateArgsForXml, buildWindowsServiceScript, buildWindowsTaskXml, deriveWindowsServiceDiagnostic, installFreshWindowsSchedulerSafely, installServiceSafely, launchctlLoadFailed, launchdJobMatchesPlist, normalizeServiceSubcommand, parseServiceArgs, parseServiceInstallState, planServiceCommand, prepareServiceInstall, probeServiceInstallation, readWindowsSchedulerXmlState, registerFreshWindowsSchedulerTask, removeNativeWindowsServiceForScheduler, repairService, resolveServiceListenPort, runLaunchctl, selectServiceSubcommand, serviceLogPath, serviceStartableFromTray, serviceStatusReport, serviceRetryCommand, serviceStatusSummary, systemdNeedsDaemonReload, systemdServiceInstallCleanupOps, uninstallSystemd, windowsListenPort, winswListenPort, startLaunchd, windowsTaskRegistrationHealthy } from "../src/service"; import type { ServiceDiagnostic } from "../src/service"; import { definitionCarriesCredential, resolvedProxyEnv, writeServiceDefinitionFile } from "../src/service"; import { buildWinswXml } from "../src/lib/winsw"; @@ -103,38 +101,18 @@ describe("service listen-port bake", () => { }); describe("systemd service unit", () => { - test("stable launcher discovery skips invalid PATH candidates and keeps the lexical executable", () => { - const first = join(TEST_DIR, "first"); - const second = join(TEST_DIR, "second"); - const probes: string[] = []; - const result = stableLauncherEntry({ - env: { PATH: [first, second].join(delimiter) }, - isExecutableFile: candidate => { - probes.push(candidate); - return candidate === join(second, "ocx"); - }, - }); - - expect(probes).toEqual([join(first, "ocx"), join(second, "ocx")]); - expect(result).toBe(join(second, "ocx")); - }); - - test("stable launcher discovery requires a regular executable file", () => { - if (process.platform === "win32") return; - const root = mkdtempSync(join(tmpdir(), "ocx-launcher-path-")); - const directoryEntry = join(root, "directory-entry"); - const nonExecutableEntry = join(root, "non-executable-entry"); - const executableEntry = join(root, "executable-entry"); - for (const entry of [directoryEntry, nonExecutableEntry, executableEntry]) mkdirSync(entry); - mkdirSync(join(directoryEntry, "ocx")); - writeFileSync(join(nonExecutableEntry, "ocx"), "#!/bin/sh\nexit 0\n", { mode: 0o644 }); - writeFileSync(join(executableEntry, "ocx"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + test("does not select the systemd executable from PATH", () => { + const inheritedPath = process.env.PATH; try { - expect(stableLauncherEntry({ - env: { PATH: [directoryEntry, nonExecutableEntry, executableEntry].join(delimiter) }, - })).toBe(join(executableEntry, "ocx")); + process.env.PATH = "/tmp/untrusted-bin:/usr/bin:/bin"; + const unit = buildUnit(resolvedProxyEnv({})); + + expect(unit).not.toContain("/tmp/untrusted-bin/ocx"); + expectTextToContainPath(unit, join("cli", "index.ts")); + expect(unit).toContain("OCX_BUN_RUNTIME_PATH"); } finally { - rmSync(root, { recursive: true, force: true }); + if (inheritedPath === undefined) delete process.env.PATH; + else process.env.PATH = inheritedPath; } }); @@ -341,7 +319,7 @@ describe("systemd service unit", () => { // The write goes through writeServiceDefinitionFile so the unit lands 0600: it can carry a // proxy credential (#2107). What this test pins is the ORDER — write, then reload. - const writeAt = installSystemd.indexOf("writeServiceDefinitionFile(unitPath(), buildUnit("); + const writeAt = installSystemd.indexOf('writeServiceDefinitionFile(unitPath(), buildUnit(), "utf8")'); const reloadAt = installSystemd.indexOf("systemctl --user daemon-reload"); const enableAt = installSystemd.indexOf("systemctl --user enable"); const restartAt = installSystemd.indexOf("systemctl --user restart"); @@ -351,15 +329,6 @@ describe("systemd service unit", () => { expect(enableAt).toBeLessThan(restartAt); expect(installSystemd).not.toContain("ocx service install"); expect(installSystemd).not.toContain("process.exit(1)"); - - // #2898: the unit and the recorded install state must agree about WHAT is launched, so - // the launcher is resolved once and handed to both. Resolving twice would let the - // staleness check validate a path the unit does not run. - const resolveAt = installSystemd.indexOf("stableLauncherEntry()"); - expect(resolveAt).toBeGreaterThan(-1); - expect(resolveAt).toBeLessThan(writeAt); - expect(installSystemd).toContain("writeServiceInstallState(\"scheduler\", launcher)"); - expect(installSystemd.match(/stableLauncherEntry\(\)/g)).toHaveLength(1); }); }); @@ -848,16 +817,9 @@ describe("launchd service plist", () => { const trustedPlist = buildPlist(); expect(trustedPlist).toContain("OCX_BUN_RUNTIME_SOURCEoverride"); expectTextToContainPath(trustedPlist, process.execPath); - // The systemd unit stamps the pair only when it BAKES that pair. A stable-launcher - // install runs `ocx` and lets it resolve the current package's Bun, so stamping a - // path there would pin the runtime to the directory a version upgrade deletes - // (#2898) — the opposite of what #848 asks for. Assert both modes explicitly. - expect(buildUnit(resolvedProxyEnv(), { launcher: null })).toContain('Environment="OCX_BUN_RUNTIME_SOURCE=override"'); - const launched = buildUnit(resolvedProxyEnv(), { launcher: "/opt/shims/ocx" }); - expect(launched).not.toContain("OCX_BUN_RUNTIME_SOURCE"); - expect(launched).not.toContain("OCX_BUN_RUNTIME_PATH"); - expectTextToContainPath(launched, process.execPath); - expect(launched).toContain("OPENCODEX_BUN_PATH="); + + expect(buildUnit()).toContain('Environment="OCX_BUN_RUNTIME_SOURCE=override"'); + expect(buildWindowsServiceScript()).toContain('set "OCX_BUN_RUNTIME_SOURCE=override"'); } finally { if (inheritedOverride === undefined) delete process.env.OPENCODEX_BUN_PATH; @@ -921,84 +883,7 @@ describe("launchd service plist", () => { } }); - // #2898. A version manager installs OpenCodex under a versioned directory and deletes the - // old one on upgrade; the baked Bun and CLI both live there. The shim does not move, so the - // unit has to name the shim and nothing from inside the version directory. - test("a stable launcher install names the launcher and bakes no versioned path", () => { - const launcher = "/home/u/.local/share/mise/shims/ocx"; - const unit = buildUnit(resolvedProxyEnv({}), { - launcher, - runtime: { path: "/opt/opencodex/versioned/bun", source: "bundled", overrideEnv: "OPENCODEX_BUN_PATH" }, - }); - - expect(unit).toContain(launcher); - expect(unit).toContain("start --port"); - // The versioned pair must be absent from BOTH the command and the environment: either one - // pins the service to a directory the next upgrade removes. - expect(unit).not.toContain("OCX_BUN_RUNTIME_PATH"); - expect(unit).not.toContain("OCX_BUN_RUNTIME_SOURCE"); - expect(unit).not.toContain("OPENCODEX_BUN_PATH"); - expect(unit).not.toContain("/opt/opencodex/versioned/bun"); - expect(unit).not.toContain("cli/index.ts"); - // The token still comes from the file at start, never from the unit (#2107). - expectTextToContainPath(unit, serviceApiTokenFilePath()); - expect(unit).toContain("OPENCODEX_API_AUTH_TOKEN"); - - // Without a launcher the unit keeps the previous shape, so source checkouts are unaffected. - const direct = buildUnit(resolvedProxyEnv({}), { launcher: null }); - expectTextToContainPath(direct, join("cli", "index.ts")); - expect(direct).toContain("OCX_BUN_RUNTIME_PATH"); - }); - - // The scenario itself, executed rather than asserted: retarget the shim the way an upgrade - // does, delete the old version, and check the generated command still reaches live code. - test("the generated launcher command follows a retargeted shim after the old version is gone", () => { - const root = mkdtempSync(join(tmpdir(), "ocx-shim-")); - const shimDir = join(root, "shims"); - const v1 = join(root, "installs", "2.35.0 package's"); - const v2 = join(root, "installs", "2.36.0 package's"); - mkdirSync(shimDir, { recursive: true }); - mkdirSync(v1, { recursive: true }); - mkdirSync(v2, { recursive: true }); - const v1Entry = join(v1, "ocx"); - const v2Entry = join(v2, "ocx"); - writeFileSync(v1Entry, 'console.log("V1", Bun.argv.slice(2).join(" "));\n'); - writeFileSync(v2Entry, 'console.log("V2", Bun.argv.slice(2).join(" "));\n'); - - const shim = join(shimDir, "ocx"); - const retargetShim = (target: string): void => { - writeFileSync(shim, `await import(${JSON.stringify(pathToFileURL(target).href)});\n`); - }; - const runShim = (): string => execFileSync( - process.execPath, - [shim, "start", "--port", "1"], - { encoding: "utf8" }, - ); - retargetShim(v1Entry); - - // stableLauncherEntry finds the shim lexically from PATH — not its versioned target. - const found = buildUnit(resolvedProxyEnv({}), { launcher: shim }); - expectTextToContainPath(found, shim); - expectTextNotToContainPath(found, v1); - - // Reproduce Windows' host-path serialization on every platform. systemdQuote() must - // escape each backslash in the unit, so raw path substring assertions are invalid. - const windowsShim = win32.join("C:\\Users\\runneradmin", "mise", "shims", "ocx"); - const windowsUnit = buildUnit(resolvedProxyEnv({}), { launcher: windowsShim }); - expectTextToContainPath(windowsUnit, windowsShim); - // Exercise the retarget through Bun on every host. Directly executing the old - // extensionless #!/bin/sh fixture was itself a POSIX-only assumption. - expect(runShim()).toContain("V1"); - - // The upgrade: shim retargeted, old version removed. - retargetShim(v2Entry); - rmSync(v1, { recursive: true, force: true }); - expect(existsSync(v1Entry)).toBe(false); - expect(runShim()).toContain("V2"); - - rmSync(root, { recursive: true, force: true }); - }); // The relative case is why the resolve() is there at all: a service unit has no meaningful // working directory, so a relative home must still be made absolute. @@ -1954,54 +1839,6 @@ describe("service diagnostics", () => { } }); - // #2898: a version manager (mise, asdf) installs OpenCodex into a VERSIONED directory and - // deletes the old one on upgrade. The baked Bun and CLI both live in that directory, so the - // unit's `exec ` stops resolving and Restart=on-failure restart-loops. - // When the install went through a stable launcher, the launcher is what systemd runs, so it - // is the only path whose absence means anything — and the replaced version directory must - // NOT be reported as stale. - test("a launcher install judges staleness by the launcher, not the replaced version dir", () => { - const oldOpenCodexHome = process.env.OPENCODEX_HOME; - const stateDir = join(TEST_DIR, "launcher-paths-home"); - try { - process.env.OPENCODEX_HOME = stateDir; - mkdirSync(stateDir, { recursive: true }); - const statePath = join(stateDir, "service-state.json"); - const launcher = join(import.meta.dir, "service.test.ts"); - const removedVersionDir = join(stateDir, "installs", "2.35.0"); - - // The upgrade case: version directory gone, launcher intact. Healthy. - writeFileSync(statePath, JSON.stringify({ - version: 2, - codexHome: stateDir, - opencodexHome: stateDir, - bunPath: join(removedVersionDir, "bun"), - cliPath: join(removedVersionDir, "cli", "index.ts"), - launcherPath: launcher, - backend: "scheduler", - }), "utf8"); - expect(bakedServicePathsDiagnostic()).toBeNull(); - - // A launcher that is itself gone is genuinely stale, and names the launcher. - const missingLauncher = join(stateDir, "shims", "ocx"); - writeFileSync(statePath, JSON.stringify({ - version: 2, - codexHome: stateDir, - opencodexHome: stateDir, - bunPath: join(import.meta.dir, "service.test.ts"), - cliPath: join(import.meta.dir, "service.test.ts"), - launcherPath: missingLauncher, - backend: "scheduler", - }), "utf8"); - const diagnostic = bakedServicePathsDiagnostic(); - expect(diagnostic).toContain("STALE baked paths"); - expect(diagnostic).toContain(missingLauncher); - } finally { - if (oldOpenCodexHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = oldOpenCodexHome; - } - }); - test("direct service status prints the diagnostics line", async () => { const service = await readText("src/service.ts"); const statusCase = service.slice(service.indexOf('case "status":'), service.indexOf('case "uninstall":'));