From e42726204490fa6829105efcf9b54284a937fc85 Mon Sep 17 00:00:00 2001 From: "clawsweeper[bot]" <274271284+clawsweeper[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 06:48:15 +0000 Subject: [PATCH] [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 f626b66c09d04c3521e98b47a3005c0cdd790c9b. - Required merge gates passed before the squash merge. Prepared head SHA: f626b66c09d04c3521e98b47a3005c0cdd790c9b Review: https://github.com/openclaw/openclaw/pull/84815#issuecomment-4505012292 Co-authored-by: samzong 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> --- CHANGELOG.md | 1 + src/cli/node-cli/daemon.ts | 3 +- .../node-daemon-install-helpers.test.ts | 32 +++ src/commands/node-daemon-install-helpers.ts | 19 +- src/daemon/arg-split.ts | 27 +- src/daemon/systemd-unit.ts | 18 +- src/daemon/systemd.test.ts | 247 +++++++++++++++++- src/daemon/systemd.ts | 224 ++++++++++++++-- 8 files changed, 538 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e6dd537fcb..f25058f7e0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/cli/node-cli/daemon.ts b/src/cli/node-cli/daemon.ts index 493816d2cab..57f48e5c806 100644 --- a/src/cli/node-cli/daemon.ts +++ b/src/cli/node-cli/daemon.ts @@ -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, }); }, diff --git a/src/commands/node-daemon-install-helpers.test.ts b/src/commands/node-daemon-install-helpers.test.ts index b97441abfeb..36c7ac282e5 100644 --- a/src/commands/node-daemon-install-helpers.test.ts +++ b/src/commands/node-daemon-install-helpers.test.ts @@ -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", + }); + }); }); diff --git a/src/commands/node-daemon-install-helpers.ts b/src/commands/node-daemon-install-helpers.ts index 7bf8d8ef6dc..648cf20dfae 100644 --- a/src/commands/node-daemon-install-helpers.ts +++ b/src/commands/node-daemon-install-helpers.ts @@ -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; + environmentValueSources?: Record; description?: string; }; +function buildNodeInstallEnvironmentValueSources(): Record< + string, + GatewayServiceEnvironmentValueSource | undefined +> { + return { + OPENCLAW_GATEWAY_TOKEN: "file", + }; +} + export async function buildNodeInstallPlan(params: { env: Record; 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, + }; } diff --git a/src/daemon/arg-split.ts b/src/daemon/arg-split.ts index cbbe1c892cb..9bdaf74a4fb 100644 --- a/src/daemon/arg-split.ts +++ b/src/daemon/arg-split.ts @@ -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(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 = ""; diff --git a/src/daemon/systemd-unit.ts b/src/daemon/systemd-unit.ts index 2d248a6ff78..d2a2049fd4a 100644 --- a/src/daemon/systemd-unit.ts +++ b/src/daemon/systemd-unit.ts @@ -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}`); +} diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index 9ebc87a71d1..c0a410eed61 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -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, ): Promise { 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; unitPath: string }) => Promise, + run: (context: { + env: Record; + unitPath: string; + nodeEnvFilePath: string; + }) => Promise, ): Promise { 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", () => { diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 90a2d23b97d..cfc440ab6aa 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -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; +}): Set { + const keys = new Set(); + 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; +}): Record { + if (params.fileManagedKeys.size === 0) { + return {}; + } + const environment: Record = {}; + 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 { + 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; + /** File-managed keys that should be written from current environment values or removed when absent. */ + fileManagedKeys?: ReadonlySet; + fileBackedEnvironment?: Record; + environment?: GatewayServiceEnv; }): Promise<{ environmentFiles: string[]; environmentKeys: Set }> { - 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 = {}; - 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 { + if (!isNodeSystemdEnvironment(env)) { + return; + } + const stateDir = resolveStateDir(env as NodeJS.ProcessEnv); + const envFilePath = resolveSystemdEnvironmentFilePath({ + stateDir, + environment: env, + }); + let existing: Record = {}; + 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`); } }