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: 8b57b0377a
Co-authored-by: mikasa0818 <244515412+mikasa0818@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
This commit is contained in:
mikasa
2026-06-23 17:05:23 +08:00
committed by GitHub
parent 7bec91c8d8
commit f5148aff25
7 changed files with 208 additions and 21 deletions
+7 -3
View File
@@ -110,14 +110,18 @@ systemctl --user daemon-reload
### Windows (Scheduled Task)
Default task name is `OpenClaw Gateway` (or `OpenClaw Gateway (<profile>)`).
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-<profile>\gateway.cmd`.
If you used a profile, delete the matching task name and the `gateway.cmd` /
`gateway.vbs` files under `~\.openclaw-<profile>`.
## Normal install vs source checkout
+5 -2
View File
@@ -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:
+88 -5
View File
@@ -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</Command>");
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);
@@ -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();
+90 -11
View File
@@ -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`);
+1
View File
@@ -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");
}
+1
View File
@@ -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,