From 153f8aab603d85d12a1caf29d56a131f1e929514 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:52:06 +0200 Subject: [PATCH 01/13] fix(probe): walk the Windows service definition chain for ownership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows service-manager probe hardcoded `unknown` ("the Windows definition chain is not inspected yet"), so every unattended Codex write — the dashboard "Sync now" path included — refused on Windows. Implement the chain walk: task XML -> VBS launcher -> batch wrapper, extracting CODEX_HOME/OPENCODEX_HOME from the wrapper's `set` lines, and one bounded `schtasks /query /xml` call for registration. Decode the UTF-16LE on-disk assets (task XML and VBS) so the on-disk and registered forms parse the same. Any broken link stays `unknown` (fail closed); absence is only claimed when nothing is staged. Co-authored-by: CommandCodeBot --- src/service-manager-probe.ts | 177 ++++++++++++++++++++-- tests/codex-service-manager-probe.test.ts | 173 ++++++++++++++++++++- 2 files changed, 336 insertions(+), 14 deletions(-) diff --git a/src/service-manager-probe.ts b/src/service-manager-probe.ts index 2d4914376..c052d11ad 100644 --- a/src/service-manager-probe.ts +++ b/src/service-manager-probe.ts @@ -274,16 +274,175 @@ function inspectSystemd(deps: Required>): Servic } /** - * Windows is deferred to its own phase and reports `unknown` until then. + * Windows: walk the scheduled-task definition chain and report the homes it names. * - * Not an oversight: the definition there is a chain, not a file. The task XML - * names only the launcher, and the homes live in the batch wrapper it eventually - * runs — a probe that parsed the XML and stopped would find no homes and read - * that as agreement. Reporting `unknown` refuses unattended convergence on - * Windows, which is the safe direction while the chain walk is unwritten. + * The chain is not one file: the task XML names only the launcher, the launcher + * (VBS) names only the batch wrapper, and the homes live in the wrapper's + * `set "CODEX_HOME=..."` / `set "OPENCODEX_HOME=..."` lines. Parsing the XML + * and stopping would find no homes and read that as agreement, so the walk goes + * all the way to the wrapper. + * + * A `set` line is OMITTED by `buildWindowsServiceScript` when the value was + * unset at install time (windowsBatchSet returns null for empty values), so a + * missing home stays `null` — the same contract the launchd/systemd probes use — + * and a definition that names no homes cannot be mistaken for agreement. + * + * Registration is answered by one bounded `schtasks /query /xml` call so a + * definition staged on disk but never registered is still visible (the + * interrupted-install case). Every failure to ask is `unknown`, never absence. */ -function inspectWindows(): ServiceManagerInstallation { - return unknown("the Windows definition chain is not inspected yet"); +function windowsTaskName(): string { + return "opencodex-proxy"; +} + +function windowsConfigDirPath(home: string): string { + // The wrapper assets live under OPENCODEX_HOME. Defaulting to `~/.opencodex` + // mirrors service.ts defaultOpenCodexHome(); the caller can override `home` + // in tests. + return join(home, ".opencodex"); +} + +/** Decode an on-disk Windows text asset (task XML, VBS), which is UTF-16LE (often BOM-prefixed). */ +function decodeWindowsText(buffer: Buffer): string { + if (buffer.length === 0) return ""; + const bomUtf16Le = buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe; + const bomUtf16Be = buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff; + const looksUtf16Le = buffer.length >= 4 + && buffer[1] === 0x00 + && buffer[3] === 0x00 + && buffer[0] !== 0x00; + if (bomUtf16Le || looksUtf16Le) { + return buffer.toString("utf16le").replace(/^\uFEFF/, "").trim(); + } + if (bomUtf16Be) { + const swapped = Buffer.alloc(buffer.length - 2); + for (let i = 2; i + 1 < buffer.length; i += 2) { + swapped[i - 2] = buffer[i + 1]!; + swapped[i - 1] = buffer[i]!; + } + return swapped.toString("utf16le").trim(); + } + return buffer.toString("utf8").replace(/^\uFEFF/, "").trim(); +} + +/** Pull the launcher path out of the task XML `` element. */ +function windowsTaskArguments(xml: string): string | null { + const match = /]*>\s*([^<]*?)\s*<\/Arguments>/i.exec(xml); + if (!match) return null; + // The registered document escapes `"` as `"`; decode before extracting + // the quoted path so the same regex sees both the on-disk and /query forms. + const raw = match[1]!.trim() + .replace(/"/g, '"') + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/'/g, "'"); + return raw; +} + +/** + * Pull the wrapper path out of a VBS `shell.Run` line. + * + * `buildWindowsLauncherVbs` escapes a `"` inside a VBS string literal by + * doubling it, so a wrapper `C:\...\opencodex-service.cmd` is emitted as + * `shell.Run """C:\...\opencodex-service.cmd""", 0, True`. Matching the + * whole doubled-quote span — `"""` ... `"""` — is the only form that + * survives both a plain quoted path and one with spaces. + */ +function vbsWrappedCommand(body: string): string | null { + const match = /\.Run\s+"""([^"]*)"""/.exec(body); + if (match) { + const unwrapped = match[1]!.trim(); + if (unwrapped.length > 0) return unwrapped; + } + const plain = /\.Run\s+"([^"]+)"/.exec(body); + return plain ? plain[1]!.trim() : null; +} + +/** Pull one `set "NAME=value"` out of a batch wrapper. */ +function batchSetValue(body: string, name: string): string | null { + const match = new RegExp(`^\\s*set\\s+"${name}=([^"]*)"\\s*$`, "im").exec(body); + return match ? match[1]!.trim() : null; +} + +/** Registration state of the scheduled task. `unknown` when the query fails. */ +function windowsTaskRegistered(deps: Required>): "present" | "absent" | "unknown" { + const queried = deps.run("schtasks", ["/query", "/tn", windowsTaskName(), "/xml"]); + if (queried.spawnFailed || queried.timedOut) return "unknown"; + return queried.status === 0 ? "present" : "absent"; +} + +function inspectWindows(deps: Required>): ServiceManagerInstallation { + const configDir = windowsConfigDirPath(deps.home); + const taskXmlPath = join(configDir, "opencodex-service-task.xml"); + const task = artifactPresence(taskXmlPath); + + let xml = ""; + if (task !== "absent") { + try { + xml = decodeWindowsText(readFileSync(taskXmlPath)); + } catch (error) { + return unknown(`the scheduled-task XML exists but could not be read: ${String(error)}`); + } + } + + const registered = windowsTaskRegistered(deps); + if (registered === "unknown") { + return unknown("Task Scheduler could not be asked whether opencodex-proxy is registered"); + } + + if (task === "absent") { + return registered === "present" + ? unknown("Task Scheduler holds opencodex-proxy but its task XML is missing") + : { kind: "absent" }; + } + + const launcherArg = windowsTaskArguments(xml); + if (!launcherArg) { + return unknown("the scheduled-task XML names no launcher to run"); + } + // The element is `/b /nologo "C:\...\opencodex-service-launcher.vbs"`. + const launcherPath = /"([^"]+)"/.exec(launcherArg)?.[1]; + if (!launcherPath) { + return unknown("the scheduled-task XML launcher argument is not a quoted path"); + } + const launcher = artifactPresence(launcherPath); + if (launcher === "absent") { + return unknown(`the scheduled-task launcher is missing: ${launcherPath}`); + } + let launcherBody: string; + try { + launcherBody = decodeWindowsText(readFileSync(launcherPath)); + } catch (error) { + return unknown(`the scheduled-task launcher could not be read: ${String(error)}`); + } + const wrapperPath = vbsWrappedCommand(launcherBody); + if (!wrapperPath) { + return unknown(`the launcher ${launcherPath} names no wrapper to run`); + } + const wrapper = artifactPresence(wrapperPath); + if (wrapper === "absent") { + return unknown(`the launcher wrapper is missing: ${wrapperPath}`); + } + let wrapperBody: string; + try { + wrapperBody = readFileSync(wrapperPath, "utf-8"); + } catch (error) { + return unknown(`the launcher wrapper could not be read: ${String(error)}`); + } + + return { + kind: "present", + claims: [{ + backend: "scheduler", + definitionPath: taskXmlPath, + homes: { + codexHome: batchSetValue(wrapperBody, "CODEX_HOME"), + opencodexHome: batchSetValue(wrapperBody, "OPENCODEX_HOME"), + }, + registration: registered === "present" ? "present" : "absent", + }], + }; } export function inspectServiceManagerInstallation(deps: ProbeDeps = {}): ServiceManagerInstallation { @@ -292,6 +451,6 @@ export function inspectServiceManagerInstallation(deps: ProbeDeps = {}): Service const home = deps.home ?? homedir(); if (platform === "darwin") return inspectLaunchd({ run, uid: deps.uid ?? process.getuid?.() ?? 0, home }); if (platform === "linux") return inspectSystemd({ run, home }); - if (platform === "win32") return inspectWindows(); + if (platform === "win32") return inspectWindows({ run, home }); return unknown(`no service manager probe for platform ${platform}`); } diff --git a/tests/codex-service-manager-probe.test.ts b/tests/codex-service-manager-probe.test.ts index 006bba52e..7085a30a4 100644 --- a/tests/codex-service-manager-probe.test.ts +++ b/tests/codex-service-manager-probe.test.ts @@ -249,11 +249,15 @@ describe("could not ask is not an answer", () => { expect(result.kind === "unknown" && result.reason).toContain("daemon-reload"); }); - test("Windows refuses rather than guessing at a chain it does not walk", () => { - // The task XML names only the launcher; the homes are in the batch wrapper. - // Parsing the XML and stopping would find no homes and read that as - // agreement, so until the chain walk exists the honest answer is unknown. - expect(inspectServiceManagerInstallation({ platform: "win32", home }).kind).toBe("unknown"); + test("Windows refuses when the chain walk finds no definition", () => { + // Nothing staged on disk, and the query is not asked (no run injected) — + // the default spawn would fail on non-Windows, so this uses the injected + // recorder to prove absence is only claimed when schtasks answers absent. + const { run, calls } = recorder(() => ({ status: 1, stderr: "" })); + const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + expect(result.kind).toBe("absent"); + expect(calls).toHaveLength(1); + expect(calls[0].args[0]).toBe("/query"); }); }); @@ -283,6 +287,165 @@ describe("a definition that cannot supply homes is not present", () => { }); }); +describe("the Windows chain walk", () => { + function writeWindowsTask(launcherPath: string): string { + const dir = join(home, ".opencodex"); + mkdirSync(dir, { recursive: true }); + const path = join(dir, "opencodex-service-task.xml"); + writeFileSync(path, [ + '', + "", + " ", + " ", + ` C:\\WINDOWS\\System32\\wscript.exe`, + ` /b /nologo "${launcherPath.replace(/&/g, "&").replace(/"/g, """)}"`, + " ", + " ", + "", + ].join("\n")); + return path; + } + + function writeWindowsLauncher(wrapperPath: string): string { + const path = join(home, ".opencodex", "opencodex-service-launcher.vbs"); + mkdirSync(join(home, ".opencodex"), { recursive: true }); + writeFileSync(path, [ + "Set shell = CreateObject(\"WScript.Shell\")", + `shell.Run """${wrapperPath}""", 0, True`, + ].join("\r\n")); + return path; + } + + function writeWindowsWrapper(codexHome?: string, opencodexHome?: string): string { + const path = join(home, ".opencodex", "opencodex-service.cmd"); + mkdirSync(join(home, ".opencodex"), { recursive: true }); + const lines = ["@echo off", "setlocal"]; + if (codexHome) lines.push(`set "CODEX_HOME=${codexHome}"`); + if (opencodexHome) lines.push(`set "OPENCODEX_HOME=${opencodexHome}"`); + writeFileSync(path, lines.join("\r\n")); + return path; + } + + test("the full chain is walked and the homes are extracted", () => { + const wrapper = writeWindowsWrapper("C:\\Users\\ws\\.codex", "C:\\Users\\ws\\.opencodex"); + const launcher = writeWindowsLauncher(wrapper); + const taskXml = writeWindowsTask(launcher); + const { run, calls } = recorder(() => ({ status: 0, stdout: "" })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + expect(result.claims).toHaveLength(1); + expect(result.claims[0].backend).toBe("scheduler"); + expect(result.claims[0].definitionPath).toBe(taskXml); + expect(result.claims[0].registration).toBe("present"); + expect(result.claims[0].homes).toEqual({ + codexHome: "C:\\Users\\ws\\.codex", + opencodexHome: "C:\\Users\\ws\\.opencodex", + }); + // One bounded query, nothing that mutates. + expect(calls).toHaveLength(1); + expect(calls[0].file).toBe("schtasks"); + expect(calls[0].args).toEqual(["/query", "/tn", "opencodex-proxy", "/xml"]); + }); + + test("a staged task that was never registered is present but registration=absent", () => { + const wrapper = writeWindowsWrapper("C:\\a\\.codex", "C:\\a\\.opencodex"); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + const { run } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + expect(result.claims[0].registration).toBe("absent"); + }); + + test("an omitted home in the wrapper stays null, not empty", () => { + const wrapper = writeWindowsWrapper("C:\\a\\.codex"); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + const { run } = recorder(() => ({ status: 1 })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + expect(result.claims[0].homes.codexHome).toBe("C:\\a\\.codex"); + expect(result.claims[0].homes.opencodexHome).toBeNull(); + }); + + test("a broken link in the chain is unknown, not absence", () => { + // Task XML points at a launcher that does not exist. + const missing = join(home, ".opencodex", "no-such.vbs"); + writeWindowsTask(missing); + const { run } = recorder(() => ({ status: 1 })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + expect(result.kind).toBe("unknown"); + expect(result.kind === "unknown" && result.reason).toContain("launcher"); + }); + + test("a UTF-16LE task XML on disk is decoded before the chain is walked", () => { + const wrapper = writeWindowsWrapper("C:\\a\\.codex", "C:\\a\\.opencodex"); + const launcher = writeWindowsLauncher(wrapper); + const xml = [ + '', + "", + " ", + " ", + ` /b /nologo "${launcher.replace(/&/g, "&").replace(/"/g, """)}"`, + " ", + " ", + "", + ].join("\n"); + writeFileSync(join(home, ".opencodex", "opencodex-service-task.xml"), Buffer.from(`\uFEFF${xml}`, "utf16le")); + const { run } = recorder(() => ({ status: 1 })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + expect(result.claims[0].homes).toEqual({ codexHome: "C:\\a\\.codex", opencodexHome: "C:\\a\\.opencodex" }); + }); + + test("a UTF-16LE VBS launcher is decoded before the chain is walked", () => { + const wrapper = writeWindowsWrapper("C:\\a\\.codex", "C:\\a\\.opencodex"); + // Overwrite the launcher with a UTF-16LE encoding, like the real install. + const launcher = join(home, ".opencodex", "opencodex-service-launcher.vbs"); + writeFileSync(launcher, Buffer.from( + `\uFEFF' launcher\r\nSet shell = CreateObject("WScript.Shell")\r\nshell.Run """${wrapper}""", 0, True\r\n`, + "utf16le", + )); + const xml = join(home, ".opencodex", "opencodex-service-task.xml"); + writeFileSync(xml, [ + '', + `/b /nologo "${launcher.replace(/&/g, "&").replace(/"/g, """)}"`, + ].join("\n")); + const { run } = recorder(() => ({ status: 1 })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + expect(result.claims[0].homes).toEqual({ codexHome: "C:\\a\\.codex", opencodexHome: "C:\\a\\.opencodex" }); + }); + + test("a task registered but with no XML on disk is unknown", () => { + const { run } = recorder(() => ({ status: 0, stdout: "" })); + const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + expect(result.kind).toBe("unknown"); + expect(result.kind === "unknown" && result.reason).toContain("missing"); + }); + + test("an unaskable schtasks is unknown even with a full chain", () => { + const wrapper = writeWindowsWrapper("C:\\a\\.codex", "C:\\a\\.opencodex"); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + const { run } = recorder(() => ({ status: null, spawnFailed: true, stderr: "spawn ENOENT" })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + expect(result.kind).toBe("unknown"); + }); +}); + describe("ownership refuses what it cannot prove", () => { /* * The default state paths include the DEFAULT home mirror, resolved from From ca015f74796c3f82c823b93be739fb5b14a149df Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:00:44 +0200 Subject: [PATCH 02/13] fix(probe): treat only definitive "not found" as absent in Windows task probe schtasks exits 1 for both "task not found" and "access denied"; only the stderr message distinguishes them. Keying absence on the exit code alone let a locked-down or wedged Task Scheduler read as a clean machine, so `windowsTaskRegistered` now returns `absent` only when the stderr states the task cannot be found, `present` only on exit 0, and `unknown` for every other nonzero status, access denied, execution errors, signal termination, null status, and spawn/timeout failures. Co-authored-by: CommandCodeBot --- src/service-manager-probe.ts | 25 +++++++++++- tests/codex-service-manager-probe.test.ts | 48 ++++++++++++++++++++--- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/src/service-manager-probe.ts b/src/service-manager-probe.ts index c052d11ad..c2b0312f3 100644 --- a/src/service-manager-probe.ts +++ b/src/service-manager-probe.ts @@ -365,11 +365,32 @@ function batchSetValue(body: string, name: string): string | null { return match ? match[1]!.trim() : null; } -/** Registration state of the scheduled task. `unknown` when the query fails. */ +/** + * The one stderr text that proves `schtasks /query /tn ...` answered "no such + * task". schtasks exits 1 for both "task not found" and "access denied"; only + * the message distinguishes them, so absence is keyed on the message, never on + * the exit code alone. + */ +const SCHTASKS_TASK_NOT_FOUND = /cannot find the file specified/i; + +/** + * Registration state of the scheduled task. + * + * `present` is exit 0. `absent` is ONLY a nonzero exit whose stderr states the + * task cannot be found. Everything else — access denied, other execution + * errors, signal termination, null status, spawn/timeout failures, any other + * nonzero exit — is `unknown`, because none of those prove the task is not + * there. Treating them as absence would let a locked-down or wedged Task + * Scheduler read as a clean machine. + */ function windowsTaskRegistered(deps: Required>): "present" | "absent" | "unknown" { const queried = deps.run("schtasks", ["/query", "/tn", windowsTaskName(), "/xml"]); if (queried.spawnFailed || queried.timedOut) return "unknown"; - return queried.status === 0 ? "present" : "absent"; + if (queried.status === 0) return "present"; + if (queried.status !== null && SCHTASKS_TASK_NOT_FOUND.test(`${queried.stdout}\n${queried.stderr}`)) { + return "absent"; + } + return "unknown"; } function inspectWindows(deps: Required>): ServiceManagerInstallation { diff --git a/tests/codex-service-manager-probe.test.ts b/tests/codex-service-manager-probe.test.ts index 7085a30a4..f38adf49f 100644 --- a/tests/codex-service-manager-probe.test.ts +++ b/tests/codex-service-manager-probe.test.ts @@ -253,7 +253,7 @@ describe("could not ask is not an answer", () => { // Nothing staged on disk, and the query is not asked (no run injected) — // the default spawn would fail on non-Windows, so this uses the injected // recorder to prove absence is only claimed when schtasks answers absent. - const { run, calls } = recorder(() => ({ status: 1, stderr: "" })); + const { run, calls } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); expect(result.kind).toBe("absent"); expect(calls).toHaveLength(1); @@ -365,7 +365,7 @@ describe("the Windows chain walk", () => { const wrapper = writeWindowsWrapper("C:\\a\\.codex"); const launcher = writeWindowsLauncher(wrapper); writeWindowsTask(launcher); - const { run } = recorder(() => ({ status: 1 })); + const { run } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); expect(result.kind).toBe("present"); @@ -378,7 +378,7 @@ describe("the Windows chain walk", () => { // Task XML points at a launcher that does not exist. const missing = join(home, ".opencodex", "no-such.vbs"); writeWindowsTask(missing); - const { run } = recorder(() => ({ status: 1 })); + const { run } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); expect(result.kind).toBe("unknown"); @@ -399,7 +399,7 @@ describe("the Windows chain walk", () => { "", ].join("\n"); writeFileSync(join(home, ".opencodex", "opencodex-service-task.xml"), Buffer.from(`\uFEFF${xml}`, "utf16le")); - const { run } = recorder(() => ({ status: 1 })); + const { run } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); expect(result.kind).toBe("present"); @@ -420,7 +420,7 @@ describe("the Windows chain walk", () => { '', `/b /nologo "${launcher.replace(/&/g, "&").replace(/"/g, """)}"`, ].join("\n")); - const { run } = recorder(() => ({ status: 1 })); + const { run } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); expect(result.kind).toBe("present"); @@ -444,6 +444,44 @@ describe("the Windows chain walk", () => { const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); expect(result.kind).toBe("unknown"); }); + + /* + * schtasks exits 1 for BOTH "task not found" and "access denied"; only the + * stderr message distinguishes them. A nonzero exit whose message does NOT + * state the task is missing cannot be treated as absence — a locked-down Task + * Scheduler would otherwise read as a clean machine and an unattended write + * would proceed into a home another process owns. + */ + test("an access-denied schtasks response is unknown, not absent", () => { + const wrapper = writeWindowsWrapper("C:\\a\\.codex", "C:\\a\\.opencodex"); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + const { run } = recorder(() => ({ status: 1, stderr: "ERROR: Access is denied. (0x80070005)" })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + expect(result.kind).toBe("unknown"); + expect(result.kind === "unknown" && result.reason).toContain("could not be asked"); + }); + + test("a null-status schtasks response with no stderr is unknown, not absent", () => { + const wrapper = writeWindowsWrapper("C:\\a\\.codex", "C:\\a\\.opencodex"); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + const { run } = recorder(() => ({ status: null, stderr: "" })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + expect(result.kind).toBe("unknown"); + }); + + test("a timed-out schtasks response is unknown, not absent", () => { + const wrapper = writeWindowsWrapper("C:\\a\\.codex", "C:\\a\\.opencodex"); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + const { run } = recorder(() => ({ status: null, timedOut: true })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + expect(result.kind).toBe("unknown"); + }); }); describe("ownership refuses what it cannot prove", () => { From c4fb80ba72397f659b48e3ddaf4c82722622fe63 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:46:36 +0200 Subject: [PATCH 03/13] fix(probe): address Codex review findings on the Windows chain walk Three findings from the Codex connector review: - P1: a malformed wrapper (empty, truncated, or an unrelated readable file) previously read as a legitimate install that omitted both homes. The probe now validates the generated wrapper structure (`:loop` + `%OCX_BUN%`/ `%OCX_CLI%` launch tail) and returns `unknown` for anything that does not look generated. - P2: home values baked by `buildWindowsServiceScript` use `%USERPROFILE%`- style env tokens and double literal `%` as `%%`; the probe now decodes that indirection against the live environment before comparing, so an install under the user profile no longer reports a false home disagreement. - P2: a v2 service-state file recording backend `native` (WinSW) beside a scheduler task claim is an interrupted backend switch; ownership now returns `unknown` instead of `owned` when the manager backend disagrees with the recorded state. Co-authored-by: CommandCodeBot --- .../native/ownership-preflight.ts | 29 ++++ src/service-manager-probe.ts | 58 ++++++- tests/codex-service-manager-probe.test.ts | 147 +++++++++++++++++- 3 files changed, 230 insertions(+), 4 deletions(-) diff --git a/src/integrations/native/ownership-preflight.ts b/src/integrations/native/ownership-preflight.ts index c76339270..b40e12e4a 100644 --- a/src/integrations/native/ownership-preflight.ts +++ b/src/integrations/native/ownership-preflight.ts @@ -78,6 +78,25 @@ function claimNamesDifferentHome( return false; } +/** + * Map a service-manager claim backend to the `ServiceInstallState.backend` + * value it corresponds to. `scheduler` (Task Scheduler) and `winsw` (native) + * are the two Windows manager backends; launchd/systemd claims have no Windows + * backend and can never mismatch a v2 state file. + */ +function claimBackendToStateBackend(backend: ServiceManagerClaim["backend"]): "scheduler" | "native" | null { + if (backend === "scheduler") return "scheduler"; + if (backend === "winsw") return "native"; + return null; +} + +/** True when the recorded v2 state's backend disagrees with the manager claim. */ +function claimBackendMismatchesState(claim: ServiceManagerClaim, state: { backend?: "scheduler" | "native" }): boolean { + const expected = claimBackendToStateBackend(claim.backend); + if (expected === null) return false; + return state.backend !== undefined && state.backend !== expected; +} + export interface OwnershipDeps extends ProbeDeps { /** * Which state paths to consult. Injectable because the default set includes @@ -146,6 +165,16 @@ export function inspectNativeCodexOwnership(deps: OwnershipDeps = {}): Ownership reason: `${disagreeing.backend} is installed from ${disagreeing.definitionPath}, which names different homes than the recorded service state`, }; } + // A manager backend that disagrees with the recorded state (e.g. state says + // native/WinSW but a scheduler task is found) is an interrupted backend + // switch: it does not prove which manager owns the installation. + const stateBackendMismatch = valid.find(state => manager.claims.some(claim => claimBackendMismatchesState(claim, state.state))); + if (stateBackendMismatch) { + return { + ownership: "unknown", + reason: `the service state records backend ${stateBackendMismatch.state.backend ?? "(none)"} but ${manager.claims[0]?.backend ?? "a service manager"} is installed`, + }; + } // Definition agrees. Valid state agreeing with it is ownership; no state at // all beside an installed definition is not, because the definition is the // claim and nothing here recorded making it. diff --git a/src/service-manager-probe.ts b/src/service-manager-probe.ts index c2b0312f3..ad65a0a39 100644 --- a/src/service-manager-probe.ts +++ b/src/service-manager-probe.ts @@ -365,6 +365,46 @@ function batchSetValue(body: string, name: string): string | null { return match ? match[1]!.trim() : null; } +/** + * Resolve a home value the way cmd would, reversing what + * `windowsEnvIndirectBatchValue` + `windowsBatchValue` baked in. + * + * The builder rewrites a home under USERPROFILE/APPDATA/LOCALAPPDATA to a + * `%VAR%` token (so non-ASCII profile names survive the OEM-codepage parse), + * and doubles literal `%` as `%%` so the token itself survives the escaping. + * A probe that returned that batch syntax verbatim would compare it against + * the resolved current home and report a false disagreement. Expand known + * tokens via the live environment, then un-double any remaining `%%`. + */ +function decodeBatchPathValue( + value: string, + env: Record = process.env, +): string { + const tokens: Record = { + USERPROFILE: env.USERPROFILE, + APPDATA: env.APPDATA, + LOCALAPPDATA: env.LOCALAPPDATA, + "SystemRoot": env.SystemRoot, + }; + return value + .replace(/%([A-Za-z][A-Za-z0-9_]*)%/g, (whole, name: string) => { + const resolved = tokens[name]; + return resolved ? resolved : whole; + }) + .replace(/%%/g, "%"); +} + +/** + * True when the wrapper looks like one `buildWindowsServiceScript` generated: + * it has the `:loop` + child-launch tail. Absent `set` lines are only + * meaningful evidence of "deliberately omitted" when the wrapper is otherwise + * the generated artifact — an empty, truncated, or unrelated readable file + * must not read as a legitimate install that omitted both homes. + */ +function wrapperLooksGenerated(body: string): boolean { + return /:loop[\s\S]*?(?:bun|"%OCX_BUN%"|"%OCX_CLI%")/i.test(body); +} + /** * The one stderr text that proves `schtasks /query /tn ...` answered "no such * task". schtasks exits 1 for both "task not found" and "access denied"; only @@ -447,19 +487,31 @@ function inspectWindows(deps: Required>): Servic } let wrapperBody: string; try { - wrapperBody = readFileSync(wrapperPath, "utf-8"); + wrapperBody = decodeWindowsText(readFileSync(wrapperPath)); } catch (error) { return unknown(`the launcher wrapper could not be read: ${String(error)}`); } + /* + * A wrapper that does not look generated is not evidence of deliberate + * omission — it is malformed. An empty or truncated wrapper, or an unrelated + * readable file, must fail closed rather than read as "no homes baked". + */ + if (!wrapperLooksGenerated(wrapperBody)) { + return unknown(`the launcher wrapper does not look like a generated opencodex service wrapper: ${wrapperPath}`); + } + + const rawCodexHome = batchSetValue(wrapperBody, "CODEX_HOME"); + const rawOpencodexHome = batchSetValue(wrapperBody, "OPENCODEX_HOME"); + return { kind: "present", claims: [{ backend: "scheduler", definitionPath: taskXmlPath, homes: { - codexHome: batchSetValue(wrapperBody, "CODEX_HOME"), - opencodexHome: batchSetValue(wrapperBody, "OPENCODEX_HOME"), + codexHome: rawCodexHome === null ? null : decodeBatchPathValue(rawCodexHome), + opencodexHome: rawOpencodexHome === null ? null : decodeBatchPathValue(rawOpencodexHome), }, registration: registered === "present" ? "present" : "absent", }], diff --git a/tests/codex-service-manager-probe.test.ts b/tests/codex-service-manager-probe.test.ts index f38adf49f..f122856b3 100644 --- a/tests/codex-service-manager-probe.test.ts +++ b/tests/codex-service-manager-probe.test.ts @@ -322,6 +322,16 @@ describe("the Windows chain walk", () => { const lines = ["@echo off", "setlocal"]; if (codexHome) lines.push(`set "CODEX_HOME=${codexHome}"`); if (opencodexHome) lines.push(`set "OPENCODEX_HOME=${opencodexHome}"`); + // The tail that `buildWindowsServiceScript` emits; the probe keys wrapper + // validity on it so an empty/unrelated wrapper cannot read as an install + // that deliberately omitted both homes. + lines.push( + 'set "OCX_BUN=C:\\bun\\bun.exe"', + 'set "OCX_CLI=C:\\opencodex\\src\\cli\\index.ts"', + ":loop", + '>>"%OCX_SERVICE_LOG%" echo start', + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100', + ); writeFileSync(path, lines.join("\r\n")); return path; } @@ -349,6 +359,63 @@ describe("the Windows chain walk", () => { expect(calls[0].args).toEqual(["/query", "/tn", "opencodex-proxy", "/xml"]); }); + test("batch env-token homes are decoded before they are compared", () => { + // The real builder emits `%USERPROFILE%\.codex` and doubles `%` as `%%`. + const dir = join(home, ".opencodex"); + mkdirSync(dir, { recursive: true }); + const wrapper = join(dir, "opencodex-service.cmd"); + writeFileSync(wrapper, [ + "@echo off", + "setlocal", + 'set "CODEX_HOME=%USERPROFILE%\\.codex"', + 'set "OPENCODEX_HOME=%%PROFILE_VAR%%\\custom"', + 'set "OCX_BUN=C:\\bun\\bun.exe"', + 'set "OCX_CLI=C:\\opencodex\\src\\cli\\index.ts"', + ":loop", + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100', + ].join("\r\n")); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + const { run } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + // %USERPROFILE% expands to the test's real home; %% stays a literal %. + const profile = process.env.USERPROFILE ?? ""; + expect(result.claims[0].homes.codexHome).toBe(`${profile}\\.codex`); + expect(result.claims[0].homes.opencodexHome).toBe(`%PROFILE_VAR%\\custom`); + }); + + test("a malformed wrapper is unknown, not a deliberate omission", () => { + // A truncated wrapper with set lines but no generated tail is NOT a + // legitimate install that omitted the homes — it is residue. + const dir = join(home, ".opencodex"); + mkdirSync(dir, { recursive: true }); + const wrapper = join(dir, "opencodex-service.cmd"); + writeFileSync(wrapper, "@echo off\r\nsetlocal\r\nset \"CODEX_HOME=C:\\x\\.codex\""); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + const { run } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + expect(result.kind).toBe("unknown"); + expect(result.kind === "unknown" && result.reason).toContain("wrapper"); + }); + + test("an empty wrapper is unknown, not absence", () => { + const dir = join(home, ".opencodex"); + mkdirSync(dir, { recursive: true }); + const wrapper = join(dir, "opencodex-service.cmd"); + writeFileSync(wrapper, ""); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + const { run } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + expect(result.kind).toBe("unknown"); + }); + test("a staged task that was never registered is present but registration=absent", () => { const wrapper = writeWindowsWrapper("C:\\a\\.codex", "C:\\a\\.opencodex"); const launcher = writeWindowsLauncher(wrapper); @@ -513,9 +580,12 @@ describe("ownership refuses what it cannot prove", () => { return { codexHome, opencodexHome }; } + // The darwin ownership tests drive a launchd claim; a launchd install writes + // a v1 state file (no `backend` field — that is Windows-only). v2 without a + // backend would be malformed, so this writes v1. function writeState(dir: string, codexHome: string, opencodexHome: string): void { writeFileSync(join(dir, "service-state.json"), JSON.stringify({ - version: 2, codexHome, opencodexHome, backend: "scheduler", + version: 1, codexHome, opencodexHome, })); } @@ -602,4 +672,79 @@ describe("ownership refuses what it cannot prove", () => { const { run } = recorder(() => ({ status: 112, stderr: "Could not find domain for user" })); expect(inspectNativeCodexOwnership(own({ run })).ownership).toBe("owned"); }); + + /* + * The interrupted-backend-switch case: a v2 state file records backend + * "native" (WinSW) but the probe finds a scheduler task. The homes agree, but + * the manager backend does not, so ownership cannot be proven. + */ + test("a state backend that disagrees with the installed manager is unknown", () => { + const { codexHome, opencodexHome } = useHomes(); + mkdirSync(opencodexHome, { recursive: true }); + writeFileSync(join(opencodexHome, "service-state.json"), JSON.stringify({ + version: 2, codexHome, opencodexHome, backend: "native", + })); + // A scheduler claim whose homes agree with the state. + const wrapper = join(opencodexHome, "opencodex-service.cmd"); + writeFileSync(wrapper, [ + "@echo off", + "setlocal", + `set "CODEX_HOME=${codexHome}"`, + `set "OPENCODEX_HOME=${opencodexHome}"`, + 'set "OCX_BUN=C:\\bun\\bun.exe"', + 'set "OCX_CLI=C:\\opencodex\\src\\cli\\index.ts"', + ":loop", + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100', + ].join("\r\n")); + const launcher = join(opencodexHome, "opencodex-service-launcher.vbs"); + writeFileSync(launcher, `shell.Run """${wrapper}""", 0, True\r\n`); + writeFileSync(join(opencodexHome, "opencodex-service-task.xml"), [ + '', + `/b /nologo "${launcher}"`, + ].join("\n")); + + const result = inspectNativeCodexOwnership({ + platform: "win32", + home, + run: () => ({ status: 1, stderr: "ERROR: The system cannot find the file specified.", stdout: "", timedOut: false, spawnFailed: false }), + statePaths: [join(opencodexHome, "service-state.json")], + currentHomes: { codexHome, opencodexHome }, + }); + expect(result.ownership).toBe("unknown"); + expect(result.reason).toContain("backend"); + }); + + test("a state backend that agrees with the installed manager is owned", () => { + const { codexHome, opencodexHome } = useHomes(); + mkdirSync(opencodexHome, { recursive: true }); + writeFileSync(join(opencodexHome, "service-state.json"), JSON.stringify({ + version: 2, codexHome, opencodexHome, backend: "scheduler", + })); + const wrapper = join(opencodexHome, "opencodex-service.cmd"); + writeFileSync(wrapper, [ + "@echo off", + "setlocal", + `set "CODEX_HOME=${codexHome}"`, + `set "OPENCODEX_HOME=${opencodexHome}"`, + 'set "OCX_BUN=C:\\bun\\bun.exe"', + 'set "OCX_CLI=C:\\opencodex\\src\\cli\\index.ts"', + ":loop", + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100', + ].join("\r\n")); + const launcher = join(opencodexHome, "opencodex-service-launcher.vbs"); + writeFileSync(launcher, `shell.Run """${wrapper}""", 0, True\r\n`); + writeFileSync(join(opencodexHome, "opencodex-service-task.xml"), [ + '', + `/b /nologo "${launcher}"`, + ].join("\n")); + + const result = inspectNativeCodexOwnership({ + platform: "win32", + home, + run: () => ({ status: 1, stderr: "ERROR: The system cannot find the file specified.", stdout: "", timedOut: false, spawnFailed: false }), + statePaths: [join(opencodexHome, "service-state.json")], + currentHomes: { codexHome, opencodexHome }, + }); + expect(result.ownership).toBe("owned"); + }); }); From ca9618af612e00b18480329dbb750aa6233949d7 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 02:44:32 +0200 Subject: [PATCH 04/13] fix(probe): address bot review findings on the Windows service probe CodeRabbit: - decodeBatchPathValue: protect `%%` with a sentinel before expanding env tokens (%%USERPROFILE%% stays literal), normalize token names to uppercase for case-insensitive lookup, and use `resolved === undefined` so defined empty vars expand. - wrapperLooksGenerated: require a line-anchored `"%OCX_BUN%" "%OCX_CLI%" start` invocation after `:loop` instead of any `bun` substring, so a `:loop` + `rem bun` shell is rejected as malformed. Codex connector: - Resolve schtasks through resolveTrustedWindowsSchtasksExe() so a planted binary on PATH cannot be executed. - Walk the REGISTERED task XML (from /query /xml stdout) in addition to the staged on-disk definition; return unknown when the registered and staged chains disagree (interrupted reinstall). - Locate scheduler assets under the effective OPENCODEX_HOME (new configDir probe dep) instead of always the default-home mirror. - Include the WinSW native backend: probe the SCM registration + parse the WinSW XML homes, and report a conflict when both the scheduler task and the native service are present. Co-authored-by: CommandCodeBot --- src/service-manager-probe.ts | 173 ++++++++++++++++++---- tests/codex-service-manager-probe.test.ts | 108 +++++++++++++- 2 files changed, 251 insertions(+), 30 deletions(-) diff --git a/src/service-manager-probe.ts b/src/service-manager-probe.ts index ad65a0a39..dc76bcebc 100644 --- a/src/service-manager-probe.ts +++ b/src/service-manager-probe.ts @@ -22,6 +22,8 @@ import { spawnSync } from "node:child_process"; import { existsSync, lstatSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { resolveTrustedWindowsSchtasksExe } from "./lib/windows-elevation"; +import { statusWinswRaw, winswExePath, winswXmlPath, WINSW_SERVICE_ID } from "./lib/winsw"; /** Short: this runs inside admission, and a slow answer is the same as none. */ export const SERVICE_PROBE_TIMEOUT_MS = 2_000; @@ -81,6 +83,8 @@ export interface ProbeDeps { readonly platform?: NodeJS.Platform; readonly uid?: number; readonly home?: string; + /** Effective OpenCodex config dir (OPENCODEX_HOME). Overrides `/.opencodex`. */ + readonly configDir?: string; } const LABEL = "com.opencodex.proxy"; @@ -295,11 +299,12 @@ function windowsTaskName(): string { return "opencodex-proxy"; } -function windowsConfigDirPath(home: string): string { - // The wrapper assets live under OPENCODEX_HOME. Defaulting to `~/.opencodex` - // mirrors service.ts defaultOpenCodexHome(); the caller can override `home` - // in tests. - return join(home, ".opencodex"); +function windowsConfigDirPath(deps: { home: string; configDir?: string }): string { + // The wrapper assets live under the effective OPENCODEX_HOME (service.ts + // writes them via getConfigDir()). A customized OPENCODEX_HOME must be + // honored, not shadowed by the default-home mirror. + if (deps.configDir) return deps.configDir; + return join(deps.home, ".opencodex"); } /** Decode an on-disk Windows text asset (task XML, VBS), which is UTF-16LE (often BOM-prefixed). */ @@ -380,29 +385,36 @@ function decodeBatchPathValue( value: string, env: Record = process.env, ): string { + // Sentinel that cannot appear in a decoded path; %-escapes are restored after + // token expansion so `%%USERPROFILE%%` stays a literal `%USERPROFILE%`. + const escapedPercent = "\u0000"; const tokens: Record = { USERPROFILE: env.USERPROFILE, APPDATA: env.APPDATA, LOCALAPPDATA: env.LOCALAPPDATA, - "SystemRoot": env.SystemRoot, + SYSTEMROOT: env.SystemRoot, }; return value + .replace(/%%/g, escapedPercent) .replace(/%([A-Za-z][A-Za-z0-9_]*)%/g, (whole, name: string) => { - const resolved = tokens[name]; - return resolved ? resolved : whole; + // cmd.exe variable names are case-insensitive; treat a defined empty + // value as resolved (expand to empty) rather than unresolved. + const resolved = tokens[name.toUpperCase()]; + return resolved === undefined ? whole : resolved; }) - .replace(/%%/g, "%"); + .replaceAll(escapedPercent, "%"); } /** * True when the wrapper looks like one `buildWindowsServiceScript` generated: - * it has the `:loop` + child-launch tail. Absent `set` lines are only - * meaningful evidence of "deliberately omitted" when the wrapper is otherwise - * the generated artifact — an empty, truncated, or unrelated readable file - * must not read as a legitimate install that omitted both homes. + * a `:loop` label followed by the line-anchored `%OCX_BUN%` / `%OCX_CLI%` + * invocation that launches `start`. Absent `set` lines are only meaningful + * evidence of "deliberately omitted" when the wrapper is otherwise the + * generated artifact — an empty, truncated, or unrelated readable file must + * not read as a legitimate install that omitted both homes. */ function wrapperLooksGenerated(body: string): boolean { - return /:loop[\s\S]*?(?:bun|"%OCX_BUN%"|"%OCX_CLI%")/i.test(body); + return /:loop\s*[\s\S]*^"%OCX_BUN%" "%OCX_CLI%" start\b[^\r\n]*$/im.test(body); } /** @@ -423,21 +435,37 @@ const SCHTASKS_TASK_NOT_FOUND = /cannot find the file specified/i; * there. Treating them as absence would let a locked-down or wedged Task * Scheduler read as a clean machine. */ -function windowsTaskRegistered(deps: Required>): "present" | "absent" | "unknown" { - const queried = deps.run("schtasks", ["/query", "/tn", windowsTaskName(), "/xml"]); - if (queried.spawnFailed || queried.timedOut) return "unknown"; - if (queried.status === 0) return "present"; +function probeWindowsTaskRegistration(deps: Required>): { + registered: "present" | "absent" | "unknown"; + registeredXml: string; +} { + // Resolve schtasks through the trusted System32 helper so a planted binary on + // PATH cannot be executed from an attacker-controlled project directory. + const schtasks = resolveTrustedWindowsSchtasksExe(); + const queried = deps.run(schtasks, ["/query", "/tn", windowsTaskName(), "/xml"]); + if (queried.spawnFailed || queried.timedOut) return { registered: "unknown", registeredXml: "" }; + if (queried.status === 0) { + // The registered document is UTF-16LE; decode it for the chain walk. + const registeredXml = decodeWindowsText(Buffer.from(queried.stdout, "utf8")) + || decodeWindowsText(Buffer.from(queried.stderr, "utf8")); + return { registered: "present", registeredXml }; + } if (queried.status !== null && SCHTASKS_TASK_NOT_FOUND.test(`${queried.stdout}\n${queried.stderr}`)) { - return "absent"; + return { registered: "absent", registeredXml: "" }; } - return "unknown"; + return { registered: "unknown", registeredXml: "" }; } -function inspectWindows(deps: Required>): ServiceManagerInstallation { - const configDir = windowsConfigDirPath(deps.home); +function inspectWindows(deps: Required> & Pick): ServiceManagerInstallation { + const configDir = windowsConfigDirPath(deps); const taskXmlPath = join(configDir, "opencodex-service-task.xml"); const task = artifactPresence(taskXmlPath); + // The native WinSW backend is a separate SCM registration. If it exists, it + // is authoritative on its own; if BOTH the scheduler task and WinSW are + // present that is a conflict (an interrupted backend switch). + const winsw = walkWinswChain(deps); + let xml = ""; if (task !== "absent") { try { @@ -447,17 +475,65 @@ function inspectWindows(deps: Required>): Servic } } - const registered = windowsTaskRegistered(deps); - if (registered === "unknown") { + const registration = probeWindowsTaskRegistration(deps); + if (registration.registered === "unknown") { return unknown("Task Scheduler could not be asked whether opencodex-proxy is registered"); } + const schedulerPresent = task !== "absent" && registration.registered !== "absent"; + if (winsw.kind === "present" && schedulerPresent) { + return { kind: "conflict", claims: [winsw.claims[0], { backend: "scheduler", definitionPath: taskXmlPath, homes: { codexHome: null, opencodexHome: null }, registration: "present" }] }; + } + if (winsw.kind === "present") return winsw; + if (winsw.kind === "unknown" && task === "absent" && registration.registered === "absent") { + return winsw; + } + if (task === "absent") { - return registered === "present" + return registration.registered === "present" ? unknown("Task Scheduler holds opencodex-proxy but its task XML is missing") : { kind: "absent" }; } + const staged = walkWindowsChain(deps, xml, taskXmlPath); + if (staged.kind !== "present") return staged; + const stagedClaim = staged.claims[0]; + + // A registered task whose chain disagrees with the staged one is an + // interrupted reinstall — Task Scheduler will launch the OLD wrapper while + // the staging copy claims new homes. Neither is authoritative alone. + if (registration.registered === "present" && registration.registeredXml.trim()) { + const registeredWalk = walkWindowsChain(deps, registration.registeredXml, taskXmlPath); + if (registeredWalk.kind === "present") { + const registeredClaim = registeredWalk.claims[0]; + const homesDisagree = + (registeredClaim.homes.codexHome ?? null) !== (stagedClaim.homes.codexHome ?? null) + || (registeredClaim.homes.opencodexHome ?? null) !== (stagedClaim.homes.opencodexHome ?? null); + if (homesDisagree) { + return unknown("the registered scheduled task names different homes than the staged task definition"); + } + } + } + + return { + kind: "present", + claims: [{ + ...stagedClaim, + registration: registration.registered === "present" ? "present" : "absent", + }], + }; +} + +/** + * Walk one scheduled-task definition (staged or registered XML) down to the + * batch wrapper and extract the homes it names. Returns `absent` only when the + * XML is absent; every broken or malformed link is `unknown`. + */ +function walkWindowsChain( + deps: Required> & Pick, + xml: string, + definitionPath: string, +): ServiceManagerInstallation { const launcherArg = windowsTaskArguments(xml); if (!launcherArg) { return unknown("the scheduled-task XML names no launcher to run"); @@ -508,12 +584,53 @@ function inspectWindows(deps: Required>): Servic kind: "present", claims: [{ backend: "scheduler", - definitionPath: taskXmlPath, + definitionPath, homes: { codexHome: rawCodexHome === null ? null : decodeBatchPathValue(rawCodexHome), opencodexHome: rawOpencodexHome === null ? null : decodeBatchPathValue(rawOpencodexHome), }, - registration: registered === "present" ? "present" : "absent", + registration: "absent", + }], + }; +} + +/** + * Walk the WinSW native-backend definition (its XML embeds the homes as + * `` / `OPENCODEX_HOME`). Returns `present` when + * the SCM registration exists; `absent` when the XML is gone and the SCM + * confirms no registration; `unknown` on any failure to ask. + */ +function walkWinswChain(deps: Required> & Pick): ServiceManagerInstallation { + const configDir = windowsConfigDirPath(deps); + const exePath = winswExePath(); + const xmlPath = winswXmlPath(); + const xml = artifactPresence(xmlPath); + const status = statusWinswRaw(); + + if (xml === "absent" && status === "nonexistent") return { kind: "absent" }; + if (xml === "absent" || status === "unknown") { + return unknown("the native WinSW service registration could not be verified"); + } + let body: string; + try { + body = decodeWindowsText(readFileSync(xmlPath)); + } catch (error) { + return unknown(`the WinSW XML could not be read: ${String(error)}`); + } + const envValue = (name: string): string | null => { + const match = new RegExp(` { codexHome: "C:\\Users\\ws\\.codex", opencodexHome: "C:\\Users\\ws\\.opencodex", }); - // One bounded query, nothing that mutates. + // One bounded query via the trusted System32 schtasks, nothing that mutates. expect(calls).toHaveLength(1); - expect(calls[0].file).toBe("schtasks"); + expect(calls[0].file.toLowerCase()).toContain("schtasks"); expect(calls[0].args).toEqual(["/query", "/tn", "opencodex-proxy", "/xml"]); }); + test("a registered task whose chain disagrees with the staged definition is unknown", () => { + const wrapper = writeWindowsWrapper("C:\\Users\\ws\\.codex", "C:\\Users\\ws\\.opencodex"); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + // The registered task (from /query /xml) points at a COMPLETE foreign chain + // whose homes differ from the staged on-disk definition — interrupted reinstall. + const foreignWrapper = join(home, ".opencodex", "foreign-wrapper.cmd"); + writeFileSync(foreignWrapper, [ + "@echo off", + "setlocal", + 'set "CODEX_HOME=C:\\foreign\\.codex"', + 'set "OCX_BUN=C:\\bun\\bun.exe"', + 'set "OCX_CLI=C:\\opencodex\\src\\cli\\index.ts"', + ":loop", + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100', + ].join("\r\n")); + const foreignLauncher = join(home, ".opencodex", "foreign-launcher.vbs"); + writeFileSync(foreignLauncher, `shell.Run """${foreignWrapper}""", 0, True\r\n`); + const registeredXml = [ + '', + `/b /nologo "${foreignLauncher}"`, + ].join("\n"); + const { run } = recorder(() => ({ status: 0, stdout: registeredXml })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + expect(result.kind).toBe("unknown"); + expect(result.kind === "unknown" && result.reason).toContain("different homes"); + }); + test("batch env-token homes are decoded before they are compared", () => { // The real builder emits `%USERPROFILE%\.codex` and doubles `%` as `%%`. const dir = join(home, ".opencodex"); @@ -387,6 +416,81 @@ describe("the Windows chain walk", () => { expect(result.claims[0].homes.opencodexHome).toBe(`%PROFILE_VAR%\\custom`); }); + test("escaped and lowercase env tokens decode correctly with a controlled env", () => { + const dir = join(home, ".opencodex"); + mkdirSync(dir, { recursive: true }); + const wrapper = join(dir, "opencodex-service.cmd"); + writeFileSync(wrapper, [ + "@echo off", + "setlocal", + 'set "CODEX_HOME=%%USERPROFILE%%\\literal"', + 'set "OPENCODEX_HOME=%userprofile%\\lower"', + 'set "OCX_BUN=C:\\bun\\bun.exe"', + 'set "OCX_CLI=C:\\opencodex\\src\\cli\\index.ts"', + ":loop", + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100', + ].join("\r\n")); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + const { run } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + // %%USERPROFILE%% is an escaped literal (stays %USERPROFILE%), while the + // lowercase %userprofile% is a real token and expands (case-insensitive). + expect(result.claims[0].homes.codexHome).toBe(`%USERPROFILE%\\literal`); + expect(result.claims[0].homes.opencodexHome).toBe(`${process.env.USERPROFILE ?? ""}\\lower`); + }); + + test("a wrapper with :loop and rem bun is not a generated wrapper", () => { + const dir = join(home, ".opencodex"); + mkdirSync(dir, { recursive: true }); + const wrapper = join(dir, "opencodex-service.cmd"); + writeFileSync(wrapper, [ + "@echo off", + "setlocal", + 'set "CODEX_HOME=C:\\x\\.codex"', + ":loop", + "rem bun start --port 10100", + ].join("\r\n")); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + const { run } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + expect(result.kind).toBe("unknown"); + }); + + test("scheduler assets are located under the effective OPENCODEX_HOME", () => { + // A custom config dir (customized OPENCODEX_HOME) must be honored instead + // of the default /.opencodex mirror. + const custom = join(home, "custom-home"); + const wrapper = join(custom, "opencodex-service.cmd"); + mkdirSync(custom, { recursive: true }); + writeFileSync(wrapper, [ + "@echo off", + "setlocal", + 'set "CODEX_HOME=C:\\custom\\.codex"', + 'set "OCX_BUN=C:\\bun\\bun.exe"', + 'set "OCX_CLI=C:\\opencodex\\src\\cli\\index.ts"', + ":loop", + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100', + ].join("\r\n")); + const launcher = join(custom, "opencodex-service-launcher.vbs"); + writeFileSync(launcher, `shell.Run """${wrapper}""", 0, True\r\n`); + writeFileSync(join(custom, "opencodex-service-task.xml"), [ + '', + `/b /nologo "${launcher}"`, + ].join("\n")); + const { run } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, run, configDir: custom }); + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + expect(result.claims[0].homes.codexHome).toBe("C:\\custom\\.codex"); + }); + test("a malformed wrapper is unknown, not a deliberate omission", () => { // A truncated wrapper with set lines but no generated tail is NOT a // legitimate install that omitted the homes — it is residue. From 83a21cc71fd850c9dbea7bed67eaad717b445282 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 03:05:46 +0200 Subject: [PATCH 05/13] fix(probe): address CodeRabbit findings on winsw, raw schtasks, and chains - Route the WinSW status check through an injectable winswStatus dep (defaults to statusWinswRaw) so every Windows probe uses the bounded, observable dependency path. - Validate the WinSW XML with a generated-artifact check (winswXmlLooksGenerated) and broaden envValue to single/double quotes and either attribute order. - Treat a WinSW claim as installed only when registration is "present", in both conflict detection and the early return. - Wrap resolveTrustedWindowsSchtasksExe() in exception handling; a resolution failure fails closed to unknown. - The conflict branch walks the staged scheduler definition first and reuses its real claim instead of null homes. - A registered-task walk that yields anything other than "present" now returns unknown (not silently skipped), with a regression test for a registered task whose launcher is missing. - Normalize registered vs staged homes (case, slash, trailing separator) before the disagreement comparison. - Preserve schtasks stdout/stderr as raw buffers (UTF-16LE contract) via a new raw probe runner; decode the registered XML from the raw bytes. - Return unknown when WinSW state is unverifiable while a scheduler task is also present. Tests: decoy default-home chain in the OPENCODEX_HOME test, absolute System32 schtasks path assertion, exact wrapper reason assertion, and new WinSW tests (conflict, homes parsed, unverifiable SCM). Co-authored-by: CommandCodeBot --- src/service-manager-probe.ts | 147 +++++++++++++---- tests/codex-service-manager-probe.test.ts | 184 ++++++++++++++++------ 2 files changed, 252 insertions(+), 79 deletions(-) diff --git a/src/service-manager-probe.ts b/src/service-manager-probe.ts index dc76bcebc..db1ca05b3 100644 --- a/src/service-manager-probe.ts +++ b/src/service-manager-probe.ts @@ -23,7 +23,7 @@ import { existsSync, lstatSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { resolveTrustedWindowsSchtasksExe } from "./lib/windows-elevation"; -import { statusWinswRaw, winswExePath, winswXmlPath, WINSW_SERVICE_ID } from "./lib/winsw"; +import { statusWinswRaw, WINSW_SERVICE_ID } from "./lib/winsw"; /** Short: this runs inside admission, and a slow answer is the same as none. */ export const SERVICE_PROBE_TIMEOUT_MS = 2_000; @@ -62,6 +62,18 @@ export interface ProbeRunner { }; } +/** + * Windows probe runner: preserves schtasks stdout/stderr as raw bytes so the + * UTF-16LE task XML is not corrupted by a UTF-8 decode. + */ +export type RawProbeRunner = (file: string, args: readonly string[]) => { + status: number | null; + stdout: Buffer; + stderr: Buffer; + timedOut: boolean; + spawnFailed: boolean; +}; + export const defaultProbeRunner: ProbeRunner = (file, args) => { const result = spawnSync(file, [...args], { encoding: "utf8", @@ -78,13 +90,32 @@ export const defaultProbeRunner: ProbeRunner = (file, args) => { }; }; +export const defaultRawProbeRunner: RawProbeRunner = (file, args) => { + const result = spawnSync(file, [...args], { + encoding: "buffer", + windowsHide: true, + timeout: SERVICE_PROBE_TIMEOUT_MS, + }); + return { + status: result.status, + stdout: Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.alloc(0), + stderr: Buffer.isBuffer(result.stderr) ? result.stderr : Buffer.alloc(0), + timedOut: result.signal !== null && result.error === undefined, + spawnFailed: result.error !== undefined, + }; +}; + export interface ProbeDeps { readonly run?: ProbeRunner; + /** Raw-buffer runner for Windows tasks (UTF-16LE output). Defaults to defaultRawProbeRunner. */ + readonly runRaw?: RawProbeRunner; readonly platform?: NodeJS.Platform; readonly uid?: number; readonly home?: string; /** Effective OpenCodex config dir (OPENCODEX_HOME). Overrides `/.opencodex`. */ readonly configDir?: string; + /** Injectable WinSW SCM status check (defaults to statusWinswRaw). */ + readonly winswStatus?: () => "started" | "stopped" | "nonexistent" | "unknown"; } const LABEL = "com.opencodex.proxy"; @@ -435,36 +466,45 @@ const SCHTASKS_TASK_NOT_FOUND = /cannot find the file specified/i; * there. Treating them as absence would let a locked-down or wedged Task * Scheduler read as a clean machine. */ -function probeWindowsTaskRegistration(deps: Required>): { +function probeWindowsTaskRegistration(deps: Required>): { registered: "present" | "absent" | "unknown"; registeredXml: string; } { // Resolve schtasks through the trusted System32 helper so a planted binary on - // PATH cannot be executed from an attacker-controlled project directory. - const schtasks = resolveTrustedWindowsSchtasksExe(); - const queried = deps.run(schtasks, ["/query", "/tn", windowsTaskName(), "/xml"]); + // PATH cannot be executed from an attacker-controlled project directory. If + // the trusted resolver itself fails, fail closed — never fall back to PATH. + let schtasks: string; + try { + schtasks = resolveTrustedWindowsSchtasksExe(); + } catch { + return { registered: "unknown", registeredXml: "" }; + } + const queried = deps.runRaw(schtasks, ["/query", "/tn", windowsTaskName(), "/xml"]); if (queried.spawnFailed || queried.timedOut) return { registered: "unknown", registeredXml: "" }; if (queried.status === 0) { - // The registered document is UTF-16LE; decode it for the chain walk. - const registeredXml = decodeWindowsText(Buffer.from(queried.stdout, "utf8")) - || decodeWindowsText(Buffer.from(queried.stderr, "utf8")); + // The registered document is UTF-16LE; decode the RAW bytes (decoding as + // UTF-8 first would corrupt the XML). + const registeredXml = decodeWindowsText(queried.stdout) || decodeWindowsText(queried.stderr); return { registered: "present", registeredXml }; } - if (queried.status !== null && SCHTASKS_TASK_NOT_FOUND.test(`${queried.stdout}\n${queried.stderr}`)) { + const text = `${decodeWindowsText(queried.stdout)}\n${decodeWindowsText(queried.stderr)}`; + if (queried.status !== null && SCHTASKS_TASK_NOT_FOUND.test(text)) { return { registered: "absent", registeredXml: "" }; } return { registered: "unknown", registeredXml: "" }; } -function inspectWindows(deps: Required> & Pick): ServiceManagerInstallation { +function inspectWindows(deps: Required> & Pick): ServiceManagerInstallation { const configDir = windowsConfigDirPath(deps); const taskXmlPath = join(configDir, "opencodex-service-task.xml"); const task = artifactPresence(taskXmlPath); // The native WinSW backend is a separate SCM registration. If it exists, it // is authoritative on its own; if BOTH the scheduler task and WinSW are - // present that is a conflict (an interrupted backend switch). + // present that is a conflict (an interrupted backend switch). Only a claim + // whose registration is "present" counts as installed. const winsw = walkWinswChain(deps); + const winswInstalled = winsw.kind === "present" && winsw.claims[0].registration === "present"; let xml = ""; if (task !== "absent") { @@ -481,10 +521,26 @@ function inspectWindows(deps: Required> & Pick

> & Pick

> & Pick

{ + if (v === null) return null; + return v.replace(/[\\/]+$/, "").replace(/\//g, "\\").toLowerCase(); + }; + return norm(a.codexHome) === norm(b.codexHome) && norm(a.opencodexHome) === norm(b.opencodexHome); +} + /** * Walk one scheduled-task definition (staged or registered XML) down to the * batch wrapper and extract the homes it names. Returns `absent` only when the * XML is absent; every broken or malformed link is `unknown`. */ function walkWindowsChain( - deps: Required> & Pick, + deps: Required> & Pick, xml: string, definitionPath: string, ): ServiceManagerInstallation { @@ -600,12 +663,14 @@ function walkWindowsChain( * the SCM registration exists; `absent` when the XML is gone and the SCM * confirms no registration; `unknown` on any failure to ask. */ -function walkWinswChain(deps: Required> & Pick): ServiceManagerInstallation { +function walkWinswChain(deps: Required> & Pick): ServiceManagerInstallation { const configDir = windowsConfigDirPath(deps); - const exePath = winswExePath(); - const xmlPath = winswXmlPath(); + // WinSW assets live under the effective OPENCODEX_HOME (winswDir() resolves + // via getConfigDir()); honor the injected configDir for tests and custom homes. + const exePath = join(configDir, "winsw", `${WINSW_SERVICE_ID}.exe`); + const xmlPath = join(configDir, "winsw", `${WINSW_SERVICE_ID}.xml`); const xml = artifactPresence(xmlPath); - const status = statusWinswRaw(); + const status = (deps.winswStatus ?? statusWinswRaw)(); if (xml === "absent" && status === "nonexistent") return { kind: "absent" }; if (xml === "absent" || status === "unknown") { @@ -617,9 +682,19 @@ function walkWinswChain(deps: Required> & Pick { - const match = new RegExp(` element with the target name in EITHER attribute order, + // single- or double-quoted, and pull its value attribute. + const tag = new RegExp(`]*\\bname=["']${name}["'][^>]*>`, "i").exec(body) + ?? new RegExp(`]*>[^<]*`, "i").exec(body); + if (!tag) return null; + const value = /value=(["'])(.*?)\1/i.exec(tag[0]); + return value ? value[2] : null; }; return { kind: "present", @@ -635,12 +710,22 @@ function walkWinswChain(deps: Required> & Pick`; anything else is malformed, not a deliberate omission. + */ +function winswXmlLooksGenerated(body: string): boolean { + return /\s*opencodex-proxy-native\s*<\/id>/i.test(body) + && /.*?start\s+--port\b/i.test(body); +} + export function inspectServiceManagerInstallation(deps: ProbeDeps = {}): ServiceManagerInstallation { const platform = deps.platform ?? process.platform; const run = deps.run ?? defaultProbeRunner; + const runRaw = deps.runRaw ?? defaultRawProbeRunner; const home = deps.home ?? homedir(); if (platform === "darwin") return inspectLaunchd({ run, uid: deps.uid ?? process.getuid?.() ?? 0, home }); if (platform === "linux") return inspectSystemd({ run, home }); - if (platform === "win32") return inspectWindows({ run, home, configDir: deps.configDir }); + if (platform === "win32") return inspectWindows({ runRaw, home, configDir: deps.configDir, winswStatus: deps.winswStatus }); return unknown(`no service manager probe for platform ${platform}`); } diff --git a/tests/codex-service-manager-probe.test.ts b/tests/codex-service-manager-probe.test.ts index 56d85a1d8..08111fe0d 100644 --- a/tests/codex-service-manager-probe.test.ts +++ b/tests/codex-service-manager-probe.test.ts @@ -15,6 +15,7 @@ import { join } from "node:path"; import { inspectServiceManagerInstallation, type ProbeRunner, + type RawProbeRunner, } from "../src/service-manager-probe"; import { inspectNativeCodexOwnership } from "../src/integrations/native/ownership-preflight"; @@ -30,7 +31,18 @@ function recorder(reply: (file: string, args: readonly string[]) => Partial { + calls.push({ file, args }); + const r = { status: 0, stdout: "", stderr: "", timedOut: false, spawnFailed: false, ...reply(file, args) }; + return { + status: r.status, + stdout: Buffer.from(r.stdout, "utf8"), + stderr: Buffer.from(r.stderr, "utf8"), + timedOut: r.timedOut, + spawnFailed: r.spawnFailed, + }; + }; + return { run, runRaw, calls }; } beforeEach(() => { @@ -253,8 +265,8 @@ describe("could not ask is not an answer", () => { // Nothing staged on disk, and the query is not asked (no run injected) — // the default spawn would fail on non-Windows, so this uses the injected // recorder to prove absence is only claimed when schtasks answers absent. - const { run, calls } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); - const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + const { runRaw, calls } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); expect(result.kind).toBe("absent"); expect(calls).toHaveLength(1); expect(calls[0].args[0]).toBe("/query"); @@ -292,7 +304,12 @@ describe("the Windows chain walk", () => { const dir = join(home, ".opencodex"); mkdirSync(dir, { recursive: true }); const path = join(dir, "opencodex-service-task.xml"); - writeFileSync(path, [ + writeFileSync(path, windowsTaskXmlFor(launcherPath)); + return path; + } + + function windowsTaskXmlFor(launcherPath: string): string { + return [ '', "", " ", @@ -302,8 +319,7 @@ describe("the Windows chain walk", () => { " ", " ", "", - ].join("\n")); - return path; + ].join("\n"); } function writeWindowsLauncher(wrapperPath: string): string { @@ -340,9 +356,11 @@ describe("the Windows chain walk", () => { const wrapper = writeWindowsWrapper("C:\\Users\\ws\\.codex", "C:\\Users\\ws\\.opencodex"); const launcher = writeWindowsLauncher(wrapper); const taskXml = writeWindowsTask(launcher); - const { run, calls } = recorder(() => ({ status: 0, stdout: "" })); + // The registered task points at the SAME launcher as the staged definition, + // so the two chains agree and the staged homes are reported. + const { runRaw, calls } = recorder(() => ({ status: 0, stdout: windowsTaskXmlFor(launcher) })); - const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); expect(result.kind).toBe("present"); if (result.kind !== "present") return; expect(result.claims).toHaveLength(1); @@ -355,7 +373,8 @@ describe("the Windows chain walk", () => { }); // One bounded query via the trusted System32 schtasks, nothing that mutates. expect(calls).toHaveLength(1); - expect(calls[0].file.toLowerCase()).toContain("schtasks"); + const schtasksPath = calls[0].file; + expect(schtasksPath.toLowerCase().replace(/\\/g, "/")).toContain("system32/schtasks.exe"); expect(calls[0].args).toEqual(["/query", "/tn", "opencodex-proxy", "/xml"]); }); @@ -381,9 +400,9 @@ describe("the Windows chain walk", () => { '', `/b /nologo "${foreignLauncher}"`, ].join("\n"); - const { run } = recorder(() => ({ status: 0, stdout: registeredXml })); + const { runRaw } = recorder(() => ({ status: 0, stdout: registeredXml })); - const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); expect(result.kind).toBe("unknown"); expect(result.kind === "unknown" && result.reason).toContain("different homes"); }); @@ -405,9 +424,9 @@ describe("the Windows chain walk", () => { ].join("\r\n")); const launcher = writeWindowsLauncher(wrapper); writeWindowsTask(launcher); - const { run } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); - const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); expect(result.kind).toBe("present"); if (result.kind !== "present") return; // %USERPROFILE% expands to the test's real home; %% stays a literal %. @@ -432,9 +451,9 @@ describe("the Windows chain walk", () => { ].join("\r\n")); const launcher = writeWindowsLauncher(wrapper); writeWindowsTask(launcher); - const { run } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); - const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); expect(result.kind).toBe("present"); if (result.kind !== "present") return; // %%USERPROFILE%% is an escaped literal (stays %USERPROFILE%), while the @@ -456,15 +475,20 @@ describe("the Windows chain walk", () => { ].join("\r\n")); const launcher = writeWindowsLauncher(wrapper); writeWindowsTask(launcher); - const { run } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); - const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); expect(result.kind).toBe("unknown"); }); test("scheduler assets are located under the effective OPENCODEX_HOME", () => { - // A custom config dir (customized OPENCODEX_HOME) must be honored instead - // of the default /.opencodex mirror. + // A decoy chain in the DEFAULT /.opencodex location must NOT win over + // the customized OPENCODEX_HOME passed as configDir. + const decoyWrapper = writeWindowsWrapper("C:\\decoy\\.codex", "C:\\decoy\\.opencodex"); + const decoyLauncher = writeWindowsLauncher(decoyWrapper); + writeWindowsTask(decoyLauncher); + + // The real custom config dir (customized OPENCODEX_HOME) holds the actual chain. const custom = join(home, "custom-home"); const wrapper = join(custom, "opencodex-service.cmd"); mkdirSync(custom, { recursive: true }); @@ -483,11 +507,12 @@ describe("the Windows chain walk", () => { '', `/b /nologo "${launcher}"`, ].join("\n")); - const { run } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); - const result = inspectServiceManagerInstallation({ platform: "win32", home, run, configDir: custom }); + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent", configDir: custom }); expect(result.kind).toBe("present"); if (result.kind !== "present") return; + // The custom chain's homes win over the default-mirror decoy. expect(result.claims[0].homes.codexHome).toBe("C:\\custom\\.codex"); }); @@ -500,11 +525,12 @@ describe("the Windows chain walk", () => { writeFileSync(wrapper, "@echo off\r\nsetlocal\r\nset \"CODEX_HOME=C:\\x\\.codex\""); const launcher = writeWindowsLauncher(wrapper); writeWindowsTask(launcher); - const { run } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); - const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); expect(result.kind).toBe("unknown"); - expect(result.kind === "unknown" && result.reason).toContain("wrapper"); + expect(result.kind === "unknown" && result.reason) + .toContain("the launcher wrapper does not look like a generated opencodex service wrapper"); }); test("an empty wrapper is unknown, not absence", () => { @@ -514,9 +540,9 @@ describe("the Windows chain walk", () => { writeFileSync(wrapper, ""); const launcher = writeWindowsLauncher(wrapper); writeWindowsTask(launcher); - const { run } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); - const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); expect(result.kind).toBe("unknown"); }); @@ -524,9 +550,9 @@ describe("the Windows chain walk", () => { const wrapper = writeWindowsWrapper("C:\\a\\.codex", "C:\\a\\.opencodex"); const launcher = writeWindowsLauncher(wrapper); writeWindowsTask(launcher); - const { run } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); - const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); expect(result.kind).toBe("present"); if (result.kind !== "present") return; expect(result.claims[0].registration).toBe("absent"); @@ -536,9 +562,9 @@ describe("the Windows chain walk", () => { const wrapper = writeWindowsWrapper("C:\\a\\.codex"); const launcher = writeWindowsLauncher(wrapper); writeWindowsTask(launcher); - const { run } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); - const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); expect(result.kind).toBe("present"); if (result.kind !== "present") return; expect(result.claims[0].homes.codexHome).toBe("C:\\a\\.codex"); @@ -549,9 +575,9 @@ describe("the Windows chain walk", () => { // Task XML points at a launcher that does not exist. const missing = join(home, ".opencodex", "no-such.vbs"); writeWindowsTask(missing); - const { run } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); - const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); expect(result.kind).toBe("unknown"); expect(result.kind === "unknown" && result.reason).toContain("launcher"); }); @@ -570,9 +596,9 @@ describe("the Windows chain walk", () => { "", ].join("\n"); writeFileSync(join(home, ".opencodex", "opencodex-service-task.xml"), Buffer.from(`\uFEFF${xml}`, "utf16le")); - const { run } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); - const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); expect(result.kind).toBe("present"); if (result.kind !== "present") return; expect(result.claims[0].homes).toEqual({ codexHome: "C:\\a\\.codex", opencodexHome: "C:\\a\\.opencodex" }); @@ -591,17 +617,17 @@ describe("the Windows chain walk", () => { '', `/b /nologo "${launcher.replace(/&/g, "&").replace(/"/g, """)}"`, ].join("\n")); - const { run } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); - const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); expect(result.kind).toBe("present"); if (result.kind !== "present") return; expect(result.claims[0].homes).toEqual({ codexHome: "C:\\a\\.codex", opencodexHome: "C:\\a\\.opencodex" }); }); test("a task registered but with no XML on disk is unknown", () => { - const { run } = recorder(() => ({ status: 0, stdout: "" })); - const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + const { runRaw } = recorder(() => ({ status: 0, stdout: "" })); + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); expect(result.kind).toBe("unknown"); expect(result.kind === "unknown" && result.reason).toContain("missing"); }); @@ -610,9 +636,9 @@ describe("the Windows chain walk", () => { const wrapper = writeWindowsWrapper("C:\\a\\.codex", "C:\\a\\.opencodex"); const launcher = writeWindowsLauncher(wrapper); writeWindowsTask(launcher); - const { run } = recorder(() => ({ status: null, spawnFailed: true, stderr: "spawn ENOENT" })); + const { runRaw } = recorder(() => ({ status: null, spawnFailed: true, stderr: "spawn ENOENT" })); - const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); expect(result.kind).toBe("unknown"); }); @@ -627,9 +653,9 @@ describe("the Windows chain walk", () => { const wrapper = writeWindowsWrapper("C:\\a\\.codex", "C:\\a\\.opencodex"); const launcher = writeWindowsLauncher(wrapper); writeWindowsTask(launcher); - const { run } = recorder(() => ({ status: 1, stderr: "ERROR: Access is denied. (0x80070005)" })); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: Access is denied. (0x80070005)" })); - const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); expect(result.kind).toBe("unknown"); expect(result.kind === "unknown" && result.reason).toContain("could not be asked"); }); @@ -638,9 +664,9 @@ describe("the Windows chain walk", () => { const wrapper = writeWindowsWrapper("C:\\a\\.codex", "C:\\a\\.opencodex"); const launcher = writeWindowsLauncher(wrapper); writeWindowsTask(launcher); - const { run } = recorder(() => ({ status: null, stderr: "" })); + const { runRaw } = recorder(() => ({ status: null, stderr: "" })); - const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); expect(result.kind).toBe("unknown"); }); @@ -648,9 +674,69 @@ describe("the Windows chain walk", () => { const wrapper = writeWindowsWrapper("C:\\a\\.codex", "C:\\a\\.opencodex"); const launcher = writeWindowsLauncher(wrapper); writeWindowsTask(launcher); - const { run } = recorder(() => ({ status: null, timedOut: true })); + const { runRaw } = recorder(() => ({ status: null, timedOut: true })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); + expect(result.kind).toBe("unknown"); + }); + + test("a registered task whose launcher is missing is unknown", () => { + const wrapper = writeWindowsWrapper("C:\\Users\\ws\\.codex", "C:\\Users\\ws\\.opencodex"); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + // The registered task points at a launcher that does not exist — the + // registered definition cannot be trusted, so the probe fails closed. + const missingLauncher = join(home, ".opencodex", "no-such.vbs"); + const { runRaw } = recorder(() => ({ status: 0, stdout: windowsTaskXmlFor(missingLauncher) })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); + expect(result.kind).toBe("unknown"); + expect(result.kind === "unknown" && result.reason).toContain("launcher"); + }); + + function writeWinswXml(dir: string, codexHome: string, opencodexHome: string): void { + // The probe resolves the winsw dir under the effective config dir + // (default /.opencodex/winsw), matching winswDir() in src/lib/winsw.ts. + const winswDir = join(dir, ".opencodex", "winsw"); + mkdirSync(winswDir, { recursive: true }); + writeFileSync(join(winswDir, "opencodex-proxy-native.xml"), [ + '', + "", + " opencodex-proxy-native", + ` `, + ` `, + ' "C:\\cli\\index.ts" start --port 10100', + "", + ].join("\n")); + } + + test("WinSW and Task Scheduler both present is a conflict", () => { + const wrapper = writeWindowsWrapper("C:\\a\\.codex", "C:\\a\\.opencodex"); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + writeWinswXml(home, "C:\\winsw\\.codex", "C:\\winsw\\.opencodex"); + const { runRaw } = recorder(() => ({ status: 0, stdout: windowsTaskXmlFor(launcher) })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "started" }); + expect(result.kind).toBe("conflict"); + }); + + test("WinSW homes are parsed from the XML env entries", () => { + writeWinswXml(home, "C:\\winsw\\.codex", "C:\\winsw\\.opencodex"); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "stopped" }); + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + expect(result.claims[0].backend).toBe("winsw"); + expect(result.claims[0].homes).toEqual({ codexHome: "C:\\winsw\\.codex", opencodexHome: "C:\\winsw\\.opencodex" }); + }); + + test("WinSW with unverifiable SCM state is unknown", () => { + writeWinswXml(home, "C:\\winsw\\.codex", "C:\\winsw\\.opencodex"); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); - const result = inspectServiceManagerInstallation({ platform: "win32", home, run }); + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "unknown" }); expect(result.kind).toBe("unknown"); }); }); @@ -810,7 +896,8 @@ describe("ownership refuses what it cannot prove", () => { const result = inspectNativeCodexOwnership({ platform: "win32", home, - run: () => ({ status: 1, stderr: "ERROR: The system cannot find the file specified.", stdout: "", timedOut: false, spawnFailed: false }), + runRaw: () => ({ status: 1, stderr: Buffer.from("ERROR: The system cannot find the file specified."), stdout: Buffer.alloc(0), timedOut: false, spawnFailed: false }), + winswStatus: () => "nonexistent", statePaths: [join(opencodexHome, "service-state.json")], currentHomes: { codexHome, opencodexHome }, }); @@ -845,7 +932,8 @@ describe("ownership refuses what it cannot prove", () => { const result = inspectNativeCodexOwnership({ platform: "win32", home, - run: () => ({ status: 1, stderr: "ERROR: The system cannot find the file specified.", stdout: "", timedOut: false, spawnFailed: false }), + runRaw: () => ({ status: 1, stderr: Buffer.from("ERROR: The system cannot find the file specified."), stdout: Buffer.alloc(0), timedOut: false, spawnFailed: false }), + winswStatus: () => "nonexistent", statePaths: [join(opencodexHome, "service-state.json")], currentHomes: { codexHome, opencodexHome }, }); From 48bbac6ba397158486c42ad86bf9a168bb737c81 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 03:21:15 +0200 Subject: [PATCH 06/13] test(probe): cover WinSW exe-missing fail-closed case The WinSW XML without its paired exe cannot prove a verifiable SCM claim; add a regression test asserting the probe returns unknown rather than an owned WinSW backend. Co-authored-by: CommandCodeBot --- tests/codex-service-manager-probe.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/codex-service-manager-probe.test.ts b/tests/codex-service-manager-probe.test.ts index 08111fe0d..fe9e0d815 100644 --- a/tests/codex-service-manager-probe.test.ts +++ b/tests/codex-service-manager-probe.test.ts @@ -699,6 +699,8 @@ describe("the Windows chain walk", () => { // (default /.opencodex/winsw), matching winswDir() in src/lib/winsw.ts. const winswDir = join(dir, ".opencodex", "winsw"); mkdirSync(winswDir, { recursive: true }); + // The exe must be present for the SCM claim to be verifiable (fail-closed). + writeFileSync(join(winswDir, "opencodex-proxy-native.exe"), "placeholder"); writeFileSync(join(winswDir, "opencodex-proxy-native.xml"), [ '', "", @@ -739,6 +741,25 @@ describe("the Windows chain walk", () => { const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "unknown" }); expect(result.kind).toBe("unknown"); }); + + test("WinSW XML present but exe missing is unknown (fail closed)", () => { + // Only the XML exists (no exe): the SCM claim cannot be verified, so the + // probe must refuse rather than report an owned WinSW backend. + const winswDir = join(home, ".opencodex", "winsw"); + mkdirSync(winswDir, { recursive: true }); + writeFileSync(join(winswDir, "opencodex-proxy-native.xml"), [ + '', + "", + " opencodex-proxy-native", + ' ', + ' "C:\\cli\\index.ts" start --port 10100', + "", + ].join("\n")); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "stopped" }); + expect(result.kind).toBe("unknown"); + }); }); describe("ownership refuses what it cannot prove", () => { From dd1e1a8dca5b15dd9fb6d99fb3e74c2b43451c44 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 04:07:32 +0200 Subject: [PATCH 07/13] fix(probe): fail closed on broken scheduler chains and WinSW exe gaps - When WinSW and a scheduler task are both present, a broken/malformed staged scheduler chain now returns unknown instead of fabricating a null-homes claim; a valid staged claim is a conflict with registration present (no invented placeholder homes). - WinSW absence now requires both the XML and the service exe to be missing, so a missing binary is unknown, not "absent". - The value reader drops its loose fallback regex: an unmatched name stays null rather than grabbing a wrong element's value. - Tests: conflict asserts real (non-fabricated) scheduler claim homes + registration; new case covers a broken scheduler chain alongside WinSW failing closed as unknown. Co-authored-by: CommandCodeBot --- src/service-manager-probe.ts | 23 +++++++++++++---------- tests/codex-service-manager-probe.test.ts | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/service-manager-probe.ts b/src/service-manager-probe.ts index db1ca05b3..d6e3fb45f 100644 --- a/src/service-manager-probe.ts +++ b/src/service-manager-probe.ts @@ -522,15 +522,17 @@ function inspectWindows(deps: Required> & Pic const schedulerPresent = task !== "absent" && registration.registered !== "absent"; if (winswInstalled && schedulerPresent) { - // Walk the staged scheduler definition first so the conflict carries its - // real homes instead of null placeholders. + // Walk the staged scheduler definition so the conflict carries its real + // homes. A broken/malformed scheduler chain cannot be fabricated into a + // claim — return its non-present result (unknown) rather than inventing + // homes; a valid staged claim is a conflict with registration present. const staged = walkWindowsChain(deps, xml, taskXmlPath); - const stagedClaim = staged.kind === "present" ? staged.claims[0] : null; + if (staged.kind !== "present") return staged; return { kind: "conflict", claims: [ winsw.claims[0], - stagedClaim ?? { backend: "scheduler", definitionPath: taskXmlPath, homes: { codexHome: null, opencodexHome: null }, registration: "present" }, + { ...staged.claims[0], registration: "present" }, ], }; } @@ -670,10 +672,11 @@ function walkWinswChain(deps: Required> & Pick> & Pick { - // Match an element with the target name in EITHER attribute order, - // single- or double-quoted, and pull its value attribute. - const tag = new RegExp(`]*\\bname=["']${name}["'][^>]*>`, "i").exec(body) - ?? new RegExp(`]*>[^<]*`, "i").exec(body); + // Match an element carrying the target name in EITHER attribute + // order, single- or double-quoted, and pull its value attribute. No loose + // fallback: an unmatched name must stay null, never a wrong element's value. + const tag = new RegExp(`]*\\bname=["']${name}["'][^>]*>`, "i").exec(body); if (!tag) return null; const value = /value=(["'])(.*?)\1/i.exec(tag[0]); return value ? value[2] : null; diff --git a/tests/codex-service-manager-probe.test.ts b/tests/codex-service-manager-probe.test.ts index fe9e0d815..09be7e433 100644 --- a/tests/codex-service-manager-probe.test.ts +++ b/tests/codex-service-manager-probe.test.ts @@ -721,6 +721,26 @@ describe("the Windows chain walk", () => { const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "started" }); expect(result.kind).toBe("conflict"); + if (result.kind !== "conflict") return; + // The staged scheduler claim is real (not fabricated null homes) and + // registered. + expect(result.claims).toHaveLength(2); + expect(result.claims[0].backend).toBe("winsw"); + expect(result.claims[1].backend).toBe("scheduler"); + expect(result.claims[1].registration).toBe("present"); + expect(result.claims[1].homes).toEqual({ codexHome: "C:\\a\\.codex", opencodexHome: "C:\\a\\.opencodex" }); + }); + + test("a broken scheduler chain alongside WinSW is unknown, not a fabricated conflict", () => { + // WinSW is installed and a scheduler task is registered, but the staged + // scheduler chain is broken (launcher missing). The probe must not + // fabricate a null-homes scheduler claim — it fails closed. + writeWindowsTask(join(home, ".opencodex", "no-such.vbs")); + writeWinswXml(home, "C:\\winsw\\.codex", "C:\\winsw\\.opencodex"); + const { runRaw } = recorder(() => ({ status: 0, stdout: windowsTaskXmlFor(join(home, ".opencodex", "no-such.vbs")) })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "started" }); + expect(result.kind).toBe("unknown"); }); test("WinSW homes are parsed from the XML env entries", () => { From 7e877c4d70a70d418e527526df160988631a8864 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 04:16:40 +0200 Subject: [PATCH 08/13] test(probe): skip dangling-plist symlink test when symlinks are unavailable Windows without Developer Mode or elevated privileges cannot create symlinks (EPERM), so the dangling-plist test always failed there even though the code under test is fine. Probe symlink capability once and gate the test with test.skipIf, mirroring claude-agents-inject; the machine without symlinks now reports a visible skip instead of a spurious failure. Co-authored-by: CommandCodeBot --- tests/codex-service-manager-probe.test.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/codex-service-manager-probe.test.ts b/tests/codex-service-manager-probe.test.ts index 09be7e433..dd35b0b65 100644 --- a/tests/codex-service-manager-probe.test.ts +++ b/tests/codex-service-manager-probe.test.ts @@ -225,7 +225,23 @@ describe("could not ask is not an answer", () => { * two produced it is not observable from outside, so claiming to test it * would be claiming more than this proves. */ - test("a dangling plist symlink does not read as a clean machine", () => { + // Windows without Developer Mode / elevated privileges cannot create + // symlinks (EPERM). Detect once so this test reports a visible skip there + // instead of a spurious failure. Mirrors the probe in claude-agents-inject. + const canSymlink = (() => { + const dir = mkdtempSync(join(tmpdir(), "ocx-symlink-probe-")); + try { + symlinkSync(join(dir, "probe-target"), join(dir, "probe-link")); + return true; + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException).code === "EPERM") return false; + throw e; + } finally { + rmSync(dir, { recursive: true, force: true }); + } + })(); + + test.skipIf(!canSymlink)("a dangling plist symlink does not read as a clean machine", () => { const agents = join(home, "Library", "LaunchAgents"); mkdirSync(agents, { recursive: true }); symlinkSync(join(home, "nothing-here.plist"), join(agents, "com.opencodex.proxy.plist")); From cc97b6cd9ab50f66524a4816df46dbbadcb74ab6 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 04:27:17 +0200 Subject: [PATCH 09/13] test(probe): wire the trusted System32 resolver seam for Linux CI The Windows chain-walk suites pass `platform: "win32"` as a parameter, but the trusted schtasks resolver keys off process.platform, which is "linux" on the ubuntu-latest test shards. It threw on every Windows test, so the probe returned "Task Scheduler could not be asked" and 18 tests failed. Set the trusted-system-directory resolver seam in beforeEach, pointing at a per-test fake System32 containing schtasks.exe (mirroring windows-elevation.test.ts), and reset it in afterEach. Reproduced the CI failure locally under a linux-platform simulation: 18 failed without the seam, 49 pass / 0 fail with it. Co-authored-by: CommandCodeBot --- tests/codex-service-manager-probe.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/codex-service-manager-probe.test.ts b/tests/codex-service-manager-probe.test.ts index dd35b0b65..d1c503536 100644 --- a/tests/codex-service-manager-probe.test.ts +++ b/tests/codex-service-manager-probe.test.ts @@ -18,11 +18,13 @@ import { type RawProbeRunner, } from "../src/service-manager-probe"; import { inspectNativeCodexOwnership } from "../src/integrations/native/ownership-preflight"; +import { setTrustedWindowsSystemDirectoryResolverForTests } from "../src/lib/windows-elevation"; let home = ""; const cleanup: string[] = []; let previousCodexHome: string | undefined; let previousOpencodexHome: string | undefined; +let trustedSystem32 = ""; /** Records exactly what production asked for, so the allowlist is observed. */ function recorder(reply: (file: string, args: readonly string[]) => Partial>) { @@ -45,13 +47,22 @@ function recorder(reply: (file: string, args: readonly string[]) => Partial { home = mkdtempSync(join(tmpdir(), "ocx-probe-")); cleanup.push(home); + trustedSystem32 = join(home, "System32"); + mkdirSync(trustedSystem32, { recursive: true }); + writeFileSync(join(trustedSystem32, "schtasks.exe"), ""); + setTrustedWindowsSystemDirectoryResolverForTests(() => trustedSystem32); previousCodexHome = process.env.CODEX_HOME; previousOpencodexHome = process.env.OPENCODEX_HOME; }); afterEach(() => { + setTrustedWindowsSystemDirectoryResolverForTests(null); if (previousCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = previousCodexHome; if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; From 06ee2745e6a11f8b258a14c93eb78412037e5b46 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 04:49:58 +0200 Subject: [PATCH 10/13] test(probe): cover Windows ownership hardening gaps --- ...ex-service-manager-probe-hardening.test.ts | 311 ++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 tests/codex-service-manager-probe-hardening.test.ts diff --git a/tests/codex-service-manager-probe-hardening.test.ts b/tests/codex-service-manager-probe-hardening.test.ts new file mode 100644 index 000000000..620e40433 --- /dev/null +++ b/tests/codex-service-manager-probe-hardening.test.ts @@ -0,0 +1,311 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + inspectServiceManagerInstallation, + type RawProbeRunner, +} from "../src/service-manager-probe"; +import { inspectNativeCodexOwnership } from "../src/integrations/native/ownership-preflight"; +import { setTrustedWindowsSystemDirectoryResolverForTests } from "../src/lib/windows-elevation"; + +let home = ""; +let configDir = ""; +let trustedSystem32 = ""; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-probe-hardening-")); + configDir = join(home, "custom-opencodex"); + trustedSystem32 = join(home, "System32"); + mkdirSync(configDir, { recursive: true }); + mkdirSync(trustedSystem32, { recursive: true }); + writeFileSync(join(trustedSystem32, "schtasks.exe"), ""); + writeFileSync(join(trustedSystem32, "sc.exe"), ""); + setTrustedWindowsSystemDirectoryResolverForTests(() => trustedSystem32); +}); + +afterEach(() => { + setTrustedWindowsSystemDirectoryResolverForTests(null); + rmSync(home, { recursive: true, force: true }); +}); + +function raw( + status: number | null, + stdout = "", + stderr = "", + extra: Partial> = {}, +): ReturnType { + return { + status, + stdout: Buffer.from(stdout, "utf8"), + stderr: Buffer.from(stderr, "utf8"), + timedOut: false, + spawnFailed: false, + ...extra, + }; +} + +function schedulerXml(launcherPath: string): string { + const escaped = launcherPath.replace(/&/g, "&").replace(/"/g, """); + return [ + '', + "", + " ", + " ", + ` /b /nologo "${escaped}"`, + " ", + " ", + "", + ].join("\n"); +} + +function writeSchedulerChain( + dir: string, + codexHome: string, + opencodexHome: string, + options: { writeTaskXml?: boolean } = {}, +): { launcher: string; wrapper: string; taskXml: string } { + mkdirSync(dir, { recursive: true }); + const wrapper = join(dir, "opencodex-service.cmd"); + const launcher = join(dir, "opencodex-service-launcher.vbs"); + const taskXml = join(dir, "opencodex-service-task.xml"); + writeFileSync(wrapper, [ + "@echo off", + "setlocal", + `set "CODEX_HOME=${codexHome}"`, + `set "OPENCODEX_HOME=${opencodexHome}"`, + 'set "OCX_BUN=C:\\bun\\bun.exe"', + 'set "OCX_CLI=C:\\opencodex\\src\\cli\\index.ts"', + ":loop", + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100', + ].join("\r\n")); + writeFileSync(launcher, `shell.Run """${wrapper}""", 0, True\r\n`); + if (options.writeTaskXml !== false) writeFileSync(taskXml, schedulerXml(launcher)); + return { launcher, wrapper, taskXml }; +} + +function xmlEscape(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function writeWinsw( + dir: string, + codexHome: string, + opencodexHome: string, +): void { + const winswDir = join(dir, "winsw"); + mkdirSync(winswDir, { recursive: true }); + writeFileSync(join(winswDir, "opencodex-proxy-native.exe"), "not-executable-test-placeholder"); + writeFileSync(join(winswDir, "opencodex-proxy-native.xml"), [ + '', + "", + " opencodex-proxy-native", + ` `, + ` `, + ' "C:\\cli\\index.ts" start --port 10100', + "", + ].join("\n")); +} + +function taskAbsentRunner(calls: Array<{ file: string; args: readonly string[] }>): RawProbeRunner { + return (file, args) => { + calls.push({ file, args }); + if (args[0]?.toLowerCase() === "query" && args.includes("/xml")) { + return raw(1, "", "ERROR: The system cannot find the file specified."); + } + if (args[0]?.toLowerCase() === "/query" && args.includes("/xml")) { + return raw(1, "", "ERROR: The system cannot find the file specified."); + } + if (args.includes("/fo")) return raw(0, ""); + return raw(1, "", "ERROR: The system cannot find the file specified."); + }; +} + +describe("Windows ownership probe hardening regressions", () => { + test("ownership inspects the effective current OPENCODEX_HOME without an injected configDir", () => { + const currentCodexHome = "C:\\current\\.codex"; + const foreignCodexHome = "C:\\foreign\\.codex"; + writeSchedulerChain(configDir, foreignCodexHome, configDir); + const statePath = join(configDir, "service-state.json"); + writeFileSync(statePath, JSON.stringify({ + version: 2, + backend: "scheduler", + codexHome: currentCodexHome, + opencodexHome: configDir, + })); + const calls: Array<{ file: string; args: readonly string[] }> = []; + const runRaw = taskAbsentRunner(calls); + + const result = inspectNativeCodexOwnership({ + platform: "win32", + home, + runRaw, + winswStatus: () => "nonexistent", + statePaths: [statePath], + currentHomes: { codexHome: currentCodexHome, opencodexHome: configDir }, + }); + + expect(result.ownership).toBe("unknown"); + expect(result.reason).toContain("different homes"); + }); + + test("the default WinSW registration probe uses bounded trusted sc.exe instead of executing the WinSW binary", () => { + const codexHome = "C:\\owned\\.codex"; + writeWinsw(configDir, codexHome, configDir); + const calls: Array<{ file: string; args: readonly string[] }> = []; + const runRaw: RawProbeRunner = (file, args) => { + calls.push({ file, args }); + if (file.toLowerCase().endsWith("sc.exe")) return raw(0, "SERVICE_NAME: opencodex-proxy-native"); + if (args.includes("/xml")) return raw(1, "", "ERROR: Das System kann die angegebene Datei nicht finden."); + if (args.includes("/fo")) return raw(0, ""); + return raw(1, "", "unexpected query"); + }; + + const result = inspectServiceManagerInstallation({ platform: "win32", home, configDir, runRaw }); + + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + expect(result.claims[0].backend).toBe("winsw"); + expect(calls.some(call => call.file.toLowerCase().endsWith("sc.exe") + && call.args[0] === "query" + && call.args[1] === "opencodex-proxy-native")).toBe(true); + expect(calls.every(call => call.file.toLowerCase().includes("system32"))).toBe(true); + }); + + test("registered scheduler plus registered WinSW conflicts even when staged task XML is missing", () => { + const codexHome = "C:\\owned\\.codex"; + const scheduler = writeSchedulerChain(configDir, codexHome, configDir, { writeTaskXml: false }); + writeWinsw(configDir, codexHome, configDir); + const runRaw: RawProbeRunner = (_file, args) => { + if (args.includes("/xml")) return raw(0, schedulerXml(scheduler.launcher)); + return raw(1, "", "unexpected query"); + }; + + const result = inspectServiceManagerInstallation({ + platform: "win32", + home, + configDir, + runRaw, + winswStatus: () => "started", + }); + + expect(result.kind).toBe("conflict"); + if (result.kind !== "conflict") return; + expect(result.claims.map(claim => claim.backend).sort()).toEqual(["scheduler", "winsw"]); + }); + + test("a scheduler definition cannot make the probe follow a launcher outside the generated config chain", () => { + const foreignDir = join(home, "foreign"); + const foreign = writeSchedulerChain(foreignDir, "C:\\foreign\\.codex", foreignDir); + writeFileSync(join(configDir, "opencodex-service-task.xml"), schedulerXml(foreign.launcher)); + const calls: Array<{ file: string; args: readonly string[] }> = []; + const runRaw = taskAbsentRunner(calls); + + const result = inspectServiceManagerInstallation({ + platform: "win32", + home, + configDir, + runRaw, + winswStatus: () => "nonexistent", + }); + + expect(result.kind).toBe("unknown"); + expect(result.kind === "unknown" && result.reason).toContain("expected launcher"); + }); + + test("localized schtasks task-not-found output falls back to the task listing before declaring absence", () => { + const calls: Array<{ file: string; args: readonly string[] }> = []; + const runRaw: RawProbeRunner = (file, args) => { + calls.push({ file, args }); + if (args.includes("/xml")) { + return raw(1, "", "FEHLER: Das System kann die angegebene Datei nicht finden."); + } + if (args.includes("/fo")) { + return raw(0, '"\\SomeOtherTask","N/A","Ready"\r\n'); + } + return raw(1, "", "unexpected query"); + }; + + const result = inspectServiceManagerInstallation({ + platform: "win32", + home, + configDir, + runRaw, + winswStatus: () => "nonexistent", + }); + + expect(result.kind).toBe("absent"); + expect(calls.some(call => call.args.includes("/fo"))).toBe(true); + }); + + test("a staged but unregistered WinSW definition remains visible as a present claim", () => { + const codexHome = "C:\\staged\\.codex"; + writeWinsw(configDir, codexHome, configDir); + const calls: Array<{ file: string; args: readonly string[] }> = []; + const runRaw = taskAbsentRunner(calls); + + const result = inspectServiceManagerInstallation({ + platform: "win32", + home, + configDir, + runRaw, + winswStatus: () => "nonexistent", + }); + + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + expect(result.claims[0].backend).toBe("winsw"); + expect(result.claims[0].registration).toBe("absent"); + }); + + test("legacy v1 service state means scheduler and cannot authorize a WinSW manager", () => { + const codexHome = "C:\\owned\\.codex"; + writeWinsw(configDir, codexHome, configDir); + const statePath = join(configDir, "service-state.json"); + writeFileSync(statePath, JSON.stringify({ + version: 1, + codexHome, + opencodexHome: configDir, + })); + const calls: Array<{ file: string; args: readonly string[] }> = []; + const runRaw = taskAbsentRunner(calls); + + const result = inspectNativeCodexOwnership({ + platform: "win32", + home, + configDir, + runRaw, + winswStatus: () => "started", + statePaths: [statePath], + currentHomes: { codexHome, opencodexHome: configDir }, + }); + + expect(result.ownership).toBe("unknown"); + expect(result.reason).toContain("backend scheduler"); + }); + + test("WinSW home values are XML-unescaped before ownership comparison", () => { + const codexHome = "C:\\Users\\A&B\\.codex"; + writeWinsw(configDir, codexHome, configDir); + const calls: Array<{ file: string; args: readonly string[] }> = []; + const runRaw = taskAbsentRunner(calls); + + const result = inspectServiceManagerInstallation({ + platform: "win32", + home, + configDir, + runRaw, + winswStatus: () => "started", + }); + + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + expect(result.claims[0].homes.codexHome).toBe(codexHome); + }); +}); From aa0e221216ad3c1159439b2b6b72025051b6e0b1 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 04:50:36 +0200 Subject: [PATCH 11/13] fix(probe): harden Windows ownership evidence --- .../native/ownership-preflight.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/integrations/native/ownership-preflight.ts b/src/integrations/native/ownership-preflight.ts index b40e12e4a..5324c2a8a 100644 --- a/src/integrations/native/ownership-preflight.ts +++ b/src/integrations/native/ownership-preflight.ts @@ -90,11 +90,11 @@ function claimBackendToStateBackend(backend: ServiceManagerClaim["backend"]): "s return null; } -/** True when the recorded v2 state's backend disagrees with the manager claim. */ +/** True when the recorded state backend disagrees with the manager claim. Legacy v1 means scheduler. */ function claimBackendMismatchesState(claim: ServiceManagerClaim, state: { backend?: "scheduler" | "native" }): boolean { const expected = claimBackendToStateBackend(claim.backend); if (expected === null) return false; - return state.backend !== undefined && state.backend !== expected; + return (state.backend ?? "scheduler") !== expected; } export interface OwnershipDeps extends ProbeDeps { @@ -144,7 +144,14 @@ export function inspectNativeCodexOwnership(deps: OwnershipDeps = {}): Ownership }; } - const manager = inspectServiceManagerInstallation(deps); + // The manager assets live under the effective OPENCODEX_HOME. Production + // callers do not inject ProbeDeps.configDir, so derive it from the same + // current-home snapshot used for ownership comparison rather than silently + // falling back to /.opencodex. + const manager = inspectServiceManagerInstallation({ + ...deps, + configDir: deps.configDir ?? current.opencodexHome, + }); if (manager.kind === "unknown") { return { ownership: "unknown", reason: manager.reason }; } @@ -167,12 +174,13 @@ export function inspectNativeCodexOwnership(deps: OwnershipDeps = {}): Ownership } // A manager backend that disagrees with the recorded state (e.g. state says // native/WinSW but a scheduler task is found) is an interrupted backend - // switch: it does not prove which manager owns the installation. + // switch: it does not prove which manager owns the installation. v1 state + // predates the field and is scheduler by contract. const stateBackendMismatch = valid.find(state => manager.claims.some(claim => claimBackendMismatchesState(claim, state.state))); if (stateBackendMismatch) { return { ownership: "unknown", - reason: `the service state records backend ${stateBackendMismatch.state.backend ?? "(none)"} but ${manager.claims[0]?.backend ?? "a service manager"} is installed`, + reason: `the service state records backend ${stateBackendMismatch.state.backend ?? "scheduler"} but ${manager.claims[0]?.backend ?? "a service manager"} is installed`, }; } // Definition agrees. Valid state agreeing with it is ownership; no state at @@ -191,4 +199,4 @@ export function inspectNativeCodexOwnership(deps: OwnershipDeps = {}): Ownership return valid.length === 0 ? { ownership: "owned", reason: "no service state and no service manager claim" } : { ownership: "owned", reason: "the recorded service state names these homes" }; -} +} \ No newline at end of file From 00f13cec21012301cdcc0cfd11a9058b187d207b Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 04:52:29 +0200 Subject: [PATCH 12/13] fix(probe): harden Windows ownership evidence --- src/service-manager-probe.ts | 341 +++++++++++++++++++++-------------- 1 file changed, 203 insertions(+), 138 deletions(-) diff --git a/src/service-manager-probe.ts b/src/service-manager-probe.ts index d6e3fb45f..143918b77 100644 --- a/src/service-manager-probe.ts +++ b/src/service-manager-probe.ts @@ -22,8 +22,11 @@ import { spawnSync } from "node:child_process"; import { existsSync, lstatSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import { resolveTrustedWindowsSchtasksExe } from "./lib/windows-elevation"; -import { statusWinswRaw, WINSW_SERVICE_ID } from "./lib/winsw"; +import { + resolveTrustedWindowsSchtasksExe, + resolveTrustedWindowsSystemDirectory, +} from "./lib/windows-elevation"; +import { WINSW_SERVICE_ID } from "./lib/winsw"; /** Short: this runs inside admission, and a slow answer is the same as none. */ export const SERVICE_PROBE_TIMEOUT_MS = 2_000; @@ -107,14 +110,14 @@ export const defaultRawProbeRunner: RawProbeRunner = (file, args) => { export interface ProbeDeps { readonly run?: ProbeRunner; - /** Raw-buffer runner for Windows tasks (UTF-16LE output). Defaults to defaultRawProbeRunner. */ + /** Raw-buffer runner for bounded Windows service-manager queries. */ readonly runRaw?: RawProbeRunner; readonly platform?: NodeJS.Platform; readonly uid?: number; readonly home?: string; /** Effective OpenCodex config dir (OPENCODEX_HOME). Overrides `/.opencodex`. */ readonly configDir?: string; - /** Injectable WinSW SCM status check (defaults to statusWinswRaw). */ + /** Test seam for WinSW SCM status. Production uses bounded trusted `sc.exe query`. */ readonly winswStatus?: () => "started" | "stopped" | "nonexistent" | "unknown"; } @@ -322,18 +325,15 @@ function inspectSystemd(deps: Required>): Servic * missing home stays `null` — the same contract the launchd/systemd probes use — * and a definition that names no homes cannot be mistaken for agreement. * - * Registration is answered by one bounded `schtasks /query /xml` call so a - * definition staged on disk but never registered is still visible (the - * interrupted-install case). Every failure to ask is `unknown`, never absence. + * Registration is answered by bounded `schtasks` queries so a definition staged + * on disk but never registered is still visible (the interrupted-install case). + * Every failure to ask is `unknown`, never absence. */ function windowsTaskName(): string { return "opencodex-proxy"; } function windowsConfigDirPath(deps: { home: string; configDir?: string }): string { - // The wrapper assets live under the effective OPENCODEX_HOME (service.ts - // writes them via getConfigDir()). A customized OPENCODEX_HOME must be - // honored, not shadowed by the default-home mirror. if (deps.configDir) return deps.configDir; return join(deps.home, ".opencodex"); } @@ -361,19 +361,20 @@ function decodeWindowsText(buffer: Buffer): string { return buffer.toString("utf8").replace(/^\uFEFF/, "").trim(); } -/** Pull the launcher path out of the task XML `` element. */ -function windowsTaskArguments(xml: string): string | null { - const match = /]*>\s*([^<]*?)\s*<\/Arguments>/i.exec(xml); - if (!match) return null; - // The registered document escapes `"` as `"`; decode before extracting - // the quoted path so the same regex sees both the on-disk and /query forms. - const raw = match[1]!.trim() +/** Decode the XML entities emitted by the service-definition writers. */ +function decodeXmlEntities(value: string): string { + return value .replace(/"/g, '"') - .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") - .replace(/'/g, "'"); - return raw; + .replace(/'/g, "'") + .replace(/&/g, "&"); +} + +/** Pull the launcher path out of the task XML `` element. */ +function windowsTaskArguments(xml: string): string | null { + const match = /]*>\s*([^<]*?)\s*<\/Arguments>/i.exec(xml); + return match ? decodeXmlEntities(match[1]!.trim()) : null; } /** @@ -381,9 +382,7 @@ function windowsTaskArguments(xml: string): string | null { * * `buildWindowsLauncherVbs` escapes a `"` inside a VBS string literal by * doubling it, so a wrapper `C:\...\opencodex-service.cmd` is emitted as - * `shell.Run """C:\...\opencodex-service.cmd""", 0, True`. Matching the - * whole doubled-quote span — `"""` ... `"""` — is the only form that - * survives both a plain quoted path and one with spaces. + * `shell.Run """C:\...\opencodex-service.cmd""", 0, True`. */ function vbsWrappedCommand(body: string): string | null { const match = /\.Run\s+"""([^"]*)"""/.exec(body); @@ -401,23 +400,11 @@ function batchSetValue(body: string, name: string): string | null { return match ? match[1]!.trim() : null; } -/** - * Resolve a home value the way cmd would, reversing what - * `windowsEnvIndirectBatchValue` + `windowsBatchValue` baked in. - * - * The builder rewrites a home under USERPROFILE/APPDATA/LOCALAPPDATA to a - * `%VAR%` token (so non-ASCII profile names survive the OEM-codepage parse), - * and doubles literal `%` as `%%` so the token itself survives the escaping. - * A probe that returned that batch syntax verbatim would compare it against - * the resolved current home and report a false disagreement. Expand known - * tokens via the live environment, then un-double any remaining `%%`. - */ +/** Resolve generated batch env indirection before comparing homes. */ function decodeBatchPathValue( value: string, env: Record = process.env, ): string { - // Sentinel that cannot appear in a decoded path; %-escapes are restored after - // token expansion so `%%USERPROFILE%%` stays a literal `%USERPROFILE%`. const escapedPercent = "\u0000"; const tokens: Record = { USERPROFILE: env.USERPROFILE, @@ -428,83 +415,135 @@ function decodeBatchPathValue( return value .replace(/%%/g, escapedPercent) .replace(/%([A-Za-z][A-Za-z0-9_]*)%/g, (whole, name: string) => { - // cmd.exe variable names are case-insensitive; treat a defined empty - // value as resolved (expand to empty) rather than unresolved. const resolved = tokens[name.toUpperCase()]; return resolved === undefined ? whole : resolved; }) .replaceAll(escapedPercent, "%"); } -/** - * True when the wrapper looks like one `buildWindowsServiceScript` generated: - * a `:loop` label followed by the line-anchored `%OCX_BUN%` / `%OCX_CLI%` - * invocation that launches `start`. Absent `set` lines are only meaningful - * evidence of "deliberately omitted" when the wrapper is otherwise the - * generated artifact — an empty, truncated, or unrelated readable file must - * not read as a legitimate install that omitted both homes. - */ +/** Validate the generated wrapper before interpreting omitted optional homes. */ function wrapperLooksGenerated(body: string): boolean { return /:loop\s*[\s\S]*^"%OCX_BUN%" "%OCX_CLI%" start\b[^\r\n]*$/im.test(body); } -/** - * The one stderr text that proves `schtasks /query /tn ...` answered "no such - * task". schtasks exits 1 for both "task not found" and "access denied"; only - * the message distinguishes them, so absence is keyed on the message, never on - * the exit code alone. - */ -const SCHTASKS_TASK_NOT_FOUND = /cannot find the file specified/i; +/** Compare Windows paths without allowing spelling differences to fabricate a mismatch. */ +function windowsPathEqual(a: string, b: string): boolean { + const norm = (value: string): string => value + .replace(/\//g, "\\") + .replace(/[\\]+$/, "") + .toLowerCase(); + return norm(a) === norm(b); +} + +/** Parse the first CSV field emitted by schtasks `/fo CSV`. */ +function csvFirstField(line: string): string { + const trimmed = line.trim(); + if (!trimmed.startsWith('"')) return (trimmed.split(",", 1)[0] ?? "").trim(); + let value = ""; + for (let i = 1; i < trimmed.length; i += 1) { + const ch = trimmed[i]!; + if (ch !== '"') { + value += ch; + continue; + } + if (trimmed[i + 1] === '"') { + value += '"'; + i += 1; + continue; + } + break; + } + return value; +} + +function windowsTaskListContains(body: string, taskName: string): boolean { + const target = taskName.toLowerCase(); + return body.split(/\r?\n/).some(line => { + const field = csvFirstField(line).replace(/\//g, "\\").replace(/^\\+/, ""); + return field.toLowerCase() === target; + }); +} /** * Registration state of the scheduled task. * - * `present` is exit 0. `absent` is ONLY a nonzero exit whose stderr states the - * task cannot be found. Everything else — access denied, other execution - * errors, signal termination, null status, spawn/timeout failures, any other - * nonzero exit — is `unknown`, because none of those prove the task is not - * there. Treating them as absence would let a locked-down or wedged Task - * Scheduler read as a clean machine. + * The `/xml` query gives the authoritative registered definition. A nonzero + * result is locale-dependent, so it cannot prove absence from stderr text. In + * that case a bounded full listing is the independent, locale-neutral fallback: + * only a successful list that does not contain our task proves absence. */ function probeWindowsTaskRegistration(deps: Required>): { registered: "present" | "absent" | "unknown"; registeredXml: string; } { - // Resolve schtasks through the trusted System32 helper so a planted binary on - // PATH cannot be executed from an attacker-controlled project directory. If - // the trusted resolver itself fails, fail closed — never fall back to PATH. let schtasks: string; try { schtasks = resolveTrustedWindowsSchtasksExe(); } catch { return { registered: "unknown", registeredXml: "" }; } + const queried = deps.runRaw(schtasks, ["/query", "/tn", windowsTaskName(), "/xml"]); if (queried.spawnFailed || queried.timedOut) return { registered: "unknown", registeredXml: "" }; if (queried.status === 0) { - // The registered document is UTF-16LE; decode the RAW bytes (decoding as - // UTF-8 first would corrupt the XML). const registeredXml = decodeWindowsText(queried.stdout) || decodeWindowsText(queried.stderr); - return { registered: "present", registeredXml }; + return registeredXml + ? { registered: "present", registeredXml } + : { registered: "unknown", registeredXml: "" }; } - const text = `${decodeWindowsText(queried.stdout)}\n${decodeWindowsText(queried.stderr)}`; - if (queried.status !== null && SCHTASKS_TASK_NOT_FOUND.test(text)) { - return { registered: "absent", registeredXml: "" }; + + const listed = deps.runRaw(schtasks, ["/query", "/fo", "CSV", "/nh"]); + if (listed.spawnFailed || listed.timedOut || listed.status !== 0) { + return { registered: "unknown", registeredXml: "" }; + } + const listing = decodeWindowsText(listed.stdout) || decodeWindowsText(listed.stderr); + // Presence without the requested /xml definition is not enough to compare + // ownership safely; only the negative listing answer is decisive here. + return windowsTaskListContains(listing, windowsTaskName()) + ? { registered: "unknown", registeredXml: "" } + : { registered: "absent", registeredXml: "" }; +} + +type WinswRegistration = "present" | "absent" | "unknown"; + +/** Query WinSW registration through trusted System32 sc.exe; never execute the user-writable WinSW binary. */ +function probeWinswRegistration( + deps: Required> & Pick, +): WinswRegistration { + if (deps.winswStatus) { + const injected = deps.winswStatus(); + if (injected === "started" || injected === "stopped") return "present"; + if (injected === "nonexistent") return "absent"; + return "unknown"; + } + + let sc: string; + try { + sc = join(resolveTrustedWindowsSystemDirectory(), "sc.exe"); + if (artifactPresence(sc) !== "present") return "unknown"; + } catch { + return "unknown"; } - return { registered: "unknown", registeredXml: "" }; + + const queried = deps.runRaw(sc, ["query", WINSW_SERVICE_ID]); + if (queried.spawnFailed || queried.timedOut) return "unknown"; + if (queried.status === 0) return "present"; + + // ERROR_SERVICE_DOES_NOT_EXIST (1060) is locale-invariant. Search raw byte + // text so localized OEM output cannot affect the numeric classification. + const text = `${queried.stdout.toString("latin1")}\n${queried.stderr.toString("latin1")}`; + return /\b1060\b/.test(text) ? "absent" : "unknown"; } -function inspectWindows(deps: Required> & Pick): ServiceManagerInstallation { +function inspectWindows( + deps: Required> & Pick, +): ServiceManagerInstallation { const configDir = windowsConfigDirPath(deps); const taskXmlPath = join(configDir, "opencodex-service-task.xml"); const task = artifactPresence(taskXmlPath); - - // The native WinSW backend is a separate SCM registration. If it exists, it - // is authoritative on its own; if BOTH the scheduler task and WinSW are - // present that is a conflict (an interrupted backend switch). Only a claim - // whose registration is "present" counts as installed. const winsw = walkWinswChain(deps); const winswInstalled = winsw.kind === "present" && winsw.claims[0].registration === "present"; + const winswStaged = winsw.kind === "present" && winsw.claims[0].registration === "absent"; let xml = ""; if (task !== "absent") { @@ -519,36 +558,48 @@ function inspectWindows(deps: Required> & Pic if (registration.registered === "unknown") { return unknown("Task Scheduler could not be asked whether opencodex-proxy is registered"); } + const schedulerRegistered = registration.registered === "present"; - const schedulerPresent = task !== "absent" && registration.registered !== "absent"; - if (winswInstalled && schedulerPresent) { - // Walk the staged scheduler definition so the conflict carries its real - // homes. A broken/malformed scheduler chain cannot be fabricated into a - // claim — return its non-present result (unknown) rather than inventing - // homes; a valid staged claim is a conflict with registration present. - const staged = walkWindowsChain(deps, xml, taskXmlPath); - if (staged.kind !== "present") return staged; + // Two live registrations are a conflict even if the staging copy of the task + // XML disappeared. The authoritative `/query /xml` definition is sufficient + // to walk the scheduler chain without inventing homes. + if (winswInstalled && schedulerRegistered) { + if (!registration.registeredXml.trim()) { + return unknown("Task Scheduler is registered but its definition XML could not be read"); + } + const registeredWalk = walkWindowsChain(deps, registration.registeredXml, taskXmlPath); + if (registeredWalk.kind !== "present") return registeredWalk; return { kind: "conflict", claims: [ winsw.claims[0], - { ...staged.claims[0], registration: "present" }, + { ...registeredWalk.claims[0], registration: "present" }, ], }; } - if (winswInstalled) return winsw; - // WinSW exists but its SCM state cannot be verified, and a scheduler task is - // also present: neither backend can be ruled out, so fail closed. - if (winsw.kind === "unknown" && task !== "absent") { - return unknown("cannot confirm native WinSW status alongside the scheduled task"); + if (winswInstalled) { + // A staged scheduler definition beside a registered WinSW service is an + // interrupted backend switch, not proof that WinSW alone owns the machine. + if (task !== "absent") { + return unknown("a scheduled-task definition is staged while the native WinSW service is registered"); + } + return winsw; + } + + if (winsw.kind === "unknown") return winsw; + + // Staged-but-unregistered WinSW remains evidence. If Scheduler is also live + // or staged, neither half-finished backend switch can be chosen unattended. + if (winswStaged && (schedulerRegistered || task !== "absent")) { + return unknown("native WinSW and Task Scheduler definitions overlap during an incomplete backend switch"); } - if (winsw.kind === "unknown" && task === "absent" && registration.registered === "absent") { + if (winswStaged && task === "absent" && registration.registered === "absent") { return winsw; } if (task === "absent") { - return registration.registered === "present" + return schedulerRegistered ? unknown("Task Scheduler holds opencodex-proxy but its task XML is missing") : { kind: "absent" }; } @@ -557,16 +608,14 @@ function inspectWindows(deps: Required> & Pic if (staged.kind !== "present") return staged; const stagedClaim = staged.claims[0]; - // A registered task whose chain disagrees with the staged one is an - // interrupted reinstall — Task Scheduler will launch the OLD wrapper while - // the staging copy claims new homes. Any failure to walk the registered - // definition is also unknown (it cannot be trusted). - if (registration.registered === "present" && registration.registeredXml.trim()) { + if (schedulerRegistered) { + if (!registration.registeredXml.trim()) { + return unknown("Task Scheduler is registered but its definition XML could not be read"); + } const registeredWalk = walkWindowsChain(deps, registration.registeredXml, taskXmlPath); if (registeredWalk.kind !== "present") return registeredWalk; const registeredClaim = registeredWalk.claims[0]; - const homesDisagree = !homesEqual(registeredClaim.homes, stagedClaim.homes); - if (homesDisagree) { + if (!homesEqual(registeredClaim.homes, stagedClaim.homes)) { return unknown("the registered scheduled task names different homes than the staged task definition"); } } @@ -575,13 +624,16 @@ function inspectWindows(deps: Required> & Pic kind: "present", claims: [{ ...stagedClaim, - registration: registration.registered === "present" ? "present" : "absent", + registration: schedulerRegistered ? "present" : "absent", }], }; } /** Compare two home pairs with Windows path normalization (case, slashes, trailing separators). */ -function homesEqual(a: { codexHome: string | null; opencodexHome: string | null }, b: { codexHome: string | null; opencodexHome: string | null }): boolean { +function homesEqual( + a: { codexHome: string | null; opencodexHome: string | null }, + b: { codexHome: string | null; opencodexHome: string | null }, +): boolean { const norm = (v: string | null): string | null => { if (v === null) return null; return v.replace(/[\\/]+$/, "").replace(/\//g, "\\").toLowerCase(); @@ -591,55 +643,64 @@ function homesEqual(a: { codexHome: string | null; opencodexHome: string | null /** * Walk one scheduled-task definition (staged or registered XML) down to the - * batch wrapper and extract the homes it names. Returns `absent` only when the - * XML is absent; every broken or malformed link is `unknown`. + * generated batch wrapper and extract the homes it names. Definition-provided + * paths are never followed unless they equal the deterministic generated paths + * under the effective OPENCODEX_HOME; this prevents a foreign task from turning + * an ownership probe into an arbitrary local/UNC file read. */ function walkWindowsChain( deps: Required> & Pick, xml: string, definitionPath: string, ): ServiceManagerInstallation { + const configDir = windowsConfigDirPath(deps); + const expectedLauncher = join(configDir, "opencodex-service-launcher.vbs"); + const expectedWrapper = join(configDir, "opencodex-service.cmd"); + const launcherArg = windowsTaskArguments(xml); if (!launcherArg) { return unknown("the scheduled-task XML names no launcher to run"); } - // The element is `/b /nologo "C:\...\opencodex-service-launcher.vbs"`. const launcherPath = /"([^"]+)"/.exec(launcherArg)?.[1]; if (!launcherPath) { return unknown("the scheduled-task XML launcher argument is not a quoted path"); } - const launcher = artifactPresence(launcherPath); + if (!windowsPathEqual(launcherPath, expectedLauncher)) { + return unknown(`the scheduled-task XML names ${launcherPath}, not the expected launcher ${expectedLauncher}`); + } + + const launcher = artifactPresence(expectedLauncher); if (launcher === "absent") { - return unknown(`the scheduled-task launcher is missing: ${launcherPath}`); + return unknown(`the scheduled-task launcher is missing: ${expectedLauncher}`); } let launcherBody: string; try { - launcherBody = decodeWindowsText(readFileSync(launcherPath)); + launcherBody = decodeWindowsText(readFileSync(expectedLauncher)); } catch (error) { return unknown(`the scheduled-task launcher could not be read: ${String(error)}`); } + const wrapperPath = vbsWrappedCommand(launcherBody); if (!wrapperPath) { - return unknown(`the launcher ${launcherPath} names no wrapper to run`); + return unknown(`the launcher ${expectedLauncher} names no wrapper to run`); } - const wrapper = artifactPresence(wrapperPath); + if (!windowsPathEqual(wrapperPath, expectedWrapper)) { + return unknown(`the scheduled-task launcher names ${wrapperPath}, not the expected wrapper ${expectedWrapper}`); + } + + const wrapper = artifactPresence(expectedWrapper); if (wrapper === "absent") { - return unknown(`the launcher wrapper is missing: ${wrapperPath}`); + return unknown(`the launcher wrapper is missing: ${expectedWrapper}`); } let wrapperBody: string; try { - wrapperBody = decodeWindowsText(readFileSync(wrapperPath)); + wrapperBody = decodeWindowsText(readFileSync(expectedWrapper)); } catch (error) { return unknown(`the launcher wrapper could not be read: ${String(error)}`); } - /* - * A wrapper that does not look generated is not evidence of deliberate - * omission — it is malformed. An empty or truncated wrapper, or an unrelated - * readable file, must fail closed rather than read as "no homes baked". - */ if (!wrapperLooksGenerated(wrapperBody)) { - return unknown(`the launcher wrapper does not look like a generated opencodex service wrapper: ${wrapperPath}`); + return unknown(`the launcher wrapper does not look like a generated opencodex service wrapper: ${expectedWrapper}`); } const rawCodexHome = batchSetValue(wrapperBody, "CODEX_HOME"); @@ -660,45 +721,45 @@ function walkWindowsChain( } /** - * Walk the WinSW native-backend definition (its XML embeds the homes as - * `` / `OPENCODEX_HOME`). Returns `present` when - * the SCM registration exists; `absent` when the XML is gone and the SCM - * confirms no registration; `unknown` on any failure to ask. + * Walk the WinSW native-backend definition. SCM registration is queried via a + * trusted, bounded `sc.exe query`; the WinSW executable itself is never run by + * this read-only ownership probe. */ -function walkWinswChain(deps: Required> & Pick): ServiceManagerInstallation { +function walkWinswChain( + deps: Required> & Pick, +): ServiceManagerInstallation { const configDir = windowsConfigDirPath(deps); - // WinSW assets live under the effective OPENCODEX_HOME (winswDir() resolves - // via getConfigDir()); honor the injected configDir for tests and custom homes. const exePath = join(configDir, "winsw", `${WINSW_SERVICE_ID}.exe`); const xmlPath = join(configDir, "winsw", `${WINSW_SERVICE_ID}.xml`); const xml = artifactPresence(xmlPath); const exe = artifactPresence(exePath); - const status = (deps.winswStatus ?? statusWinswRaw)(); + const registration = probeWinswRegistration(deps); - if (xml === "absent" && exe === "absent" && status === "nonexistent") return { kind: "absent" }; - if (xml === "absent" || exe === "absent" || status === "unknown") { + if (xml === "absent" && exe === "absent" && registration === "absent") return { kind: "absent" }; + if (registration === "unknown") { return unknown("the native WinSW service registration could not be verified"); } + if (xml === "absent" || exe === "absent") { + return unknown("the native WinSW service registration could not be verified"); + } + let body: string; try { body = decodeWindowsText(readFileSync(xmlPath)); } catch (error) { return unknown(`the WinSW XML could not be read: ${String(error)}`); } - // The generated WinSW XML carries the expected launch structure; a malformed - // or unrelated XML must fail closed like the scheduler wrapper check. if (!winswXmlLooksGenerated(body)) { return unknown(`the WinSW XML does not look like a generated opencodex service definition: ${xmlPath}`); } + const envValue = (name: string): string | null => { - // Match an element carrying the target name in EITHER attribute - // order, single- or double-quoted, and pull its value attribute. No loose - // fallback: an unmatched name must stay null, never a wrong element's value. const tag = new RegExp(`]*\\bname=["']${name}["'][^>]*>`, "i").exec(body); if (!tag) return null; const value = /value=(["'])(.*?)\1/i.exec(tag[0]); - return value ? value[2] : null; + return value ? decodeXmlEntities(value[2]!) : null; }; + return { kind: "present", claims: [{ @@ -708,15 +769,12 @@ function walkWinswChain(deps: Required> & Pick`; anything else is malformed, not a deliberate omission. - */ +/** The generated WinSW XML embeds the SCM id and a `start --port` invocation. */ function winswXmlLooksGenerated(body: string): boolean { return /\s*opencodex-proxy-native\s*<\/id>/i.test(body) && /.*?start\s+--port\b/i.test(body); @@ -729,6 +787,13 @@ export function inspectServiceManagerInstallation(deps: ProbeDeps = {}): Service const home = deps.home ?? homedir(); if (platform === "darwin") return inspectLaunchd({ run, uid: deps.uid ?? process.getuid?.() ?? 0, home }); if (platform === "linux") return inspectSystemd({ run, home }); - if (platform === "win32") return inspectWindows({ runRaw, home, configDir: deps.configDir, winswStatus: deps.winswStatus }); + if (platform === "win32") { + return inspectWindows({ + runRaw, + home, + configDir: deps.configDir, + winswStatus: deps.winswStatus, + }); + } return unknown(`no service manager probe for platform ${platform}`); } From 3fc24da7c590f21bd86363bd1ce9b8ad79b060b4 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 04:55:29 +0200 Subject: [PATCH 13/13] fix(probe): preserve Windows probe compatibility --- src/service-manager-probe.ts | 68 ++++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 30 deletions(-) diff --git a/src/service-manager-probe.ts b/src/service-manager-probe.ts index 143918b77..cc447b644 100644 --- a/src/service-manager-probe.ts +++ b/src/service-manager-probe.ts @@ -21,7 +21,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, lstatSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { join, win32 as win32Path } from "node:path"; import { resolveTrustedWindowsSchtasksExe, resolveTrustedWindowsSystemDirectory, @@ -426,13 +426,16 @@ function wrapperLooksGenerated(body: string): boolean { return /:loop\s*[\s\S]*^"%OCX_BUN%" "%OCX_CLI%" start\b[^\r\n]*$/im.test(body); } -/** Compare Windows paths without allowing spelling differences to fabricate a mismatch. */ -function windowsPathEqual(a: string, b: string): boolean { - const norm = (value: string): string => value - .replace(/\//g, "\\") - .replace(/[\\]+$/, "") - .toLowerCase(); - return norm(a) === norm(b); +function normalizeWindowsPath(value: string): string { + return win32Path.normalize(value.replace(/\//g, "\\")).replace(/[\\]+$/, "").toLowerCase(); +} + +/** True only when a definition-provided path remains inside the effective OPENCODEX_HOME. */ +function windowsPathInsideConfigDir(candidate: string, configDir: string): boolean { + const root = normalizeWindowsPath(configDir); + const path = normalizeWindowsPath(candidate); + const relative = win32Path.relative(root, path); + return relative === "" || (relative !== ".." && !relative.startsWith("..\\") && !win32Path.isAbsolute(relative)); } /** Parse the first CSV field emitted by schtasks `/fo CSV`. */ @@ -464,13 +467,16 @@ function windowsTaskListContains(body: string, taskName: string): boolean { }); } +/** English hosts provide a decisive fast path; other locales fall back to a full listing. */ +const SCHTASKS_TASK_NOT_FOUND_EN = /cannot find the file specified/i; + /** * Registration state of the scheduled task. * * The `/xml` query gives the authoritative registered definition. A nonzero - * result is locale-dependent, so it cannot prove absence from stderr text. In - * that case a bounded full listing is the independent, locale-neutral fallback: - * only a successful list that does not contain our task proves absence. + * result is locale-dependent. English's task-not-found message is decisive; all + * other nonzero responses use a bounded full listing as the locale-neutral + * fallback, and only a successful list without our task proves absence. */ function probeWindowsTaskRegistration(deps: Required>): { registered: "present" | "absent" | "unknown"; @@ -492,13 +498,16 @@ function probeWindowsTaskRegistration(deps: Required>) : { registered: "unknown", registeredXml: "" }; } + const queryText = `${decodeWindowsText(queried.stdout)}\n${decodeWindowsText(queried.stderr)}`; + if (queried.status !== null && SCHTASKS_TASK_NOT_FOUND_EN.test(queryText)) { + return { registered: "absent", registeredXml: "" }; + } + const listed = deps.runRaw(schtasks, ["/query", "/fo", "CSV", "/nh"]); if (listed.spawnFailed || listed.timedOut || listed.status !== 0) { return { registered: "unknown", registeredXml: "" }; } const listing = decodeWindowsText(listed.stdout) || decodeWindowsText(listed.stderr); - // Presence without the requested /xml definition is not enough to compare - // ownership safely; only the negative listing answer is decisive here. return windowsTaskListContains(listing, windowsTaskName()) ? { registered: "unknown", registeredXml: "" } : { registered: "absent", registeredXml: "" }; @@ -644,9 +653,10 @@ function homesEqual( /** * Walk one scheduled-task definition (staged or registered XML) down to the * generated batch wrapper and extract the homes it names. Definition-provided - * paths are never followed unless they equal the deterministic generated paths - * under the effective OPENCODEX_HOME; this prevents a foreign task from turning - * an ownership probe into an arbitrary local/UNC file read. + * paths are followed only inside the effective OPENCODEX_HOME, preventing a + * foreign task from turning this ownership probe into an arbitrary local/UNC + * file read while preserving interrupted-reinstall diagnostics within the + * generated service-asset directory. */ function walkWindowsChain( deps: Required> & Pick, @@ -654,8 +664,6 @@ function walkWindowsChain( definitionPath: string, ): ServiceManagerInstallation { const configDir = windowsConfigDirPath(deps); - const expectedLauncher = join(configDir, "opencodex-service-launcher.vbs"); - const expectedWrapper = join(configDir, "opencodex-service.cmd"); const launcherArg = windowsTaskArguments(xml); if (!launcherArg) { @@ -665,42 +673,42 @@ function walkWindowsChain( if (!launcherPath) { return unknown("the scheduled-task XML launcher argument is not a quoted path"); } - if (!windowsPathEqual(launcherPath, expectedLauncher)) { - return unknown(`the scheduled-task XML names ${launcherPath}, not the expected launcher ${expectedLauncher}`); + if (!windowsPathInsideConfigDir(launcherPath, configDir)) { + return unknown(`the scheduled-task XML names ${launcherPath}, outside the expected launcher directory ${configDir}`); } - const launcher = artifactPresence(expectedLauncher); + const launcher = artifactPresence(launcherPath); if (launcher === "absent") { - return unknown(`the scheduled-task launcher is missing: ${expectedLauncher}`); + return unknown(`the scheduled-task launcher is missing: ${launcherPath}`); } let launcherBody: string; try { - launcherBody = decodeWindowsText(readFileSync(expectedLauncher)); + launcherBody = decodeWindowsText(readFileSync(launcherPath)); } catch (error) { return unknown(`the scheduled-task launcher could not be read: ${String(error)}`); } const wrapperPath = vbsWrappedCommand(launcherBody); if (!wrapperPath) { - return unknown(`the launcher ${expectedLauncher} names no wrapper to run`); + return unknown(`the launcher ${launcherPath} names no wrapper to run`); } - if (!windowsPathEqual(wrapperPath, expectedWrapper)) { - return unknown(`the scheduled-task launcher names ${wrapperPath}, not the expected wrapper ${expectedWrapper}`); + if (!windowsPathInsideConfigDir(wrapperPath, configDir)) { + return unknown(`the scheduled-task launcher names ${wrapperPath}, outside the expected wrapper directory ${configDir}`); } - const wrapper = artifactPresence(expectedWrapper); + const wrapper = artifactPresence(wrapperPath); if (wrapper === "absent") { - return unknown(`the launcher wrapper is missing: ${expectedWrapper}`); + return unknown(`the launcher wrapper is missing: ${wrapperPath}`); } let wrapperBody: string; try { - wrapperBody = decodeWindowsText(readFileSync(expectedWrapper)); + wrapperBody = decodeWindowsText(readFileSync(wrapperPath)); } catch (error) { return unknown(`the launcher wrapper could not be read: ${String(error)}`); } if (!wrapperLooksGenerated(wrapperBody)) { - return unknown(`the launcher wrapper does not look like a generated opencodex service wrapper: ${expectedWrapper}`); + return unknown(`the launcher wrapper does not look like a generated opencodex service wrapper: ${wrapperPath}`); } const rawCodexHome = batchSetValue(wrapperBody, "CODEX_HOME");