From eca9f468246e7451ab676f844818ea43da414d2a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 06:34:40 +0100 Subject: [PATCH] fix: honor node systemd unit activation --- CHANGELOG.md | 1 + src/daemon/systemd.test.ts | 92 ++++++++++++++++++++++++++++++++++++++ src/daemon/systemd.ts | 4 +- 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a1f6afa2a4..a83cca4aceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,7 @@ Docs: https://docs.openclaw.ai - Docker: copy patched dependency files into runtime images so downstream `pnpm install` layers keep working. Fixes #69224. Thanks @gucasbrg. - Agents/runtime: submit heartbeat, cron, and exec wakeups as transient runtime context instead of visible user prompts, keeping synthetic system work out of chat transcripts. Fixes #66496 and #66814. Thanks @jeades and @mandomaker. - Telegram: include native quote excerpts automatically for threaded replies and reply tags when the original Telegram text is available, without adding another config knob. Fixes #6975. Thanks @rex05ai. +- Node/Linux: make `openclaw node install` enable and restart the `openclaw-node` systemd unit instead of the gateway unit on node-only VMs. Fixes #68287. Thanks @dlebee-agent. - Telegram: preserve exact selected quote text when sending native quote replies, and retry with legacy replies if Telegram rejects quote parameters. (#71952) Thanks @rubencu. - Plugins/CLI: preserve manifest name, description, format, and source metadata in cold `openclaw plugins list` output without importing plugin runtime. Thanks @shakkernerd. - Security/audit: read channel exposure and plugin allowlist ownership from read-only plugin index metadata so cold audits do not depend on loaded channel runtime. Thanks @shakkernerd. diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index d02d4e93723..bcbd4fa702a 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -17,6 +17,7 @@ vi.mock("node:child_process", async () => { import { splitArgsPreservingQuotes } from "./arg-split.js"; import { parseSystemdExecStart } from "./systemd-unit.js"; import { + installSystemdService, isNonFatalSystemdInstallProbeError, isSystemdServiceEnabled, isSystemdUserServiceAvailable, @@ -26,6 +27,7 @@ import { resolveSystemdUserUnitPath, stageSystemdService, stopSystemdService, + uninstallSystemdService, } from "./systemd.js"; type ExecFileError = Error & { @@ -36,6 +38,7 @@ type ExecFileError = Error & { const TEST_SERVICE_HOME = "/home/test"; const TEST_MANAGED_HOME = "/tmp/openclaw-test-home"; const GATEWAY_SERVICE = "openclaw-gateway.service"; +const NODE_SERVICE = "openclaw-node.service"; const createExecFileError = ( message: string, @@ -749,6 +752,95 @@ describe("stageSystemdService", () => { }); }); +describe("systemd service install and uninstall", () => { + async function withNodeSystemdFixture( + run: (context: { env: Record; unitPath: string }) => Promise, + ): Promise { + const tempHomeRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-node-systemd-")); + const home = path.join(tempHomeRoot, "home"); + const stateDir = path.join(home, ".openclaw"); + const env = { + HOME: home, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_SYSTEMD_UNIT: "openclaw-node", + }; + const unitPath = resolveSystemdUserUnitPath(env); + + try { + await fs.mkdir(stateDir, { recursive: true }); + await run({ env, unitPath }); + } finally { + await fs.rm(tempHomeRoot, { recursive: true, force: true }); + } + } + + beforeEach(() => { + vi.restoreAllMocks(); + execFileMock.mockReset(); + }); + + it("activates the OPENCLAW_SYSTEMD_UNIT override during install", async () => { + await withNodeSystemdFixture(async ({ env, unitPath }) => { + execFileMock + .mockImplementationOnce((_cmd, args, _opts, cb) => { + assertUserSystemctlArgs(args, "status"); + cb(null, "", ""); + }) + .mockImplementationOnce((_cmd, args, _opts, cb) => { + assertUserSystemctlArgs(args, "daemon-reload"); + cb(null, "", ""); + }) + .mockImplementationOnce((_cmd, args, _opts, cb) => { + assertUserSystemctlArgs(args, "enable", NODE_SERVICE); + cb(null, "", ""); + }) + .mockImplementationOnce((_cmd, args, _opts, cb) => { + assertUserSystemctlArgs(args, "restart", NODE_SERVICE); + cb(null, "", ""); + }); + + await installSystemdService({ + env, + stdout: { write: vi.fn() } as unknown as NodeJS.WritableStream, + programArguments: ["/usr/bin/openclaw", "node", "run"], + workingDirectory: "/tmp", + environment: { + OPENCLAW_SYSTEMD_UNIT: "openclaw-node", + }, + }); + + const unit = await fs.readFile(unitPath, "utf8"); + expect(unitPath).toMatch(/openclaw-node\.service$/); + expect(unit).toContain("openclaw node run"); + expect(execFileMock).toHaveBeenCalledTimes(4); + }); + }); + + it("disables the OPENCLAW_SYSTEMD_UNIT override during uninstall", async () => { + await withNodeSystemdFixture(async ({ env, unitPath }) => { + await fs.mkdir(path.dirname(unitPath), { recursive: true }); + await fs.writeFile(unitPath, "[Unit]\nDescription=OpenClaw Node\n", "utf8"); + + 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 { write, stdout } = createWritableStreamMock(); + await uninstallSystemdService({ env, stdout }); + + await expect(fs.access(unitPath)).rejects.toMatchObject({ code: "ENOENT" }); + expect(String(write.mock.calls[0]?.[0])).toContain("Removed systemd service"); + expect(execFileMock).toHaveBeenCalledTimes(2); + }); + }); +}); + 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 8156f9e8561..1a300e0f025 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -539,7 +539,7 @@ export async function stageSystemdService({ } async function activateSystemdService(params: { env: GatewayServiceEnv }) { - const serviceName = resolveGatewaySystemdServiceName(params.env.OPENCLAW_PROFILE); + const serviceName = resolveSystemdServiceName(params.env); const unitName = `${serviceName}.service`; const reload = await execSystemctlUser(params.env, ["daemon-reload"]); if (reload.code !== 0) { @@ -588,7 +588,7 @@ export async function uninstallSystemdService({ stdout, }: GatewayServiceManageArgs): Promise { await assertSystemdAvailable(env); - const serviceName = resolveGatewaySystemdServiceName(env.OPENCLAW_PROFILE); + const serviceName = resolveSystemdServiceName(env); const unitName = `${serviceName}.service`; await execSystemctlUser(env, ["disable", "--now", unitName]);