mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
29577fb03b
* fix(daemon): keep backslashes and quotes intact in generated systemd units systemdEscapeArg escaped only pairs of backslashes and rendered a quote as two backslashes plus a quote, so any value holding a lone backslash or a quote did not survive the readers in this module -- or systemd itself. The installed Linux service received a different value than the operator configured, with nothing reporting the difference. parseSystemdEnvAssignment also carried a private copy of the shared unquoting loop whose escape branch compared one character against a two-character literal, so it never unescaped anything. Service inspection reads through that path, so status and doctor reported the escaped form. Align the writer with serializeSystemdEnvironmentFileValue and route the reader through the shared splitter its three siblings already use. Adds round-trip tables mirroring the Windows cmd sibling. * fix(daemon): align systemd round-trip parser with current main --------- Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
144 lines
4.7 KiB
TypeScript
144 lines
4.7 KiB
TypeScript
/** Renders and parses systemd unit snippets for managed gateway services. */
|
|
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
|
|
import { splitArgsPreservingQuotes } from "./arg-split.js";
|
|
import type { GatewayServiceRenderArgs } from "./service-types.js";
|
|
|
|
const SYSTEMD_LINE_BREAKS = /[\r\n]/;
|
|
|
|
function assertNoSystemdLineBreaks(value: string, label: string): void {
|
|
if (SYSTEMD_LINE_BREAKS.test(value)) {
|
|
throw new Error(`${label} cannot contain CR or LF characters.`);
|
|
}
|
|
}
|
|
|
|
function systemdEscapeArg(value: string): string {
|
|
assertNoSystemdLineBreaks(value, "Systemd unit values");
|
|
if (!/[\s"\\]/.test(value)) {
|
|
return value;
|
|
}
|
|
// systemd ExecStart/Environment parsing consumes one backslash before the next
|
|
// character, so every backslash and quote must be escaped for the value to
|
|
// survive the round-trip byte-for-byte. Escaping only backslash pairs left a
|
|
// lone backslash unescaped, and the reader then swallowed the byte after it.
|
|
const escaped = value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
|
return `"${escaped}"`;
|
|
}
|
|
|
|
function renderEnvLines(env: Record<string, string | undefined> | undefined): string[] {
|
|
if (!env) {
|
|
return [];
|
|
}
|
|
const entries = Object.entries(env).filter(
|
|
([, value]) => typeof value === "string" && value.trim(),
|
|
);
|
|
if (entries.length === 0) {
|
|
return [];
|
|
}
|
|
return entries.map(([key, value]) => {
|
|
const rawValue = value ?? "";
|
|
assertNoSystemdLineBreaks(key, "Systemd environment variable names");
|
|
assertNoSystemdLineBreaks(rawValue, "Systemd environment variable values");
|
|
return `Environment=${systemdEscapeArg(`${key}=${rawValue.trim()}`)}`;
|
|
});
|
|
}
|
|
|
|
function renderEnvironmentFileLines(environmentFiles: string[] | undefined): string[] {
|
|
if (!environmentFiles) {
|
|
return [];
|
|
}
|
|
return normalizeStringEntries(environmentFiles).map((entry) => {
|
|
assertNoSystemdLineBreaks(entry, "Systemd EnvironmentFile values");
|
|
return `EnvironmentFile=-${systemdEscapeArg(entry)}`;
|
|
});
|
|
}
|
|
|
|
export function buildSystemdUnit({
|
|
description,
|
|
programArguments,
|
|
workingDirectory,
|
|
environment,
|
|
environmentFiles,
|
|
}: GatewayServiceRenderArgs): string {
|
|
const execStart = programArguments.map(systemdEscapeArg).join(" ");
|
|
const descriptionValue = description?.trim() || "OpenClaw Gateway";
|
|
assertNoSystemdLineBreaks(descriptionValue, "Systemd Description");
|
|
const descriptionLine = `Description=${descriptionValue}`;
|
|
const workingDirLine = workingDirectory
|
|
? `WorkingDirectory=${systemdEscapeArg(workingDirectory)}`
|
|
: null;
|
|
const envLines = renderEnvLines(environment);
|
|
const environmentFileLines = renderEnvironmentFileLines(environmentFiles);
|
|
return [
|
|
"[Unit]",
|
|
descriptionLine,
|
|
"After=network-online.target",
|
|
"Wants=network-online.target",
|
|
"StartLimitBurst=5",
|
|
"StartLimitIntervalSec=60",
|
|
"",
|
|
"[Service]",
|
|
`ExecStart=${execStart}`,
|
|
"Restart=always",
|
|
"RestartSec=5",
|
|
"RestartPreventExitStatus=78",
|
|
"TimeoutStopSec=30",
|
|
"TimeoutStartSec=30",
|
|
"SuccessExitStatus=0 143",
|
|
// Transient child processes may be selected by the OOM killer before the
|
|
// gateway. Keep the service running when that happens; the child surface is
|
|
// already responsible for reporting the failed command/session.
|
|
"OOMPolicy=continue",
|
|
// Keep service children in the same lifecycle so restarts do not leave
|
|
// orphan ACP/runtime workers behind.
|
|
"KillMode=control-group",
|
|
workingDirLine,
|
|
...environmentFileLines,
|
|
...envLines,
|
|
"",
|
|
"[Install]",
|
|
"WantedBy=default.target",
|
|
"",
|
|
]
|
|
.filter((line) => line !== null)
|
|
.join("\n");
|
|
}
|
|
|
|
export function parseSystemdExecStart(value: string): string[] {
|
|
return splitArgsPreservingQuotes(value, { escapeMode: "backslash" });
|
|
}
|
|
|
|
function parseSystemdEnvAssignment(raw: string): { key: string; value: string } | null {
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
// The shared splitter already removes quotes and consumes escapes before an
|
|
// assignment reaches this helper.
|
|
const eq = trimmed.indexOf("=");
|
|
if (eq <= 0) {
|
|
return null;
|
|
}
|
|
const key = trimmed.slice(0, eq).trim();
|
|
if (!key) {
|
|
return null;
|
|
}
|
|
const value = trimmed.slice(eq + 1);
|
|
return { key, value };
|
|
}
|
|
|
|
export function parseSystemdEnvAssignments(raw: string): Array<{ key: string; value: string }> {
|
|
return splitArgsPreservingQuotes(raw, {
|
|
escapeMode: "backslash",
|
|
quoteChars: ['"', "'"],
|
|
quoteStart: "item-start",
|
|
}).flatMap((entry) => {
|
|
const parsed = parseSystemdEnvAssignment(entry);
|
|
return parsed ? [parsed] : [];
|
|
});
|
|
}
|
|
|
|
export function renderSystemdEnvAssignment(key: string, value: string): string {
|
|
return systemdEscapeArg(`${key}=${value}`);
|
|
}
|