refactor(daemon): share quoted arg splitter

This commit is contained in:
Peter Steinberger
2026-02-15 12:49:30 +00:00
parent 216f4d4669
commit 108ea4336b
4 changed files with 91 additions and 62 deletions

View File

@@ -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']);
});
});

48
src/daemon/arg-split.ts Normal file
View File

@@ -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;
}

View File

@@ -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, string | undefined>): 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<string, string | undefined>): Promise<{

View File

@@ -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 {