diff --git a/devlog/_plan/260905_bug_triage_stack/030_wp4_launchd_stable_launcher.md b/devlog/_plan/260905_bug_triage_stack/030_wp4_launchd_stable_launcher.md
index e65fde130e..ad3d3cd656 100644
--- a/devlog/_plan/260905_bug_triage_stack/030_wp4_launchd_stable_launcher.md
+++ b/devlog/_plan/260905_bug_triage_stack/030_wp4_launchd_stable_launcher.md
@@ -6,6 +6,18 @@ against the current tree at this cycle's P before implementing.
---
+## wp4 P stale-check + amendments (binding over the lane text below)
+
+Stale-check at 37622b92d (stack pos 2 head): `cliEntry(runtime)` :66, `writeServiceInstallState(backend, launcherPath)` :238, `buildPlist` :489-527, `installLaunchd` :2244-2272, `buildServiceLauncherShellCommand` :567, `buildUnit` launcher/env shape :3268-3301, `bakedServicePathsDiagnostic` launcher-aware :3236 — all as the lane text assumed.
+
+Amendment (missed by the lane): `launchdStart` (:2281-2300) decides "already loaded from the current plist" by matching the live `launchctl print` output against `buildServiceShellCommand(entry.bun, entry.cli)`. After this change a launcher-baked plist would never match that string, so every healthy `ocx service start` on macOS would fall through to the failure branch. Fix: derive the expected command from the recorded install state — `readServiceInstallState()?.launcherPath` present ⇒ `buildServiceLauncherShellCommand(launcherPath)`, else the Bun+CLI pair — and pin it with a test in the existing `launchdStart` describe.
+
+`ServiceInstallState.launcherPath` doc comment says "Linux only"; update to "launchd and systemd".
+
+Docs: lifecycle.md :245/:256 as the lane text says; structure/04 :36 generalised. Optional `version-skew.ts` wording change is **deferred** (separate concern, keeps this PR to launcher parity).
+
+---
+
1) VERDICT: FIXABLE
Launchd parity is **not fixed**. The stable launcher fixes which version starts next; it does **not** replace an already-running proxy after `mise upgrade`.
@@ -163,3 +175,10 @@ Risk: explicit security review is warranted under [MAINTAINERS.md:60](/Users/jun
- Stable-launcher parity does **not** satisfy automatic repair/request refusal during an already-running version mismatch. That remains a separate product decision.
- Actual mise shim selection under launchd’s login-shell environment needs a macOS smoke check; no live service or real upgrade was exercised.
- Read-only investigation only: no files, Git state, GitHub comments, service state or tests were changed/run.
+
+Audit round 1 fold (GO-WITH-FIXES, 1 blocker): `serviceStatusReport()` (:4243) compares the live
+job against `buildServiceShellCommand(entry.bun, entry.cli, installedServiceListenPort())` too —
+same fix as `launchdStart`: one helper `expectedLaunchdCommand(port)` that reads
+`readServiceInstallState()?.launcherPath` and returns the launcher command when present, else the
+Bun+CLI pair (never rediscovering PATH at start/status). `launchdStart` also gets the installed port.
+
diff --git a/devlog/_plan/260905_bug_triage_stack/060_ledger.md b/devlog/_plan/260905_bug_triage_stack/060_ledger.md
index a9d73d4993..1a2f2eabc6 100644
--- a/devlog/_plan/260905_bug_triage_stack/060_ledger.md
+++ b/devlog/_plan/260905_bug_triage_stack/060_ledger.md
@@ -8,3 +8,4 @@ Append-only. One row per external action or verification.
| 2026-09-04T22:53Z | #3467 / PR #3547 | pushed fd1dbbedb (--no-verify), PR opened base dev | typecheck 0; focused 48 pass; privacy ok; CI trailing |
| 2026-09-04T23:16Z | #3462 / PR #3551 | pushed 9611cdf4b (--no-verify), PR opened base #3547 head | typecheck 0; focused 71 pass; privacy ok; CI trailing |
+| 2026-09-04T23:36Z | #3464 / PR #3554 | pushed b4fbd293f (--no-verify), PR opened base #3551 head, Refs not Closes | typecheck 0; service.test.ts 199 pass; privacy ok; CI trailing |
diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md
index 3f3e286863..93c6fa63f3 100644
--- a/docs-site/src/content/docs/reference/cli/lifecycle.md
+++ b/docs-site/src/content/docs/reference/cli/lifecycle.md
@@ -242,18 +242,22 @@ 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
+On macOS and Linux, the launchd plist and the systemd unit invoke 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
+used to leave the service definition pointing at files that no longer existed — systemd then
+restart-looped while still reporting the service as installed, and launchd kept the old build serving
+until it was restarted by hand. A shim path survives the upgrade, so the definition 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
+Definitions 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.
+`ocx service repair` once after upgrading; after that, each service start follows the launcher.
+An already-running proxy is not replaced by an external upgrade: restart the service (or run
+`ocx service repair`) so the new build serves, and treat a CLI/proxy version mismatch warning as
+exactly that signal.
| Subcommand | Action |
| --- | --- |
diff --git a/src/service.ts b/src/service.ts
index 84b7da3817..b37e88c3db 100644
--- a/src/service.ts
+++ b/src/service.ts
@@ -205,9 +205,9 @@ export interface ServiceInstallState {
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
+ * 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.
*/
@@ -486,8 +486,21 @@ function writeServiceApiTokenFile(): string | null {
return path;
}
-export function buildPlist(proxyEnv: { name: string; value: string }[] = resolvedProxyEnv()): string {
- const { bun, bunRuntimeSource, cli } = cliEntry();
+/**
+ * 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();
@@ -495,8 +508,16 @@ export function buildPlist(proxyEnv: { name: string; value: string }[] = resolve
const opencodexHome = process.env.OPENCODEX_HOME?.trim();
const envLines = [
` OCX_SERVICE1`,
- ` ${BUN_RUNTIME_SOURCE_ENV}${bunRuntimeSource}`,
- ` ${BUN_RUNTIME_PATH_ENV}${plistString(bun)}`,
+ ...(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,
@@ -504,7 +525,9 @@ export function buildPlist(proxyEnv: { name: string; value: string }[] = resolve
...proxyEnv.map(({ name, value }) =>
` ${name}${plistString(value)}`),
].filter((line): line is string => Boolean(line)).join("\n");
- const command = buildServiceShellCommand(bun, cli);
+ const command = launcher
+ ? buildServiceLauncherShellCommand(launcher)
+ : buildServiceShellCommand(bun, cli);
return `
@@ -569,6 +592,23 @@ function buildServiceLauncherShellCommand(launcher: string, port = resolveServic
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 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
@@ -2251,7 +2291,10 @@ function installLaunchd(): void {
// Capture this BEFORE writing: the write below makes the plist exist unconditionally,
// so a post-write existsSync would call every fresh install an "installed" service.
const wasInstalled = existsSync(p);
- writeServiceDefinitionFile(p, buildPlist(), "utf8");
+ // Resolve the launcher ONCE and hand the same value to the plist and to install state,
+ // so the staleness diagnostic judges exactly what launchd runs.
+ const launcher = stableLauncherEntry();
+ writeServiceDefinitionFile(p, buildPlist(resolvedProxyEnv(), { launcher }), "utf8");
// Best-effort: an absent job is fine here, and a failed unload is caught by the
// load verification below with a better message than a raw unload error.
runLaunchctl(["unload", p]);
@@ -2268,7 +2311,7 @@ function installLaunchd(): void {
+ `then re-run '${wasInstalled ? "ocx service repair" : "ocx service install"}'.`,
);
}
- writeServiceInstallState();
+ writeServiceInstallState("scheduler", launcher);
}
/**
* Deps are named for the layer they replace, not for the process API: `launchctl`
@@ -2291,9 +2334,8 @@ export function startLaunchd(deps: {
// already be bootstrapped from THIS plist, which is a no-op rather than an error.
// `install` can assume a stale job (it just rewrote the plist); `start` cannot, and
// throwing here would break `ocx service start` on every healthy service.
- const entry = cliEntry();
const live = (deps.matches ?? launchdJobMatchesPlist)(
- buildServiceShellCommand(entry.bun, entry.cli),
+ expectedLaunchdCommand(installedServiceListenPort()),
);
if (live.loaded && live.matchesPlist) {
console.log("ℹ️ service was already loaded from the current plist; nothing to do.");
@@ -4234,14 +4276,11 @@ export async function serviceStatusReport(
// Linux/Windows and make the stale-plist case untestable there.
const stalePlist = deps.matchesPlist?.() ?? (process.platform === "darwin"
? (() => {
- const entry = cliEntry();
// Pass the INSTALLED port explicitly: the default third argument is
// resolveServiceListenPort(), which reads OCX_BAKE_PORT/config.port, so after
// a config edit the expected string would never match and every run would
// print a false "OLDER plist".
- return launchdJobMatchesPlist(
- buildServiceShellCommand(entry.bun, entry.cli, installedServiceListenPort()),
- );
+ return launchdJobMatchesPlist(expectedLaunchdCommand(installedServiceListenPort()));
})()
: null);
const staleLine = stalePlist && stalePlist.loaded && !stalePlist.matchesPlist
diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md
index 7dc0745c99..c5efc0d5e8 100644
--- a/structure/04_transports-and-sidecars.md
+++ b/structure/04_transports-and-sidecars.md
@@ -33,19 +33,21 @@ enumeration twice made a measured 12.3-second fallback cost roughly 25 seconds b
- 다른 대안 대신 이 방식을 선택한 이유: Removing or weakening revalidation widens the install race, while a global/TTL cache can outlive startup and stale absence can authorize the wrong home. Exact targeted-result identity lets the ordinary no-task locale fallback coalesce without hiding changed evidence.
- 장점, 단점 및 영향: The reported stable zh-CN absence path performs two cheap targeted queries and one full listing. A task that appears is detected by the second targeted query; changed or failed evidence triggers a fresh fail-closed decision, so unusual churn may still pay for two listings rather than guess.
-## Linux stable service launcher
+## Stable service launcher (launchd and systemd)
-Systemd installation resolves the first absolute `ocx` PATH candidate that is both a regular file
+Launchd and systemd installation resolve the first absolute `ocx` PATH candidate that is both a regular file
and executable, keeps that path lexical so a version-manager shim remains an indirection, and
-records the same single resolution in the unit and service state. Unit construction never performs
-PATH discovery itself: callers provide either the resolved launcher or an explicit direct Bun/CLI
+records the same single resolution in the service definition and service state. Definition
+construction (`buildPlist`, `buildUnit`) never performs PATH discovery itself: callers provide either the resolved launcher or an explicit direct Bun/CLI
fallback, keeping diagnostics and tests independent of the host PATH.
Launcher mode omits the package-local Bun provenance pair because an upgrade may delete that
versioned tree. The only runtime path carried through the launcher is a pre-Bun, proof-bound
`OPENCODEX_BUN_PATH` whose durable runtime source is `override`; bundled and process fallbacks are
rediscovered by the current launcher. The API-auth token remains file-backed and is loaded only by
-the service shell at start.
+the service shell at start. On macOS, `start` and detailed `status` compare the live launchd job
+against `expectedLaunchdCommand`, which follows the recorded `launcherPath` rather than re-walking
+PATH, so a launcher-backed job is never misreported as an older plist (#3464).
[Decision Log]
- 목적과 의도: Keep systemd services upgrade-stable without losing an explicitly trusted Bun override or accepting a non-executable PATH placeholder.
diff --git a/tests/service/service.test.ts b/tests/service/service.test.ts
index 8c9d10cb8a..e36a2785b6 100644
--- a/tests/service/service.test.ts
+++ b/tests/service/service.test.ts
@@ -7,7 +7,7 @@ import { pathToFileURL } from "node:url";
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 as buildWindowsTaskXmlProduction, buildWindowsTaskXmlDocument, deriveWindowsServiceDiagnostic, deriveWindowsServiceDiagnosticForCurrentUser, installFreshWindowsSchedulerSafely, installServiceSafely, launchctlLoadFailed, launchdJobMatchesPlist, normalizeServiceSubcommand, parseServiceArgs, parseServiceInstallState, planServiceCommand, prepareServiceInstall, probeServiceInstallation, readWindowsSchedulerXmlState, registerFreshWindowsSchedulerTask, removeNativeWindowsServiceForScheduler, repairService, reportServiceServing, resolveServiceListenPort, runLaunchctl, selectServiceSubcommand, SERVICE_INSTALL_HEALTH_MS, SERVICE_INSTALL_HEALTH_WINDOWS_MS, serviceInstallHealthMs, serviceLogPath, serviceStartableFromTray, serviceStatusReport, serviceRetryCommand, serviceStatusSummary, stableLauncherEntry, systemdNeedsDaemonReload, systemdServiceInstallCleanupOps, uninstallSystemd, windowsListenPort, winswListenPort, startLaunchd, windowsTaskRegistrationHealthy as windowsTaskRegistrationHealthyProduction } from "../../src/service";
+import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, confirmServiceServing, launchdListenPort, systemdListenPort, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsSchtasksCreateArgsForXml, buildWindowsServiceScript, buildWindowsTaskXml as buildWindowsTaskXmlProduction, buildWindowsTaskXmlDocument, deriveWindowsServiceDiagnostic, deriveWindowsServiceDiagnosticForCurrentUser, expectedLaunchdCommand, installFreshWindowsSchedulerSafely, installServiceSafely, launchctlLoadFailed, launchdJobMatchesPlist, normalizeServiceSubcommand, parseServiceArgs, parseServiceInstallState, planServiceCommand, prepareServiceInstall, probeServiceInstallation, readWindowsSchedulerXmlState, registerFreshWindowsSchedulerTask, removeNativeWindowsServiceForScheduler, repairService, reportServiceServing, resolveServiceListenPort, runLaunchctl, selectServiceSubcommand, SERVICE_INSTALL_HEALTH_MS, SERVICE_INSTALL_HEALTH_WINDOWS_MS, serviceInstallHealthMs, serviceLogPath, serviceStartableFromTray, serviceStatusReport, serviceRetryCommand, serviceStatusSummary, stableLauncherEntry, systemdNeedsDaemonReload, systemdServiceInstallCleanupOps, uninstallSystemd, windowsListenPort, winswListenPort, startLaunchd, windowsTaskRegistrationHealthy as windowsTaskRegistrationHealthyProduction } from "../../src/service";
import type { ServiceDiagnostic } from "../../src/service";
import { definitionCarriesCredential, resolvedProxyEnv, writeServiceDefinitionFile } from "../../src/service";
import { buildWinswXml } from "../../src/lib/winsw";
@@ -1182,6 +1182,68 @@ describe("launchd service plist", () => {
expect(direct).toContain("OCX_BUN_RUNTIME_PATH");
});
+
+ // #3464. The macOS counterpart of the systemd launcher test above: a mise/asdf upgrade replaces
+ // the versioned package directory, and a plist that named the old Bun + CLI pair keeps launchd
+ // on the stale build until someone restarts it. Naming the shim lets the next start follow it.
+ test("a stable launcher install names the launcher in the plist and bakes no versioned path (#3464)", () => {
+ const launcher = "/home/u/.local/share/mise/shims/ocx";
+ const plist = buildPlist(resolvedProxyEnv({}), {
+ launcher,
+ runtime: { path: "/opt/opencodex/versioned/bun", source: "bundled", overrideEnv: "OPENCODEX_BUN_PATH" },
+ });
+
+ expect(plist).toContain(launcher);
+ expect(plist).toContain("start --port");
+ for (const forbidden of [
+ "OCX_BUN_RUNTIME_PATH",
+ "OCX_BUN_RUNTIME_SOURCE",
+ "OPENCODEX_BUN_PATH",
+ "/opt/opencodex/versioned/bun",
+ "cli/index.ts",
+ ]) expect(plist).not.toContain(forbidden);
+ // The token still comes from the file at start, never from the plist.
+ expectTextToContainPath(plist, serviceApiTokenFilePath());
+ expect(plist).toContain("OPENCODEX_API_AUTH_TOKEN");
+ // launchdListenPort reads the same "start --port N" tail from either command shape.
+ expect(launchdListenPort({ readPlist: () => plist })).toBe(resolveServiceListenPort());
+
+ // Without a launcher the plist keeps the previous shape, so source checkouts are unaffected.
+ const direct = buildPlist(resolvedProxyEnv({}), { launcher: null });
+ expectTextToContainPath(direct, join("cli", "index.ts"));
+ expect(direct).toContain("OCX_BUN_RUNTIME_PATH");
+ expect(direct).toContain("OCX_BUN_RUNTIME_SOURCE");
+ });
+
+ test("launcher mode preserves only a proof-bound Bun override, never an ambient one (#3464)", () => {
+ const launcher = "/home/u/.local/share/mise/shims/ocx";
+ const trusted = buildPlist(resolvedProxyEnv({}), {
+ launcher,
+ runtime: { path: "/custom/bun", source: "override", overrideEnv: "OPENCODEX_BUN_PATH" },
+ });
+ expect(trusted).toContain("OPENCODEX_BUN_PATH/custom/bun");
+ expect(trusted).not.toContain("OCX_BUN_RUNTIME_PATH");
+
+ const bundled = buildPlist(resolvedProxyEnv({}), {
+ launcher,
+ runtime: { path: "/custom/bun", source: "bundled", overrideEnv: "OPENCODEX_BUN_PATH" },
+ });
+ expect(bundled).not.toContain("OPENCODEX_BUN_PATH");
+ expect(bundled).not.toContain("/custom/bun");
+ });
+
+ test("launcher paths with shell and XML metacharacters stay quoted in the plist (#3464)", () => {
+ const launcher = "/home/u/My Tools & Shims/it's/ocx";
+ const plist = buildPlist(resolvedProxyEnv({}), {
+ launcher,
+ runtime: { path: "/opt/bun", source: "bundled", overrideEnv: "OPENCODEX_BUN_PATH" },
+ });
+ // XML-escaped ampersand inside the ProgramArguments string; the shell quoting survives.
+ expect(plist).toContain("&");
+ expect(plist).not.toContain("Shims/it's/ocx start");
+ expect(launchdListenPort({ readPlist: () => plist })).toBe(resolveServiceListenPort());
+ });
+
// 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", () => {
@@ -3143,6 +3205,38 @@ describe("launchctl load verification", () => {
});
});
+
+ // #3464. start and status compare the live job against the command the plist SHOULD carry.
+ // A launcher install carries the launcher line, so the comparison must follow the recorded
+ // install state or every healthy launcher-backed service reads as "an OLDER plist".
+ describe("expectedLaunchdCommand follows the recorded launcher", () => {
+ const entry = { bun: "/opt/opencodex/versioned/bun", cli: "/opt/opencodex/versioned/src/cli/index.ts" };
+ const base = { version: 2 as const, codexHome: "/h/.codex", opencodexHome: "/h/.opencodex", backend: "scheduler" as const };
+
+ test("a recorded launcher yields the launcher exec line at the installed port", () => {
+ const command = expectedLaunchdCommand(14001, {
+ state: { ...base, bunPath: entry.bun, cliPath: entry.cli, launcherPath: "/home/u/.local/share/mise/shims/ocx" },
+ entry,
+ });
+ expect(command).toContain("exec '/home/u/.local/share/mise/shims/ocx' start --port 14001");
+ expect(command).not.toContain(entry.cli);
+ });
+
+ test("v1 / legacy state without a launcher yields the Bun + CLI pair", () => {
+ const command = expectedLaunchdCommand(14001, {
+ state: { version: 1, codexHome: "/h/.codex", opencodexHome: "/h/.opencodex", bunPath: entry.bun, cliPath: entry.cli },
+ entry,
+ });
+ expect(command).toContain(`exec '${entry.bun}' '${entry.cli}' start --port 14001`);
+ });
+
+ test("missing state falls back to the Bun + CLI pair and never re-walks PATH", () => {
+ const command = expectedLaunchdCommand(14001, { state: null, entry });
+ expect(command).toContain(`exec '${entry.bun}' '${entry.cli}' start --port 14001`);
+ expect(command).not.toContain("shims/ocx");
+ });
+ });
+
describe("startLaunchd", () => {
// A runLaunchctl RESULT, not a spawnSync result.
const failedLoad = () => ({ ok: true, stdout: "", stderr: "Load failed: 5: Input/output error" });