diff --git a/CHANGELOG.md b/CHANGELOG.md index c775d61d192..cc10e8c1a52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - Media-understanding/attachments: fail closed when a local attachment path cannot be canonically resolved via `realpath`, so a `realpath` error can no longer downgrade the canonical-roots allowlist check to a non-canonical comparison; attachments that also have a URL still fall back to the network fetch path. (#66022) Thanks @eleqtrizit. - Agents/gateway-tool: reject `config.patch` and `config.apply` calls from the model-facing gateway tool when they would newly enable any flag enumerated by `openclaw security audit` (for example `dangerouslyDisableDeviceAuth`, `allowInsecureAuth`, `dangerouslyAllowHostHeaderOriginFallback`, `hooks.gmail.allowUnsafeExternalContent`, `tools.exec.applyPatch.workspaceOnly: false`); already-enabled flags pass through unchanged so non-dangerous edits in the same patch still apply, and direct authenticated operator RPC behavior is unchanged. (#62006) Thanks @eleqtrizit. - Telegram/forum topics: persist learned topic names to the Telegram session sidecar store so agent context can keep using human topic names after a restart instead of relearning from future service metadata. (#66107) Thanks @obviyus. +- Doctor/systemd: keep `openclaw doctor --repair` and service reinstall from re-embedding dotenv-backed secrets in user systemd units, while preserving newer inline overrides over stale state-dir `.env` values. (#66249) Thanks @tmimmanuel. ## 2026.4.14-beta.1 diff --git a/src/config/state-dir-dotenv.ts b/src/config/state-dir-dotenv.ts index 670f6ee7cca..6fce928aa48 100644 --- a/src/config/state-dir-dotenv.ts +++ b/src/config/state-dir-dotenv.ts @@ -14,24 +14,7 @@ function isBlockedServiceEnvVar(key: string): boolean { return isDangerousHostEnvVarName(key) || isDangerousHostEnvOverrideVarName(key); } -/** - * Read and parse `~/.openclaw/.env` (or `$OPENCLAW_STATE_DIR/.env`), returning - * a filtered record of key-value pairs suitable for embedding in a service - * environment (LaunchAgent plist, systemd unit, Scheduled Task). - */ -export function readStateDirDotEnvVars( - env: Record, -): Record { - const stateDir = resolveStateDir(env as NodeJS.ProcessEnv); - const dotEnvPath = path.join(stateDir, ".env"); - - let content: string; - try { - content = fs.readFileSync(dotEnvPath, "utf8"); - } catch { - return {}; - } - +function parseStateDirDotEnvContent(content: string): Record { const parsed = dotenv.parse(content); const entries: Record = {}; for (const [rawKey, value] of Object.entries(parsed)) { @@ -50,6 +33,27 @@ export function readStateDirDotEnvVars( return entries; } +export function readStateDirDotEnvVarsFromStateDir(stateDir: string): Record { + const dotEnvPath = path.join(stateDir, ".env"); + try { + return parseStateDirDotEnvContent(fs.readFileSync(dotEnvPath, "utf8")); + } catch { + return {}; + } +} + +/** + * Read and parse `~/.openclaw/.env` (or `$OPENCLAW_STATE_DIR/.env`), returning + * a filtered record of key-value pairs suitable for embedding in a service + * environment (LaunchAgent plist, systemd unit, Scheduled Task). + */ +export function readStateDirDotEnvVars( + env: Record, +): Record { + const stateDir = resolveStateDir(env as NodeJS.ProcessEnv); + return readStateDirDotEnvVarsFromStateDir(stateDir); +} + /** * Durable service env sources survive beyond the invoking shell and are safe to * persist into gateway install metadata. diff --git a/src/daemon/service-types.ts b/src/daemon/service-types.ts index 28d684d67ec..22ced3954d9 100644 --- a/src/daemon/service-types.ts +++ b/src/daemon/service-types.ts @@ -56,4 +56,5 @@ export type GatewayServiceRenderArgs = { programArguments: string[]; workingDirectory?: string; environment?: GatewayServiceEnv; + environmentFiles?: string[]; }; diff --git a/src/daemon/systemd-unit.test.ts b/src/daemon/systemd-unit.test.ts index 9c8a759bc92..59e97873c56 100644 --- a/src/daemon/systemd-unit.test.ts +++ b/src/daemon/systemd-unit.test.ts @@ -38,4 +38,20 @@ describe("buildSystemdUnit", () => { }), ).toThrow(/CR or LF/); }); + + it("renders EnvironmentFile entries before inline Environment values", () => { + const unit = buildSystemdUnit({ + description: "OpenClaw Gateway", + programArguments: ["/usr/bin/openclaw", "gateway", "run"], + environmentFiles: ["/home/test/.openclaw/.env"], + environment: { + OPENCLAW_GATEWAY_PORT: "18789", + }, + }); + expect(unit).toContain("EnvironmentFile=-/home/test/.openclaw/.env"); + expect(unit).toContain("Environment=OPENCLAW_GATEWAY_PORT=18789"); + expect(unit.indexOf("EnvironmentFile=-/home/test/.openclaw/.env")).toBeLessThan( + unit.indexOf("Environment=OPENCLAW_GATEWAY_PORT=18789"), + ); + }); }); diff --git a/src/daemon/systemd-unit.ts b/src/daemon/systemd-unit.ts index d1ac77c1afa..2d248a6ff78 100644 --- a/src/daemon/systemd-unit.ts +++ b/src/daemon/systemd-unit.ts @@ -35,11 +35,25 @@ function renderEnvLines(env: Record | undefined): st }); } +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"; @@ -49,6 +63,7 @@ export function buildSystemdUnit({ ? `WorkingDirectory=${systemdEscapeArg(workingDirectory)}` : null; const envLines = renderEnvLines(environment); + const environmentFileLines = renderEnvironmentFileLines(environmentFiles); return [ "[Unit]", descriptionLine, @@ -69,6 +84,7 @@ export function buildSystemdUnit({ // orphan ACP/runtime workers behind. "KillMode=control-group", workingDirLine, + ...environmentFileLines, ...envLines, "", "[Install]", diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index c411c5b8dd7..ae50ea1c9d3 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import os from "node:os"; +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; const execFileMock = vi.hoisted(() => vi.fn()); @@ -26,6 +27,7 @@ import { readSystemdServiceExecStart, restartSystemdService, resolveSystemdUserUnitPath, + stageSystemdService, stopSystemdService, } from "./systemd.js"; @@ -640,6 +642,116 @@ describe("readSystemdServiceExecStart", () => { }); }); +describe("stageSystemdService", () => { + beforeEach(() => { + vi.restoreAllMocks(); + execFileMock.mockReset(); + }); + + it("writes dotenv-backed values to a separate env file and keeps inline env minimal", async () => { + const tempHomeRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-systemd-stage-")); + const home = path.join(tempHomeRoot, "home"); + const stateDir = path.join(home, ".openclaw"); + const env = { + HOME: home, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_SYSTEMD_UNIT: "openclaw-gateway-stage-test", + }; + const unitPath = resolveSystemdUserUnitPath(env); + const envFilePath = path.join(stateDir, "gateway.systemd.env"); + + await fs.mkdir(stateDir, { recursive: true }); + await fs.writeFile( + path.join(stateDir, ".env"), + ["OPENCLAW_GATEWAY_TOKEN=dotenv-token", "LLM_API_KEY=dotenv-key"].join("\n"), + "utf8", + ); + + execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => { + assertUserSystemctlArgs(args, "status"); + cb(null, "", ""); + }); + + try { + await stageSystemdService({ + env, + stdout: { write: vi.fn() } as unknown as NodeJS.WritableStream, + programArguments: ["/usr/bin/openclaw", "gateway", "run"], + workingDirectory: "/tmp", + environment: { + OPENCLAW_GATEWAY_TOKEN: "dotenv-token", + LLM_API_KEY: "dotenv-key", + OPENCLAW_GATEWAY_PORT: "18789", + }, + }); + + const [unit, envFile, envFileStat] = await Promise.all([ + fs.readFile(unitPath, "utf8"), + fs.readFile(envFilePath, "utf8"), + fs.stat(envFilePath), + ]); + + expect(unit).toContain(`EnvironmentFile=-${envFilePath}`); + expect(unit).toContain("Environment=OPENCLAW_GATEWAY_PORT=18789"); + expect(unit).not.toContain("Environment=OPENCLAW_GATEWAY_TOKEN=dotenv-token"); + expect(unit).not.toContain("Environment=LLM_API_KEY=dotenv-key"); + expect(envFile).toBe("OPENCLAW_GATEWAY_TOKEN=dotenv-token\nLLM_API_KEY=dotenv-key\n"); + expect(envFileStat.mode & 0o777).toBe(0o600); + } finally { + await fs.rm(tempHomeRoot, { recursive: true, force: true }); + } + }); + + it("keeps inline overrides out of the generated env file", async () => { + const tempHomeRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-systemd-stage-")); + const home = path.join(tempHomeRoot, "home"); + const stateDir = path.join(home, ".openclaw"); + const env = { + HOME: home, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_SYSTEMD_UNIT: "openclaw-gateway-stage-test", + }; + const unitPath = resolveSystemdUserUnitPath(env); + const envFilePath = path.join(stateDir, "gateway.systemd.env"); + + await fs.mkdir(stateDir, { recursive: true }); + await fs.writeFile( + path.join(stateDir, ".env"), + ["OPENCLAW_GATEWAY_TOKEN=stale-token", "LLM_API_KEY=dotenv-key"].join("\n"), + "utf8", + ); + + execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => { + assertUserSystemctlArgs(args, "status"); + cb(null, "", ""); + }); + + try { + await stageSystemdService({ + env, + stdout: { write: vi.fn() } as unknown as NodeJS.WritableStream, + programArguments: ["/usr/bin/openclaw", "gateway", "run"], + workingDirectory: "/tmp", + environment: { + OPENCLAW_GATEWAY_TOKEN: "fresh-token", + LLM_API_KEY: "dotenv-key", + }, + }); + + const [unit, envFile] = await Promise.all([ + fs.readFile(unitPath, "utf8"), + fs.readFile(envFilePath, "utf8"), + ]); + + expect(unit).toContain(`EnvironmentFile=-${envFilePath}`); + expect(unit).toContain("Environment=OPENCLAW_GATEWAY_TOKEN=fresh-token"); + expect(envFile).toBe("LLM_API_KEY=dotenv-key\n"); + } finally { + await fs.rm(tempHomeRoot, { recursive: true, force: true }); + } + }); +}); + describe("systemd service control", () => { const assertMachineRestartArgs = (args: string[]) => { assertMachineUserSystemctlArgs(args, "debian", "restart", GATEWAY_SERVICE); diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index c1677e65c63..8156f9e8561 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -1,6 +1,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { resolveStateDir } from "../config/paths.js"; +import { readStateDirDotEnvVarsFromStateDir } from "../config/state-dir-dotenv.js"; import { formatErrorMessage } from "../infra/errors.js"; import { parseStrictInteger, parseStrictPositiveInteger } from "../infra/parse-finite-number.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; @@ -40,6 +42,8 @@ import { parseSystemdExecStart, } from "./systemd-unit.js"; +const SYSTEMD_GATEWAY_DOTENV_FILENAME = "gateway.systemd.env"; + function resolveSystemdUnitPathForName(env: GatewayServiceEnv, name: string): string { const home = toPosixPath(resolveHomeDir(env)); return path.posix.join(home, ".config", "systemd", "user", `${name}.service`); @@ -449,16 +453,65 @@ async function writeSystemdUnit({ } const serviceDescription = resolveGatewayServiceDescription({ env, environment, description }); + const stateDir = resolveStateDir(env as NodeJS.ProcessEnv); + const stateDirDotEnvVars = Object.fromEntries( + Object.entries(readStateDirDotEnvVarsFromStateDir(stateDir)).filter(([key, value]) => { + const inlineValue = environment?.[key]; + if (typeof inlineValue !== "string") { + return true; + } + return inlineValue.trim() === value.trim(); + }), + ); + const environmentFiles = await writeSystemdGatewayEnvironmentFile({ + stateDir, + dotenvVars: stateDirDotEnvVars, + }); + const environmentSansDotEnvEntries = Object.fromEntries( + Object.entries(environment ?? {}).filter(([key, value]) => { + if (typeof value !== "string") { + return false; + } + const stateDirValue = stateDirDotEnvVars[key]; + if (typeof stateDirValue !== "string") { + return true; + } + return value.trim() !== stateDirValue.trim(); + }), + ); const unit = buildSystemdUnit({ description: serviceDescription, programArguments, workingDirectory, - environment, + environment: environmentSansDotEnvEntries, + environmentFiles, }); await fs.writeFile(unitPath, unit, "utf8"); return { unitPath, backedUp }; } +async function writeSystemdGatewayEnvironmentFile(params: { + stateDir: string; + dotenvVars: Record; +}): Promise { + const entries = Object.entries(params.dotenvVars); + if (entries.length === 0) { + return []; + } + for (const [key, value] of entries) { + if (/[\r\n]/.test(value)) { + throw new Error( + `state-dir .env contains a multiline value for ${key}; systemd EnvironmentFile values must be single-line`, + ); + } + } + const envFilePath = path.join(params.stateDir, SYSTEMD_GATEWAY_DOTENV_FILENAME); + const content = entries.map(([key, value]) => `${key}=${value}`).join("\n"); + await fs.writeFile(envFilePath, `${content}\n`, { encoding: "utf8", mode: 0o600 }); + await fs.chmod(envFilePath, 0o600); + return [envFilePath]; +} + export async function stageSystemdService({ stdout, ...args