From 86733c84cc2019250d009ea6f75b5cceccfd0fc4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 07:14:32 -0700 Subject: [PATCH] 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. --- docs/gateway/doctor/gateway-and-services.md | 2 +- src/daemon/inspect.ts | 12 ++-- src/daemon/inspect.windows.registered.test.ts | 61 +++++++++++++++++- src/daemon/inspect.windows.test.ts | 63 ++++++++++++------- src/daemon/schtasks-layout.ts | 32 ++++++++++ 5 files changed, 139 insertions(+), 31 deletions(-) diff --git a/docs/gateway/doctor/gateway-and-services.md b/docs/gateway/doctor/gateway-and-services.md index d7bd9f6ede55..08fa9a72f04d 100644 --- a/docs/gateway/doctor/gateway-and-services.md +++ b/docs/gateway/doctor/gateway-and-services.md @@ -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. diff --git a/src/daemon/inspect.ts b/src/daemon/inspect.ts index b716f33d1a2b..14fcbcf013a5 100644 --- a/src/daemon/inspect.ts +++ b/src/daemon/inspect.ts @@ -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.", diff --git a/src/daemon/inspect.windows.registered.test.ts b/src/daemon/inspect.windows.registered.test.ts index 09668bddeb63..401ccfee2c7b 100644 --- a/src/daemon/inspect.windows.registered.test.ts +++ b/src/daemon/inspect.windows.registered.test.ts @@ -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"; diff --git a/src/daemon/inspect.windows.test.ts b/src/daemon/inspect.windows.test.ts index ee8dc91caa71..a48e09275690 100644 --- a/src/daemon/inspect.windows.test.ts +++ b/src/daemon/inspect.windows.test.ts @@ -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([ diff --git a/src/daemon/schtasks-layout.ts b/src/daemon/schtasks-layout.ts index 62979957a7c9..653d07b39dae 100644 --- a/src/daemon/schtasks-layout.ts +++ b/src/daemon/schtasks-layout.ts @@ -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 ||