From 4a796cdfe0fe26b26c7d1f6254d99b49be7c8d10 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 5 Sep 2026 08:31:41 +0900 Subject: [PATCH 1/5] =?UTF-8?q?docs(devlog):=20wp4=20P=20stale-check=20?= =?UTF-8?q?=E2=80=94=20launchdStart=20match=20must=20follow=20the=20launch?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../030_wp4_launchd_stable_launcher.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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..ea5bf4b40a 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`. From fd7512767b0b80644eef449f332abae2f4391d30 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 5 Sep 2026 08:33:17 +0900 Subject: [PATCH 2/5] =?UTF-8?q?docs(devlog):=20wp4=20audit=20fold=20?= =?UTF-8?q?=E2=80=94=20status=20report=20follows=20the=20launcher=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../030_wp4_launchd_stable_launcher.md | 7 +++++++ 1 file changed, 7 insertions(+) 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 ea5bf4b40a..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 @@ -175,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. + From bd85598d67a73355fefbb7a01df04cece4049607 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 5 Sep 2026 08:35:24 +0900 Subject: [PATCH 3/5] fix(service): bake the stable ocx launcher into the launchd plist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a mise/asdf upgrade the installed CLI was 2.42.0 while the launchd proxy kept serving 2.10.1-preview: buildPlist baked the package-local Bun + CLI pair, so launchd never saw the new package until someone restarted the service by hand (#3464). systemd had already moved to stableLauncherEntry() for exactly this failure (#2898 / #2909); launchd is the macOS counterpart. - buildPlist takes the same { launcher, runtime } deps as buildUnit; with a launcher the job execs that path and omits the OCX_BUN_RUNTIME_* provenance pair, keeping only a proof-bound OPENCODEX_BUN_PATH override. Without one the plist is byte-identical. - installLaunchd resolves the launcher once, passes the same value to the plist and to install state (launcherPath), so the stale-path diagnostic judges what launchd runs. - startLaunchd and serviceStatusReport compared the live job against the Bun + CLI command; a launcher-backed job would have read as "an OLDER plist" on every healthy start. expectedLaunchdCommand(port) follows the recorded launcherPath (never re-walks PATH) and both callers use the installed port. This fixes which build the next launchd start runs. It does not replace an already running proxy after an external upgrade — that remains a restart / repair step, noted in the docs — so the issue stays open for that half. Refs #3464 --- .../content/docs/reference/cli/lifecycle.md | 18 ++-- src/service.ts | 71 ++++++++++---- structure/04_transports-and-sidecars.md | 12 ++- tests/service/service.test.ts | 96 ++++++++++++++++++- 4 files changed, 168 insertions(+), 29 deletions(-) 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..d56da892c3 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 = "/Users/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 = "/Users/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 = "/Users/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: "/Users/u/.local/share/mise/shims/ocx" }, + entry, + }); + expect(command).toContain("exec '/Users/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" }); From b4fbd293f4970889e7c1581740ccd68392d196d2 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 5 Sep 2026 08:35:41 +0900 Subject: [PATCH 4/5] test(service): keep launcher fixtures out of the home-path privacy scan --- tests/service/service.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/service/service.test.ts b/tests/service/service.test.ts index d56da892c3..e36a2785b6 100644 --- a/tests/service/service.test.ts +++ b/tests/service/service.test.ts @@ -1187,7 +1187,7 @@ describe("launchd service plist", () => { // 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 = "/Users/u/.local/share/mise/shims/ocx"; + 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" }, @@ -1216,7 +1216,7 @@ describe("launchd service plist", () => { }); test("launcher mode preserves only a proof-bound Bun override, never an ambient one (#3464)", () => { - const launcher = "/Users/u/.local/share/mise/shims/ocx"; + const launcher = "/home/u/.local/share/mise/shims/ocx"; const trusted = buildPlist(resolvedProxyEnv({}), { launcher, runtime: { path: "/custom/bun", source: "override", overrideEnv: "OPENCODEX_BUN_PATH" }, @@ -1233,7 +1233,7 @@ describe("launchd service plist", () => { }); test("launcher paths with shell and XML metacharacters stay quoted in the plist (#3464)", () => { - const launcher = "/Users/u/My Tools & Shims/it's/ocx"; + 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" }, @@ -3215,10 +3215,10 @@ describe("launchctl load verification", () => { 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: "/Users/u/.local/share/mise/shims/ocx" }, + state: { ...base, bunPath: entry.bun, cliPath: entry.cli, launcherPath: "/home/u/.local/share/mise/shims/ocx" }, entry, }); - expect(command).toContain("exec '/Users/u/.local/share/mise/shims/ocx' start --port 14001"); + expect(command).toContain("exec '/home/u/.local/share/mise/shims/ocx' start --port 14001"); expect(command).not.toContain(entry.cli); }); From 1314498568e5fd1a39a50d3f46ecb1097aa41deb Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 5 Sep 2026 08:36:18 +0900 Subject: [PATCH 5/5] =?UTF-8?q?docs(devlog):=20ledger=20=E2=80=94=20#3554?= =?UTF-8?q?=20opened?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devlog/_plan/260905_bug_triage_stack/060_ledger.md | 1 + 1 file changed, 1 insertion(+) 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 |