diff --git a/src/daemon/arg-split.test.ts b/src/daemon/arg-split.test.ts new file mode 100644 index 0000000000..f9b8c89448 --- /dev/null +++ b/src/daemon/arg-split.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { splitArgsPreservingQuotes } from "./arg-split.js"; + +describe("splitArgsPreservingQuotes", () => { + it("splits on whitespace outside quotes", () => { + expect(splitArgsPreservingQuotes('/usr/bin/openclaw gateway start --name "My Bot"')).toEqual([ + "/usr/bin/openclaw", + "gateway", + "start", + "--name", + "My Bot", + ]); + }); + + it("supports systemd-style backslash escaping", () => { + expect( + splitArgsPreservingQuotes('openclaw --name "My \\"Bot\\"" --foo bar', { + escapeMode: "backslash", + }), + ).toEqual(["openclaw", "--name", 'My "Bot"', "--foo", "bar"]); + }); + + it("supports schtasks-style escaped quotes while preserving other backslashes", () => { + expect( + splitArgsPreservingQuotes('openclaw --path "C:\\\\Program Files\\\\OpenClaw"', { + escapeMode: "backslash-quote-only", + }), + ).toEqual(["openclaw", "--path", "C:\\\\Program Files\\\\OpenClaw"]); + + expect( + splitArgsPreservingQuotes('openclaw --label "My \\"Quoted\\" Name"', { + escapeMode: "backslash-quote-only", + }), + ).toEqual(["openclaw", "--label", 'My "Quoted" Name']); + }); +}); diff --git a/src/daemon/arg-split.ts b/src/daemon/arg-split.ts new file mode 100644 index 0000000000..f87071b18a --- /dev/null +++ b/src/daemon/arg-split.ts @@ -0,0 +1,48 @@ +export type ArgSplitEscapeMode = "none" | "backslash" | "backslash-quote-only"; + +export function splitArgsPreservingQuotes( + value: string, + options?: { escapeMode?: ArgSplitEscapeMode }, +): string[] { + const args: string[] = []; + let current = ""; + let inQuotes = false; + const escapeMode = options?.escapeMode ?? "none"; + + for (let i = 0; i < value.length; i++) { + const char = value[i]; + if (escapeMode === "backslash" && char === "\\") { + if (i + 1 < value.length) { + current += value[i + 1]; + i++; + } + continue; + } + if ( + escapeMode === "backslash-quote-only" && + char === "\\" && + i + 1 < value.length && + value[i + 1] === '"' + ) { + current += '"'; + i++; + continue; + } + if (char === '"') { + inQuotes = !inQuotes; + continue; + } + if (!inQuotes && /\s/.test(char)) { + if (current) { + args.push(current); + current = ""; + } + continue; + } + current += char; + } + if (current) { + args.push(current); + } + return args; +} diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index 7b5c4f6bd2..d29d470ffe 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { GatewayServiceRuntime } from "./service-runtime.js"; +import { splitArgsPreservingQuotes } from "./arg-split.js"; import { formatGatewayServiceDescription, resolveGatewayWindowsTaskName } from "./constants.js"; import { formatLine } from "./output.js"; import { resolveGatewayStateDir } from "./paths.js"; @@ -48,36 +49,9 @@ function resolveTaskUser(env: Record): string | null } function parseCommandLine(value: string): string[] { - const args: string[] = []; - let current = ""; - let inQuotes = false; - - for (let i = 0; i < value.length; i++) { - const char = value[i]; - // `buildTaskScript` only escapes quotes (`\"`). - // Keep all other backslashes literal so drive and UNC paths are preserved. - if (char === "\\" && i + 1 < value.length && value[i + 1] === '"') { - current += value[i + 1]; - i++; - continue; - } - if (char === '"') { - inQuotes = !inQuotes; - continue; - } - if (!inQuotes && /\s/.test(char)) { - if (current) { - args.push(current); - current = ""; - } - continue; - } - current += char; - } - if (current) { - args.push(current); - } - return args; + // `buildTaskScript` only escapes quotes (`\"`). + // Keep all other backslashes literal so drive and UNC paths are preserved. + return splitArgsPreservingQuotes(value, { escapeMode: "backslash-quote-only" }); } export async function readScheduledTaskCommand(env: Record): Promise<{ diff --git a/src/daemon/systemd-unit.ts b/src/daemon/systemd-unit.ts index e7a8d09488..f947d4e2be 100644 --- a/src/daemon/systemd-unit.ts +++ b/src/daemon/systemd-unit.ts @@ -1,3 +1,5 @@ +import { splitArgsPreservingQuotes } from "./arg-split.js"; + function systemdEscapeArg(value: string): string { if (!/[\\s"\\\\]/.test(value)) { return value; @@ -63,38 +65,7 @@ export function buildSystemdUnit({ } export function parseSystemdExecStart(value: string): string[] { - const args: string[] = []; - let current = ""; - let inQuotes = false; - let escapeNext = false; - - for (const char of value) { - if (escapeNext) { - current += char; - escapeNext = false; - continue; - } - if (char === "\\\\") { - escapeNext = true; - continue; - } - if (char === '"') { - inQuotes = !inQuotes; - continue; - } - if (!inQuotes && /\s/.test(char)) { - if (current) { - args.push(current); - current = ""; - } - continue; - } - current += char; - } - if (current) { - args.push(current); - } - return args; + return splitArgsPreservingQuotes(value, { escapeMode: "backslash" }); } export function parseSystemdEnvAssignment(raw: string): { key: string; value: string } | null {