refactor(daemon): extract schtasks cmd-set codec helpers

This commit is contained in:
Peter Steinberger
2026-02-19 16:07:00 +01:00
parent a688ccf24a
commit e1059e95aa
4 changed files with 129 additions and 126 deletions

56
src/daemon/cmd-set.ts Normal file
View File

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

View File

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

View File

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

View File

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