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;
}
return `"${value.replace(/\\\\/g, " \\\\\\\\").replace(/" /g, '\\\\"' )}"`;
}
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 environmentFiles
.map((entry) => entry.trim())
.filter(Boolean )
.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" ,
// 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" });
}
export function parseSystemdEnvAssignment(raw: string): { key: string; value: string } | null {
const trimmed = raw.trim();
if (!trimmed) {
return null ;
}
const unquoted = (() => {
if (!(trimmed.startsWith('"' ) && trimmed.endsWith('"' ))) {
return trimmed;
}
let out = "" ;
let escapeNext = false ;
for (const ch of trimmed.slice(1 , -1 )) {
if (escapeNext) {
out += ch;
escapeNext = false ;
continue ;
}
if (ch === "\\\\" ) {
escapeNext = true ;
continue ;
}
out += ch;
}
return out;
})();
const eq = unquoted.indexOf("=" );
if (eq <= 0 ) {
return null ;
}
const key = unquoted.slice(0 , eq).trim();
if (!key) {
return null ;
}
const value = unquoted.slice(eq + 1 );
return { key, value };
}
Messung V0.5 in Prozent C=99 H=98 G=98
¤ Dauer der Verarbeitung: 0.10 Sekunden
(vorverarbeitet am 2026-06-07)
¤
*© Formatika GbR, Deutschland