From f5148aff2505020cb01bfffc313aa7cfd768fd59 Mon Sep 17 00:00:00 2001 From: mikasa <0668001030@xydigit.com> Date: Tue, 23 Jun 2026 17:05:23 +0800 Subject: [PATCH] =?UTF-8?q?fix=20#89231:=20[Bug]:=20Windows=20installer-cr?= =?UTF-8?q?eated=20scheduled=20task=20launches=20gateway.cmd=20with=20visi?= =?UTF-8?q?ble=20console=20=E2=80=94=20should=20use=20windowless=20launche?= =?UTF-8?q?r=20(#95480)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged via squash. Prepared head SHA: 8b57b0377ac811f640eee52702da54390c20a639 Co-authored-by: mikasa0818 <244515412+mikasa0818@users.noreply.github.com> Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com> Reviewed-by: @vincentkoc --- docs/install/uninstall.md | 10 +- docs/platforms/windows.md | 7 +- src/daemon/schtasks.install.test.ts | 93 ++++++++++++++++- src/daemon/schtasks.startup-fallback.test.ts | 16 +++ src/daemon/schtasks.ts | 101 +++++++++++++++++-- src/daemon/service-env.test.ts | 1 + src/daemon/service-env.ts | 1 + 7 files changed, 208 insertions(+), 21 deletions(-) diff --git a/docs/install/uninstall.md b/docs/install/uninstall.md index dc8295d3605c..be1bcb375913 100644 --- a/docs/install/uninstall.md +++ b/docs/install/uninstall.md @@ -110,14 +110,18 @@ systemctl --user daemon-reload ### Windows (Scheduled Task) Default task name is `OpenClaw Gateway` (or `OpenClaw Gateway ()`). -The task script lives under your state dir. +The task script lives under your state dir as `gateway.cmd`; current installs may +also create a windowless `gateway.vbs` launcher that Task Scheduler runs instead +of opening `gateway.cmd` directly. ```powershell schtasks /Delete /F /TN "OpenClaw Gateway" -Remove-Item -Force "$env:USERPROFILE\.openclaw\gateway.cmd" +Remove-Item -Force "$env:USERPROFILE\.openclaw\gateway.cmd" -ErrorAction SilentlyContinue +Remove-Item -Force "$env:USERPROFILE\.openclaw\gateway.vbs" -ErrorAction SilentlyContinue ``` -If you used a profile, delete the matching task name and `~\.openclaw-\gateway.cmd`. +If you used a profile, delete the matching task name and the `gateway.cmd` / +`gateway.vbs` files under `~\.openclaw-`. ## Normal install vs source checkout diff --git a/docs/platforms/windows.md b/docs/platforms/windows.md index 01a652f15d1c..b3f0680e0863 100644 --- a/docs/platforms/windows.md +++ b/docs/platforms/windows.md @@ -124,8 +124,11 @@ openclaw gateway status --json ``` Native Windows CLI and Gateway flows are supported and continue to improve. -Managed startup uses Windows Scheduled Tasks when available and falls back to a -per-user Startup-folder login item if task creation is denied. +Managed startup uses Windows Scheduled Tasks when available. The task keeps the +readable `gateway.cmd` script in the OpenClaw state dir, but launches it through +a generated `gateway.vbs` WScript wrapper so the background Gateway does not open +a visible console window. If task creation is denied, OpenClaw falls back to a +per-user Startup-folder login item. To install the Gateway service: diff --git a/src/daemon/schtasks.install.test.ts b/src/daemon/schtasks.install.test.ts index 1a483c239e2d..3746dd2630eb 100644 --- a/src/daemon/schtasks.install.test.ts +++ b/src/daemon/schtasks.install.test.ts @@ -4,8 +4,14 @@ import os from "node:os"; import path from "node:path"; import { PassThrough } from "node:stream"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { installScheduledTask, readScheduledTaskCommand } from "./schtasks.js"; +import { + installScheduledTask, + readScheduledTaskCommand, + resolveTaskScriptPath, + uninstallScheduledTask, +} from "./schtasks.js"; import { auditGatewayServiceConfig, SERVICE_AUDIT_CODES } from "./service-audit.js"; +import { buildServiceEnvironment } from "./service-env.js"; const schtasksCalls: string[][] = []; const schtasksResponses: { code: number; stdout: string; stderr: string }[] = []; @@ -74,13 +80,13 @@ describe("installScheduledTask", () => { }); } - function expectInitialTaskQueries(): void { + function expectInitialTaskQueries(taskName = "OpenClaw Gateway"): void { expect(schtasksCalls[0]).toEqual(["/Query"]); - expect(schtasksCalls[1]).toEqual(["/Query", "/TN", "OpenClaw Gateway"]); + expect(schtasksCalls[1]).toEqual(["/Query", "/TN", taskName]); } - function expectTaskRunCall(index: number): void { - expect(schtasksCalls[index]).toEqual(["/Run", "/TN", "OpenClaw Gateway"]); + function expectTaskRunCall(index: number, taskName = "OpenClaw Gateway"): void { + expect(schtasksCalls[index]).toEqual(["/Run", "/TN", taskName]); } it("writes quoted set assignments and escapes metacharacters", async () => { @@ -242,6 +248,83 @@ describe("installScheduledTask", () => { }); }); + it("uses the hidden launcher for generated Windows gateway service installs", async () => { + await withUserProfileDir(async (_tmpDir, env) => { + schtasksResponses.push(okSchtasksResponse, missingTaskResponse); + const callerEnv = { + ...env, + HOME: env.USERPROFILE, + USERDOMAIN: "WORKSTATION", + USERNAME: "alice", + OPENCLAW_WINDOWS_TASK_NAME: "OpenClaw Custom Gateway", + }; + const gatewayEnv = buildServiceEnvironment({ + env: callerEnv, + port: 18789, + platform: "win32", + }); + + expect(callerEnv.OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER).toBeUndefined(); + expect(gatewayEnv.OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER).toBe("1"); + expect(gatewayEnv.OPENCLAW_WINDOWS_TASK_NAME).toBe("OpenClaw Gateway"); + + const { scriptPath } = await installScheduledTask({ + env: callerEnv, + stdout: new PassThrough(), + programArguments: ["node", "gateway.js"], + environment: { + ...gatewayEnv, + USERDOMAIN: "EVIL", + USERNAME: "mallory", + }, + }); + const launcherPath = scriptPath.replace(/\.cmd$/i, ".vbs"); + const script = await fs.readFile(scriptPath, "utf8"); + const launcher = await fs.readFile(launcherPath, "utf8"); + + expect(schtasksCalls[2]?.slice(0, 5)).toEqual([ + "/Create", + "/F", + "/TN", + "OpenClaw Custom Gateway", + "/XML", + ]); + expect(schtasksCalls[2]?.slice(6)).toEqual(["/RU", "WORKSTATION\\alice", "/NP"]); + const captured = xmlPayloadCaptures.find((entry) => entry.index === 2); + expect(captured?.xml).toContain("gateway.vbs"); + expect(script).toContain('set "OPENCLAW_WINDOWS_TASK_NAME=OpenClaw Custom Gateway"'); + expect(launcher).toContain("WScript.Shell"); + expect(launcher).toContain(`Run """${scriptPath}""", 0, False`); + expectTaskRunCall(3, "OpenClaw Custom Gateway"); + }); + }); + + it("removes a generated hidden launcher when the caller env lacks its marker", async () => { + await withUserProfileDir(async (_tmpDir, env) => { + schtasksResponses.push(okSchtasksResponse, missingTaskResponse); + const scriptPath = resolveTaskScriptPath(env); + const parsedScriptPath = path.parse(scriptPath); + const launcherPath = path.join(parsedScriptPath.dir, `${parsedScriptPath.name}.vbs`); + await fs.mkdir(parsedScriptPath.dir, { recursive: true }); + await fs.writeFile(scriptPath, "@echo off\n", "utf8"); + await fs.writeFile(launcherPath, 'CreateObject("WScript.Shell")\n', "utf8"); + + await uninstallScheduledTask({ + env, + stdout: new PassThrough(), + }); + + const remaining: string[] = []; + for (const candidate of [scriptPath, launcherPath]) { + try { + await fs.access(candidate); + remaining.push(candidate); + } catch {} + } + expect(remaining).toEqual([]); + }); + }); + it("creates the Scheduled Task via XML with battery start/continue enabled (#59299)", async () => { await withUserProfileDir(async (_tmpDir, env) => { schtasksResponses.push(okSchtasksResponse, missingTaskResponse); diff --git a/src/daemon/schtasks.startup-fallback.test.ts b/src/daemon/schtasks.startup-fallback.test.ts index 0d376ddc4d37..20d125696e03 100644 --- a/src/daemon/schtasks.startup-fallback.test.ts +++ b/src/daemon/schtasks.startup-fallback.test.ts @@ -676,6 +676,22 @@ describe("Windows startup fallback", () => { }); }); + it("removes hidden Startup-folder entries when the caller env lacks the marker", async () => { + await withWindowsEnv("openclaw-win-startup-", async ({ env }) => { + schtasksResponses.push({ code: 0, stdout: "", stderr: "" }); + const startupEntryPath = resolveStartupEntryPath(env, "vbs"); + await fs.mkdir(path.dirname(startupEntryPath), { recursive: true }); + await fs.writeFile(startupEntryPath, 'CreateObject("WScript.Shell")\n', "utf8"); + + await uninstallScheduledTask({ + env, + stdout: new PassThrough(), + }); + + await expect(fs.access(startupEntryPath)).rejects.toThrow(); + }); + }); + it("reports runtime from the gateway listener when using the Startup fallback", async () => { await withWindowsEnv("openclaw-win-startup-", async ({ env }) => { addStartupFallbackMissingResponses(); diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index aa30d3510686..867d2ff2c342 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -100,9 +100,10 @@ function resolveStartupEntryPath(env: GatewayServiceEnv, extension?: "cmd" | "vb function resolveStartupEntryPaths(env: GatewayServiceEnv): string[] { const primaryPath = resolveStartupEntryPath(env); const legacyCmdPath = resolveStartupEntryPath(env, "cmd"); - // Hidden VBS launchers supersede cmd launchers, but uninstall must remove the - // legacy cmd path from older installs too. - return uniqueStrings([primaryPath, legacyCmdPath]); + const hiddenLauncherPath = resolveStartupEntryPath(env, "vbs"); + // Hidden VBS launchers supersede cmd launchers, but lifecycle operations must + // discover both variants even when the caller env lacks the persisted marker. + return uniqueStrings([primaryPath, legacyCmdPath, hiddenLauncherPath]); } // `/TR` is parsed by schtasks itself, while the generated `gateway.cmd` line is parsed by cmd.exe. @@ -923,6 +924,70 @@ async function restartStartupEntry( return { outcome: "completed" }; } +const CALLER_OWNED_SERVICE_IDENTITY_KEYS = [ + "OPENCLAW_LAUNCHD_LABEL", + "OPENCLAW_SYSTEMD_UNIT", + "OPENCLAW_WINDOWS_TASK_NAME", +] as const; + +function resolveScheduledTaskRenderEnv( + env: GatewayServiceEnv, + environment: GatewayServiceEnv | undefined, +): GatewayServiceEnv { + if (!environment) { + return env; + } + const merged = { ...env, ...environment }; + for (const key of CALLER_OWNED_SERVICE_IDENTITY_KEYS) { + const value = env[key]?.trim(); + if (value) { + merged[key] = value; + } + } + return merged; +} + +function resolveScheduledTaskScriptEnvironment( + taskEnv: GatewayServiceEnv, + environment: GatewayServiceEnv | undefined, +): GatewayServiceEnv | undefined { + const scriptEnv = environment ? { ...environment } : {}; + for (const key of CALLER_OWNED_SERVICE_IDENTITY_KEYS) { + const value = taskEnv[key]?.trim(); + if (value) { + scriptEnv[key] = value; + } + } + return Object.keys(scriptEnv).length > 0 ? scriptEnv : undefined; +} + +const SCHEDULED_TASK_ACTIVATION_KEYS = [ + "OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER", + "OPENCLAW_TASK_SCRIPT_NAME", + "OPENCLAW_TASK_SCRIPT", + "OPENCLAW_SERVICE_KIND", + "OPENCLAW_GATEWAY_PORT", + "OPENCLAW_STATE_DIR", + "OPENCLAW_PROFILE", +] as const; + +function resolveScheduledTaskActivationEnv( + env: GatewayServiceEnv, + environment: GatewayServiceEnv | undefined, +): GatewayServiceEnv { + if (!environment) { + return env; + } + const activationEnv = { ...env }; + for (const key of SCHEDULED_TASK_ACTIVATION_KEYS) { + const value = environment[key]; + if (value !== undefined) { + activationEnv[key] = value; + } + } + return activationEnv; +} + async function writeScheduledTaskScript({ env, programArguments, @@ -933,17 +998,24 @@ async function writeScheduledTaskScript({ scriptPath: string; taskLaunchPath: string; taskDescription: string; + taskEnv: GatewayServiceEnv; }> { await assertSchtasksAvailable().catch(() => undefined); - const scriptPath = resolveTaskScriptPath(env); - const taskLaunchPath = resolveTaskLauncherScriptPath(env, scriptPath); + const taskEnv = resolveScheduledTaskRenderEnv(env, environment); + const scriptPath = resolveTaskScriptPath(taskEnv); + const taskLaunchPath = resolveTaskLauncherScriptPath(taskEnv, scriptPath); await fs.mkdir(path.dirname(scriptPath), { recursive: true }); - const taskDescription = resolveGatewayServiceDescription({ env, environment, description }); + const taskDescription = resolveGatewayServiceDescription({ + env: taskEnv, + environment, + description, + }); + const scriptEnvironment = resolveScheduledTaskScriptEnvironment(taskEnv, environment); const script = buildTaskScript({ description: taskDescription, programArguments, workingDirectory, - environment, + environment: scriptEnvironment, }); await fs.writeFile(scriptPath, script, "utf8"); if (taskLaunchPath !== scriptPath) { @@ -953,7 +1025,7 @@ async function writeScheduledTaskScript({ }); await fs.writeFile(taskLaunchPath, launcher, "utf8"); } - return { scriptPath, taskLaunchPath, taskDescription }; + return { scriptPath, taskLaunchPath, taskDescription, taskEnv }; } export async function stageScheduledTask({ @@ -1243,7 +1315,7 @@ export async function installScheduledTask( ): Promise<{ scriptPath: string }> { const staged = await writeScheduledTaskScript(args); await activateScheduledTask({ - env: args.env, + env: resolveScheduledTaskActivationEnv(args.env, args.environment), stdout: args.stdout, scriptPath: staged.scriptPath, taskLaunchPath: staged.taskLaunchPath, @@ -1271,8 +1343,15 @@ export async function uninstallScheduledTask({ } const scriptPath = resolveTaskScriptPath(env); - const launcherPath = resolveTaskLauncherScriptPath(env, scriptPath); - if (launcherPath !== scriptPath) { + const parsedScriptPath = path.parse(scriptPath); + const launcherPaths = uniqueStrings([ + resolveTaskLauncherScriptPath(env, scriptPath), + path.join(parsedScriptPath.dir, `${parsedScriptPath.name}.vbs`), + ]); + for (const launcherPath of launcherPaths) { + if (launcherPath === scriptPath) { + continue; + } try { await fs.unlink(launcherPath); stdout.write(`${formatLine("Removed task launcher", launcherPath)}\n`); diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index 8d4c8a653e7a..0b64f3b2f413 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -602,6 +602,7 @@ describe("buildServiceEnvironment", () => { expect(typeof env.OPENCLAW_SERVICE_VERSION).toBe("string"); expect(env.OPENCLAW_SYSTEMD_UNIT).toBe("openclaw-gateway.service"); expect(env.OPENCLAW_WINDOWS_TASK_NAME).toBe("OpenClaw Gateway"); + expect(env.OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER).toBe("1"); if (process.platform === "darwin") { expect(env.OPENCLAW_LAUNCHD_LABEL).toBe("ai.openclaw.gateway"); } diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index f71fa2300742..35245287cc32 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -435,6 +435,7 @@ export function buildServiceEnvironment(params: { OPENCLAW_LAUNCHD_LABEL: resolvedLaunchdLabel, OPENCLAW_SYSTEMD_UNIT: systemdUnit, OPENCLAW_WINDOWS_TASK_NAME: resolveGatewayWindowsTaskName(profile), + OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER: "1", OPENCLAW_SERVICE_MARKER: GATEWAY_SERVICE_MARKER, OPENCLAW_SERVICE_KIND: GATEWAY_SERVICE_KIND, OPENCLAW_SERVICE_VERSION: VERSION,