Skip to content
Draft
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
16 changes: 4 additions & 12 deletions docs-site/src/content/docs/reference/cli/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- |
Expand Down
111 changes: 11 additions & 100 deletions src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <old-bun> <old-cli>` 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`);
Expand Down Expand Up @@ -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;
Expand All @@ -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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Mark legacy launcher units stale until repaired

When upgrading an installation whose existing v2 state contains launcherPath, this parser now silently accepts but discards that marker. If the recorded Bun and CLI paths still exist—such as an in-place npm upgrade—bakedServicePathsDiagnostic() returns healthy and diagnoseService() reports the service viable even though systemd's already-loaded unit still executes the PATH-selected launcher this security fix is intended to distrust. Treat any legacy launcherPath state/unit as stale and require or perform a repair before reporting it viable.

AGENTS.md reference: AGENTS.md:L326-L332

Useful? React with 👍 / 👎.

if (state[key] !== undefined && (typeof state[key] !== "string" || state[key].length === 0)) return null;
}
if (state.version === 1) {
Expand All @@ -228,15 +178,14 @@ 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,
codexHome: currentCodexHome(),
opencodexHome: currentOpenCodexHome(),
bunPath: bun,
cliPath: cli,
...(launcherPath ? { launcherPath } : {}),
backend,
...(backend === "native" ? { winswVersion: WINSW_VERSION, winswSha256: WINSW_SHA256 } : {}),
};
Expand Down Expand Up @@ -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 <n>` 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
Expand Down Expand Up @@ -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;
Expand All @@ -2597,40 +2527,24 @@ 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());
const codexSqliteHome = systemdEnvironmentAssignment("CODEX_SQLITE_HOME", currentCodexSqliteHomeAbsolute());
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
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading