-
Notifications
You must be signed in to change notification settings - Fork 957
fix(service): launch the systemd unit through the stable ocx executable #2909
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,7 +9,7 @@ import { execFileSync, execSync, spawnSync } from "node:child_process"; | |
| import { findLiveProxy, proxyIdentityAt, SERVICE_STOP_LIVENESS } from "./server/proxy-liveness"; | ||
| import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs"; | ||
| import { homedir, tmpdir } from "node:os"; | ||
| import { dirname, join, posix, resolve, win32 } from "node:path"; | ||
| import { 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"; | ||
|
|
@@ -67,6 +67,37 @@ function cliEntry(): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: str | |
| 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. | ||
| */ | ||
| function stableLauncherEntry(deps: { env?: NodeJS.ProcessEnv; exists?: (path: string) => boolean } = {}): string | null { | ||
| const env = deps.env ?? process.env; | ||
| const exists = deps.exists ?? existsSync; | ||
| const entries = (env.PATH ?? "").split(":"); | ||
| for (const entry of entries) { | ||
| if (!entry || !isAbsolute(entry)) continue; | ||
| const candidate = join(entry, "ocx"); | ||
| if (exists(candidate)) return candidate; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- available knowledge files ---'
find /tmp/coderabbit-repo-knowledge/lidge-jun-opencodex-7afea732 -maxdepth 2 -type f -print | sort
printf '%s\n' '--- scoped convention headers ---'
for f in /tmp/coderabbit-repo-knowledge/lidge-jun-opencodex-7afea732/*/*.md; do
[ -f "$f" ] || continue
printf '%s\n' "### $f"
head -5 "$f"
done
printf '%s\n' '--- service.ts outline ---'
ast-grep outline src/service.ts
printf '%s\n' '--- service.ts targeted source ---'
sed -n '1,180p' src/service.tsRepository: lidge-jun/opencodex Length of output: 32287 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- src convention ---'
cat /tmp/coderabbit-repo-knowledge/lidge-jun-opencodex-7afea732/conventions/src.md
printf '%s\n' '--- tests convention ---'
cat /tmp/coderabbit-repo-knowledge/lidge-jun-opencodex-7afea732/conventions/tests.md
printf '%s\n' '--- src learnings ---'
cat /tmp/coderabbit-repo-knowledge/lidge-jun-opencodex-7afea732/learnings/src.md
printf '%s\n' '--- test learnings ---'
cat /tmp/coderabbit-repo-knowledge/lidge-jun-opencodex-7afea732/learnings/tests.md
printf '%s\n' '--- systemd and launcher source ---'
sed -n '510,565p' src/service.ts
sed -n '2570,2715p' src/service.ts
printf '%s\n' '--- launcher references ---'
rg -n -C 3 'stableLauncherEntry|buildServiceLauncherShellCommand|launcherPath|ExecStart' src testsRepository: lidge-jun/opencodex Length of output: 46530 Select an executable file before returning the launcher. At Require a regular executable file before returning the candidate. Continue scanning otherwise. Add a focused regression test for this PATH order. 🤖 Prompt for AI Agents |
||
| } | ||
| return null; | ||
| } | ||
|
|
||
| function plistPath(): string { | ||
| return join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`); | ||
| } | ||
|
|
@@ -155,6 +186,14 @@ 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; | ||
|
|
@@ -167,7 +206,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", "winswVersion", "winswSha256"] as const) { | ||
| 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) { | ||
|
|
@@ -178,14 +217,15 @@ export function parseServiceInstallState(value: unknown): ServiceInstallState | | |
| return state as unknown as ServiceInstallState; | ||
| } | ||
|
|
||
| function writeServiceInstallState(backend: ServiceBackend = "scheduler"): void { | ||
| 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 } : {}), | ||
| }; | ||
|
|
@@ -501,6 +541,17 @@ 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 | ||
|
|
@@ -2507,6 +2558,14 @@ 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; | ||
|
|
@@ -2527,24 +2586,33 @@ function unitPath(): string { | |
| return join(unitDir(), `${TASK}.service`); | ||
| } | ||
|
|
||
| export function buildUnit(proxyEnv: { name: string; value: string }[] = resolvedProxyEnv()): string { | ||
| export function buildUnit( | ||
| proxyEnv: { name: string; value: string }[] = resolvedProxyEnv(), | ||
| deps: { launcher?: string | null } = {}, | ||
| ): string { | ||
| const { bun, bunRuntimeSource, cli } = cliEntry(); | ||
| // A stable launcher replaces the versioned pair entirely: baking OCX_BUN_RUNTIME_PATH | ||
| // alongside it would pin the runtime to the directory the upgrade deletes, which is the | ||
| // defect being fixed. The launcher resolves the current package's Bun itself. | ||
| const launcher = deps.launcher !== undefined ? deps.launcher : stableLauncherEntry(); | ||
| 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"), | ||
| systemdEnvironmentAssignment(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource), | ||
| systemdEnvironmentAssignment(BUN_RUNTIME_PATH_ENV, bun), | ||
| ...(launcher ? [] : [ | ||
| systemdEnvironmentAssignment(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource), | ||
| systemdEnvironmentAssignment(BUN_RUNTIME_PATH_ENV, bun), | ||
| ]), | ||
|
Comment on lines
+2605
to
+2608
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a Linux service install or repair is run through the npm launcher with AGENTS.md reference: src/AGENTS.md:L10-L10 Useful? React with 👍 / 👎. |
||
| systemdEnvironmentAssignment("PATH", path), | ||
| codexHome, | ||
| codexSqliteHome, | ||
| opencodexHome, | ||
| ...proxyEnv.map(({ name, value }) => systemdEnvironmentAssignment(name, value)), | ||
| ].filter((line): line is string => Boolean(line)).join("\n"); | ||
| const command = `${buildServiceShellCommand(bun, cli)} >> ${shellQuote(log)} 2>&1`; | ||
| const command = `${launcher ? buildServiceLauncherShellCommand(launcher) : buildServiceShellCommand(bun, cli)} >> ${shellQuote(log)} 2>&1`; | ||
| return `[Unit] | ||
| Description=OpenCodex Proxy Server | ||
| After=network-online.target | ||
|
|
@@ -2602,11 +2670,14 @@ function installSystemd(): void { | |
| recordOwnedConfigPath(getConfigDir(), serviceStatePath()); | ||
| if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true }); | ||
| writeServiceApiTokenFile(); | ||
| writeServiceDefinitionFile(unitPath(), buildUnit(), "utf8"); | ||
| // 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"); | ||
| sh("systemctl --user daemon-reload"); | ||
| sh(`systemctl --user enable ${TASK}`); | ||
| sh(`systemctl --user restart ${TASK}`); | ||
| writeServiceInstallState(); | ||
| writeServiceInstallState("scheduler", launcher); | ||
| } | ||
| /** | ||
| * Whether systemd's in-memory unit differs from the file on disk. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If an earlier absolute
PATHdirectory contains a non-executable file or directory namedocx, this existence-only check records it as the launcher even though command lookup cannot execute it. The installation then reports success, but systemd receivesEACCES/EISDIRand restart-loops;bakedServicePathsDiagnostic()also reports the service healthy while that path continues to exist. Verify that each candidate is a regular executable, for example withstatSyncandaccessSync(..., X_OK), before selecting it or continuing to a later entry.Useful? React with 👍 / 👎.