Skip to content

Commit f5148af

Browse files
fix #89231: [Bug]: Windows installer-created scheduled task launches gateway.cmd with visible console — should use windowless launcher (#95480)
Merged via squash. Prepared head SHA: 8b57b03 Co-authored-by: mikasa0818 <[email protected]> Co-authored-by: vincentkoc <[email protected]> Reviewed-by: @vincentkoc
1 parent 7bec91c commit f5148af

7 files changed

Lines changed: 208 additions & 21 deletions

File tree

docs/install/uninstall.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,14 +110,18 @@ systemctl --user daemon-reload
110110
### Windows (Scheduled Task)
111111

112112
Default task name is `OpenClaw Gateway` (or `OpenClaw Gateway (<profile>)`).
113-
The task script lives under your state dir.
113+
The task script lives under your state dir as `gateway.cmd`; current installs may
114+
also create a windowless `gateway.vbs` launcher that Task Scheduler runs instead
115+
of opening `gateway.cmd` directly.
114116

115117
```powershell
116118
schtasks /Delete /F /TN "OpenClaw Gateway"
117-
Remove-Item -Force "$env:USERPROFILE\.openclaw\gateway.cmd"
119+
Remove-Item -Force "$env:USERPROFILE\.openclaw\gateway.cmd" -ErrorAction SilentlyContinue
120+
Remove-Item -Force "$env:USERPROFILE\.openclaw\gateway.vbs" -ErrorAction SilentlyContinue
118121
```
119122

120-
If you used a profile, delete the matching task name and `~\.openclaw-<profile>\gateway.cmd`.
123+
If you used a profile, delete the matching task name and the `gateway.cmd` /
124+
`gateway.vbs` files under `~\.openclaw-<profile>`.
121125

122126
## Normal install vs source checkout
123127

docs/platforms/windows.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,11 @@ openclaw gateway status --json
124124
```
125125

126126
Native Windows CLI and Gateway flows are supported and continue to improve.
127-
Managed startup uses Windows Scheduled Tasks when available and falls back to a
128-
per-user Startup-folder login item if task creation is denied.
127+
Managed startup uses Windows Scheduled Tasks when available. The task keeps the
128+
readable `gateway.cmd` script in the OpenClaw state dir, but launches it through
129+
a generated `gateway.vbs` WScript wrapper so the background Gateway does not open
130+
a visible console window. If task creation is denied, OpenClaw falls back to a
131+
per-user Startup-folder login item.
129132

130133
To install the Gateway service:
131134

src/daemon/schtasks.install.test.ts

Lines changed: 88 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,14 @@ import os from "node:os";
44
import path from "node:path";
55
import { PassThrough } from "node:stream";
66
import { beforeEach, describe, expect, it, vi } from "vitest";
7-
import { installScheduledTask, readScheduledTaskCommand } from "./schtasks.js";
7+
import {
8+
installScheduledTask,
9+
readScheduledTaskCommand,
10+
resolveTaskScriptPath,
11+
uninstallScheduledTask,
12+
} from "./schtasks.js";
813
import { auditGatewayServiceConfig, SERVICE_AUDIT_CODES } from "./service-audit.js";
14+
import { buildServiceEnvironment } from "./service-env.js";
915

1016
const schtasksCalls: string[][] = [];
1117
const schtasksResponses: { code: number; stdout: string; stderr: string }[] = [];
@@ -74,13 +80,13 @@ describe("installScheduledTask", () => {
7480
});
7581
}
7682

77-
function expectInitialTaskQueries(): void {
83+
function expectInitialTaskQueries(taskName = "OpenClaw Gateway"): void {
7884
expect(schtasksCalls[0]).toEqual(["/Query"]);
79-
expect(schtasksCalls[1]).toEqual(["/Query", "/TN", "OpenClaw Gateway"]);
85+
expect(schtasksCalls[1]).toEqual(["/Query", "/TN", taskName]);
8086
}
8187

82-
function expectTaskRunCall(index: number): void {
83-
expect(schtasksCalls[index]).toEqual(["/Run", "/TN", "OpenClaw Gateway"]);
88+
function expectTaskRunCall(index: number, taskName = "OpenClaw Gateway"): void {
89+
expect(schtasksCalls[index]).toEqual(["/Run", "/TN", taskName]);
8490
}
8591

8692
it("writes quoted set assignments and escapes metacharacters", async () => {
@@ -242,6 +248,83 @@ describe("installScheduledTask", () => {
242248
});
243249
});
244250

251+
it("uses the hidden launcher for generated Windows gateway service installs", async () => {
252+
await withUserProfileDir(async (_tmpDir, env) => {
253+
schtasksResponses.push(okSchtasksResponse, missingTaskResponse);
254+
const callerEnv = {
255+
...env,
256+
HOME: env.USERPROFILE,
257+
USERDOMAIN: "WORKSTATION",
258+
USERNAME: "alice",
259+
OPENCLAW_WINDOWS_TASK_NAME: "OpenClaw Custom Gateway",
260+
};
261+
const gatewayEnv = buildServiceEnvironment({
262+
env: callerEnv,
263+
port: 18789,
264+
platform: "win32",
265+
});
266+
267+
expect(callerEnv.OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER).toBeUndefined();
268+
expect(gatewayEnv.OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER).toBe("1");
269+
expect(gatewayEnv.OPENCLAW_WINDOWS_TASK_NAME).toBe("OpenClaw Gateway");
270+
271+
const { scriptPath } = await installScheduledTask({
272+
env: callerEnv,
273+
stdout: new PassThrough(),
274+
programArguments: ["node", "gateway.js"],
275+
environment: {
276+
...gatewayEnv,
277+
USERDOMAIN: "EVIL",
278+
USERNAME: "mallory",
279+
},
280+
});
281+
const launcherPath = scriptPath.replace(/\.cmd$/i, ".vbs");
282+
const script = await fs.readFile(scriptPath, "utf8");
283+
const launcher = await fs.readFile(launcherPath, "utf8");
284+
285+
expect(schtasksCalls[2]?.slice(0, 5)).toEqual([
286+
"/Create",
287+
"/F",
288+
"/TN",
289+
"OpenClaw Custom Gateway",
290+
"/XML",
291+
]);
292+
expect(schtasksCalls[2]?.slice(6)).toEqual(["/RU", "WORKSTATION\\alice", "/NP"]);
293+
const captured = xmlPayloadCaptures.find((entry) => entry.index === 2);
294+
expect(captured?.xml).toContain("gateway.vbs</Command>");
295+
expect(script).toContain('set "OPENCLAW_WINDOWS_TASK_NAME=OpenClaw Custom Gateway"');
296+
expect(launcher).toContain("WScript.Shell");
297+
expect(launcher).toContain(`Run """${scriptPath}""", 0, False`);
298+
expectTaskRunCall(3, "OpenClaw Custom Gateway");
299+
});
300+
});
301+
302+
it("removes a generated hidden launcher when the caller env lacks its marker", async () => {
303+
await withUserProfileDir(async (_tmpDir, env) => {
304+
schtasksResponses.push(okSchtasksResponse, missingTaskResponse);
305+
const scriptPath = resolveTaskScriptPath(env);
306+
const parsedScriptPath = path.parse(scriptPath);
307+
const launcherPath = path.join(parsedScriptPath.dir, `${parsedScriptPath.name}.vbs`);
308+
await fs.mkdir(parsedScriptPath.dir, { recursive: true });
309+
await fs.writeFile(scriptPath, "@echo off\n", "utf8");
310+
await fs.writeFile(launcherPath, 'CreateObject("WScript.Shell")\n', "utf8");
311+
312+
await uninstallScheduledTask({
313+
env,
314+
stdout: new PassThrough(),
315+
});
316+
317+
const remaining: string[] = [];
318+
for (const candidate of [scriptPath, launcherPath]) {
319+
try {
320+
await fs.access(candidate);
321+
remaining.push(candidate);
322+
} catch {}
323+
}
324+
expect(remaining).toEqual([]);
325+
});
326+
});
327+
245328
it("creates the Scheduled Task via XML with battery start/continue enabled (#59299)", async () => {
246329
await withUserProfileDir(async (_tmpDir, env) => {
247330
schtasksResponses.push(okSchtasksResponse, missingTaskResponse);

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,22 @@ describe("Windows startup fallback", () => {
676676
});
677677
});
678678

679+
it("removes hidden Startup-folder entries when the caller env lacks the marker", async () => {
680+
await withWindowsEnv("openclaw-win-startup-", async ({ env }) => {
681+
schtasksResponses.push({ code: 0, stdout: "", stderr: "" });
682+
const startupEntryPath = resolveStartupEntryPath(env, "vbs");
683+
await fs.mkdir(path.dirname(startupEntryPath), { recursive: true });
684+
await fs.writeFile(startupEntryPath, 'CreateObject("WScript.Shell")\n', "utf8");
685+
686+
await uninstallScheduledTask({
687+
env,
688+
stdout: new PassThrough(),
689+
});
690+
691+
await expect(fs.access(startupEntryPath)).rejects.toThrow();
692+
});
693+
});
694+
679695
it("reports runtime from the gateway listener when using the Startup fallback", async () => {
680696
await withWindowsEnv("openclaw-win-startup-", async ({ env }) => {
681697
addStartupFallbackMissingResponses();

src/daemon/schtasks.ts

Lines changed: 90 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,10 @@ function resolveStartupEntryPath(env: GatewayServiceEnv, extension?: "cmd" | "vb
100100
function resolveStartupEntryPaths(env: GatewayServiceEnv): string[] {
101101
const primaryPath = resolveStartupEntryPath(env);
102102
const legacyCmdPath = resolveStartupEntryPath(env, "cmd");
103-
// Hidden VBS launchers supersede cmd launchers, but uninstall must remove the
104-
// legacy cmd path from older installs too.
105-
return uniqueStrings([primaryPath, legacyCmdPath]);
103+
const hiddenLauncherPath = resolveStartupEntryPath(env, "vbs");
104+
// Hidden VBS launchers supersede cmd launchers, but lifecycle operations must
105+
// discover both variants even when the caller env lacks the persisted marker.
106+
return uniqueStrings([primaryPath, legacyCmdPath, hiddenLauncherPath]);
106107
}
107108

108109
// `/TR` is parsed by schtasks itself, while the generated `gateway.cmd` line is parsed by cmd.exe.
@@ -923,6 +924,70 @@ async function restartStartupEntry(
923924
return { outcome: "completed" };
924925
}
925926

927+
const CALLER_OWNED_SERVICE_IDENTITY_KEYS = [
928+
"OPENCLAW_LAUNCHD_LABEL",
929+
"OPENCLAW_SYSTEMD_UNIT",
930+
"OPENCLAW_WINDOWS_TASK_NAME",
931+
] as const;
932+
933+
function resolveScheduledTaskRenderEnv(
934+
env: GatewayServiceEnv,
935+
environment: GatewayServiceEnv | undefined,
936+
): GatewayServiceEnv {
937+
if (!environment) {
938+
return env;
939+
}
940+
const merged = { ...env, ...environment };
941+
for (const key of CALLER_OWNED_SERVICE_IDENTITY_KEYS) {
942+
const value = env[key]?.trim();
943+
if (value) {
944+
merged[key] = value;
945+
}
946+
}
947+
return merged;
948+
}
949+
950+
function resolveScheduledTaskScriptEnvironment(
951+
taskEnv: GatewayServiceEnv,
952+
environment: GatewayServiceEnv | undefined,
953+
): GatewayServiceEnv | undefined {
954+
const scriptEnv = environment ? { ...environment } : {};
955+
for (const key of CALLER_OWNED_SERVICE_IDENTITY_KEYS) {
956+
const value = taskEnv[key]?.trim();
957+
if (value) {
958+
scriptEnv[key] = value;
959+
}
960+
}
961+
return Object.keys(scriptEnv).length > 0 ? scriptEnv : undefined;
962+
}
963+
964+
const SCHEDULED_TASK_ACTIVATION_KEYS = [
965+
"OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER",
966+
"OPENCLAW_TASK_SCRIPT_NAME",
967+
"OPENCLAW_TASK_SCRIPT",
968+
"OPENCLAW_SERVICE_KIND",
969+
"OPENCLAW_GATEWAY_PORT",
970+
"OPENCLAW_STATE_DIR",
971+
"OPENCLAW_PROFILE",
972+
] as const;
973+
974+
function resolveScheduledTaskActivationEnv(
975+
env: GatewayServiceEnv,
976+
environment: GatewayServiceEnv | undefined,
977+
): GatewayServiceEnv {
978+
if (!environment) {
979+
return env;
980+
}
981+
const activationEnv = { ...env };
982+
for (const key of SCHEDULED_TASK_ACTIVATION_KEYS) {
983+
const value = environment[key];
984+
if (value !== undefined) {
985+
activationEnv[key] = value;
986+
}
987+
}
988+
return activationEnv;
989+
}
990+
926991
async function writeScheduledTaskScript({
927992
env,
928993
programArguments,
@@ -933,17 +998,24 @@ async function writeScheduledTaskScript({
933998
scriptPath: string;
934999
taskLaunchPath: string;
9351000
taskDescription: string;
1001+
taskEnv: GatewayServiceEnv;
9361002
}> {
9371003
await assertSchtasksAvailable().catch(() => undefined);
938-
const scriptPath = resolveTaskScriptPath(env);
939-
const taskLaunchPath = resolveTaskLauncherScriptPath(env, scriptPath);
1004+
const taskEnv = resolveScheduledTaskRenderEnv(env, environment);
1005+
const scriptPath = resolveTaskScriptPath(taskEnv);
1006+
const taskLaunchPath = resolveTaskLauncherScriptPath(taskEnv, scriptPath);
9401007
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
941-
const taskDescription = resolveGatewayServiceDescription({ env, environment, description });
1008+
const taskDescription = resolveGatewayServiceDescription({
1009+
env: taskEnv,
1010+
environment,
1011+
description,
1012+
});
1013+
const scriptEnvironment = resolveScheduledTaskScriptEnvironment(taskEnv, environment);
9421014
const script = buildTaskScript({
9431015
description: taskDescription,
9441016
programArguments,
9451017
workingDirectory,
946-
environment,
1018+
environment: scriptEnvironment,
9471019
});
9481020
await fs.writeFile(scriptPath, script, "utf8");
9491021
if (taskLaunchPath !== scriptPath) {
@@ -953,7 +1025,7 @@ async function writeScheduledTaskScript({
9531025
});
9541026
await fs.writeFile(taskLaunchPath, launcher, "utf8");
9551027
}
956-
return { scriptPath, taskLaunchPath, taskDescription };
1028+
return { scriptPath, taskLaunchPath, taskDescription, taskEnv };
9571029
}
9581030

9591031
export async function stageScheduledTask({
@@ -1243,7 +1315,7 @@ export async function installScheduledTask(
12431315
): Promise<{ scriptPath: string }> {
12441316
const staged = await writeScheduledTaskScript(args);
12451317
await activateScheduledTask({
1246-
env: args.env,
1318+
env: resolveScheduledTaskActivationEnv(args.env, args.environment),
12471319
stdout: args.stdout,
12481320
scriptPath: staged.scriptPath,
12491321
taskLaunchPath: staged.taskLaunchPath,
@@ -1271,8 +1343,15 @@ export async function uninstallScheduledTask({
12711343
}
12721344

12731345
const scriptPath = resolveTaskScriptPath(env);
1274-
const launcherPath = resolveTaskLauncherScriptPath(env, scriptPath);
1275-
if (launcherPath !== scriptPath) {
1346+
const parsedScriptPath = path.parse(scriptPath);
1347+
const launcherPaths = uniqueStrings([
1348+
resolveTaskLauncherScriptPath(env, scriptPath),
1349+
path.join(parsedScriptPath.dir, `${parsedScriptPath.name}.vbs`),
1350+
]);
1351+
for (const launcherPath of launcherPaths) {
1352+
if (launcherPath === scriptPath) {
1353+
continue;
1354+
}
12761355
try {
12771356
await fs.unlink(launcherPath);
12781357
stdout.write(`${formatLine("Removed task launcher", launcherPath)}\n`);

src/daemon/service-env.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,7 @@ describe("buildServiceEnvironment", () => {
602602
expect(typeof env.OPENCLAW_SERVICE_VERSION).toBe("string");
603603
expect(env.OPENCLAW_SYSTEMD_UNIT).toBe("openclaw-gateway.service");
604604
expect(env.OPENCLAW_WINDOWS_TASK_NAME).toBe("OpenClaw Gateway");
605+
expect(env.OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER).toBe("1");
605606
if (process.platform === "darwin") {
606607
expect(env.OPENCLAW_LAUNCHD_LABEL).toBe("ai.openclaw.gateway");
607608
}

src/daemon/service-env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,7 @@ export function buildServiceEnvironment(params: {
435435
OPENCLAW_LAUNCHD_LABEL: resolvedLaunchdLabel,
436436
OPENCLAW_SYSTEMD_UNIT: systemdUnit,
437437
OPENCLAW_WINDOWS_TASK_NAME: resolveGatewayWindowsTaskName(profile),
438+
OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER: "1",
438439
OPENCLAW_SERVICE_MARKER: GATEWAY_SERVICE_MARKER,
439440
OPENCLAW_SERVICE_KIND: GATEWAY_SERVICE_KIND,
440441
OPENCLAW_SERVICE_VERSION: VERSION,

0 commit comments

Comments
 (0)