fix(windows): skip foreign scheduled tasks during Gateway discovery

Queued or running foreign tasks are not evidence of OpenClaw ownership.
Ignore unreadable unrelated launchers, while selected, canonical, legacy,
and recognizable OpenClaw launchers keep incomplete inventory fail-closed.
Observe static launcher references in later actions without granting
multi-action tasks effective-command or lifecycle authority.

Reproduced the Hotpatch Monitoring inventory failure on native Windows.
The same 203-task snapshot now resolves discovery and the live-dist fence;
scripts/prepare-vitest-runtime.mjs changes from exit 1 to exit 0.
Focused local coverage: 246 passed, one existing skip; types, lint,
formatting, docs sanity, and Codex review through P2 passed.
This commit is contained in:
Peter Steinberger 2026-09-30 07:14:32 -07:00
parent 1ea44e0b68
commit 86733c84cc
5 changed files with 139 additions and 31 deletions

View file

@ -23,7 +23,7 @@ update the app to update the Gateway; normal config and state checks still apply
Cleanup previews include only legacy launchd services and recognized legacy systemd unit names in the user scope. Legacy Windows services, unrecognized Linux unit names, and services in the system scope remain findings for manual review.
Windows extra-service hints use read-only `schtasks /Query` inspection. Node hosts remain visible in diagnostics; discovery alone does not make a service a removal target.
Windows extra-service hints use read-only `schtasks /Query` inspection. Node hosts remain visible in diagnostics; discovery alone does not make a service a removal target. Unreadable unrelated Scheduled Tasks do not block discovery or test runtime preparation, regardless of whether they are running. Selected tasks, recognized OpenClaw launchers, and custom aliases with OpenClaw launcher content still report incomplete inspection when their command cannot be verified.
Linux user-service cleanup preserves the unit file if stopping or disabling the service fails. An interrupted status probe does not permit file-only removal; that fallback is reported only when `systemctl` is unavailable.

View file

@ -584,14 +584,11 @@ async function scanGatewayServices(
const selected =
normalizeWindowsTaskIdentity(name) === normalizeWindowsTaskIdentity(resolveTaskName(env));
const knownTask = selected || isOpenClawGatewayTaskName(name) || isLegacyLabel(name);
// A stopped unrelated task cannot hold the checkout's live dist. Keep unknown,
// queued, and running tasks fail-closed when their command cannot be read.
const mayHoldLiveGateway = task.state !== 1 && task.state !== 3;
const launcherReference = actionArgv.some((argv) =>
argv.some((arg) => /\.(?:bat|cmd|vbs)$/i.test(arg) && detectLauncherGatewayMarker(arg)),
);
if (!task.actions?.length) {
if ((requireComplete && mayHoldLiveGateway) || knownTask) {
if (knownTask) {
errors.push({ source: name, message: "Scheduled Task action could not be inspected." });
}
continue;
@ -608,7 +605,7 @@ async function scanGatewayServices(
if (
requireComplete &&
task.actions.length > 1 &&
(hasGatewayAction || (hasLauncherAction && (mayHoldLiveGateway || knownTask)))
(hasGatewayAction || (hasLauncherAction && (knownTask || launcherReference)))
) {
errors.push({
source: name,
@ -639,7 +636,7 @@ async function scanGatewayServices(
},
},
);
if (requireComplete && mayHoldLiveGateway && !command) {
if (!command && (knownTask || recognizableLauncher)) {
throw new Error("Registered launcher disappeared during inspection.");
}
profile = command ? resolveWindowsServiceCommandProfile(command) : undefined;
@ -664,7 +661,8 @@ async function scanGatewayServices(
recordDeadline();
break;
}
if ((requireComplete && mayHoldLiveGateway) || knownTask || recognizableLauncher) {
// Native liveness alone does not make a foreign task an OpenClaw owner.
if (knownTask || recognizableLauncher) {
errors.push({
source: name,
message: "Scheduled Task launcher could not be inspected.",

View file

@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { findExtraGatewayServices } from "./inspect.js";
import { findExtraGatewayServices, listManagedOpenClawGatewayServices } from "./inspect.js";
import { readScheduledTaskCommand } from "./schtasks-layout.js";
const spawnSync = vi.hoisted(() => vi.fn());
@ -12,6 +12,65 @@ vi.mock("node:child_process", async (importOriginal) => ({
beforeEach(() => spawnSync.mockReset());
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
it.each([
"single foreign launcher",
"multiple foreign launchers",
"later Gateway launcher",
"later wrapped Gateway launcher",
])("qualifies complete inventory failures from launcher evidence: %s", async (kind) => {
const taskName = "\\Microsoft\\Windows\\Hotpatch\\Monitoring";
const first = "C:\\fixtures\\maintenance.cmd";
const second = "C:\\fixtures\\assistant.vbs";
const wrapped = kind === "later wrapped Gateway launcher";
const gateway = `C:\\fixtures\\assistant.${wrapped ? "bat" : "cmd"}`;
const task = {
taskPath: taskName,
state: 4,
actions: (kind === "single foreign launcher" ? [first] : [first, second]).map((pathname) => ({
type: 0,
path: pathname === second && wrapped ? "C:\\Windows\\System32\\cmd.exe" : pathname,
arguments: pathname === second && wrapped ? `/d /c "${gateway}"` : "",
workingDirectory: "",
})),
};
spawnSync
.mockReturnValueOnce({ status: 0, stdout: JSON.stringify([task]) })
.mockReturnValue({ status: 0, stdout: JSON.stringify(task) });
const readFile = vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => {
if (pathname === second) {
return Buffer.from(
`Set shell = CreateObject("WScript.Shell")\r\nWScript.Quit shell.Run("""${gateway}""", 0, True)`,
);
}
if (pathname === gateway && kind.startsWith("later")) {
return Buffer.from(
'@echo off\r\n"C:\\Node\\node.exe" "C:\\OpenClaw\\openclaw.mjs" --profile rescue gateway run\r\n',
);
}
if (pathname !== first && pathname !== gateway) {
throw new Error("Unexpected launcher read");
}
return Buffer.from('@echo off\r\nif "%HOTPATCH_ENABLED%"=="1" call maintenance.exe\r\n');
});
const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
try {
const inventory = await listManagedOpenClawGatewayServices(
{ USERPROFILE: "C:\\Users\\test", APPDATA: tempDirs.make("foreign-task-startup-") },
{ requireComplete: true },
);
expect(inventory).toEqual({
services: [],
errors: kind.startsWith("later")
? [{ source: taskName, message: expect.stringContaining("could not be inspected") }]
: [],
});
} finally {
readFile.mockRestore();
Object.defineProperty(process, "platform", platform);
}
});
it("excludes a static non-Gateway runtime command without admitting its missing profile", async () => {
const taskName = "\\OpenClaw Helper (non-gateway)";
const scriptPath = "C:\\openclaw-schtasks\\non-gateway\\non-gateway.cmd";

View file

@ -230,7 +230,7 @@ describe("findExtraGatewayServices (win32)", () => {
);
it.each(["missing action", "unreadable launcher", "disappeared launcher", "multiple actions"])(
"does not silently omit a custom task from complete inventory: %s",
"does not silently omit a selected custom task from complete inventory: %s",
async (fault) => {
const label = "\\Custom Assistant";
const selected = task(label, "C:\\custom\\assistant.cmd", "");
@ -248,8 +248,8 @@ describe("findExtraGatewayServices (win32)", () => {
}
return null;
});
expect((await listManagedOpenClawGatewayServices(nativeEnv)).errors).toEqual([]);
const result = await listManagedOpenClawGatewayServices(nativeEnv, { requireComplete: true });
const env = { ...nativeEnv, OPENCLAW_WINDOWS_TASK_NAME: label };
const result = await listManagedOpenClawGatewayServices(env, { requireComplete: true });
expect(result.services).toEqual([]);
expect(result.errors).toEqual([
{ source: label, message: expect.stringContaining("could not be inspected") },
@ -258,25 +258,41 @@ describe("findExtraGatewayServices (win32)", () => {
},
);
it("does not block a live-dist fence on unrelated stopped tasks with unreadable actions", async () => {
const stopped = { ...task("\\Maintenance", "C:\\tools\\maintenance.cmd", ""), state: 3 };
const noActions = { taskPath: "\\Native Maintenance", state: 3, actions: [] };
listScheduledTasksMock.mockReturnValue([stopped, noActions]);
readScheduledTaskCommandMock.mockRejectedValue(new Error("Access denied"));
it.each([null, 0, 1, 2, 3, 4])(
"excludes unrelated unreadable tasks from complete inventory in native state %s",
async (state) => {
listScheduledTasksMock.mockReturnValue([
{ ...task("\\Maintenance", "C:\\tools\\maintenance.cmd", ""), state },
{ taskPath: "\\Native Maintenance", state, actions: [] },
]);
readScheduledTaskCommandMock.mockRejectedValue(new Error("Access denied"));
await expect(
listManagedOpenClawGatewayServices(nativeEnv, { requireComplete: true }),
).resolves.toEqual({ services: [], errors: [] });
await expect(
listManagedOpenClawGatewayServices(nativeEnv, { requireComplete: true }),
).resolves.toEqual({ services: [], errors: [] });
},
);
for (const state of [4, 0]) {
stopped.state = state;
const active = await listManagedOpenClawGatewayServices(nativeEnv, { requireComplete: true });
expect(active.errors).toContainEqual({
source: "\\Maintenance",
message: "Scheduled Task launcher could not be inspected.",
});
}
});
it.each([null, 3])(
"keeps disappeared OpenClaw launchers incomplete in native state %s",
async (state) => {
const labels = ["\\OpenClaw Gateway (dev)", "\\Clawdbot Gateway", "\\Custom Service"];
listScheduledTasksMock.mockReturnValue(
labels.map((label) => ({ ...task(label, "C:\\OpenClaw\\gateway.cmd", ""), state })),
);
readScheduledTaskCommandMock.mockResolvedValue(null);
const result = await listManagedOpenClawGatewayServices(nativeEnv, { requireComplete: true });
expect(result.services).toEqual([]);
expect(result.errors).toEqual(
labels.map((source) => ({
source,
message: "Scheduled Task launcher could not be inspected.",
})),
);
},
);
it("refuses a mixed task whose later action runs the Gateway", async () => {
const label = "\\Mixed Assistant";
@ -303,13 +319,16 @@ describe("findExtraGatewayServices (win32)", () => {
["direct", "C:\\custom\\assistant.bat", ""],
["through cmd.exe", "C:\\Windows\\System32\\cmd.exe", "/c C:\\custom\\assistant.bat"],
])(
"refuses an uninspectable custom bat launcher %s in complete inventory",
"refuses an uninspectable selected bat launcher %s in complete inventory",
async (_mode, executable, args) => {
const label = "\\Custom Assistant";
listScheduledTasksMock.mockReturnValue([task(label, executable, args)]);
readScheduledTaskCommandMock.mockRejectedValue(new Error("Unsupported launcher"));
const result = await listManagedOpenClawGatewayServices(nativeEnv, { requireComplete: true });
const result = await listManagedOpenClawGatewayServices(
{ ...nativeEnv, OPENCLAW_WINDOWS_TASK_NAME: label },
{ requireComplete: true },
);
expect(result.services).toEqual([]);
expect(result.errors).toEqual([

View file

@ -301,6 +301,38 @@ async function readWindowsTaskCommand(
}
};
const action = registered?.status === "found" ? registered.actions?.[0] : undefined;
if (
registered?.status === "found" &&
normalizeWindowsTaskIdentity(registered.taskPath ?? "") ===
normalizeWindowsTaskIdentity(taskName) &&
registered.actions &&
registered.actions.length > 1 &&
options?.onLauncherContent
) {
// Inventory needs evidence from later custom actions even though a multi-action
// task cannot supply one effective Gateway command or lifecycle authority.
for (const candidate of registered.actions) {
if (candidate.type !== 0) {
continue;
}
const argv = [
candidate.path,
...splitArgsPreservingQuotes(candidate.arguments, { escapeMode: "backslash-quote-only" }),
];
for (const pathname of argv.filter((arg) => /\.(?:bat|cmd|vbs)$/i.test(arg))) {
try {
assertStaticTaskPath(pathname);
const scriptPath = /\.bat$/i.test(pathname)
? pathname
: (await readTaskLauncher(pathname, options.onLauncherContent, false, deadline))
.scriptPath;
options.onLauncherContent(await readTaskFile(scriptPath, deadline), scriptPath);
} catch {
assertInspectionDeadline();
}
}
}
}
if (
registered?.status === "found" &&
(!registered.taskPath ||