mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
[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 headf626b66c09. - Required merge gates passed before the squash merge. Prepared head SHA:f626b66c09Review: 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:
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
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 = "";
|
||||
|
||||
@@ -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
@@ -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
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user