fix: honor node systemd unit activation

This commit is contained in:
Peter Steinberger
2026-04-26 06:34:40 +01:00
parent 33b6962273
commit eca9f46824
3 changed files with 95 additions and 2 deletions

View File

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

View File

@@ -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<string, string>; unitPath: string }) => Promise<void>,
): Promise<void> {
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);

View File

@@ -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<void> {
await assertSystemdAvailable(env);
const serviceName = resolveGatewaySystemdServiceName(env.OPENCLAW_PROFILE);
const serviceName = resolveSystemdServiceName(env);
const unitName = `${serviceName}.service`;
await execSystemctlUser(env, ["disable", "--now", unitName]);