From 83b1316ca3616acc4ab5e484ddcb2bb27663a487 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sun, 30 Aug 2026 16:11:48 +0900 Subject: [PATCH 1/2] test(service): block live scheduler mutation under the test guard The repository test runner rewrites HOME and OPENCODEX_HOME, but Windows Task Scheduler is machine-global. A partially-faked repair test fell through the default scheduler runner, replaced the user's real opencodex-proxy task with a temporary test-home launcher, passed, and then deleted that launcher during cleanup. When the explicit test-home guard is armed, allow only read-only /query calls to the live scheduler runner. Every create, delete, run, end, or change operation must be injected. Production is inert because only the repository test preload arms this guard. The regression drives a valid fresh registration through the default create path. Before the fix its fake recorder receives /create /tn opencodex-proxy ... /f; after the fix the call is rejected before even the recorder. --- src/lib/windows-elevation.ts | 14 ++++++++++++++ src/service.ts | 15 +++++++++++++++ tests/service.test.ts | 26 ++++++++++++++++++++++++++ tests/windows-elevation-spawn.test.ts | 10 ++++++++++ 4 files changed, 65 insertions(+) diff --git a/src/lib/windows-elevation.ts b/src/lib/windows-elevation.ts index 7d9b1f5fcd..c6ea2f6280 100644 --- a/src/lib/windows-elevation.ts +++ b/src/lib/windows-elevation.ts @@ -2,6 +2,7 @@ import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process" import { existsSync } from "node:fs"; import { isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path"; import { dlopen, ptr, type Pointer } from "bun:ffi"; +import { isTestHomeGuardArmed } from "./test-home-guard"; type ElevationSpawn = ( command: string, @@ -530,6 +531,19 @@ export function startPowerShellCommand(commandScript: string): WindowsElevationE }; } + // HOME isolation cannot contain UAC children or other machine-global effects. Keep the + // final process boundary closed while the real launcher is installed; explicitly injected + // launchers remain available to tests that exercise the elevation protocol in memory. + if (isTestHomeGuardArmed() && elevationSpawn === spawn) { + return { + launcherPid: null, + completion: Promise.reject(new WindowsElevationError( + "launch-failed", + "Refusing to launch a live Windows elevation process from an armed test process; inject the elevation launcher instead.", + )), + }; + } + let child: ChildProcess; try { child = elevationSpawn( diff --git a/src/service.ts b/src/service.ts index 4dfb0de6d7..290afb847c 100644 --- a/src/service.ts +++ b/src/service.ts @@ -50,6 +50,7 @@ import { recordOwnedConfigPath } from "./lib/config-ownership"; import { killWindowsSchedulerWrappers } from "./lib/windows-service-wrappers"; import { maybeShowStarPrompt } from "./cli/star-prompt"; import { systemdProperty } from "./service-manager-probe"; +import { isTestHomeGuardArmed } from "./lib/test-home-guard"; const LABEL = "com.opencodex.proxy"; const TASK = "opencodex-proxy"; @@ -900,6 +901,20 @@ function windowsWscript(): string { let querySchtasksForTests: ((args: string[]) => string) | null = null; function querySchtasks(args: string[]): string { + // The repository preload isolates HOME and OPENCODEX_HOME, but Task Scheduler is + // machine-global. A partially-faked service test once fell through here and replaced the + // user's real `opencodex-proxy` task with a launcher inside its temporary test home; the + // test passed and cleanup deleted that launcher. Queries are observation-only, but every + // other operation must be injected while the explicit test-home guard is armed. + if ( + isTestHomeGuardArmed() + && args[0]?.trim().toLowerCase() !== "/query" + ) { + throw new Error( + "refusing to mutate the machine-global Windows Task Scheduler from an armed test process; " + + "inject the scheduler operation instead of calling the live manager.", + ); + } if (querySchtasksForTests) return querySchtasksForTests(args); return runFile(windowsSchtasks(), args); } diff --git a/tests/service.test.ts b/tests/service.test.ts index 3b48a76c43..7a36768a94 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -1034,6 +1034,32 @@ describe("launchd service plist", () => { }); describe("service lifecycle cleanup ordering", () => { + test("an armed test cannot fall through to a live Task Scheduler mutation", async () => { + mkdirSync(TEST_DIR, { recursive: true }); + const attemptNonce = "test-home-guard-registration"; + const xmlPath = join(TEST_DIR, "guarded-task.xml"); + writeFileSync( + xmlPath, + `\uFEFF${buildWindowsTaskXml(undefined, undefined, attemptNonce)}`, + { encoding: "utf16le" }, + ); + const observedCalls: string[][] = []; + serviceModule.setQuerySchtasksForTests(args => { + observedCalls.push([...args]); + return ""; + }); + try { + await expect(registerFreshWindowsSchedulerTask(xmlPath, attemptNonce)).rejects.toThrow( + "refusing to mutate the machine-global Windows Task Scheduler from an armed test process", + ); + // The guard runs before even the test recorder. Before this regression fix the recorder + // receives `/create /tn opencodex-proxy ... /f`, proving the live runner was reachable. + expect(observedCalls).toEqual([]); + } finally { + serviceModule.setQuerySchtasksForTests(null); + } + }); + test("native service switch treats unknown as installed and requires confirmed absence", () => { const calls: string[] = []; const statuses: Array<"unknown" | "stopped" | "nonexistent"> = [ diff --git a/tests/windows-elevation-spawn.test.ts b/tests/windows-elevation-spawn.test.ts index 39a1c07f9e..10b65ddd12 100644 --- a/tests/windows-elevation-spawn.test.ts +++ b/tests/windows-elevation-spawn.test.ts @@ -82,6 +82,16 @@ describe("runWindowsElevated spawn contract", () => { return child; } + test("an armed test cannot launch the live Windows elevation boundary", async () => { + // The probe is deliberately inert: if the guard regresses, it can only start an + // non-RunAs PowerShell executing a fixed exit 0, never UAC or Task Scheduler mutation. + const execution = startPowerShellCommand("exit 0"); + expect(execution.launcherPid).toBeNull(); + await expect(execution.completion).rejects.toThrow( + "Refusing to launch a live Windows elevation process from an armed test process", + ); + }); + test("returns exit code 0", async () => { fakeChild({ code: 0 }); await expect(runWindowsElevated("schtasks.exe", ["/query"])).resolves.toBe(0); From 56bd40d24255a93f8962d1f32c692f03addcf1a7 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Mon, 31 Aug 2026 03:03:06 +0900 Subject: [PATCH 2/2] fix(service): block native cleanup under test guard --- src/service.ts | 11 ++++++++++- tests/service.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/service.ts b/src/service.ts index 290afb847c..e1c8c5c02c 100644 --- a/src/service.ts +++ b/src/service.ts @@ -2343,8 +2343,17 @@ export interface RemoveNativeWindowsServiceDeps { export function removeNativeWindowsServiceForScheduler( deps: RemoveNativeWindowsServiceDeps = {}, ): void { - const status = deps.status ?? statusWinswRaw; const uninstall = deps.uninstall ?? uninstallWinswService; + // The test home cannot contain SCM. A partially mocked scheduler install must inject + // the native-service mutation too; otherwise it can stop/delete the user's live WinSW + // registration even though every filesystem path points at the isolated test home. + if (isTestHomeGuardArmed() && uninstall === uninstallWinswService) { + throw new Error( + "refusing to mutate the machine-global Windows native service from an armed test process; " + + "inject the native-service removal instead of calling the live manager.", + ); + } + const status = deps.status ?? statusWinswRaw; const sleep = deps.sleep ?? Bun.sleepSync; const settleChecks = Math.max(1, deps.settleChecks ?? 20); // Transactional backend switch: installing the scheduler backend removes a native diff --git a/tests/service.test.ts b/tests/service.test.ts index 7a36768a94..61e69fc5ff 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -1060,6 +1060,32 @@ describe("service lifecycle cleanup ordering", () => { } }); + test("an armed partial install cannot fall through to live native-service removal", async () => { + const calls: string[] = []; + await expect(installFreshWindowsSchedulerSafely({ + stageRegistrationXml: () => { calls.push("stage"); return "attempt.xml"; }, + register: async () => { calls.push("register"); }, + recordOwnership: () => { calls.push("record-ownership"); return true; }, + prepare: async () => { calls.push("prepare"); }, + // Intentionally omit removeNativeService: the production default must fail closed. + publishAssets: () => { calls.push("publish-assets"); }, + runTask: () => { calls.push("run-task"); }, + writeState: () => { calls.push("write-state"); }, + rollbackTask: async () => { calls.push("rollback-task"); return null; }, + removeStagedXml: () => { calls.push("remove-stage"); }, + })).rejects.toThrow( + "refusing to mutate the machine-global Windows native service from an armed test process", + ); + expect(calls).toEqual([ + "stage", + "register", + "remove-stage", + "record-ownership", + "prepare", + "rollback-task", + ]); + }); + test("native service switch treats unknown as installed and requires confirmed absence", () => { const calls: string[] = []; const statuses: Array<"unknown" | "stopped" | "nonexistent"> = [