diff --git a/src/service-manager-probe.ts b/src/service-manager-probe.ts index c82c02ffd56..5e9e6587122 100644 --- a/src/service-manager-probe.ts +++ b/src/service-manager-probe.ts @@ -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; @@ -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, }); @@ -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. @@ -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>): ServiceManagerInstallation { const definitionPath = join(deps.home, "Library", "LaunchAgents", `${LABEL}.plist`); @@ -357,6 +394,16 @@ function inspectLaunchd(deps: Required>) 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()}`); } @@ -368,12 +415,10 @@ function inspectLaunchd(deps: Required>) 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" }; diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 20c6cefe2c6..71781fd0a71 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -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 diff --git a/tests/codex-integration/codex-service-manager-probe.test.ts b/tests/codex-integration/codex-service-manager-probe.test.ts index 05751dad428..56d2b5919b3 100644 --- a/tests/codex-integration/codex-service-manager-probe.test.ts +++ b/tests/codex-integration/codex-service-manager-probe.test.ts @@ -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 = 'IOConsoleUsers'; +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>) { - 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>, 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) => { @@ -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//…` + * 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: "" }; + 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. *