Skip to content

Commit 9ac7773

Browse files
authored
fix(node): hide Windows task launcher (#81267)
1 parent 5817e47 commit 9ac7773

7 files changed

Lines changed: 274 additions & 71 deletions

File tree

CHANGELOG.md

Lines changed: 47 additions & 46 deletions
Large diffs are not rendered by default.

src/daemon/node-service.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ function withNodeServiceEnv(
1717
OPENCLAW_LAUNCHD_LABEL: resolveNodeLaunchAgentLabel(),
1818
OPENCLAW_SYSTEMD_UNIT: resolveNodeSystemdServiceName(),
1919
OPENCLAW_WINDOWS_TASK_NAME: resolveNodeWindowsTaskName(),
20+
OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER: "1",
2021
OPENCLAW_TASK_SCRIPT_NAME: NODE_WINDOWS_TASK_SCRIPT_NAME,
2122
OPENCLAW_LOG_PREFIX: "node",
2223
OPENCLAW_SERVICE_MARKER: NODE_SERVICE_MARKER,
@@ -33,6 +34,7 @@ function withNodeInstallEnv(args: GatewayServiceInstallArgs): GatewayServiceInst
3334
OPENCLAW_LAUNCHD_LABEL: resolveNodeLaunchAgentLabel(),
3435
OPENCLAW_SYSTEMD_UNIT: resolveNodeSystemdServiceName(),
3536
OPENCLAW_WINDOWS_TASK_NAME: resolveNodeWindowsTaskName(),
37+
OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER: "1",
3638
OPENCLAW_TASK_SCRIPT_NAME: NODE_WINDOWS_TASK_SCRIPT_NAME,
3739
OPENCLAW_LOG_PREFIX: "node",
3840
OPENCLAW_SERVICE_MARKER: NODE_SERVICE_MARKER,

src/daemon/schtasks.install.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,68 @@ describe("installScheduledTask", () => {
183183
});
184184
});
185185

186+
it("creates hidden launcher Windows tasks when requested", async () => {
187+
await withUserProfileDir(async (_tmpDir, env) => {
188+
schtasksResponses.push(okSchtasksResponse, missingTaskResponse);
189+
190+
const { scriptPath } = await installDefaultGatewayTask({
191+
...env,
192+
USERDOMAIN: "WORKSTATION",
193+
USERNAME: "alice",
194+
OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER: "1",
195+
});
196+
const launcherPath = scriptPath.replace(/\.cmd$/i, ".vbs");
197+
const launcher = await fs.readFile(launcherPath, "utf8");
198+
199+
expectInitialTaskQueries();
200+
expect(schtasksCalls[2]).toEqual([
201+
"/Create",
202+
"/F",
203+
"/SC",
204+
"ONLOGON",
205+
"/RL",
206+
"LIMITED",
207+
"/TN",
208+
"OpenClaw Gateway",
209+
"/TR",
210+
expect.stringContaining("gateway.vbs"),
211+
"/RU",
212+
"WORKSTATION\\alice",
213+
"/NP",
214+
"/IT",
215+
]);
216+
expect(launcher).toContain("WScript.Shell");
217+
expect(launcher).toContain(scriptPath);
218+
expect(launcher).toContain(`Run """${scriptPath}""", 0, False`);
219+
expectTaskRunCall(3);
220+
});
221+
});
222+
223+
it("updates existing tasks to use the hidden launcher when requested", async () => {
224+
await withUserProfileDir(async (_tmpDir, env) => {
225+
schtasksResponses.push(okSchtasksResponse, okSchtasksResponse, okSchtasksResponse);
226+
227+
const { scriptPath } = await installDefaultGatewayTask({
228+
...env,
229+
USERDOMAIN: "WORKSTATION",
230+
USERNAME: "alice",
231+
OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER: "true",
232+
});
233+
const launcherPath = scriptPath.replace(/\.cmd$/i, ".vbs");
234+
235+
expectInitialTaskQueries();
236+
expect(schtasksCalls[2]).toEqual([
237+
"/Change",
238+
"/TN",
239+
"OpenClaw Gateway",
240+
"/TR",
241+
expect.stringContaining("gateway.vbs"),
242+
]);
243+
expect(schtasksCalls[2]?.[4]).toContain(launcherPath);
244+
expectTaskRunCall(3);
245+
});
246+
});
247+
186248
it("falls back to /Create when /Change fails on an existing task", async () => {
187249
await withUserProfileDir(async (_tmpDir, env) => {
188250
schtasksResponses.push(okSchtasksResponse, okSchtasksResponse, accessDeniedResponse);

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

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,17 +69,18 @@ const {
6969
restartScheduledTask,
7070
resolveTaskScriptPath,
7171
stopScheduledTask,
72+
uninstallScheduledTask,
7273
} = await import("./schtasks.js");
7374

74-
function resolveStartupEntryPath(env: Record<string, string>) {
75+
function resolveStartupEntryPath(env: Record<string, string>, extension = "cmd") {
7576
return path.join(
7677
env.APPDATA,
7778
"Microsoft",
7879
"Windows",
7980
"Start Menu",
8081
"Programs",
8182
"Startup",
82-
"OpenClaw Gateway.cmd",
83+
`OpenClaw Gateway.${extension}`,
8384
);
8485
}
8586

@@ -213,6 +214,27 @@ describe("Windows startup fallback", () => {
213214
});
214215
});
215216

217+
it("uses a hidden Startup-folder launcher when requested", async () => {
218+
await withWindowsEnv("openclaw-win-startup-", async ({ env }) => {
219+
addStartupFallbackMissingResponses([
220+
{ code: 5, stdout: "", stderr: "ERROR: Access is denied." },
221+
]);
222+
223+
const result = await installGatewayScheduledTask({
224+
...env,
225+
OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER: "1",
226+
});
227+
228+
const startupEntryPath = resolveStartupEntryPath(env, "vbs");
229+
const startupScript = await fs.readFile(startupEntryPath, "utf8");
230+
expect(result.scriptPath).toBe(resolveTaskScriptPath(env));
231+
expect(startupScript).toContain("WScript.Shell");
232+
expect(startupScript).toContain("gateway.cmd");
233+
expect(startupScript).toContain(`Run """${result.scriptPath}""", 0, False`);
234+
expectStartupFallbackSpawn();
235+
});
236+
});
237+
216238
it("falls back to a Startup-folder launcher when schtasks create returns Spanish access denied", async () => {
217239
await withWindowsEnv("openclaw-win-startup-", async ({ env }) => {
218240
addStartupFallbackMissingResponses([
@@ -381,6 +403,40 @@ describe("Windows startup fallback", () => {
381403
});
382404
});
383405

406+
it("keeps legacy Startup-folder cmd entries visible after hidden launcher opt-in", async () => {
407+
await withWindowsEnv("openclaw-win-startup-", async ({ env }) => {
408+
addStartupFallbackMissingResponses();
409+
await writeStartupFallbackEntry(env);
410+
411+
await expect(
412+
isScheduledTaskInstalled({
413+
env: {
414+
...env,
415+
OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER: "1",
416+
},
417+
}),
418+
).resolves.toBe(true);
419+
});
420+
});
421+
422+
it("removes legacy Startup-folder cmd entries after hidden launcher opt-in", async () => {
423+
await withWindowsEnv("openclaw-win-startup-", async ({ env }) => {
424+
schtasksResponses.push({ code: 0, stdout: "", stderr: "" });
425+
const startupEntryPath = await writeStartupFallbackEntry(env);
426+
const stdout = new PassThrough();
427+
428+
await uninstallScheduledTask({
429+
env: {
430+
...env,
431+
OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER: "1",
432+
},
433+
stdout,
434+
});
435+
436+
await expect(fs.access(startupEntryPath)).rejects.toThrow();
437+
});
438+
});
439+
384440
it("reports runtime from the gateway listener when using the Startup fallback", async () => {
385441
await withWindowsEnv("openclaw-win-startup-", async ({ env }) => {
386442
addStartupFallbackMissingResponses();

src/daemon/schtasks.ts

Lines changed: 93 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,19 @@ function sanitizeWindowsFilename(value: string): string {
7979
return value.replace(/[<>:"/\\|?*]/g, "_").replace(/\p{Cc}/gu, "_");
8080
}
8181

82-
function resolveStartupEntryPath(env: GatewayServiceEnv): string {
82+
function resolveStartupEntryPath(env: GatewayServiceEnv, extension?: "cmd" | "vbs"): string {
8383
const taskName = resolveTaskName(env);
84-
return path.join(resolveWindowsStartupDir(env), `${sanitizeWindowsFilename(taskName)}.cmd`);
84+
const entryExtension = extension ?? (shouldUseHiddenWindowsTaskLauncher(env) ? "vbs" : "cmd");
85+
return path.join(
86+
resolveWindowsStartupDir(env),
87+
`${sanitizeWindowsFilename(taskName)}.${entryExtension}`,
88+
);
89+
}
90+
91+
function resolveStartupEntryPaths(env: GatewayServiceEnv): string[] {
92+
const primaryPath = resolveStartupEntryPath(env);
93+
const legacyCmdPath = resolveStartupEntryPath(env, "cmd");
94+
return Array.from(new Set([primaryPath, legacyCmdPath]));
8595
}
8696

8797
// `/TR` is parsed by schtasks itself, while the generated `gateway.cmd` line is parsed by cmd.exe.
@@ -108,6 +118,19 @@ function resolveTaskUser(env: GatewayServiceEnv): string | null {
108118
return username;
109119
}
110120

121+
function shouldUseHiddenWindowsTaskLauncher(env: GatewayServiceEnv): boolean {
122+
const value = normalizeLowercaseStringOrEmpty(env.OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER);
123+
return value === "1" || value === "true" || value === "yes";
124+
}
125+
126+
function resolveTaskLauncherScriptPath(env: GatewayServiceEnv, scriptPath: string): string {
127+
if (!shouldUseHiddenWindowsTaskLauncher(env)) {
128+
return scriptPath;
129+
}
130+
const parsed = path.parse(scriptPath);
131+
return path.join(parsed.dir, `${parsed.name}.vbs`);
132+
}
133+
111134
export async function readScheduledTaskCommand(
112135
env: GatewayServiceEnv,
113136
): Promise<GatewayServiceCommandConfig | null> {
@@ -292,6 +315,27 @@ function buildStartupLauncherScript(params: { description?: string; scriptPath:
292315
return `${lines.join("\r\n")}\r\n`;
293316
}
294317

318+
function quoteVbsString(value: string): string {
319+
return `"${value.replace(/"/g, '""')}"`;
320+
}
321+
322+
function quoteVbsRunCommand(scriptPath: string): string {
323+
return quoteVbsString(`"${scriptPath}"`);
324+
}
325+
326+
function buildHiddenLauncherScript(params: { description?: string; scriptPath: string }): string {
327+
const lines = [];
328+
const trimmedDescription = params.description?.trim();
329+
if (trimmedDescription) {
330+
assertNoCmdLineBreak(trimmedDescription, "Hidden launcher description");
331+
lines.push(`' ${trimmedDescription}`);
332+
}
333+
lines.push(
334+
`CreateObject("WScript.Shell").Run ${quoteVbsRunCommand(params.scriptPath)}, 0, False`,
335+
);
336+
return `${lines.join("\r\n")}\r\n`;
337+
}
338+
295339
async function assertSchtasksAvailable() {
296340
const res = await execSchtasks(["/Query"]);
297341
if (res.code === 0) {
@@ -302,12 +346,13 @@ async function assertSchtasksAvailable() {
302346
}
303347

304348
async function isStartupEntryInstalled(env: GatewayServiceEnv): Promise<boolean> {
305-
try {
306-
await fs.access(resolveStartupEntryPath(env));
307-
return true;
308-
} catch {
309-
return false;
349+
for (const startupEntryPath of resolveStartupEntryPaths(env)) {
350+
try {
351+
await fs.access(startupEntryPath);
352+
return true;
353+
} catch {}
310354
}
355+
return false;
311356
}
312357

313358
async function isRegisteredScheduledTask(env: GatewayServiceEnv): Promise<boolean> {
@@ -605,10 +650,12 @@ async function writeScheduledTaskScript({
605650
description,
606651
}: Omit<GatewayServiceInstallArgs, "stdout">): Promise<{
607652
scriptPath: string;
653+
taskLaunchPath: string;
608654
taskDescription: string;
609655
}> {
610656
await assertSchtasksAvailable().catch(() => undefined);
611657
const scriptPath = resolveTaskScriptPath(env);
658+
const taskLaunchPath = resolveTaskLauncherScriptPath(env, scriptPath);
612659
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
613660
const taskDescription = resolveGatewayServiceDescription({ env, environment, description });
614661
const script = buildTaskScript({
@@ -618,7 +665,14 @@ async function writeScheduledTaskScript({
618665
environment,
619666
});
620667
await fs.writeFile(scriptPath, script, "utf8");
621-
return { scriptPath, taskDescription };
668+
if (taskLaunchPath !== scriptPath) {
669+
const launcher = buildHiddenLauncherScript({
670+
description: taskDescription,
671+
scriptPath,
672+
});
673+
await fs.writeFile(taskLaunchPath, launcher, "utf8");
674+
}
675+
return { scriptPath, taskLaunchPath, taskDescription };
622676
}
623677

624678
export async function stageScheduledTask({
@@ -636,7 +690,7 @@ async function updateExistingScheduledTask(params: {
636690
env: GatewayServiceEnv;
637691
stdout: NodeJS.WritableStream;
638692
taskName: string;
639-
quotedScript: string;
693+
quotedLaunchPath: string;
640694
scriptPath: string;
641695
}): Promise<boolean> {
642696
if (!(await isRegisteredScheduledTask(params.env))) {
@@ -647,7 +701,7 @@ async function updateExistingScheduledTask(params: {
647701
"/TN",
648702
params.taskName,
649703
"/TR",
650-
params.quotedScript,
704+
params.quotedLaunchPath,
651705
]);
652706
if (change.code !== 0) {
653707
return false;
@@ -820,14 +874,15 @@ async function activateScheduledTask(params: {
820874
env: GatewayServiceEnv;
821875
stdout: NodeJS.WritableStream;
822876
scriptPath: string;
877+
taskLaunchPath: string;
823878
description?: string;
824879
}) {
825880
const taskDescription = params.description ?? "OpenClaw Gateway";
826881

827882
const taskName = resolveTaskName(params.env);
828-
const quotedScript = quoteSchtasksArg(params.scriptPath);
883+
const quotedLaunchPath = quoteSchtasksArg(params.taskLaunchPath);
829884

830-
if (await updateExistingScheduledTask({ ...params, taskName, quotedScript })) {
885+
if (await updateExistingScheduledTask({ ...params, taskName, quotedLaunchPath })) {
831886
return;
832887
}
833888

@@ -841,11 +896,12 @@ async function activateScheduledTask(params: {
841896
"/TN",
842897
taskName,
843898
"/TR",
844-
quotedScript,
899+
quotedLaunchPath,
845900
];
846901
const taskUser = resolveTaskUser(params.env);
902+
const taskUserArgs = taskUser ? ["/RU", taskUser, "/NP", "/IT"] : [];
847903
let create = await execSchtasks(
848-
taskUser ? [...baseArgs, "/RU", taskUser, "/NP", "/IT"] : baseArgs,
904+
taskUserArgs.length > 0 ? [...baseArgs, ...taskUserArgs] : baseArgs,
849905
);
850906
if (create.code !== 0 && taskUser) {
851907
create = await execSchtasks(baseArgs);
@@ -855,10 +911,15 @@ async function activateScheduledTask(params: {
855911
if (shouldFallbackToStartupEntry({ code: create.code, detail })) {
856912
const startupEntryPath = resolveStartupEntryPath(params.env);
857913
await fs.mkdir(path.dirname(startupEntryPath), { recursive: true });
858-
const launcher = buildStartupLauncherScript({
859-
description: taskDescription,
860-
scriptPath: params.scriptPath,
861-
});
914+
const launcher = shouldUseHiddenWindowsTaskLauncher(params.env)
915+
? buildHiddenLauncherScript({
916+
description: taskDescription,
917+
scriptPath: params.scriptPath,
918+
})
919+
: buildStartupLauncherScript({
920+
description: taskDescription,
921+
scriptPath: params.scriptPath,
922+
});
862923
await fs.writeFile(startupEntryPath, launcher, "utf8");
863924
await launchFallbackTaskScript(params.env);
864925
writeFormattedLines(
@@ -898,6 +959,7 @@ export async function installScheduledTask(
898959
env: args.env,
899960
stdout: args.stdout,
900961
scriptPath: staged.scriptPath,
962+
taskLaunchPath: staged.taskLaunchPath,
901963
description: staged.taskDescription,
902964
});
903965
return { scriptPath: staged.scriptPath };
@@ -914,13 +976,21 @@ export async function uninstallScheduledTask({
914976
await execSchtasks(["/Delete", "/F", "/TN", taskName]);
915977
}
916978

917-
const startupEntryPath = resolveStartupEntryPath(env);
918-
try {
919-
await fs.unlink(startupEntryPath);
920-
stdout.write(`${formatLine("Removed Windows login item", startupEntryPath)}\n`);
921-
} catch {}
979+
for (const startupEntryPath of resolveStartupEntryPaths(env)) {
980+
try {
981+
await fs.unlink(startupEntryPath);
982+
stdout.write(`${formatLine("Removed Windows login item", startupEntryPath)}\n`);
983+
} catch {}
984+
}
922985

923986
const scriptPath = resolveTaskScriptPath(env);
987+
const launcherPath = resolveTaskLauncherScriptPath(env, scriptPath);
988+
if (launcherPath !== scriptPath) {
989+
try {
990+
await fs.unlink(launcherPath);
991+
stdout.write(`${formatLine("Removed task launcher", launcherPath)}\n`);
992+
} catch {}
993+
}
924994
try {
925995
await fs.unlink(scriptPath);
926996
stdout.write(`${formatLine("Removed task script", scriptPath)}\n`);

0 commit comments

Comments
 (0)