Skip to content
Merged
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
61 changes: 53 additions & 8 deletions src/service-manager-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export type ServiceManagerInstallation =

/** Injected so a test can observe the EXACT argv production emits. */
export interface ProbeRunner {
(file: string, args: readonly string[]): {
(file: string, args: readonly string[], options?: { readonly input?: string }): {
status: number | null;
stdout: string;
stderr: string;
Expand Down Expand Up @@ -152,9 +152,10 @@ export function createWindowsTaskListingCache(): WindowsTaskListingCache {
};
}

export const defaultProbeRunner: ProbeRunner = (file, args) => {
export const defaultProbeRunner: ProbeRunner = (file, args, options) => {
const result = spawnSync(file, [...args], {
encoding: "utf8",
input: options?.input,
windowsHide: true,
timeout: SERVICE_PROBE_TIMEOUT_MS,
});
Expand Down Expand Up @@ -220,6 +221,12 @@ const LAUNCHCTL_NO_SUCH_SERVICE = 113;
* headless Mac — no GUI domain, and no installation either.
*/
const LAUNCHCTL_NO_SUCH_DOMAIN = 112;
/**
* Observed on a headless Mac (#7697), but unsupported action is not proof that
* no GUI login exists. Only independent session evidence can qualify GUI 125;
* a user-domain 125 remains unknown because its services are independent.
*/
const LAUNCHCTL_DOMAIN_UNSUPPORTED_ACTION = 125;

/**
* Residue on disk, distinguished from a path that could not be read.
Expand Down Expand Up @@ -338,6 +345,36 @@ function inspectSystemdOffline(home: string): ServiceManagerInstallation {
};
}

/**
* TN2083: /dev/console identifies only the foreground user; switched-out GUI
* sessions survive. XNU's IOService::updateConsoleUsers publishes the complete
* supplied array on the registry root, with UID separate from OnConsole:
* https://github.com/apple-oss-distributions/xnu/blob/main/iokit/Kernel/IOService.cpp
* https://github.com/apple-oss-distributions/xnu/blob/main/iokit/IOKit/IOKitKeysPrivate.h
* Both commands inherit the runner's 2s bound. Missing or malformed evidence
* cannot prove absence; do not cache session evidence across admissions.
*/
function launchdGuiSessionAbsent(run: ProbeRunner, uid: number): boolean {
try {
const registry = run("/usr/sbin/ioreg", ["-a", "-r", "-d", "1", "-n", "Root"]);
if (registry.spawnFailed || registry.timedOut || registry.status !== 0 || !registry.stdout.trim()) return false;
const converted = run("/usr/bin/plutil", ["-extract", "0.IOConsoleUsers", "json", "-o", "-", "-"], {
input: registry.stdout,
});
if (converted.spawnFailed || converted.timedOut || converted.status !== 0) return false;
const sessions: unknown = JSON.parse(converted.stdout);
if (!Array.isArray(sessions)) return false;
return sessions.every(session => {
if (!session || typeof session !== "object" || Array.isArray(session)) return false;
const sessionUid: unknown = session.kCGSSessionUserIDKey;
return typeof sessionUid === "number" && Number.isInteger(sessionUid)
&& sessionUid >= 0 && sessionUid < 0xffff_ffff && sessionUid !== uid;
});
} catch {
return false;
}
}

function inspectLaunchd(deps: Required<Pick<ProbeDeps, "run" | "uid" | "home">>): ServiceManagerInstallation {
const definitionPath = join(deps.home, "Library", "LaunchAgents", `${LABEL}.plist`);

Expand All @@ -357,6 +394,16 @@ function inspectLaunchd(deps: Required<Pick<ProbeDeps, "run" | "uid" | "home">>)
if (printed.status === 0) { registration = "present"; break; }
if (printed.status === LAUNCHCTL_NO_SUCH_SERVICE) continue;
if (printed.status === LAUNCHCTL_NO_SUCH_DOMAIN) { unreachableDomains += 1; continue; }
/*
* Unsupported action alone says nothing about loaded jobs. Qualify GUI
* 125 only with independent evidence excluding this UID's GUI sessions;
* the separate user-domain query and plist checks remain mandatory.
*/
if (printed.status === LAUNCHCTL_DOMAIN_UNSUPPORTED_ACTION && domain.startsWith("gui/")) {
if (!launchdGuiSessionAbsent(deps.run, deps.uid)) return unknown("GUI login session absence could not be established");
unreachableDomains += 1;
continue;
}
return unknown(`launchctl print exited ${String(printed.status)}: ${printed.stderr.trim()}`);
}

Expand All @@ -368,12 +415,10 @@ function inspectLaunchd(deps: Required<Pick<ProbeDeps, "run" | "uid" | "home">>)
return unknown("launchd has a job loaded but its plist is missing");
}
/*
* Nothing staged, and every domain either answered "no such service" or does
* not exist. 112 is an answer ABOUT THE DOMAIN and is label-independent —
* querying a domain with no service name at all returns it — so an
* unreachable domain cannot be hiding a job of ours. Calling this `unknown`
* instead would refuse every write on a fresh headless Mac, which has no
* GUI domain and no installation either.
* Nothing staged, and every domain either answered service-scoped 113,
* did not exist (112), or returned GUI 125 corroborated by independent
* evidence of no GUI session for this UID. A user-domain 113 alone cannot
* exclude a job in the GUI domain's separate service set.
*/
void unreachableDomains;
return { kind: "absent" };
Expand Down
2 changes: 1 addition & 1 deletion structure/ops/service-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ service manager has installed for opencodex, read-only and fail-closed. It reads
definition itself and parses the `CODEX_HOME` and `OPENCODEX_HOME` values embedded in it, because
installation writes the definition before the state file: an interrupted reinstall can leave the
two naming different homes, and on macOS a logged-out user can have the plist on disk with no GUI
domain to query. The probe returns what it saw and does not decide ownership; callers such as
domain to query (`gui/` 125 is unreachable only when a bounded read of the complete IOConsoleUsers roster excludes the UID; unreadable evidence or `user/` 125 stays unknown, #7697). 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
Expand Down
129 changes: 125 additions & 4 deletions tests/codex-integration/codex-service-manager-probe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,25 @@ let previousCodexHome: string | undefined;
let previousOpencodexHome: string | undefined;
let trustedSystem32 = "";

/** A minimal ioreg -a Root reply: the console-user array sits at 0.IOConsoleUsers. */
const IOREG_ROOT_PLIST = '<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><array><dict><key>IOConsoleUsers</key><array/></dict></array></plist>';
const PLUTIL_EXTRACT_ARGS = ["-extract", "0.IOConsoleUsers", "json", "-o", "-", "-"];

/** Records exactly what production asked for, so the allowlist is observed. */
function recorder(reply: (file: string, args: readonly string[]) => Partial<ReturnType<ProbeRunner>>) {
const calls: { file: string; args: readonly string[] }[] = [];
const run: ProbeRunner = (file, args) => {
calls.push({ file, args });
function recorder(reply: (file: string, args: readonly string[]) => Partial<ReturnType<ProbeRunner>>, sessions?: string) {
const calls: { file: string; args: readonly string[]; input?: string }[] = [];
const run: ProbeRunner = (file, args, options) => {
calls.push({ file, args, input: options?.input });
if (sessions !== undefined && file === "/usr/sbin/ioreg") {
return { status: 0, stdout: IOREG_ROOT_PLIST, stderr: "", timedOut: false, spawnFailed: false };
}
if (sessions !== undefined && file === "/usr/bin/plutil") {
// Answer only the extraction production performs on the ioreg reply; anything else fails.
const extracts = JSON.stringify(args) === JSON.stringify(PLUTIL_EXTRACT_ARGS) && options?.input === IOREG_ROOT_PLIST;
return extracts
? { status: 0, stdout: sessions, stderr: "", timedOut: false, spawnFailed: false }
: { status: 1, stdout: "", stderr: "Could not extract value", timedOut: false, spawnFailed: false };
}
return { status: 0, stdout: "", stderr: "", timedOut: false, spawnFailed: false, ...reply(file, args) };
};
const runRaw: RawProbeRunner = (file, args) => {
Expand Down Expand Up @@ -242,6 +256,113 @@ describe("could not ask is not an answer", () => {
expect(result.kind).not.toBe("absent");
});

/**
* Measured on a headless macOS session (#7697): `launchctl print gui/<uid>/…`
* answers `Could not print domain: 125: Domain does not support specified
* action`, while the user-domain query in the same session answers 113. 125
* alone does not establish whether a GUI session exists. These headless
* fixtures independently supply an empty console-session roster; the user
* domain and on-disk definition still need to be checked.
*/
test("GUI-domain exit 125 with the user domain answering 113 and no plist is absent", () => {
const { run, calls } = recorder((_file, args) => (
String(args[1]).startsWith("gui/")
? { status: 125, stderr: "Could not print domain: 125: Domain does not support specified action" }
: { status: 113 }
), "[]");
const result = inspectServiceManagerInstallation({ run, platform: "darwin", uid: 501, home });
// The early return the issue reported skipped the second domain entirely.
expect(calls.filter(c => c.file === "/bin/launchctl").map(c => c.args[1])).toEqual([
"gui/501/com.opencodex.proxy",
"user/501/com.opencodex.proxy",
]);
expect(result).toEqual({ kind: "absent" });
expect(calls.filter(c => c.file !== "/bin/launchctl")).toEqual([
{ file: "/usr/sbin/ioreg", args: ["-a", "-r", "-d", "1", "-n", "Root"], input: undefined },
{ file: "/usr/bin/plutil", args: PLUTIL_EXTRACT_ARGS, input: IOREG_ROOT_PLIST },
]);
});

test("GUI-domain exit 125 still reports a staged plist with its homes", () => {
const path = writePlist("/somewhere/.codex", "/somewhere/.opencodex");
const { run } = recorder((_file, args) => (
String(args[1]).startsWith("gui/") ? { status: 125 } : { status: 113 }
), "[]");
const result = inspectServiceManagerInstallation({ run, platform: "darwin", uid: 501, home });
expect(result.kind).toBe("present");
if (result.kind !== "present") return;
expect(result.claims[0].registration).toBe("absent");
expect(result.claims[0].definitionPath).toBe(path);
expect(result.claims[0].homes).toEqual({ codexHome: "/somewhere/.codex", opencodexHome: "/somewhere/.opencodex" });
});

test("GUI-domain exit 125 does not hide a job loaded in the user domain", () => {
writePlist("/somewhere/.codex", null);
const { run } = recorder((_file, args) => (
String(args[1]).startsWith("gui/") ? { status: 125 } : { status: 0 }
), "[]");
const result = inspectServiceManagerInstallation({ run, platform: "darwin", uid: 501, home });
expect(result.kind).toBe("present");
if (result.kind !== "present") return;
expect(result.claims[0].registration).toBe("present");
});

test("user-domain exit 125 is still unknown — the fallback failing to answer is not an answer", () => {
const { run, calls } = recorder((_file, args) => (
String(args[1]).startsWith("gui/") ? { status: 113 } : { status: 125 }
));
const result = inspectServiceManagerInstallation({ run, platform: "darwin", uid: 501, home });
expect(result.kind).toBe("unknown");
expect(calls.every(call => call.file === "/bin/launchctl")).toBe(true);
});

test("GUI 125 with only another UID in the complete roster is absent", () => {
const { run } = recorder((_file, args) => ({
status: String(args[1]).startsWith("gui/") ? 125 : 113,
}), '[{"kCGSSessionUserIDKey":502}]');
expect(inspectServiceManagerInstallation({ run, platform: "darwin", uid: 501, home })).toEqual({ kind: "absent" });
});

for (const [name, sessions] of [
["console owned by uid", '[{"kCGSSessionUserIDKey":501,"kCGSSessionOnConsoleKey":true}]'],
["uid switched out while another user owns console", '[{"kCGSSessionUserIDKey":502},{"kCGSSessionUserIDKey":501,"kCGSSessionOnConsoleKey":false}]'],
["malformed JSON", "not JSON"],
["missing roster", "null"],
["malformed session", "[{}]"],
["string UID", '[{"kCGSSessionUserIDKey":"501"}]'],
["fractional UID", '[{"kCGSSessionUserIDKey":501.5}]'],
["negative UID", '[{"kCGSSessionUserIDKey":-1}]'],
["UID sentinel", '[{"kCGSSessionUserIDKey":4294967295}]'],
["invalid tail after another UID", '[{"kCGSSessionUserIDKey":502},null]'],
]) {
test(`GUI-domain exit 125 stays unknown: ${name}`, () => {
const { run } = recorder((_file, args) => ({
status: String(args[1]).startsWith("gui/") ? 125 : 113,
}), sessions);
expect(inspectServiceManagerInstallation({ run, platform: "darwin", uid: 501, home }).kind).toBe("unknown");
expect(inspectNativeCodexOwnership({
run, platform: "darwin", uid: 501, home, statePaths: [],
currentHomes: { codexHome: join(home, ".codex"), opencodexHome: join(home, ".opencodex") },
}).ownership).toBe("unknown");
});
}

for (const file of ["/usr/sbin/ioreg", "/usr/bin/plutil"]) {
for (const failure of ["unreadable", "timeout", "spawn failure"]) {
test(`GUI-domain exit 125 stays unknown: ${file} ${failure}`, () => {
const { run } = recorder((command, args) => {
if (command === file) return {
status: failure === "unreadable" ? 1 : null,
timedOut: failure === "timeout", spawnFailed: failure === "spawn failure",
};
if (command === "/usr/sbin/ioreg") return { status: 0, stdout: "<plist/>" };
return { status: String(args[1]).startsWith("gui/") ? 125 : 113 };
});
expect(inspectServiceManagerInstallation({ run, platform: "darwin", uid: 501, home }).kind).toBe("unknown");
});
}
}

/**
* A dangling symlink at the plist path must not read as a clean machine.
*
Expand Down
Loading