[Fix] Keep node systemd tokens out of unit files (#84815)

Summary:
- This replacement PR marks the Linux node daemon gateway token as file-backed, writes it to `node.systemd.env`, sanitizes and migrates systemd env artifacts, adds regression tests, and updates the changelog.
- Reproducibility: yes. from source inspection: current `main` copies `OPENCLAW_GATEWAY_TOKEN` into the node s ... e-backed before systemd rendering. I did not run a local live systemd install during this read-only review.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(systemd): scrub single-quoted env tokens
- PR branch already contained follow-up commit before automerge: [Fix] Keep node systemd tokens out of unit files

Validation:
- ClawSweeper review passed for head f626b66c09.
- Required merge gates passed before the squash merge.

Prepared head SHA: f626b66c09
Review: https://github.com/openclaw/openclaw/pull/84815#issuecomment-4505012292

Co-authored-by: samzong <samzong.lu@gmail.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
This commit is contained in:
clawsweeper[bot]
2026-05-21 06:48:15 +00:00
committed by GitHub
co-authored by samzong clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> takhoffman
parent 9ec9fbf58d
commit e427262044
8 changed files with 538 additions and 33 deletions
+1
View File
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Media/audio: skip empty structured sherpa-onnx transcripts instead of treating the raw JSON payload as spoken text. (#84667) Thanks @TurboTheTurtle.
- Node/Linux: keep `OPENCLAW_GATEWAY_TOKEN` out of generated systemd unit files by writing node service token values to a node-specific env file. (#84408)
- Memory-core/dreaming: reuse stable narrative subagent session keys per workspace and phase while keeping per-run idempotency and bounded cleanup, so stale `dreaming-narrative-*` sessions do not accumulate. Fixes #68252, #69187, and #70402. (#70464) Thanks @chiyouYCH.
- Trajectory/support: tolerate partial skill snapshot entries when building support metadata so rejected skill path scans no longer abort trajectory capture. (#71185) Thanks @lukeboyett.
- Agents/Pi: disable the embedded pi-coding-agent runtime auto-retry so OpenClaw's own retry and failover loop does not replay failed tool calls through a nested SDK retry. Fixes #73781. (#74434) Thanks @yelog.
+2 -1
View File
@@ -136,7 +136,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
const tlsFingerprint =
normalizeOptionalString(opts.tlsFingerprint) || config?.gateway?.tlsFingerprint;
const tls = Boolean(opts.tls) || Boolean(tlsFingerprint) || Boolean(config?.gateway?.tls);
const { programArguments, workingDirectory, environment, description } =
const { programArguments, workingDirectory, environment, environmentValueSources, description } =
await buildNodeInstallPlan({
env: process.env,
host,
@@ -168,6 +168,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
programArguments,
workingDirectory,
environment,
environmentValueSources,
description,
});
},
@@ -55,6 +55,9 @@ describe("buildNodeInstallPlan", () => {
expect(plan.environment).toEqual({
OPENCLAW_SERVICE_VERSION: "2026.3.22",
});
expect(plan.environmentValueSources).toEqual({
OPENCLAW_GATEWAY_TOKEN: "file",
});
expect(mocks.resolvePreferredNodePath).not.toHaveBeenCalled();
expect(mocks.buildNodeServiceEnvironment).toHaveBeenCalledWith({
env: {},
@@ -90,4 +93,33 @@ describe("buildNodeInstallPlan", () => {
extraPathDirs: undefined,
});
});
it("marks node gateway tokens as file-backed service env", async () => {
mocks.resolveNodeProgramArguments.mockResolvedValue({
programArguments: ["node", "node-host"],
workingDirectory: "/Users/me",
});
mocks.resolveSystemNodeInfo.mockResolvedValue({
path: "/usr/bin/node",
version: "22.0.0",
supported: true,
});
mocks.renderSystemNodeWarning.mockReturnValue(undefined);
mocks.buildNodeServiceEnvironment.mockReturnValue({
OPENCLAW_GATEWAY_TOKEN: "node-token",
OPENCLAW_SERVICE_VERSION: "2026.3.22",
});
const plan = await buildNodeInstallPlan({
env: { OPENCLAW_GATEWAY_TOKEN: "node-token" },
host: "127.0.0.1",
port: 18789,
runtime: "node",
});
expect(plan.environment.OPENCLAW_GATEWAY_TOKEN).toBe("node-token");
expect(plan.environmentValueSources).toEqual({
OPENCLAW_GATEWAY_TOKEN: "file",
});
});
});
+18 -1
View File
@@ -1,6 +1,7 @@
import { formatNodeServiceDescription } from "../daemon/constants.js";
import { resolveNodeProgramArguments } from "../daemon/program-args.js";
import { buildNodeServiceEnvironment } from "../daemon/service-env.js";
import type { GatewayServiceEnvironmentValueSource } from "../daemon/service-types.js";
import {
emitDaemonInstallRuntimeWarning,
resolveDaemonInstallRuntimeInputs,
@@ -13,9 +14,19 @@ type NodeInstallPlan = {
programArguments: string[];
workingDirectory?: string;
environment: Record<string, string | undefined>;
environmentValueSources?: Record<string, GatewayServiceEnvironmentValueSource | undefined>;
description?: string;
};
function buildNodeInstallEnvironmentValueSources(): Record<
string,
GatewayServiceEnvironmentValueSource | undefined
> {
return {
OPENCLAW_GATEWAY_TOKEN: "file",
};
}
export async function buildNodeInstallPlan(params: {
env: Record<string, string | undefined>;
host: string;
@@ -65,5 +76,11 @@ export async function buildNodeInstallPlan(params: {
version: environment.OPENCLAW_SERVICE_VERSION,
});
return { programArguments, workingDirectory, environment, description };
return {
programArguments,
workingDirectory,
environment,
environmentValueSources: buildNodeInstallEnvironmentValueSources(),
description,
};
}
+21 -6
View File
@@ -1,13 +1,21 @@
type ArgSplitEscapeMode = "none" | "backslash" | "backslash-quote-only";
type ArgSplitQuoteChar = '"' | "'";
type ArgSplitQuoteStart = "anywhere" | "item-start";
export function splitArgsPreservingQuotes(
value: string,
options?: { escapeMode?: ArgSplitEscapeMode },
options?: {
escapeMode?: ArgSplitEscapeMode;
quoteChars?: readonly ArgSplitQuoteChar[];
quoteStart?: ArgSplitQuoteStart;
},
): string[] {
const args: string[] = [];
let current = "";
let inQuotes = false;
let quoteChar: ArgSplitQuoteChar | null = null;
const escapeMode = options?.escapeMode ?? "none";
const quoteChars = new Set<ArgSplitQuoteChar>(options?.quoteChars ?? ['"']);
const quoteStart = options?.quoteStart ?? "anywhere";
for (let i = 0; i < value.length; i++) {
const char = value[i];
@@ -28,11 +36,18 @@ export function splitArgsPreservingQuotes(
i++;
continue;
}
if (char === '"') {
inQuotes = !inQuotes;
continue;
if (quoteChars.has(char as ArgSplitQuoteChar)) {
if (quoteChar === char) {
quoteChar = null;
continue;
}
const canOpenQuote = quoteStart === "anywhere" || current.length === 0;
if (!quoteChar && canOpenQuote) {
quoteChar = char as ArgSplitQuoteChar;
continue;
}
}
if (!inQuotes && /\s/.test(char)) {
if (!quoteChar && /\s/.test(char)) {
if (current) {
args.push(current);
current = "";
+17 -1
View File
@@ -106,7 +106,8 @@ export function parseSystemdEnvAssignment(raw: string): { key: string; value: st
}
const unquoted = (() => {
if (!(trimmed.startsWith('"') && trimmed.endsWith('"'))) {
const quote = trimmed[0];
if (!((quote === '"' || quote === "'") && trimmed.endsWith(quote))) {
return trimmed;
}
let out = "";
@@ -137,3 +138,18 @@ export function parseSystemdEnvAssignment(raw: string): { key: string; value: st
const value = unquoted.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}`);
}
+242 -5
View File
@@ -21,7 +21,7 @@ vi.mock("node:child_process", async () => {
});
import { splitArgsPreservingQuotes } from "./arg-split.js";
import { parseSystemdExecStart } from "./systemd-unit.js";
import { parseSystemdEnvAssignments, parseSystemdExecStart } from "./systemd-unit.js";
import {
installSystemdService,
isNonFatalSystemdInstallProbeError,
@@ -608,6 +608,24 @@ describe("splitArgsPreservingQuotes", () => {
});
});
describe("parseSystemdEnvAssignments", () => {
it("parses single-quoted whole assignments", () => {
expect(
parseSystemdEnvAssignments("'OPENCLAW_GATEWAY_TOKEN=single quoted token' FOO=bar"),
).toEqual([
{ key: "OPENCLAW_GATEWAY_TOKEN", value: "single quoted token" },
{ key: "FOO", value: "bar" },
]);
});
it("keeps apostrophes inside unquoted assignment values literal", () => {
expect(parseSystemdEnvAssignments("FOO=can't OPENCLAW_GATEWAY_TOKEN=token")).toEqual([
{ key: "FOO", value: "can't" },
{ key: "OPENCLAW_GATEWAY_TOKEN", value: "token" },
]);
});
});
describe("parseSystemdExecStart", () => {
it("preserves quoted arguments", () => {
const execStart = '/usr/bin/openclaw gateway start --name "My Bot"';
@@ -756,6 +774,7 @@ describe("stageSystemdService", () => {
stateDir: string;
unitPath: string;
envFilePath: string;
nodeEnvFilePath: string;
}) => Promise<void>,
): Promise<void> {
const tempHomeRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-systemd-stage-"));
@@ -768,10 +787,11 @@ describe("stageSystemdService", () => {
};
const unitPath = resolveSystemdUserUnitPath(env);
const envFilePath = path.join(stateDir, "gateway.systemd.env");
const nodeEnvFilePath = path.join(stateDir, "node.systemd.env");
try {
await fs.mkdir(stateDir, { recursive: true });
await run({ env, stateDir, unitPath, envFilePath });
await run({ env, stateDir, unitPath, envFilePath, nodeEnvFilePath });
} finally {
await fs.rm(tempHomeRoot, { recursive: true, force: true });
}
@@ -826,6 +846,172 @@ describe("stageSystemdService", () => {
});
});
it("writes node file-backed managed values to the node env file instead of the unit", async () => {
await withStageFixture(async ({ env, stateDir, unitPath, envFilePath, nodeEnvFilePath }) => {
await fs.rm(stateDir, { recursive: true, force: true });
mockSystemctlStatusOk();
await stageSystemdService({
env,
stdout: { write: vi.fn() } as unknown as NodeJS.WritableStream,
programArguments: ["/usr/bin/openclaw", "node", "run"],
workingDirectory: "/tmp",
environment: {
OPENCLAW_GATEWAY_TOKEN: "file-backed-token",
OPENCLAW_GATEWAY_PORT: "18789",
OPENCLAW_SERVICE_KIND: "node",
},
environmentValueSources: {
OPENCLAW_GATEWAY_TOKEN: "file",
},
});
const [unit, envFile, envFileStat] = await Promise.all([
fs.readFile(unitPath, "utf8"),
fs.readFile(nodeEnvFilePath, "utf8"),
fs.stat(nodeEnvFilePath),
]);
expect(unit).toContain(`EnvironmentFile=-${nodeEnvFilePath}`);
expect(unit).toContain("Environment=OPENCLAW_GATEWAY_PORT=18789");
expect(unit).not.toContain("Environment=OPENCLAW_GATEWAY_TOKEN=file-backed-token");
expect(envFile).toBe("OPENCLAW_GATEWAY_TOKEN=file-backed-token\n");
expect(envFileStat.mode & 0o777).toBe(0o600);
await expect(fs.access(envFilePath)).rejects.toThrow();
});
});
it("migrates operator entries from the legacy gateway env file when writing node env files", async () => {
await withStageFixture(async ({ env, unitPath, envFilePath, nodeEnvFilePath }) => {
const legacyGatewayEnvFile =
["OPENCLAW_GATEWAY_TOKEN=legacy-node-token", "OPENROUTER_API_KEY=operator-key"].join("\n") +
"\n";
await fs.writeFile(envFilePath, legacyGatewayEnvFile, {
encoding: "utf8",
mode: 0o600,
});
mockSystemctlStatusOk();
await stageSystemdService({
env,
stdout: { write: vi.fn() } as unknown as NodeJS.WritableStream,
programArguments: ["/usr/bin/openclaw", "node", "run"],
workingDirectory: "/tmp",
environment: {
OPENCLAW_GATEWAY_TOKEN: "fresh-file-token",
OPENCLAW_GATEWAY_PORT: "18789",
OPENCLAW_SERVICE_KIND: "node",
},
environmentValueSources: {
OPENCLAW_GATEWAY_TOKEN: "file",
},
});
const [unit, nodeEnvFile, gatewayEnvFile] = await Promise.all([
fs.readFile(unitPath, "utf8"),
fs.readFile(nodeEnvFilePath, "utf8"),
fs.readFile(envFilePath, "utf8"),
]);
expect(unit).toContain(`EnvironmentFile=-${nodeEnvFilePath}`);
expect(unit).not.toContain("OPENCLAW_GATEWAY_TOKEN=fresh-file-token");
expect(nodeEnvFile).toBe(
"OPENROUTER_API_KEY=operator-key\nOPENCLAW_GATEWAY_TOKEN=fresh-file-token\n",
);
expect(gatewayEnvFile).toBe(legacyGatewayEnvFile);
});
});
it("clears stale node file-backed managed keys without touching the gateway env file", async () => {
await withStageFixture(async ({ env, unitPath, envFilePath, nodeEnvFilePath }) => {
await fs.writeFile(envFilePath, "OPENCLAW_GATEWAY_TOKEN=stale-token\n", {
encoding: "utf8",
mode: 0o600,
});
await fs.writeFile(nodeEnvFilePath, "OPENCLAW_GATEWAY_TOKEN=stale-node-token\n", {
encoding: "utf8",
mode: 0o600,
});
mockSystemctlStatusOk();
await stageSystemdService({
env,
stdout: { write: vi.fn() } as unknown as NodeJS.WritableStream,
programArguments: ["/usr/bin/openclaw", "node", "run"],
workingDirectory: "/tmp",
environment: {
OPENCLAW_GATEWAY_PORT: "18789",
OPENCLAW_SERVICE_KIND: "node",
},
environmentValueSources: {
OPENCLAW_GATEWAY_TOKEN: "file",
},
});
const unit = await fs.readFile(unitPath, "utf8");
expect(unit).not.toContain("EnvironmentFile=");
await expect(fs.access(nodeEnvFilePath)).rejects.toThrow();
await expect(fs.readFile(envFilePath, "utf8")).resolves.toBe(
"OPENCLAW_GATEWAY_TOKEN=stale-token\n",
);
});
});
it("sanitizes file-backed managed values out of the backup unit on re-stage", async () => {
await withStageFixture(async ({ env, unitPath }) => {
await fs.mkdir(path.dirname(unitPath), { recursive: true });
await fs.writeFile(
unitPath,
[
"[Service]",
"ExecStart=/usr/bin/openclaw node run",
"Environment=FOO=bar OPENCLAW_GATEWAY_TOKEN=inline-token BAZ=qux",
"Environment=OPENCLAW_GATEWAY_TOKEN=token-only-line",
"Environment='OPENCLAW_GATEWAY_TOKEN=single-quoted-token' FROM_SINGLE=kept",
"Environment=OPENCLAW_GATEWAY_PORT=18789",
].join("\n"),
{ encoding: "utf8", mode: 0o600 },
);
await fs.chmod(unitPath, 0o600);
mockSystemctlStatusOk();
await stageSystemdService({
env,
stdout: { write: vi.fn() } as unknown as NodeJS.WritableStream,
programArguments: ["/usr/bin/openclaw", "node", "run"],
workingDirectory: "/tmp",
environment: {
OPENCLAW_GATEWAY_TOKEN: "fresh-token",
OPENCLAW_GATEWAY_PORT: "18789",
OPENCLAW_SERVICE_KIND: "node",
},
environmentValueSources: {
OPENCLAW_GATEWAY_TOKEN: "file",
},
});
const [unit, backupUnit, backupStat] = await Promise.all([
fs.readFile(unitPath, "utf8"),
fs.readFile(`${unitPath}.bak`, "utf8"),
fs.stat(`${unitPath}.bak`),
]);
expect(unit).not.toContain("Environment=OPENCLAW_GATEWAY_TOKEN=fresh-token");
expect(backupUnit).not.toContain("Environment=OPENCLAW_GATEWAY_TOKEN=inline-token");
expect(backupUnit).not.toContain("Environment=OPENCLAW_GATEWAY_TOKEN=token-only-line");
expect(backupUnit).not.toContain("single-quoted-token");
expect(backupUnit).toContain("Environment=FOO=bar BAZ=qux");
expect(backupUnit).toContain("Environment=FROM_SINGLE=kept");
expect(backupUnit).toContain("Environment=OPENCLAW_GATEWAY_PORT=18789");
expect(backupStat.mode & 0o777).toBe(0o600);
});
});
it("keeps inline overrides out of the generated env file", async () => {
await withStageFixture(async ({ env, stateDir, unitPath, envFilePath }) => {
await fs.writeFile(
@@ -972,7 +1158,11 @@ describe("stageSystemdService", () => {
describe("systemd service install and uninstall", () => {
async function withNodeSystemdFixture(
run: (context: { env: Record<string, string>; unitPath: string }) => Promise<void>,
run: (context: {
env: Record<string, string>;
unitPath: string;
nodeEnvFilePath: string;
}) => Promise<void>,
): Promise<void> {
const tempHomeRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-node-systemd-"));
const home = path.join(tempHomeRoot, "home");
@@ -981,12 +1171,14 @@ describe("systemd service install and uninstall", () => {
HOME: home,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_SYSTEMD_UNIT: "openclaw-node",
OPENCLAW_SERVICE_KIND: "node",
};
const unitPath = resolveSystemdUserUnitPath(env);
const nodeEnvFilePath = path.join(stateDir, "node.systemd.env");
try {
await fs.mkdir(stateDir, { recursive: true });
await run({ env, unitPath });
await run({ env, unitPath, nodeEnvFilePath });
} finally {
await fs.rm(tempHomeRoot, { recursive: true, force: true });
}
@@ -1213,9 +1405,14 @@ describe("systemd service install and uninstall", () => {
});
it("disables the OPENCLAW_SYSTEMD_UNIT override during uninstall", async () => {
await withNodeSystemdFixture(async ({ env, unitPath }) => {
await withNodeSystemdFixture(async ({ env, unitPath, nodeEnvFilePath }) => {
await fs.mkdir(path.dirname(unitPath), { recursive: true });
await fs.writeFile(unitPath, "[Unit]\nDescription=OpenClaw Node\n", "utf8");
await fs.writeFile(
nodeEnvFilePath,
"OPENCLAW_GATEWAY_TOKEN=stale-node-token\nOPENROUTER_API_KEY=operator-key\n",
{ encoding: "utf8", mode: 0o600 },
);
execFileMock
.mockImplementationOnce((_cmd, args, _opts, cb) => {
@@ -1237,10 +1434,50 @@ describe("systemd service install and uninstall", () => {
accessError = error as NodeJS.ErrnoException;
}
expect(accessError?.code).toBe("ENOENT");
await expect(fs.readFile(nodeEnvFilePath, "utf8")).resolves.toBe(
"OPENROUTER_API_KEY=operator-key\n",
);
expect(requireFirstWrite(write)).toContain("Removed systemd service");
expect(execFileMock).toHaveBeenCalledTimes(2);
});
});
it("preserves node env file values when unit removal fails during uninstall", async () => {
await withNodeSystemdFixture(async ({ env, unitPath, nodeEnvFilePath }) => {
await fs.mkdir(path.dirname(unitPath), { recursive: true });
await fs.writeFile(unitPath, "[Unit]\nDescription=OpenClaw Node\n", "utf8");
await fs.writeFile(
nodeEnvFilePath,
"OPENCLAW_GATEWAY_TOKEN=stale-node-token\nOPENROUTER_API_KEY=operator-key\n",
{ encoding: "utf8", mode: 0o600 },
);
execFileMock
.mockImplementationOnce((_cmd, args, _opts, cb) => {
assertUserSystemctlArgs(args, "status");
cb(null, "", "");
})
.mockImplementationOnce((_cmd, args, _opts, cb) => {
assertUserSystemctlArgs(args, "disable", "--now", NODE_SERVICE);
cb(null, "", "");
});
const unlinkError = new Error("EACCES: permission denied") as NodeJS.ErrnoException;
unlinkError.code = "EACCES";
vi.spyOn(fs, "unlink").mockRejectedValueOnce(unlinkError);
const { stdout } = createWritableStreamMock();
await expect(uninstallSystemdService({ env, stdout })).rejects.toThrow(
"EACCES: permission denied",
);
await expect(fs.readFile(unitPath, "utf8")).resolves.toContain("OpenClaw Node");
await expect(fs.readFile(nodeEnvFilePath, "utf8")).resolves.toBe(
"OPENCLAW_GATEWAY_TOKEN=stale-node-token\nOPENROUTER_API_KEY=operator-key\n",
);
expect(execFileMock).toHaveBeenCalledTimes(2);
});
});
});
describe("systemd service control", () => {
+205 -19
View File
@@ -21,6 +21,7 @@ import { parseKeyValueOutput } from "./runtime-parse.js";
import {
hasEnvironmentFileSource,
hasInlineEnvironmentSource,
isEnvironmentFileOnlySource,
readManagedServiceEnvKeysFromEnvironment,
} from "./service-managed-env.js";
import type { GatewayServiceRuntime } from "./service-runtime.js";
@@ -43,10 +44,13 @@ import {
import {
buildSystemdUnit,
parseSystemdEnvAssignment,
parseSystemdEnvAssignments,
parseSystemdExecStart,
renderSystemdEnvAssignment,
} from "./systemd-unit.js";
const SYSTEMD_GATEWAY_DOTENV_FILENAME = "gateway.systemd.env";
const SYSTEMD_NODE_DOTENV_FILENAME = "node.systemd.env";
function resolveSystemdUnitPathForName(env: GatewayServiceEnv, name: string): string {
const home = toPosixPath(resolveHomeDir(env));
@@ -198,6 +202,105 @@ function collectSystemdInlineManagedKeys(params: {
return keys;
}
function collectSystemdFileManagedKeys(params: {
environmentValueSources?: Record<string, GatewayServiceEnvironmentValueSource | undefined>;
}): Set<string> {
const keys = new Set<string>();
for (const [rawKey, source] of Object.entries(params.environmentValueSources ?? {})) {
const key = normalizeSystemdEnvironmentKey(rawKey);
if (key && isEnvironmentFileOnlySource(source)) {
keys.add(key);
}
}
return keys;
}
function collectSystemdFileBackedEnvironment(params: {
environment?: GatewayServiceEnv;
fileManagedKeys: ReadonlySet<string>;
}): Record<string, string> {
if (params.fileManagedKeys.size === 0) {
return {};
}
const environment: Record<string, string> = {};
for (const [rawKey, rawValue] of Object.entries(params.environment ?? {})) {
if (typeof rawValue !== "string" || !rawValue.trim()) {
continue;
}
const key = normalizeSystemdEnvironmentKey(rawKey);
if (key && params.fileManagedKeys.has(key)) {
environment[rawKey] = rawValue;
}
}
return environment;
}
function sanitizeSystemdUnitBackupContent(params: {
content: string;
fileManagedKeys: ReadonlySet<string>;
}): string {
if (params.fileManagedKeys.size === 0) {
return params.content;
}
const sanitizedLines: string[] = [];
for (const rawLine of params.content.split("\n")) {
const line = rawLine.trim();
if (!line.startsWith("Environment=")) {
sanitizedLines.push(rawLine);
continue;
}
const assignments = parseSystemdEnvAssignments(line.slice("Environment=".length).trim());
if (assignments.length === 0) {
sanitizedLines.push(rawLine);
continue;
}
const keptAssignments = assignments.filter(({ key }) => {
const normalizedKey = normalizeSystemdEnvironmentKey(key);
return !normalizedKey || !params.fileManagedKeys.has(normalizedKey);
});
if (keptAssignments.length === assignments.length) {
sanitizedLines.push(rawLine);
continue;
}
if (keptAssignments.length === 0) {
continue;
}
const leadingWhitespace = rawLine.match(/^\s*/)?.[0] ?? "";
sanitizedLines.push(
`${leadingWhitespace}Environment=${keptAssignments
.map(({ key, value }) => renderSystemdEnvAssignment(key, value))
.join(" ")}`,
);
}
return sanitizedLines.join("\n");
}
function resolveSystemdEnvironmentFilePath(params: {
stateDir: string;
environment?: GatewayServiceEnv;
}): string {
const serviceKind = params.environment?.OPENCLAW_SERVICE_KIND?.trim();
const filename =
serviceKind === "node" ? SYSTEMD_NODE_DOTENV_FILENAME : SYSTEMD_GATEWAY_DOTENV_FILENAME;
return path.join(params.stateDir, filename);
}
function resolveLegacyNodeSystemdEnvironmentFilePath(params: {
stateDir: string;
environment?: GatewayServiceEnv;
}): string | null {
if (params.environment?.OPENCLAW_SERVICE_KIND?.trim() !== "node") {
return null;
}
const legacyPath = path.join(params.stateDir, SYSTEMD_GATEWAY_DOTENV_FILENAME);
const currentPath = resolveSystemdEnvironmentFilePath(params);
return legacyPath === currentPath ? null : legacyPath;
}
function isNodeSystemdEnvironment(env: GatewayServiceEnv): boolean {
return env.OPENCLAW_SERVICE_KIND?.trim() === "node";
}
function expandSystemdSpecifier(input: string, env: GatewayServiceEnv): string {
// Support the common unit-specifier used in user services.
return input.replaceAll("%h", toPosixPath(resolveHomeDir(env)));
@@ -590,13 +693,23 @@ async function writeSystemdUnit({
const unitPath = resolveSystemdUnitPath(env);
await fs.mkdir(path.dirname(unitPath), { recursive: true });
const fileManagedKeys = collectSystemdFileManagedKeys({
environmentValueSources,
});
// Preserve user customizations: back up existing unit file before overwriting.
let backedUp = false;
try {
await fs.access(unitPath);
const backupPath = `${unitPath}.bak`;
await fs.copyFile(unitPath, backupPath);
const existingUnit = await fs.readFile(unitPath, "utf8");
const existingStat = await fs.stat(unitPath);
const backupMode = existingStat.mode & 0o777 || 0o600;
const backupUnit = sanitizeSystemdUnitBackupContent({
content: existingUnit,
fileManagedKeys,
});
await fs.writeFile(backupPath, backupUnit, { encoding: "utf8", mode: backupMode });
await fs.chmod(backupPath, backupMode);
backedUp = true;
} catch {
// File does not exist yet — nothing to back up.
@@ -621,6 +734,12 @@ async function writeSystemdUnit({
stateDir,
dotenvVars: stateDirDotEnvVars,
inlineManagedKeys,
fileManagedKeys,
fileBackedEnvironment: collectSystemdFileBackedEnvironment({
environment,
fileManagedKeys,
}),
environment,
});
const environmentSansDotEnvEntries = Object.fromEntries(
Object.entries(environment ?? {}).filter(([key, value]) => {
@@ -659,8 +778,12 @@ async function writeSystemdGatewayEnvironmentFile(params: {
/** OpenClaw-managed keys that must not be preserved from an old env file; stale file values
* would override fresh inline Environment= entries because EnvironmentFile takes precedence. */
inlineManagedKeys?: ReadonlySet<string>;
/** File-managed keys that should be written from current environment values or removed when absent. */
fileManagedKeys?: ReadonlySet<string>;
fileBackedEnvironment?: Record<string, string>;
environment?: GatewayServiceEnv;
}): Promise<{ environmentFiles: string[]; environmentKeys: Set<string> }> {
const incoming = params.dotenvVars;
const incoming = { ...params.dotenvVars, ...params.fileBackedEnvironment };
for (const [key, value] of Object.entries(incoming)) {
if (/[\r\n]/.test(value)) {
throw new Error(
@@ -668,27 +791,45 @@ async function writeSystemdGatewayEnvironmentFile(params: {
);
}
}
const envFilePath = path.join(params.stateDir, SYSTEMD_GATEWAY_DOTENV_FILENAME);
const envFilePath = resolveSystemdEnvironmentFilePath({
stateDir: params.stateDir,
environment: params.environment,
});
// Read the existing env file first so we can preserve operator-added secrets
// (e.g. provider API keys) across upgrades and re-stages.
// Read existing env files first so we can preserve operator-added secrets
// (e.g. provider API keys) across upgrades and re-stages. Node units used
// to share gateway.systemd.env, so migrate those entries into node.systemd.env.
// OpenClaw-managed keys (identified by inlineManagedKeys) are excluded: a stale
// file copy would override the fresh inline Environment= value because systemd's
// EnvironmentFile takes precedence over inline Environment= directives.
let existing: Record<string, string> = {};
try {
existing = await readSystemdEnvironmentFile(envFilePath);
} catch {
// File does not exist yet — nothing to preserve.
const legacyNodeEnvFilePath = resolveLegacyNodeSystemdEnvironmentFilePath({
stateDir: params.stateDir,
environment: params.environment,
});
for (const sourceEnvFilePath of [legacyNodeEnvFilePath, envFilePath]) {
if (!sourceEnvFilePath) {
continue;
}
try {
Object.assign(existing, await readSystemdEnvironmentFile(sourceEnvFilePath));
} catch {
// File does not exist yet — nothing to preserve.
}
}
const operatorOnly = params.inlineManagedKeys
? Object.fromEntries(
Object.entries(existing).filter(([key]) => {
const normalized = normalizeSystemdEnvironmentKey(key);
return !normalized || !params.inlineManagedKeys!.has(normalized);
}),
)
: existing;
const managedKeysToDrop = new Set([
...(params.inlineManagedKeys ?? []),
...(params.fileManagedKeys ?? []),
]);
const operatorOnly =
managedKeysToDrop.size > 0
? Object.fromEntries(
Object.entries(existing).filter(([key]) => {
const normalized = normalizeSystemdEnvironmentKey(key);
return !normalized || !managedKeysToDrop.has(normalized);
}),
)
: existing;
const merged = { ...operatorOnly, ...incoming };
const environmentKeys = new Set(
Object.keys(merged).flatMap((key) => {
@@ -699,17 +840,52 @@ async function writeSystemdGatewayEnvironmentFile(params: {
// If the merged result is empty there is nothing to write and no file needed.
if (Object.keys(merged).length === 0) {
await fs.rm(envFilePath, { force: true }).catch(() => undefined);
return { environmentFiles: [], environmentKeys };
}
const content = Object.entries(merged)
.map(([key, value]) => `${key}=${value}`)
.join("\n");
await fs.mkdir(path.dirname(envFilePath), { recursive: true });
await fs.writeFile(envFilePath, `${content}\n`, { encoding: "utf8", mode: 0o600 });
await fs.chmod(envFilePath, 0o600);
return { environmentFiles: [envFilePath], environmentKeys };
}
async function removeNodeSystemdManagedEnvironmentKeys(env: GatewayServiceEnv): Promise<void> {
if (!isNodeSystemdEnvironment(env)) {
return;
}
const stateDir = resolveStateDir(env as NodeJS.ProcessEnv);
const envFilePath = resolveSystemdEnvironmentFilePath({
stateDir,
environment: env,
});
let existing: Record<string, string> = {};
try {
existing = await readSystemdEnvironmentFile(envFilePath);
} catch {
return;
}
const managedKeys = new Set([normalizeSystemdEnvironmentKey("OPENCLAW_GATEWAY_TOKEN")]);
const remaining = Object.fromEntries(
Object.entries(existing).filter(([key]) => {
const normalized = normalizeSystemdEnvironmentKey(key);
return !normalized || !managedKeys.has(normalized);
}),
);
if (Object.keys(remaining).length === 0) {
await fs.rm(envFilePath, { force: true });
return;
}
const content = Object.entries(remaining)
.map(([key, value]) => `${key}=${value}`)
.join("\n");
await fs.writeFile(envFilePath, `${content}\n`, { encoding: "utf8", mode: 0o600 });
await fs.chmod(envFilePath, 0o600);
}
export async function stageSystemdService({
stdout,
...args
@@ -814,10 +990,20 @@ export async function uninstallSystemdService({
await execSystemctlUser(env, ["disable", "--now", unitName]);
const unitPath = resolveSystemdUnitPath(env);
let removed = false;
try {
await fs.unlink(unitPath);
removed = true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
// Unit file was already absent; still clean generated node env state below.
}
await removeNodeSystemdManagedEnvironmentKeys(env);
if (removed) {
stdout.write(`${formatLine("Removed systemd service", unitPath)}\n`);
} catch {
} else {
stdout.write(`Systemd service not found at ${unitPath}\n`);
}
}