From dafe52e8cf1a041d898cfb304a485fa05e5f58fb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 15:52:08 +0100 Subject: [PATCH] fix(daemon): escape schtasks environment assignments --- src/daemon/schtasks.test.ts | 98 ++++++++++++++++++++++++++++++++++++- src/daemon/schtasks.ts | 80 ++++++++++++++++++++++++------ 2 files changed, 161 insertions(+), 17 deletions(-) diff --git a/src/daemon/schtasks.test.ts b/src/daemon/schtasks.test.ts index e4911ab03f..d7ef78c501 100644 --- a/src/daemon/schtasks.test.ts +++ b/src/daemon/schtasks.test.ts @@ -1,8 +1,27 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { parseSchtasksQuery, readScheduledTaskCommand, resolveTaskScriptPath } from "./schtasks.js"; +import { PassThrough } from "node:stream"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + installScheduledTask, + parseSchtasksQuery, + readScheduledTaskCommand, + resolveTaskScriptPath, +} from "./schtasks.js"; + +const schtasksCalls: string[][] = []; + +vi.mock("./schtasks-exec.js", () => ({ + execSchtasks: async (argv: string[]) => { + schtasksCalls.push(argv); + return { code: 0, stdout: "", stderr: "" }; + }, +})); + +beforeEach(() => { + schtasksCalls.length = 0; +}); describe("schtasks runtime parsing", () => { it.each(["Ready", "Running"])("parses %s status", (status) => { @@ -198,4 +217,79 @@ describe("readScheduledTaskCommand", () => { }, ); }); + + it("parses quoted set assignments with escaped metacharacters", async () => { + await withScheduledTaskScript( + { + scriptLines: [ + "@echo off", + 'set "OC_AMP=left & right"', + 'set "OC_PIPE=a | b"', + 'set "OC_CARET=^^"', + 'set "OC_PERCENT=%%TEMP%%"', + 'set "OC_BANG=^!token^!"', + 'set "OC_QUOTE=he said ^"hi^""', + "node gateway.js --verbose", + ], + }, + async (env) => { + const result = await readScheduledTaskCommand(env); + expect(result?.environment).toEqual({ + OC_AMP: "left & right", + OC_PIPE: "a | b", + OC_CARET: "^", + OC_PERCENT: "%TEMP%", + OC_BANG: "!token!", + OC_QUOTE: 'he said "hi"', + }); + }, + ); + }); +}); + +describe("installScheduledTask", () => { + it("writes quoted set assignments and escapes metacharacters", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-schtasks-install-")); + try { + const env = { + USERPROFILE: tmpDir, + OPENCLAW_PROFILE: "default", + }; + const { scriptPath } = await installScheduledTask({ + env, + stdout: new PassThrough(), + programArguments: ["node", "gateway.js", "--verbose"], + environment: { + OC_INJECT: "safe & whoami | calc", + OC_CARET: "a^b", + OC_PERCENT: "%TEMP%", + OC_BANG: "!token!", + OC_QUOTE: 'he said "hi"', + }, + }); + + const script = await fs.readFile(scriptPath, "utf8"); + expect(script).toContain('set "OC_INJECT=safe & whoami | calc"'); + expect(script).toContain('set "OC_CARET=a^^b"'); + expect(script).toContain('set "OC_PERCENT=%%TEMP%%"'); + expect(script).toContain('set "OC_BANG=^!token^!"'); + expect(script).toContain('set "OC_QUOTE=he said ^"hi^""'); + expect(script).not.toContain("set OC_INJECT="); + + const parsed = await readScheduledTaskCommand(env); + expect(parsed?.environment).toMatchObject({ + OC_INJECT: "safe & whoami | calc", + OC_CARET: "a^b", + OC_PERCENT: "%TEMP%", + OC_BANG: "!token!", + OC_QUOTE: 'he said "hi"', + }); + + expect(schtasksCalls[0]).toEqual(["/Query"]); + expect(schtasksCalls[1]?.[0]).toBe("/Create"); + expect(schtasksCalls[2]).toEqual(["/Run", "/TN", "OpenClaw Gateway"]); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index 1b58d9704e..1c6f4b8d30 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -1,11 +1,5 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { splitArgsPreservingQuotes } from "./arg-split.js"; -import { resolveGatewayServiceDescription, resolveGatewayWindowsTaskName } from "./constants.js"; -import { formatLine, writeFormattedLines } from "./output.js"; -import { resolveGatewayStateDir } from "./paths.js"; -import { parseKeyValueOutput } from "./runtime-parse.js"; -import { execSchtasks } from "./schtasks-exec.js"; import type { GatewayServiceRuntime } from "./service-runtime.js"; import type { GatewayServiceCommandConfig, @@ -16,6 +10,12 @@ import type { GatewayServiceManageArgs, GatewayServiceRenderArgs, } from "./service-types.js"; +import { splitArgsPreservingQuotes } from "./arg-split.js"; +import { resolveGatewayServiceDescription, resolveGatewayWindowsTaskName } from "./constants.js"; +import { formatLine, writeFormattedLines } from "./output.js"; +import { resolveGatewayStateDir } from "./paths.js"; +import { parseKeyValueOutput } from "./runtime-parse.js"; +import { execSchtasks } from "./schtasks-exec.js"; function resolveTaskName(env: GatewayServiceEnv): string { const override = env.OPENCLAW_WINDOWS_TASK_NAME?.trim(); @@ -42,6 +42,61 @@ function quoteCmdArg(value: string): string { return `"${value.replace(/"/g, '\\"')}"`; } +function escapeCmdSetAssignmentComponent(value: string): string { + return value.replace(/\^/g, "^^").replace(/%/g, "%%").replace(/!/g, "^!").replace(/"/g, '^"'); +} + +function unescapeCmdSetAssignmentComponent(value: string): string { + let out = ""; + for (let i = 0; i < value.length; i += 1) { + const ch = value[i]; + const next = value[i + 1]; + if (ch === "^" && (next === "^" || next === '"' || next === "!")) { + out += next; + i += 1; + continue; + } + if (ch === "%" && next === "%") { + out += "%"; + i += 1; + continue; + } + out += ch; + } + return out; +} + +function parseCmdSetAssignment(line: string): { key: string; value: string } | null { + const raw = line.trim(); + if (!raw) { + return null; + } + const quoted = raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2; + const assignment = quoted ? raw.slice(1, -1) : raw; + const index = assignment.indexOf("="); + if (index <= 0) { + return null; + } + const key = assignment.slice(0, index).trim(); + const value = assignment.slice(index + 1).trim(); + if (!key) { + return null; + } + if (!quoted) { + return { key, value }; + } + return { + key: unescapeCmdSetAssignmentComponent(key), + value: unescapeCmdSetAssignmentComponent(value), + }; +} + +function renderCmdSetAssignment(key: string, value: string): string { + const escapedKey = escapeCmdSetAssignmentComponent(key); + const escapedValue = escapeCmdSetAssignmentComponent(value); + return `set "${escapedKey}=${escapedValue}"`; +} + function resolveTaskUser(env: GatewayServiceEnv): string | null { const username = env.USERNAME || env.USER || env.LOGNAME; if (!username) { @@ -84,14 +139,9 @@ export async function readScheduledTaskCommand( continue; } if (line.toLowerCase().startsWith("set ")) { - const assignment = line.slice(4).trim(); - const index = assignment.indexOf("="); - if (index > 0) { - const key = assignment.slice(0, index).trim(); - const value = assignment.slice(index + 1).trim(); - if (key) { - environment[key] = value; - } + const assignment = parseCmdSetAssignment(line.slice(4)); + if (assignment) { + environment[assignment.key] = assignment.value; } continue; } @@ -157,7 +207,7 @@ function buildTaskScript({ if (!value) { continue; } - lines.push(`set ${key}=${value}`); + lines.push(renderCmdSetAssignment(key, value)); } } const command = programArguments.map(quoteCmdArg).join(" ");