mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
@@ -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`);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user