Skip to content

Commit 6e842f6

Browse files
committed
fix(systemd): scrub single-quoted env tokens
Signed-off-by: samzong <samzong.lu@gmail.com>
1 parent a137175 commit 6e842f6

4 files changed

Lines changed: 51 additions & 10 deletions

File tree

src/daemon/arg-split.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
type ArgSplitEscapeMode = "none" | "backslash" | "backslash-quote-only";
2+
type ArgSplitQuoteChar = '"' | "'";
3+
type ArgSplitQuoteStart = "anywhere" | "item-start";
24

35
export function splitArgsPreservingQuotes(
46
value: string,
5-
options?: { escapeMode?: ArgSplitEscapeMode },
7+
options?: {
8+
escapeMode?: ArgSplitEscapeMode;
9+
quoteChars?: readonly ArgSplitQuoteChar[];
10+
quoteStart?: ArgSplitQuoteStart;
11+
},
612
): string[] {
713
const args: string[] = [];
814
let current = "";
9-
let inQuotes = false;
15+
let quoteChar: ArgSplitQuoteChar | null = null;
1016
const escapeMode = options?.escapeMode ?? "none";
17+
const quoteChars = new Set<ArgSplitQuoteChar>(options?.quoteChars ?? ['"']);
18+
const quoteStart = options?.quoteStart ?? "anywhere";
1119

1220
for (let i = 0; i < value.length; i++) {
1321
const char = value[i];
@@ -28,11 +36,18 @@ export function splitArgsPreservingQuotes(
2836
i++;
2937
continue;
3038
}
31-
if (char === '"') {
32-
inQuotes = !inQuotes;
33-
continue;
39+
if (quoteChars.has(char as ArgSplitQuoteChar)) {
40+
if (quoteChar === char) {
41+
quoteChar = null;
42+
continue;
43+
}
44+
const canOpenQuote = quoteStart === "anywhere" || current.length === 0;
45+
if (!quoteChar && canOpenQuote) {
46+
quoteChar = char as ArgSplitQuoteChar;
47+
continue;
48+
}
3449
}
35-
if (!inQuotes && /\s/.test(char)) {
50+
if (!quoteChar && /\s/.test(char)) {
3651
if (current) {
3752
args.push(current);
3853
current = "";

src/daemon/systemd-unit.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,8 @@ export function parseSystemdEnvAssignment(raw: string): { key: string; value: st
106106
}
107107

108108
const unquoted = (() => {
109-
if (!(trimmed.startsWith('"') && trimmed.endsWith('"'))) {
109+
const quote = trimmed[0];
110+
if (!((quote === '"' || quote === "'") && trimmed.endsWith(quote))) {
110111
return trimmed;
111112
}
112113
let out = "";
@@ -139,7 +140,11 @@ export function parseSystemdEnvAssignment(raw: string): { key: string; value: st
139140
}
140141

141142
export function parseSystemdEnvAssignments(raw: string): Array<{ key: string; value: string }> {
142-
return splitArgsPreservingQuotes(raw, { escapeMode: "backslash" }).flatMap((entry) => {
143+
return splitArgsPreservingQuotes(raw, {
144+
escapeMode: "backslash",
145+
quoteChars: ['"', "'"],
146+
quoteStart: "item-start",
147+
}).flatMap((entry) => {
143148
const parsed = parseSystemdEnvAssignment(entry);
144149
return parsed ? [parsed] : [];
145150
});

src/daemon/systemd.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ vi.mock("node:child_process", async () => {
2121
});
2222

2323
import { splitArgsPreservingQuotes } from "./arg-split.js";
24-
import { parseSystemdExecStart } from "./systemd-unit.js";
24+
import { parseSystemdEnvAssignments, parseSystemdExecStart } from "./systemd-unit.js";
2525
import {
2626
installSystemdService,
2727
isNonFatalSystemdInstallProbeError,
@@ -608,6 +608,24 @@ describe("splitArgsPreservingQuotes", () => {
608608
});
609609
});
610610

611+
describe("parseSystemdEnvAssignments", () => {
612+
it("parses single-quoted whole assignments", () => {
613+
expect(
614+
parseSystemdEnvAssignments("'OPENCLAW_GATEWAY_TOKEN=single quoted token' FOO=bar"),
615+
).toEqual([
616+
{ key: "OPENCLAW_GATEWAY_TOKEN", value: "single quoted token" },
617+
{ key: "FOO", value: "bar" },
618+
]);
619+
});
620+
621+
it("keeps apostrophes inside unquoted assignment values literal", () => {
622+
expect(parseSystemdEnvAssignments("FOO=can't OPENCLAW_GATEWAY_TOKEN=token")).toEqual([
623+
{ key: "FOO", value: "can't" },
624+
{ key: "OPENCLAW_GATEWAY_TOKEN", value: "token" },
625+
]);
626+
});
627+
});
628+
611629
describe("parseSystemdExecStart", () => {
612630
it("preserves quoted arguments", () => {
613631
const execStart = '/usr/bin/openclaw gateway start --name "My Bot"';
@@ -953,6 +971,7 @@ describe("stageSystemdService", () => {
953971
"ExecStart=/usr/bin/openclaw node run",
954972
"Environment=FOO=bar OPENCLAW_GATEWAY_TOKEN=inline-token BAZ=qux",
955973
"Environment=OPENCLAW_GATEWAY_TOKEN=token-only-line",
974+
"Environment='OPENCLAW_GATEWAY_TOKEN=single-quoted-token' FROM_SINGLE=kept",
956975
"Environment=OPENCLAW_GATEWAY_PORT=18789",
957976
].join("\n"),
958977
{ encoding: "utf8", mode: 0o600 },
@@ -985,7 +1004,9 @@ describe("stageSystemdService", () => {
9851004
expect(unit).not.toContain("Environment=OPENCLAW_GATEWAY_TOKEN=fresh-token");
9861005
expect(backupUnit).not.toContain("Environment=OPENCLAW_GATEWAY_TOKEN=inline-token");
9871006
expect(backupUnit).not.toContain("Environment=OPENCLAW_GATEWAY_TOKEN=token-only-line");
1007+
expect(backupUnit).not.toContain("single-quoted-token");
9881008
expect(backupUnit).toContain("Environment=FOO=bar BAZ=qux");
1009+
expect(backupUnit).toContain("Environment=FROM_SINGLE=kept");
9891010
expect(backupUnit).toContain("Environment=OPENCLAW_GATEWAY_PORT=18789");
9901011
expect(backupStat.mode & 0o777).toBe(0o600);
9911012
});

src/daemon/systemd.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -812,7 +812,7 @@ async function writeSystemdGatewayEnvironmentFile(params: {
812812
continue;
813813
}
814814
try {
815-
existing = { ...existing, ...(await readSystemdEnvironmentFile(sourceEnvFilePath)) };
815+
Object.assign(existing, await readSystemdEnvironmentFile(sourceEnvFilePath));
816816
} catch {
817817
// File does not exist yet — nothing to preserve.
818818
}

0 commit comments

Comments
 (0)