Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
68 changes: 67 additions & 1 deletion src/config/serving-runtimes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<DelegatedExit>;
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 }
Expand Down Expand Up @@ -397,9 +425,47 @@ export async function deferServiceChildToNewerRuntime(options: {
readonly deps?: DeferToNewerRuntimeDeps;
}): Promise<number | null> {
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
Expand Down
38 changes: 19 additions & 19 deletions src/service-manager-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -238,21 +239,23 @@ function unknown(reason: string): ServiceManagerInstallation {
return { kind: "unknown", reason };
}

/** Decode the entities buildPlist's plistString applies. `&amp;` goes last so a
* literal `&amp;lt;` written by a double-escaped value stays `&lt;`. */
function plistStringValue(raw: string): string {
return raw
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, "\"")
.replace(/&apos;/g, "'")
.replace(/&amp;/g, "&");
}

/** Pull `<key>NAME</key><string>VALUE</string>` out of a plist body. */
function plistEnvValue(body: string, key: string): string | null {
const match = body.match(
new RegExp(`<key>\\s*${key}\\s*</key>\\s*<string>([^<]*)</string>`),
);
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;
}

/**
Expand Down Expand Up @@ -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",
}],
};
Expand Down Expand Up @@ -463,16 +465,14 @@ function inspectSystemd(deps: Required<Pick<ProbeDeps, "run" | "home">>): 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,
}],
};
Expand Down
75 changes: 75 additions & 0 deletions src/service/systemd-env.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
16 changes: 15 additions & 1 deletion structure/ops/service-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
17 changes: 15 additions & 2 deletions tests/codex-integration/codex-service-manager-probe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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&amp;D/.codex", "/Users/example/R&amp;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 });
Expand Down
Loading
Loading