Skip to content

Commit 1b760b5

Browse files
committed
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.
1 parent 9cd4e4b commit 1b760b5

4 files changed

Lines changed: 65 additions & 0 deletions

File tree

src/lib/windows-elevation.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"
22
import { existsSync } from "node:fs";
33
import { isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path";
44
import { dlopen, ptr, type Pointer } from "bun:ffi";
5+
import { isTestHomeGuardArmed } from "./test-home-guard";
56

67
type ElevationSpawn = (
78
command: string,
@@ -530,6 +531,19 @@ export function startPowerShellCommand(commandScript: string): WindowsElevationE
530531
};
531532
}
532533

534+
// HOME isolation cannot contain UAC children or other machine-global effects. Keep the
535+
// final process boundary closed while the real launcher is installed; explicitly injected
536+
// launchers remain available to tests that exercise the elevation protocol in memory.
537+
if (isTestHomeGuardArmed() && elevationSpawn === spawn) {
538+
return {
539+
launcherPid: null,
540+
completion: Promise.reject(new WindowsElevationError(
541+
"launch-failed",
542+
"Refusing to launch a live Windows elevation process from an armed test process; inject the elevation launcher instead.",
543+
)),
544+
};
545+
}
546+
533547
let child: ChildProcess;
534548
try {
535549
child = elevationSpawn(

src/service.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import { recordOwnedConfigPath } from "./lib/config-ownership";
5050
import { killWindowsSchedulerWrappers } from "./lib/windows-service-wrappers";
5151
import { maybeShowStarPrompt } from "./cli/star-prompt";
5252
import { systemdProperty } from "./service-manager-probe";
53+
import { isTestHomeGuardArmed } from "./lib/test-home-guard";
5354

5455
const LABEL = "com.opencodex.proxy";
5556
const TASK = "opencodex-proxy";
@@ -900,6 +901,20 @@ function windowsWscript(): string {
900901
let querySchtasksForTests: ((args: string[]) => string) | null = null;
901902

902903
function querySchtasks(args: string[]): string {
904+
// The repository preload isolates HOME and OPENCODEX_HOME, but Task Scheduler is
905+
// machine-global. A partially-faked service test once fell through here and replaced the
906+
// user's real `opencodex-proxy` task with a launcher inside its temporary test home; the
907+
// test passed and cleanup deleted that launcher. Queries are observation-only, but every
908+
// other operation must be injected while the explicit test-home guard is armed.
909+
if (
910+
isTestHomeGuardArmed()
911+
&& args[0]?.trim().toLowerCase() !== "/query"
912+
) {
913+
throw new Error(
914+
"refusing to mutate the machine-global Windows Task Scheduler from an armed test process; "
915+
+ "inject the scheduler operation instead of calling the live manager.",
916+
);
917+
}
903918
if (querySchtasksForTests) return querySchtasksForTests(args);
904919
return runFile(windowsSchtasks(), args);
905920
}

tests/service.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,6 +1034,32 @@ describe("launchd service plist", () => {
10341034
});
10351035

10361036
describe("service lifecycle cleanup ordering", () => {
1037+
test("an armed test cannot fall through to a live Task Scheduler mutation", async () => {
1038+
mkdirSync(TEST_DIR, { recursive: true });
1039+
const attemptNonce = "test-home-guard-registration";
1040+
const xmlPath = join(TEST_DIR, "guarded-task.xml");
1041+
writeFileSync(
1042+
xmlPath,
1043+
`\uFEFF${buildWindowsTaskXml(undefined, undefined, attemptNonce)}`,
1044+
{ encoding: "utf16le" },
1045+
);
1046+
const observedCalls: string[][] = [];
1047+
serviceModule.setQuerySchtasksForTests(args => {
1048+
observedCalls.push([...args]);
1049+
return "";
1050+
});
1051+
try {
1052+
await expect(registerFreshWindowsSchedulerTask(xmlPath, attemptNonce)).rejects.toThrow(
1053+
"refusing to mutate the machine-global Windows Task Scheduler from an armed test process",
1054+
);
1055+
// The guard runs before even the test recorder. Before this regression fix the recorder
1056+
// receives `/create /tn opencodex-proxy ... /f`, proving the live runner was reachable.
1057+
expect(observedCalls).toEqual([]);
1058+
} finally {
1059+
serviceModule.setQuerySchtasksForTests(null);
1060+
}
1061+
});
1062+
10371063
test("native service switch treats unknown as installed and requires confirmed absence", () => {
10381064
const calls: string[] = [];
10391065
const statuses: Array<"unknown" | "stopped" | "nonexistent"> = [

tests/windows-elevation-spawn.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,16 @@ describe("runWindowsElevated spawn contract", () => {
8282
return child;
8383
}
8484

85+
test("an armed test cannot launch the live Windows elevation boundary", async () => {
86+
// The probe is deliberately inert: if the guard regresses, it can only start an
87+
// non-RunAs PowerShell executing a fixed exit 0, never UAC or Task Scheduler mutation.
88+
const execution = startPowerShellCommand("exit 0");
89+
expect(execution.launcherPid).toBeNull();
90+
await expect(execution.completion).rejects.toThrow(
91+
"Refusing to launch a live Windows elevation process from an armed test process",
92+
);
93+
});
94+
8595
test("returns exit code 0", async () => {
8696
fakeChild({ code: 0 });
8797
await expect(runWindowsElevated("schtasks.exe", ["/query"])).resolves.toBe(0);

0 commit comments

Comments
 (0)