mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:00:42 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -56,4 +56,5 @@ export type GatewayServiceRenderArgs = {
|
||||
programArguments: string[];
|
||||
workingDirectory?: string;
|
||||
environment?: GatewayServiceEnv;
|
||||
environmentFiles?: string[];
|
||||
};
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user