From 26c979673664cccff6cc1ab17110ec244c51b2e4 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sat, 7 Mar 2026 17:10:48 +0530 Subject: [PATCH] fix: check managed systemd unit before is-enabled (#38819) --- CHANGELOG.md | 1 + src/daemon/systemd.test.ts | 37 ++++++++++++++++++++++++++++++------- src/daemon/systemd.ts | 11 ++++++++++- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9305f490e67..2dc17c5dcfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -228,6 +228,7 @@ Docs: https://docs.openclaw.ai - Agents/reply MEDIA delivery: normalize local assistant `MEDIA:` paths before block/final delivery, keep media dedupe aligned with message-tool sends, and contain malformed media normalization failures so generated files send reliably instead of falling back to empty responses. (#38572) Thanks @obviyus. - Sessions/bootstrap cache rollover invalidation: clear cached workspace bootstrap snapshots whenever an existing `sessionKey` rolls to a new `sessionId` across auto-reply, command, and isolated cron session resolvers, so `AGENTS.md`/`MEMORY.md`/`USER.md` updates are reloaded after daily, idle, or forced session resets instead of staying stale until gateway restart. (#38494) Thanks @LivingInDrm. - Gateway/Telegram polling health monitor: skip stale-socket restarts for Telegram long-polling channels and thread channel identity through shared health evaluation so polling connections are not restarted on the WebSocket stale-socket heuristic. (#38395) Thanks @ql-wade and @Takhoffman. +- Daemon/systemd fresh-install probe: check for OpenClaw's managed user unit before running `systemctl --user is-enabled`, so first-time Linux installs no longer fail on generic missing-unit probe errors. (#38819) Thanks @adaHubble. ## 2026.3.2 diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index 71bfef54d6d..9fc8283b84a 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -1,3 +1,4 @@ +import fs from "node:fs/promises"; import { beforeEach, describe, expect, it, vi } from "vitest"; const execFileMock = vi.hoisted(() => vi.fn()); @@ -66,44 +67,65 @@ describe("systemd availability", () => { }); describe("isSystemdServiceEnabled", () => { + const mockManagedUnitPresent = () => { + vi.spyOn(fs, "access").mockResolvedValue(undefined); + }; + beforeEach(() => { + vi.restoreAllMocks(); execFileMock.mockReset(); }); it("returns false when systemctl is not present", async () => { const { isSystemdServiceEnabled } = await import("./systemd.js"); + mockManagedUnitPresent(); execFileMock.mockImplementation((_cmd, _args, _opts, cb) => { const err = new Error("spawn systemctl EACCES") as Error & { code?: string }; err.code = "EACCES"; cb(err, "", ""); }); - const result = await isSystemdServiceEnabled({ env: {} }); + const result = await isSystemdServiceEnabled({ env: { HOME: "/tmp/openclaw-test-home" } }); expect(result).toBe(false); }); + it("returns false without calling systemctl when the managed unit file is missing", async () => { + const { isSystemdServiceEnabled } = await import("./systemd.js"); + const err = new Error("missing unit") as NodeJS.ErrnoException; + err.code = "ENOENT"; + vi.spyOn(fs, "access").mockRejectedValueOnce(err); + + const result = await isSystemdServiceEnabled({ env: { HOME: "/tmp/openclaw-test-home" } }); + + expect(result).toBe(false); + expect(execFileMock).not.toHaveBeenCalled(); + }); + it("calls systemctl is-enabled when systemctl is present", async () => { const { isSystemdServiceEnabled } = await import("./systemd.js"); + mockManagedUnitPresent(); execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => { expect(args).toEqual(["--user", "is-enabled", "openclaw-gateway.service"]); cb(null, "enabled", ""); }); - const result = await isSystemdServiceEnabled({ env: {} }); + const result = await isSystemdServiceEnabled({ env: { HOME: "/tmp/openclaw-test-home" } }); expect(result).toBe(true); }); it("returns false when systemctl reports disabled", async () => { const { isSystemdServiceEnabled } = await import("./systemd.js"); + mockManagedUnitPresent(); execFileMock.mockImplementationOnce((_cmd, _args, _opts, cb) => { const err = new Error("disabled") as Error & { code?: number }; err.code = 1; cb(err, "disabled", ""); }); - const result = await isSystemdServiceEnabled({ env: {} }); + const result = await isSystemdServiceEnabled({ env: { HOME: "/tmp/openclaw-test-home" } }); expect(result).toBe(false); }); it("throws when systemctl is-enabled fails for non-state errors", async () => { const { isSystemdServiceEnabled } = await import("./systemd.js"); + mockManagedUnitPresent(); execFileMock .mockImplementationOnce((_cmd, args, _opts, cb) => { expect(args).toEqual(["--user", "is-enabled", "openclaw-gateway.service"]); @@ -119,13 +141,14 @@ describe("isSystemdServiceEnabled", () => { err.code = 1; cb(err, "", "permission denied"); }); - await expect(isSystemdServiceEnabled({ env: {} })).rejects.toThrow( - "systemctl is-enabled unavailable: permission denied", - ); + await expect( + isSystemdServiceEnabled({ env: { HOME: "/tmp/openclaw-test-home" } }), + ).rejects.toThrow("systemctl is-enabled unavailable: permission denied"); }); it("returns false when systemctl is-enabled exits with code 4 (not-found)", async () => { const { isSystemdServiceEnabled } = await import("./systemd.js"); + mockManagedUnitPresent(); execFileMock.mockImplementationOnce((_cmd, _args, _opts, cb) => { // On Ubuntu 24.04, `systemctl --user is-enabled ` exits with // code 4 and prints "not-found" to stdout when the unit doesn't exist. @@ -135,7 +158,7 @@ describe("isSystemdServiceEnabled", () => { err.code = 4; cb(err, "not-found\n", ""); }); - const result = await isSystemdServiceEnabled({ env: {} }); + const result = await isSystemdServiceEnabled({ env: { HOME: "/tmp/openclaw-test-home" } }); expect(result).toBe(false); }); }); diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 08353048c59..9d8849a2ba5 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -423,7 +423,16 @@ export async function restartSystemdService({ export async function isSystemdServiceEnabled(args: GatewayServiceEnvArgs): Promise { const env = args.env ?? process.env; - const serviceName = resolveSystemdServiceName(args.env ?? {}); + try { + await fs.access(resolveSystemdUnitPath(env)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return false; + } + throw error; + } + + const serviceName = resolveSystemdServiceName(env); const unitName = `${serviceName}.service`; const res = await execSystemctlUser(env, ["is-enabled", unitName]); if (res.code === 0) {