diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 402eede4dc3..01fc80e472d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -589,6 +589,7 @@ "codex-runtime-wsl-desktop.test.ts": "codex-integration", "codex-service-manager-probe-hardening.test.ts": "codex-integration", "codex-service-manager-probe.test.ts": "codex-integration", + "codex-systemd-env-probe.test.ts": "codex-integration", "codex-shim-autorestore.test.ts": "codex-integration", "codex-shim-destroyed-probe.test.ts": "codex-integration", "codex-shim-ensure-failure.test.ts": "codex-integration", diff --git a/src/config/serving-runtimes.ts b/src/config/serving-runtimes.ts index 12a2492c033..9bd9cacac36 100644 --- a/src/config/serving-runtimes.ts +++ b/src/config/serving-runtimes.ts @@ -33,7 +33,8 @@ import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; import { atomicWriteFile } from "./atomic-write"; import { getConfigDir } from "./paths"; import { ConfigMutationLockError, withConfigMutationLockSync } from "./mutation-lock"; -import { SERVICE_MANAGED_ENV } from "../service/state"; +import { inspectServiceManagerInstallation, type ServiceManagerClaim } from "../service-manager-probe"; +import { compareServicePathToInstall, currentServiceHomes, SERVICE_MANAGED_ENV } from "../service/state"; import { WINDOWS_WRAPPER_PROTOCOL_ENV } from "../service/windows-wrapper-exit"; export function servingRuntimesPath(dir: string = getConfigDir()): string { @@ -253,11 +254,38 @@ export function selectNewerServingRuntime( return best === null ? null : { ...best.record, version: best.version }; } +/** + * Whether the census names a command different from self that records a newer + * version and still exists on disk. Cheap and spawn-free: use it to gate the + * expensive service-manager provenance probe instead of probing on every start. + */ +export function hasNewerRecordedServingRuntime( + selfVersion: string, + selfCommand: readonly string[], + deps: NewerServingRuntimeDeps = {}, +): boolean { + const self = parseStrictSemver(selfVersion); + if (self === null) return false; + const exists = deps.exists ?? existsSync; + const selfKey = servingRuntimeCommandKey(selfCommand); + return readServingRuntimes(deps.dir ?? getConfigDir()) + .filter(record => servingRuntimeCommandKey(record.command) !== selfKey) + .filter(record => { + const recorded = parseStrictSemver(record.version); + return recorded !== null && compareStrictSemver(recorded, self) > 0; + }) + .some(record => record.command.every(part => exists(part) && trustedRecordedPath(part))); +} + export interface DeferToNewerRuntimeDeps extends NewerServingRuntimeDeps { readonly runInherited?: (command: readonly string[], args: readonly string[]) => Promise; readonly log?: (line: string) => void; /** Environment the service manager gave this child; decides its stay-out exit code. */ readonly env?: NodeJS.ProcessEnv; + /** Test seam for the manager-definition provenance check at the pre-probe gate. */ + readonly installedServiceOwnsCurrentHome?: () => boolean; + /** Test seam for the recorded-candidate gate that keeps the manager probe off empty censuses. */ + readonly hasNewerRecordedRuntime?: () => boolean; } interface DelegatedExit { readonly exitCode: number; readonly ready: boolean } @@ -397,9 +425,47 @@ export async function deferServiceChildToNewerRuntime(options: { readonly deps?: DeferToNewerRuntimeDeps; }): Promise { if (options.sibling || !isManagedServiceEnvironment(options.env) || options.env[DELEGATED_ONCE_ENV] === "1") return null; + // Cheap gate first: with no recorded newer candidate there is nothing to delegate to, so + // the manager-definition probe (a potential full Task Scheduler listing) must not run. + const hasCandidate = options.deps?.hasNewerRecordedRuntime + ?? (() => hasNewerRecordedServingRuntime(options.selfVersion, options.selfCommand, options.deps)); + if (!hasCandidate()) return null; + const installedServiceOwnsCurrentHome = options.deps?.installedServiceOwnsCurrentHome + ?? serviceManagerOwnsCurrentHome; + // Bun may populate the live environment from a project-controlled dotenv file. Markers + // therefore identify the service protocol but are not authority to execute census paths: + // require a live manager registration whose generated definition names this home first. + if (!installedServiceOwnsCurrentHome()) return null; return deferToNewerServiceRuntime(options.selfVersion, options.selfCommand, options.port, { env: options.env, ...options.deps }); } +/** + * A claim's null home key means the definition omitted it, which only happens for a + * default-home install. Such a claim authorizes nothing when the current process resolved + * a non-default home — otherwise a project .env setting the env var plus the service marker + * would borrow a default install's registration for a project-chosen census directory. + */ +export function serviceClaimMatchesCurrentHomes( + claim: ServiceManagerClaim, + current: { codexHome: string; opencodexHome: string }, + env: NodeJS.ProcessEnv = process.env, +): boolean { + return (claim.homes.codexHome === null + ? !env.CODEX_HOME?.trim() + : compareServicePathToInstall(claim.homes.codexHome, current.codexHome) === "same") + && (claim.homes.opencodexHome === null + ? !env.OPENCODEX_HOME?.trim() + : compareServicePathToInstall(claim.homes.opencodexHome, current.opencodexHome) === "same"); +} + +function serviceManagerOwnsCurrentHome(): boolean { + const current = currentServiceHomes(); + const installation = inspectServiceManagerInstallation({ configDir: current.opencodexHome }); + if (installation.kind !== "present") return false; + return installation.claims.some(claim => claim.registration === "present" + && serviceClaimMatchesCurrentHomes(claim, current)); +} + /** * A child a service manager started, as its environment proves it. launchd, systemd, and * current or repaired WinSW definitions write `OCX_SERVICE_MANAGED=1`; the Windows Task diff --git a/src/service-manager-probe.ts b/src/service-manager-probe.ts index 418987e990c..5b66b1109ca 100644 --- a/src/service-manager-probe.ts +++ b/src/service-manager-probe.ts @@ -32,6 +32,7 @@ import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV } from "./lib/bun-runtime" import { WINDOWS_WRAPPER_PROTOCOL_ENV, WINDOWS_WRAPPER_STAY_OUT_EXIT_CODE } from "./service/windows-wrapper-exit"; import { buildWindowsServiceScript, windowsTaskActionMatches } from "./service/windows-taskxml"; import { inspectServiceStateEvidence, serviceStatePathsForOpenCodexHome } from "./service/state"; +import { parseSystemdUnitHomes } from "./service/systemd-env"; /** Short: this runs inside admission, and a slow answer is the same as none. */ export const SERVICE_PROBE_TIMEOUT_MS = 2_000; @@ -238,21 +239,23 @@ function unknown(reason: string): ServiceManagerInstallation { return { kind: "unknown", reason }; } +/** Decode the entities buildPlist's plistString applies. `&` goes last so a + * literal `&lt;` written by a double-escaped value stays `<`. */ +function plistStringValue(raw: string): string { + return raw + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, "\"") + .replace(/'/g, "'") + .replace(/&/g, "&"); +} + /** Pull `NAMEVALUE` out of a plist body. */ function plistEnvValue(body: string, key: string): string | null { const match = body.match( new RegExp(`\\s*${key}\\s*\\s*([^<]*)`), ); - return match ? match[1] : null; -} - -/** Pull `Environment="NAME=VALUE"` (quoted or bare) out of a systemd unit. */ -function unitEnvValue(body: string, key: string): string | null { - for (const line of body.split("\n")) { - const match = line.match(new RegExp(`^\\s*Environment=\\s*"?${key}=([^"\\n]*)"?\\s*$`)); - if (match) return match[1]; - } - return null; + return match ? plistStringValue(match[1]) : null; } /** @@ -321,15 +324,14 @@ function inspectSystemdOffline(home: string): ServiceManagerInstallation { } catch (error) { return unknown(`the session bus is unreachable and the systemd unit could not be read: ${String(error)}`); } + const parsed = parseSystemdUnitHomes(body); + if (parsed.kind === "invalid") return unknown("the systemd unit has unsupported or ambiguous environment assignments"); return { kind: "present", claims: [{ backend: "systemd", definitionPath, - homes: { - codexHome: unitEnvValue(body, "CODEX_HOME"), - opencodexHome: unitEnvValue(body, "OPENCODEX_HOME"), - }, + homes: parsed.homes, registration: "absent", }], }; @@ -463,16 +465,14 @@ function inspectSystemd(deps: Required>): Servic } catch (error) { return unknown(`the systemd unit exists but could not be read: ${String(error)}`); } - + const parsed = parseSystemdUnitHomes(body); + if (parsed.kind === "invalid") return unknown("the systemd unit has unsupported or ambiguous environment assignments"); return { kind: "present", claims: [{ backend: "systemd", definitionPath, - homes: { - codexHome: unitEnvValue(body, "CODEX_HOME"), - opencodexHome: unitEnvValue(body, "OPENCODEX_HOME"), - }, + homes: parsed.homes, registration, }], }; diff --git a/src/service/systemd-env.ts b/src/service/systemd-env.ts new file mode 100644 index 00000000000..7ecfd44a52f --- /dev/null +++ b/src/service/systemd-env.ts @@ -0,0 +1,75 @@ +type SystemdHomes = { codexHome: string | null; opencodexHome: string | null }; +type SystemdHomeParse = { kind: "parsed"; homes: SystemdHomes } | { kind: "invalid" }; + +/** Inverse of systemd.ts's generated quoted values, without expanding systemd specifiers. */ +function decodeQuoted(value: string): string | undefined { + let decoded = ""; + for (let index = 0; index < value.length; index++) { + const char = value[index]!; + if (char === "%") { + if (value[++index] !== "%") return undefined; + decoded += "%"; + } else if (char === "\\") { + const escaped = value[++index]; + if (escaped === "n") decoded += "\n"; + else if (escaped === "\\" || escaped === '"') decoded += escaped; + else return undefined; + } else if (char === '"' || char === "\0") { + return undefined; + } else { + decoded += char; + } + } + return decoded; +} + +/** + * Missing homes are null; unsupported or ambiguous assignments invalidate the entire definition. + * + * A directive name is everything before the first `=`, stripped — systemd matches that + * name literally and case-sensitively, so only the exact `Environment` can carry the + * home assignments this parser decodes. Every other name must be a plain + * non-environment identifier to be skipped: `EnvironmentFile=`, `PassEnvironment=` and + * `UnsetEnvironment=` mutate the applied environment the same way, an escaped or + * malformed name cannot be proven inert, and a line ending in an odd number of + * backslashes continues into the next under systemd's parser rather than standing on + * its own — all of them invalidate the definition instead of passing over silently. + */ +export function parseSystemdUnitHomes(body: string): SystemdHomeParse { + const homes: SystemdHomes = { codexHome: null, opencodexHome: null }; + for (const rawLine of body.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || /^[#;\[]/.test(line)) continue; + // systemd folds physical continuations before recognizing directive names. The writer never + // emits them; accepting individual lines could hide an override or invent a home assignment. + const trailingBackslashes = /\\+$/.exec(rawLine)?.[0].length ?? 0; + if (trailingBackslashes % 2 === 1) return { kind: "invalid" }; + const separator = line.indexOf("="); + const lvalue = (separator === -1 ? line : line.slice(0, separator)).trim(); + if (lvalue !== "Environment") { + if (!/^[A-Za-z][A-Za-z0-9_.\-]*$/.test(lvalue) || lvalue.includes("Environment")) { + return { kind: "invalid" }; + } + continue; + } + const directive = /^Environment\s*=\s*(.*)$/.exec(line); + if (!directive) return { kind: "invalid" }; + const encoded = directive[1]!; + let assignment: string | undefined; + if (encoded.startsWith('"') && encoded.endsWith('"')) { + assignment = decodeQuoted(encoded.slice(1, -1)); + } else if (encoded.length > 0 && !/[\s"'\\%\0]/.test(encoded)) { + // Older writers emitted one unquoted assignment. Anything needing quoting stays unknown. + assignment = encoded; + } + if (assignment === undefined) return { kind: "invalid" }; + const pair = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s.exec(assignment); + if (!pair) return { kind: "invalid" }; + const name = pair[1]!; + if (name !== "CODEX_HOME" && name !== "OPENCODEX_HOME") continue; + const key = name === "CODEX_HOME" ? "codexHome" : "opencodexHome"; + if (homes[key] !== null || pair[2] === "") return { kind: "invalid" }; + homes[key] = pair[2]!; + } + return { kind: "parsed", homes }; +} diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 23c0562120d..b2af9304843 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -32,7 +32,10 @@ wrapper-protocol marker. Legacy WinSW definitions carrying only `OCX_SERVICE=1` delegate until `ocx service repair` rewrites the XML. The census update uses the shared cross-process config mutation lock; recorded paths must resolve to files owned by the current user without group/world write permission on POSIX. Candidate -probes are newest-recorded first, capped at four three-second attempts; a failed probe +execution additionally requires a live service-manager registration whose generated +definition names the current homes; environment markers alone never authorize a census +probe because Bun can load them from a project dotenv file. Candidate probes are +newest-recorded first, capped at four three-second attempts; a failed probe falls through within that cap, and a failed launch or any pre-bind child exit (0 and the stay-out code included) leaves this install serving: its own lease-held bind fence then re-applies every stay-out condition, so a deliberate stand-down is still honored. A one-hop @@ -76,6 +79,17 @@ two naming different homes, and on macOS a logged-out user can have the plist on domain to query. The probe returns what it saw and does not decide ownership; callers such as `src/integrations/native/ownership-preflight.ts` compare the homes. Every command it runs is read-only and time-bounded, so it is safe while the proxy runs under that same manager. +Systemd home parsing in `src/service/systemd-env.ts` decodes the generated quoted escapes and +doubled percent signs, with legacy simple bare assignments retained. Unknown escapes, unresolved +specifiers, malformed quotes, resets and duplicate home assignments make the whole definition +unknown in both online and offline probes; they never become omitted homes for ownership comparison. +Non-comment physical line continuations also make the definition unknown before directive matching; +the generated format uses single physical lines, while systemd otherwise folds continuations first. +Directive names are matched literally like systemd's parser: only an exact `Environment` is +decoded, while env-bearing siblings (`EnvironmentFile=`, `PassEnvironment=`, `UnsetEnvironment=`), +escaped or malformed directive names, and `.include` all invalidate the definition instead of +being skipped, because a directive the parser ignored could still change the environment the +unit applies. On Windows, the generated-wrapper check accepts package installs that invoke the source CLI. A standalone wrapper that invokes `start` directly must carry the generated protocol and runtime markers, one quoted `OCX_BUN` assignment, and no `OCX_CLI` assignment in either quoting form. diff --git a/tests/codex-integration/codex-service-manager-probe.test.ts b/tests/codex-integration/codex-service-manager-probe.test.ts index 237f1c4dbac..47af65f0bb4 100644 --- a/tests/codex-integration/codex-service-manager-probe.test.ts +++ b/tests/codex-integration/codex-service-manager-probe.test.ts @@ -92,8 +92,8 @@ function writeUnit(codexHome: string, opencodexHome: string): string { const path = join(dir, "opencodex-proxy.service"); writeFileSync(path, [ "[Service]", - `Environment="CODEX_HOME=${codexHome}"`, - `Environment="OPENCODEX_HOME=${opencodexHome}"`, + `Environment=${JSON.stringify(`CODEX_HOME=${codexHome}`).replace(/%/g, "%%")}`, + `Environment=${JSON.stringify(`OPENCODEX_HOME=${opencodexHome}`).replace(/%/g, "%%")}`, ].join("\n")); return path; } @@ -190,6 +190,19 @@ describe("absence has to be proven twice", () => { expect(result.claims[0].homes).toEqual({ codexHome: "/somewhere/.codex", opencodexHome: "/somewhere/.opencodex" }); }); + test("XML-escaped home values decode back to the installed home", () => { + writePlist("/Users/example/R&D/.codex", "/Users/example/R&D/.opencodex"); + const { run } = recorder(() => ({ status: 113 })); + const result = inspectServiceManagerInstallation({ run, platform: "darwin", uid: 501, home }); + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + // The writer escapes XML; a raw compare would read a foreign home and skip delegation. + expect(result.claims[0].homes).toEqual({ + codexHome: "/Users/example/R&D/.codex", + opencodexHome: "/Users/example/R&D/.opencodex", + }); + }); + test("a registration with no definition file is unknown, not present", () => { const { run } = recorder(() => ({ status: 0, stdout: "state = running" })); const result = inspectServiceManagerInstallation({ run, platform: "darwin", uid: 501, home }); diff --git a/tests/codex-integration/codex-systemd-env-probe.test.ts b/tests/codex-integration/codex-systemd-env-probe.test.ts new file mode 100644 index 00000000000..5e2ca306321 --- /dev/null +++ b/tests/codex-integration/codex-systemd-env-probe.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildUnit } from "../../src/service/systemd"; +import { inspectServiceManagerInstallation, type ProbeRunner } from "../../src/service-manager-probe"; +import { inspectNativeCodexOwnership } from "../../src/integrations/native/ownership-preflight"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const envKeys = ["CODEX_HOME", "OPENCODEX_HOME", "CODEX_SQLITE_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME"] as const; +let previous: (string | undefined)[]; +let testHome: string; +let unitPath: string; +beforeEach(() => { + previous = envKeys.map(key => process.env[key]); + testHome = mkdtempSync(join(tmpdir(), "ocx-systemd-env-")); + process.env.XDG_CONFIG_HOME = join(testHome, ".config"); + process.env.XDG_DATA_HOME = join(testHome, ".local", "share"); + delete process.env.CODEX_SQLITE_HOME; + const unitDir = join(testHome, ".config", "systemd", "user"); + mkdirSync(unitDir, { recursive: true }); + unitPath = join(unitDir, "opencodex-proxy.service"); +}); +afterEach(() => { + envKeys.forEach((key, index) => { + if (previous[index] === undefined) delete process.env[key]; + else process.env[key] = previous[index]; + }); + removeTreeWithRetry(testHome); +}); + +for (const offline of [false, true]) describe(`systemd home decoding (${offline ? "offline" : "online"})`, () => { + function probe() { + const run: ProbeRunner = (file, args) => { + expect(file).toBe("systemctl"); + expect(args.slice(0, 3)).toEqual(["--user", "show", "opencodex-proxy"]); + return { + status: offline ? 1 : 0, timedOut: false, spawnFailed: false, + stderr: offline ? "Failed to connect to bus" : "", + stdout: offline ? "" : `LoadState=loaded\nActiveState=active\nFragmentPath=${unitPath}\nNeedDaemonReload=no\n`, + }; + }; + return { run, platform: "linux" as const, home: testHome }; + } + + test("production buildUnit values round trip quotes, percent, backslash, spaces and newline", () => { + const codexHome = '/fixture/a "quoted" 50% \\ path\nsecond/.codex'; + const opencodexHome = '/fixture/b \\n literal %% "value"/.opencodex'; + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = opencodexHome; + writeFileSync(unitPath, buildUnit([], { + launcher: "/fixture/bin/ocx", + runtime: { path: "/fixture/bin/bun", source: "bundled", overrideEnv: "OPENCODEX_BUN_PATH" }, + })); + const result = inspectServiceManagerInstallation(probe()); + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + expect(result.claims[0]?.homes).toEqual({ codexHome, opencodexHome }); + expect(result.claims[0]?.registration).toBe(offline ? "absent" : "present"); + }); + + test("legacy simple bare assignments remain literal and omitted homes remain null", () => { + writeFileSync(unitPath, "[Service]\nEnvironment=CODEX_HOME=/fixture/.codex\n"); + const result = inspectServiceManagerInstallation(probe()); + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + expect(result.claims[0]?.homes).toEqual({ codexHome: "/fixture/.codex", opencodexHome: null }); + }); + + test("comment backslashes and an escaped trailing backslash do not continue a directive", () => { + writeFileSync(unitPath, [ + "[Service]", "# ignored \\", "; ignored \\", "Description=literal\\\\", + "Environment=CODEX_HOME=/fixture/.codex", + ].join("\n")); + const result = inspectServiceManagerInstallation(probe()); + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + expect(result.claims[0]?.homes.codexHome).toBe("/fixture/.codex"); + }); + + test("non-environment directives are skipped, including X- extensions", () => { + writeFileSync(unitPath, [ + "[Unit]", + "Description=OpenCodex Proxy Server", + "After=network-online.target", + "", + "[Service]", + "Type=simple", + "X-Custom=ignored extension", + "# a comment", + "; another comment", + "ExecStart=\"/bin/sh\" -lc \"ocx start\"", + "Environment=OPENCODEX_HOME=/fixture/.opencodex", + ].join("\n")); + const result = inspectServiceManagerInstallation(probe()); + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + expect(result.claims[0]?.homes).toEqual({ codexHome: null, opencodexHome: "/fixture/.opencodex" }); + }); + + test.each([ + ["unknown escape", 'Environment="CODEX_HOME=/fixture/\\q"'], + ["unsupported tab escape", 'Environment="CODEX_HOME=/fixture/\\t"'], + ["systemd specifier", 'Environment="CODEX_HOME=%h/.codex"'], + ["single trailing percent", 'Environment="CODEX_HOME=/fixture/50%"'], + ["unclosed quote", 'Environment="CODEX_HOME=/fixture/.codex'], + ["escaped closing quote", 'Environment="CODEX_HOME=/fixture/\\"'], + ["extra quoted assignment", 'Environment="CODEX_HOME=/fixture/.codex" "CODEX_HOME=/foreign"'], + ["duplicate home", 'Environment=CODEX_HOME=/fixture/.codex\nEnvironment=CODEX_HOME=/foreign'], + ["duplicate identical home", 'Environment=CODEX_HOME=/fixture/.codex\nEnvironment=CODEX_HOME=/fixture/.codex'], + ["bare whitespace", 'Environment=CODEX_HOME=/fixture/with space'], + ["empty home", 'Environment=CODEX_HOME='], + ["reset assignment", 'Environment='], + ["missing assignment separator", 'Environment="CODEX_HOME"'], + ["continued directive name", 'Environment\\\n="CODEX_HOME=/foreign"'], + ["continued directive across comments", 'Environment\\\n# ignored\n; ignored\n="CODEX_HOME=/foreign"'], + ["continuation consuming a home assignment", 'ExecStart=/fixture/bin/ocx \\\nEnvironment="CODEX_HOME=/fixture/.codex"'], + ["bare directive without =", 'Environment'], + ["environment-file directive", 'EnvironmentFile=/fixture/env.list'], + ["pass-environment directive", 'PassEnvironment=OPENCODEX_HOME'], + ["unset-environment directive", 'UnsetEnvironment=OPENCODEX_HOME'], + ["suffixed environment directive", 'EnvironmentOther="OPENCODEX_HOME=/foreign"'], + ["extension environment directive", 'X-Environment="OPENCODEX_HOME=/foreign"'], + ["escaped directive name", 'Environ\\x6dent="OPENCODEX_HOME=/foreign"'], + ["specifier directive name", 'Environmen%74="OPENCODEX_HOME=/foreign"'], + ["spaced directive name", 'Environ ment="OPENCODEX_HOME=/foreign"'], + ["include directive", '.include /fixture/extra.conf'], + ])("%s makes the whole definition unknown despite another matching home", (_, malformed) => { + const homes = { codexHome: "/fixture/.codex", opencodexHome: "/fixture/.opencodex" }; + const statePath = join(testHome, "service-state.json"); + writeFileSync(statePath, JSON.stringify({ version: 1, ...homes })); + writeFileSync(unitPath, `[Service]\nEnvironment="OPENCODEX_HOME=${homes.opencodexHome}"\n${malformed}\n`); + expect(inspectNativeCodexOwnership({ + ...probe(), statePaths: [statePath], currentHomes: homes, realpathSync: path => path, + }).ownership).toBe("unknown"); + expect(inspectServiceManagerInstallation(probe()).kind).toBe("unknown"); + }); +}); diff --git a/tests/config/serving-runtimes.test.ts b/tests/config/serving-runtimes.test.ts index 0396e961a63..be6015bb759 100644 --- a/tests/config/serving-runtimes.test.ts +++ b/tests/config/serving-runtimes.test.ts @@ -6,12 +6,14 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { deferServiceChildToNewerRuntime, + hasNewerRecordedServingRuntime, isManagedServiceEnvironment, deferToNewerServiceRuntime, probeServedRuntimeVersion, readServingRuntimes, recordServingRuntime, selectNewerServingRuntime, + serviceClaimMatchesCurrentHomes, servingRuntimeCommandKey, servingRuntimesPath, type ServedRuntimeRecord, @@ -463,6 +465,7 @@ describe("deferServiceChildToNewerRuntime", () => { const deps = { dir, exists: () => true, + installedServiceOwnsCurrentHome: () => true, run: () => ({ status: 0, stdout: "opencodex 2.68.0", stderr: "" }), runInherited: async () => { throw new Error("must not run"); }, log: () => {}, @@ -472,6 +475,18 @@ describe("deferServiceChildToNewerRuntime", () => { expect(await deferServiceChildToNewerRuntime({ ...base, sibling: false, env: {} })).toBeNull(); expect(await deferServiceChildToNewerRuntime({ ...base, sibling: false, env: { OCX_SERVICE: "1" } })).toBeNull(); expect(await deferServiceChildToNewerRuntime({ ...base, sibling: false, env: { OCX_SERVICE_MANAGED: "1", OCX_DELEGATED_ONCE: "1" } })).toBeNull(); + let probes = 0; + expect(await deferServiceChildToNewerRuntime({ + ...base, + sibling: false, + env: { OCX_SERVICE_MANAGED: "1" }, + deps: { + ...deps, + installedServiceOwnsCurrentHome: () => false, + run: () => { probes += 1; return { status: 0, stdout: "opencodex 2.68.0", stderr: "" }; }, + }, + })).toBeNull(); + expect(probes).toBe(0); // The Task Scheduler wrapper's own environment, read back from the batch it generates. const batch = buildWindowsServiceScript({ bun: "C:\\ocx\\bun.exe", bunRuntimeSource: "bundled", cli: null }, 10100, []); const wrapperEnv = Object.fromEntries([...batch.matchAll(/^set "(OCX_[A-Z_]+)=([^"]*)"$/gm)].map(m => [m[1]!, m[2]!])); @@ -492,6 +507,65 @@ describe("deferServiceChildToNewerRuntime", () => { })).toBe(42); }); + test("an empty census never reaches the manager probe", async () => { + // The 401-task listing a localized Windows host pays for /query is real work; + // with nothing recorded there is no candidate to prove provenance for. + const dir = freshDir(); + let probes = 0; + expect(await deferServiceChildToNewerRuntime({ + sibling: false, + env: { OCX_SERVICE_MANAGED: "1" }, + selfVersion: "2.67.0", + selfCommand: [join("/", "npm", "bun.exe"), join("/", "npm", "index.ts")], + deps: { + dir, + exists: () => true, + installedServiceOwnsCurrentHome: () => { probes += 1; return true; }, + run: () => ({ status: 0, stdout: "", stderr: "" }), + runInherited: async () => ({ exitCode: 0, ready: true }), + log: () => {}, + }, + })).toBeNull(); + expect(probes).toBe(0); + }); + + test("hasNewerRecordedServingRuntime ignores self, stale, and missing commands", () => { + const dir = freshDir(); + const self = [join("/", "npm", "bun.exe"), join("/", "npm", "index.ts")]; + expect(hasNewerRecordedServingRuntime("2.67.0", self, { dir })).toBe(false); + recordServingRuntime(record(self, "2.68.0"), dir); + recordServingRuntime(record([fakeBinary(dir, "older.exe")], "2.66.0"), dir); + recordServingRuntime(record([join(dir, "missing.exe")], "2.70.0"), dir); + expect(hasNewerRecordedServingRuntime("2.67.0", self, { dir })).toBe(false); + recordServingRuntime(record([fakeBinary(dir, "newer.exe")], "2.68.0"), dir); + expect(hasNewerRecordedServingRuntime("2.67.0", self, { dir, exists: () => true })).toBe(true); + }); + + test("a claim omitting home keys only authorizes a default-home process", () => { + const dir = freshDir(); + const current = { codexHome: join(dir, ".codex"), opencodexHome: join(dir, ".opencodex") }; + const omitted = { + backend: "launchd" as const, + definitionPath: join(dir, "com.opencodex.proxy.plist"), + homes: { codexHome: null, opencodexHome: null }, + registration: "present" as const, + }; + // A project-controlled .env can set both vars; the omitted-key claim must not + // follow them to a project-chosen home. + expect(serviceClaimMatchesCurrentHomes(omitted, current, { + CODEX_HOME: current.codexHome, OPENCODEX_HOME: current.opencodexHome, + })).toBe(false); + expect(serviceClaimMatchesCurrentHomes(omitted, current, {})).toBe(true); + // A recorded home still must match the current one. + const recorded = { ...omitted, homes: { codexHome: current.codexHome, opencodexHome: current.opencodexHome } }; + expect(serviceClaimMatchesCurrentHomes(recorded, current, {})).toBe(true); + expect(serviceClaimMatchesCurrentHomes(recorded, current, { + CODEX_HOME: current.codexHome, OPENCODEX_HOME: current.opencodexHome, + })).toBe(true); + const foreign = { ...omitted, homes: { codexHome: "/elsewhere/.codex", opencodexHome: current.opencodexHome } }; + expect(serviceClaimMatchesCurrentHomes(foreign, current, {})).toBe(false); + }); + test("a generated WinSW service child delegates to a newer recorded install", async () => { const dir = freshDir(); const newer = fakeBinary(dir, "ocx-newer.exe"); @@ -517,6 +591,7 @@ describe("deferServiceChildToNewerRuntime", () => { deps: { dir, exists: () => true, + installedServiceOwnsCurrentHome: () => true, run: () => ({ status: 0, stdout: "opencodex 2.68.0", stderr: "" }), runInherited: async (command, args) => { expect(command).toEqual([newer]); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 4303c879bac..79bb19c7bf5 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -598,6 +598,7 @@ "codex-runtime-wsl-desktop.test.ts": "codex-integration", "codex-service-manager-probe-hardening.test.ts": "codex-integration", "codex-service-manager-probe.test.ts": "codex-integration", + "codex-systemd-env-probe.test.ts": "codex-integration", "codex-shim-autorestore.test.ts": "codex-integration", "codex-shim-destroyed-probe.test.ts": "codex-integration", "codex-shim-ensure-failure.test.ts": "codex-integration", diff --git a/tests/server/codex-agent-role-routes.test.ts b/tests/server/codex-agent-role-routes.test.ts index f0420bcb9e5..4e0e2982ff7 100644 --- a/tests/server/codex-agent-role-routes.test.ts +++ b/tests/server/codex-agent-role-routes.test.ts @@ -18,7 +18,8 @@ function restore(name: keyof typeof saved): void { } beforeEach(() => { - root = mkdtempSync(join(tmpdir(), "ocx-agent-role-routes-")); + // Match getCodexHome(), which resolves aliases before filesystem access (for example macOS /var). + root = fs.realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-agent-role-routes-"))); mkdirSync(join(root, "codex", "agents"), { recursive: true }); mkdirSync(join(root, "home", ".omo"), { recursive: true }); writeFileSync(join(root, "codex", "agents", "explorer.toml"), ROLE);