fix: avoid inline dotenv secrets in systemd unit during service repair (#66249) (thanks @tmimmanuel)

* fix(daemon): avoid inline dotenv secrets in systemd unit during service repair

* fix(daemon): sanitize systemd envfile and dedupe state-dir resolution

* fix(daemon): fail on multiline dotenv values for systemd envfile

* test(daemon): cover systemd envfile staging

* fix: keep systemd envfile overrides intact (#66249) (thanks @tmimmanuel)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
tmimmanuel
2026-04-14 05:36:10 +02:00
committed by GitHub
parent 3c501d3554
commit a2ab9e6a8e
7 changed files with 222 additions and 19 deletions

View File

@@ -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

View File

@@ -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<string, string | undefined>,
): Record<string, string> {
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<string, string> {
const parsed = dotenv.parse(content);
const entries: Record<string, string> = {};
for (const [rawKey, value] of Object.entries(parsed)) {
@@ -50,6 +33,27 @@ export function readStateDirDotEnvVars(
return entries;
}
export function readStateDirDotEnvVarsFromStateDir(stateDir: string): Record<string, string> {
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<string, string | undefined>,
): Record<string, string> {
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.

View File

@@ -56,4 +56,5 @@ export type GatewayServiceRenderArgs = {
programArguments: string[];
workingDirectory?: string;
environment?: GatewayServiceEnv;
environmentFiles?: string[];
};

View File

@@ -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"),
);
});
});

View File

@@ -35,11 +35,25 @@ function renderEnvLines(env: Record<string, string | undefined> | 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]",

View File

@@ -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);

View File

@@ -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<string, string>;
}): Promise<string[]> {
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