Skip to content

Commit 66b3fb7

Browse files
committed
fix(daemon): skip pre-existing gateway listeners as schtask launch evidence (#91144)
1 parent 2a21de6 commit 66b3fb7

2 files changed

Lines changed: 118 additions & 23 deletions

File tree

src/daemon/schtasks.startup-fallback.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,62 @@ describe("Windows startup fallback", () => {
384384
});
385385
});
386386

387+
it("falls back and clears the foreground gateway when it is the only listener after /Run (#91144)", async () => {
388+
await withWindowsEnv("openclaw-win-startup-", async ({ env }) => {
389+
fastForwardTaskStartWait();
390+
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([5555]);
391+
addAcceptedRunNeverStartsResponses();
392+
393+
await installGatewayScheduledTask(env);
394+
395+
expectGatewayTermination(5555);
396+
expectStartupFallbackSpawn();
397+
});
398+
});
399+
400+
it("does not fall back when a new listener PID appears after /Run, ignoring the pre-existing one (#91144)", async () => {
401+
await withWindowsEnv("openclaw-win-startup-", async ({ env }) => {
402+
findVerifiedGatewayListenerPidsOnPortSync
403+
.mockReturnValueOnce([5555]) // pre-snapshot before /Run
404+
.mockReturnValue([5555, 9999]); // new PID 9999 appears after /Run
405+
addStartupFallbackMissingResponses();
406+
407+
await installGatewayScheduledTask(env);
408+
409+
expect(spawn).not.toHaveBeenCalled();
410+
});
411+
});
412+
413+
it("falls back and clears foreground gateway found only in Win32 process snapshot (#91144)", async () => {
414+
await withWindowsEnv("openclaw-win-startup-", async ({ env }) => {
415+
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
416+
fastForwardTaskStartWait();
417+
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([5555]);
418+
spawnSync.mockImplementation((command, args) => {
419+
if (
420+
command === "powershell" &&
421+
Array.isArray(args) &&
422+
args.includes(
423+
"Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress",
424+
)
425+
) {
426+
return makeSpawnSyncResult({
427+
stdout: JSON.stringify([
428+
{ ProcessId: 5555, CommandLine: "openclaw gateway --port 18789" },
429+
]),
430+
});
431+
}
432+
return makeSpawnSyncResult();
433+
});
434+
addAcceptedRunNeverStartsResponses();
435+
436+
await installGatewayScheduledTask(env);
437+
438+
expectTaskkillPid(5555);
439+
expectStartupFallbackSpawn();
440+
});
441+
});
442+
387443
it("does not treat a gateway listener as node Scheduled Task launch evidence", async () => {
388444
await withWindowsEnv("openclaw-win-startup-", async ({ env }) => {
389445
fastForwardTaskStartWait();

src/daemon/schtasks.ts

Lines changed: 62 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,35 +1020,48 @@ async function updateExistingScheduledTask(params: {
10201020
async function shouldFallbackScheduledTaskLaunch(params: {
10211021
env: GatewayServiceEnv;
10221022
scriptPath: string;
1023+
taskPort: number | null;
1024+
preExistingListenerPids: ReadonlySet<number>;
10231025
}): Promise<boolean> {
1026+
const taskName = resolveTaskName(params.env);
1027+
1028+
// Reads raw schtasks state without the listener-backed enrichment so a
1029+
// foreground gateway on the same port cannot mask a task startup failure.
10241030
const readLaunchObservation = async (): Promise<{
10251031
state: "running" | "not-yet-run" | "other";
10261032
signature: string;
10271033
}> => {
1028-
const runtime = await readScheduledTaskRuntime(params.env).catch(() => null);
1029-
if (runtime?.status === "running") {
1030-
return {
1031-
state: "running",
1032-
signature: [runtime.state, runtime.lastRunTime, runtime.lastRunResult, runtime.detail]
1033-
.filter(Boolean)
1034-
.join("|"),
1035-
};
1034+
let parsedInfo: ScheduledTaskInfo | null = null;
1035+
try {
1036+
const available = await execSchtasks(["/Query"]);
1037+
if (available.code === 0) {
1038+
const res = await execSchtasks(["/Query", "/TN", taskName, "/V", "/FO", "LIST"]);
1039+
if (res.code === 0) {
1040+
parsedInfo = parseSchtasksQuery(res.stdout || "");
1041+
}
1042+
}
1043+
} catch {
1044+
// ignore
1045+
}
1046+
const derived = parsedInfo ? deriveScheduledTaskRuntimeStatus(parsedInfo) : null;
1047+
const sig = [parsedInfo?.status, parsedInfo?.lastRunTime, parsedInfo?.lastRunResult]
1048+
.filter(Boolean)
1049+
.join("|");
1050+
if (derived?.status === "running") {
1051+
return { state: "running", signature: sig };
1052+
}
1053+
if (params.taskPort) {
1054+
const pids = await resolveScheduledTaskGatewayListenerPids(params.taskPort);
1055+
const newPids = pids.filter((pid) => !params.preExistingListenerPids.has(pid));
1056+
if (newPids.length > 0) {
1057+
return { state: "running", signature: sig };
1058+
}
10361059
}
1037-
const normalizedResult = normalizeTaskResultCode(runtime?.lastRunResult);
1060+
const normalizedResult = normalizeTaskResultCode(parsedInfo?.lastRunResult);
10381061
if (normalizedResult && NOT_YET_RUN_RESULT_CODES.has(normalizedResult)) {
1039-
return {
1040-
state: "not-yet-run",
1041-
signature: [runtime?.state, runtime?.lastRunTime, runtime?.lastRunResult, runtime?.detail]
1042-
.filter(Boolean)
1043-
.join("|"),
1044-
};
1062+
return { state: "not-yet-run", signature: sig };
10451063
}
1046-
return {
1047-
state: "other",
1048-
signature: [runtime?.state, runtime?.lastRunTime, runtime?.lastRunResult, runtime?.detail]
1049-
.filter(Boolean)
1050-
.join("|"),
1051-
};
1064+
return { state: "other", signature: sig };
10521065
};
10531066

10541067
const hasLaunchEvidence = async (): Promise<boolean> => {
@@ -1061,7 +1074,10 @@ async function shouldFallbackScheduledTaskLaunch(params: {
10611074
const manageGatewayPort = shouldManageGatewayListenerPort(params.env);
10621075
if (manageGatewayPort && taskPort) {
10631076
const listenerPids = await resolveScheduledTaskGatewayListenerPids(taskPort);
1064-
if (listenerPids.length > 0) {
1077+
const newListenerPids = listenerPids.filter(
1078+
(pid) => !params.preExistingListenerPids.has(pid),
1079+
);
1080+
if (newListenerPids.length > 0) {
10651081
return true;
10661082
}
10671083
}
@@ -1101,6 +1117,10 @@ async function shouldFallbackScheduledTaskLaunch(params: {
11011117
if (!commandLine) {
11021118
return false;
11031119
}
1120+
const pid = getSnapshotProcessId(entry);
1121+
if (pid !== null && params.preExistingListenerPids.has(pid)) {
1122+
return false;
1123+
}
11041124
const argv = parseCmdScriptCommandLine(entry.CommandLine ?? "");
11051125
return (
11061126
isGatewayArgv(argv, { allowGatewayBinary: true }) &&
@@ -1133,15 +1153,34 @@ async function runScheduledTaskOrThrow(params: {
11331153
env: GatewayServiceEnv;
11341154
scriptPath: string;
11351155
}): Promise<void> {
1156+
// Snapshot only verified gateway PIDs before launching so a foreground gateway
1157+
// cannot mask task-start evidence and only owned processes are terminated (#91144).
1158+
const manageGatewayPort = shouldManageGatewayListenerPort(params.env);
1159+
const taskPort = manageGatewayPort ? await resolveScheduledTaskPort(params.env) : null;
1160+
const preExistingListenerPids = taskPort
1161+
? new Set(findVerifiedGatewayListenerPidsOnPortSync(taskPort))
1162+
: new Set<number>();
1163+
11361164
const run = await execSchtasks(["/Run", "/TN", params.taskName]);
11371165
if (run.code !== 0) {
11381166
throw new Error(`schtasks run failed: ${run.stderr || run.stdout}`.trim());
11391167
}
11401168
if (
1141-
!(await shouldFallbackScheduledTaskLaunch({ env: params.env, scriptPath: params.scriptPath }))
1169+
!(await shouldFallbackScheduledTaskLaunch({
1170+
env: params.env,
1171+
scriptPath: params.scriptPath,
1172+
taskPort,
1173+
preExistingListenerPids,
1174+
}))
11421175
) {
11431176
return;
11441177
}
1178+
// A foreground gateway on the port causes the supervised fallback to yield to
1179+
// it as "healthy" and exit, leaving no managed gateway after the foreground closes.
1180+
// Use the pre-existing snapshot to avoid a TOCTOU kill of a newly-started managed gateway.
1181+
for (const pid of preExistingListenerPids) {
1182+
await terminateGatewayProcessTree(pid, 300);
1183+
}
11451184
await launchFallbackTaskScript(params.env);
11461185
}
11471186

0 commit comments

Comments
 (0)