diff --git a/src/daemon/cmd-set.ts b/src/daemon/cmd-set.ts new file mode 100644 index 0000000000..ae692ee583 --- /dev/null +++ b/src/daemon/cmd-set.ts @@ -0,0 +1,56 @@ +export type CmdSetAssignment = { key: string; value: string }; + +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; +} + +export function parseCmdSetAssignment(line: string): CmdSetAssignment | 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), + }; +} + +export function renderCmdSetAssignment(key: string, value: string): string { + const escapedKey = escapeCmdSetAssignmentComponent(key); + const escapedValue = escapeCmdSetAssignmentComponent(value); + return `set "${escapedKey}=${escapedValue}"`; +} diff --git a/src/daemon/schtasks.install.test.ts b/src/daemon/schtasks.install.test.ts new file mode 100644 index 0000000000..fb5458dc1f --- /dev/null +++ b/src/daemon/schtasks.install.test.ts @@ -0,0 +1,66 @@ +import fs from "node:fs/promises"; +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"; + +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("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.test.ts b/src/daemon/schtasks.test.ts index d7ef78c501..3923f197ba 100644 --- a/src/daemon/schtasks.test.ts +++ b/src/daemon/schtasks.test.ts @@ -1,27 +1,8 @@ import fs from "node:fs/promises"; 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, - 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; -}); +import { describe, expect, it } from "vitest"; +import { parseSchtasksQuery, readScheduledTaskCommand, resolveTaskScriptPath } from "./schtasks.js"; describe("schtasks runtime parsing", () => { it.each(["Ready", "Running"])("parses %s status", (status) => { @@ -246,50 +227,3 @@ describe("readScheduledTaskCommand", () => { ); }); }); - -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 1c6f4b8d30..ab9f9240fd 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -11,6 +11,7 @@ import type { GatewayServiceRenderArgs, } from "./service-types.js"; import { splitArgsPreservingQuotes } from "./arg-split.js"; +import { parseCmdSetAssignment, renderCmdSetAssignment } from "./cmd-set.js"; import { resolveGatewayServiceDescription, resolveGatewayWindowsTaskName } from "./constants.js"; import { formatLine, writeFormattedLines } from "./output.js"; import { resolveGatewayStateDir } from "./paths.js"; @@ -42,61 +43,6 @@ 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) { @@ -132,20 +78,21 @@ export async function readScheduledTaskCommand( if (!line) { continue; } + const lower = line.toLowerCase(); if (line.startsWith("@echo")) { continue; } - if (line.toLowerCase().startsWith("rem ")) { + if (lower.startsWith("rem ")) { continue; } - if (line.toLowerCase().startsWith("set ")) { + if (lower.startsWith("set ")) { const assignment = parseCmdSetAssignment(line.slice(4)); if (assignment) { environment[assignment.key] = assignment.value; } continue; } - if (line.toLowerCase().startsWith("cd /d ")) { + if (lower.startsWith("cd /d ")) { workingDirectory = line.slice("cd /d ".length).trim().replace(/^"|"$/g, ""); continue; }