From 8293292c5d08ee8dd95cd3f0525ca80790d37b4f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 7 Mar 2026 16:43:51 -0800 Subject: [PATCH] Daemon: scope relaxed systemd probes to install flows --- src/cli/daemon-cli/install.test.ts | 23 ++++++++++++ src/cli/daemon-cli/install.ts | 9 +++-- src/commands/configure.daemon.test.ts | 15 ++++++++ src/commands/configure.daemon.ts | 6 +++- src/daemon/systemd.test.ts | 52 +++++++++++++++++++++------ src/daemon/systemd.ts | 16 +++++---- 6 files changed, 102 insertions(+), 19 deletions(-) diff --git a/src/cli/daemon-cli/install.test.ts b/src/cli/daemon-cli/install.test.ts index 054beebd060..2a948668fab 100644 --- a/src/cli/daemon-cli/install.test.ts +++ b/src/cli/daemon-cli/install.test.ts @@ -256,4 +256,27 @@ describe("runDaemonInstall", () => { expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1); expect(actionState.warnings.some((warning) => warning.includes("Auto-generated"))).toBe(true); }); + + it("continues Linux install when service probe hits a non-fatal systemd bus failure", async () => { + service.isLoaded.mockRejectedValueOnce( + new Error("systemctl is-enabled unavailable: Failed to connect to bus"), + ); + + await runDaemonInstall({ json: true }); + + expect(actionState.failed).toEqual([]); + expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1); + }); + + it("fails install when service probe reports an unrelated error", async () => { + service.isLoaded.mockRejectedValueOnce( + new Error("systemctl is-enabled unavailable: read-only file system"), + ); + + await runDaemonInstall({ json: true }); + + expect(actionState.failed[0]?.message).toContain("Gateway service check failed"); + expect(actionState.failed[0]?.message).toContain("read-only file system"); + expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/cli/daemon-cli/install.ts b/src/cli/daemon-cli/install.ts index a5210b41c1a..fb76bc38002 100644 --- a/src/cli/daemon-cli/install.ts +++ b/src/cli/daemon-cli/install.ts @@ -7,6 +7,7 @@ import { resolveGatewayInstallToken } from "../../commands/gateway-install-token import { loadConfig, resolveGatewayPort } from "../../config/config.js"; import { resolveIsNixMode } from "../../config/paths.js"; import { resolveGatewayService } from "../../daemon/service.js"; +import { isNonFatalSystemdInstallProbeError } from "../../daemon/systemd.js"; import { defaultRuntime } from "../../runtime.js"; import { formatCliCommand } from "../command-format.js"; import { @@ -48,8 +49,12 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { try { loaded = await service.isLoaded({ env: process.env }); } catch (err) { - fail(`Gateway service check failed: ${String(err)}`); - return; + if (isNonFatalSystemdInstallProbeError(err)) { + loaded = false; + } else { + fail(`Gateway service check failed: ${String(err)}`); + return; + } } if (loaded) { if (!opts.force) { diff --git a/src/commands/configure.daemon.test.ts b/src/commands/configure.daemon.test.ts index f24be6c326b..9a7aa76e0c8 100644 --- a/src/commands/configure.daemon.test.ts +++ b/src/commands/configure.daemon.test.ts @@ -123,6 +123,21 @@ describe("maybeInstallDaemon", () => { expect(serviceInstall).toHaveBeenCalledTimes(1); }); + it("rethrows install probe failures that are not the known non-fatal Linux systemd cases", async () => { + serviceIsLoaded.mockRejectedValueOnce( + new Error("systemctl is-enabled unavailable: read-only file system"), + ); + + await expect( + maybeInstallDaemon({ + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + port: 18789, + }), + ).rejects.toThrow("systemctl is-enabled unavailable: read-only file system"); + + expect(serviceInstall).not.toHaveBeenCalled(); + }); + it("continues the WSL2 daemon install flow when service status probe reports systemd unavailability", async () => { serviceIsLoaded.mockRejectedValueOnce( new Error("systemctl --user unavailable: Failed to connect to bus: No medium found"), diff --git a/src/commands/configure.daemon.ts b/src/commands/configure.daemon.ts index ddc5b067f69..4f943982a38 100644 --- a/src/commands/configure.daemon.ts +++ b/src/commands/configure.daemon.ts @@ -1,6 +1,7 @@ import { withProgress } from "../cli/progress.js"; import { loadConfig } from "../config/config.js"; import { resolveGatewayService } from "../daemon/service.js"; +import { isNonFatalSystemdInstallProbeError } from "../daemon/systemd.js"; import type { RuntimeEnv } from "../runtime.js"; import { note } from "../terminal/note.js"; import { confirm, select } from "./configure.shared.js"; @@ -23,7 +24,10 @@ export async function maybeInstallDaemon(params: { let loaded = false; try { loaded = await service.isLoaded({ env: process.env }); - } catch { + } catch (error) { + if (!isNonFatalSystemdInstallProbeError(error)) { + throw error; + } loaded = false; } let shouldCheckLinger = false; diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index 7271823007c..eb9427868d9 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -11,6 +11,7 @@ vi.mock("node:child_process", () => ({ import { splitArgsPreservingQuotes } from "./arg-split.js"; import { parseSystemdExecStart } from "./systemd-unit.js"; import { + isNonFatalSystemdInstallProbeError, isSystemdUserServiceAvailable, parseSystemdShow, restartSystemdService, @@ -163,8 +164,11 @@ describe("isSystemdServiceEnabled", () => { cb(err, "", ""); }); - const result = await isSystemdServiceEnabled({ env: { HOME: "/tmp/openclaw-test-home" } }); - expect(result).toBe(false); + await expect( + isSystemdServiceEnabled({ env: { HOME: "/tmp/openclaw-test-home" } }), + ).rejects.toThrow( + "systemctl is-enabled unavailable: Command failed: systemctl --user is-enabled openclaw-gateway.service", + ); }); it("returns false when is-enabled cannot connect to the user bus without machine fallback", async () => { @@ -182,10 +186,11 @@ describe("isSystemdServiceEnabled", () => { ); }); - const result = await isSystemdServiceEnabled({ - env: { HOME: "/tmp/openclaw-test-home", USER: "", LOGNAME: "" }, - }); - expect(result).toBe(false); + await expect( + isSystemdServiceEnabled({ + env: { HOME: "/tmp/openclaw-test-home", USER: "", LOGNAME: "" }, + }), + ).rejects.toThrow("systemctl is-enabled unavailable: Failed to connect to bus"); }); it("returns false when both direct and machine-scope is-enabled checks report bus unavailability", async () => { @@ -218,10 +223,11 @@ describe("isSystemdServiceEnabled", () => { ); }); - const result = await isSystemdServiceEnabled({ - env: { HOME: "/tmp/openclaw-test-home", USER: "debian" }, - }); - expect(result).toBe(false); + await expect( + isSystemdServiceEnabled({ + env: { HOME: "/tmp/openclaw-test-home", USER: "debian" }, + }), + ).rejects.toThrow("systemctl is-enabled unavailable: Failed to connect to user scope bus"); }); it("throws when generic wrapper errors report infrastructure failures", async () => { @@ -281,6 +287,32 @@ describe("isSystemdServiceEnabled", () => { }); }); +describe("isNonFatalSystemdInstallProbeError", () => { + it("matches wrapper-only WSL install probe failures", () => { + expect( + isNonFatalSystemdInstallProbeError( + new Error("Command failed: systemctl --user is-enabled openclaw-gateway.service"), + ), + ).toBe(true); + }); + + it("matches bus-unavailable install probe failures", () => { + expect( + isNonFatalSystemdInstallProbeError( + new Error("systemctl is-enabled unavailable: Failed to connect to bus"), + ), + ).toBe(true); + }); + + it("does not match real infrastructure failures", () => { + expect( + isNonFatalSystemdInstallProbeError( + new Error("systemctl is-enabled unavailable: read-only file system"), + ), + ).toBe(false); + }); +}); + describe("systemd runtime parsing", () => { it("parses active state details", () => { const output = [ diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 463e6a49f4b..8d7f14331f2 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -210,6 +210,15 @@ function isGenericSystemctlIsEnabledFailure(detail: string): boolean { ); } +export function isNonFatalSystemdInstallProbeError(error: unknown): boolean { + const detail = error instanceof Error ? error.message : typeof error === "string" ? error : ""; + if (!detail) { + return false; + } + const normalized = detail.toLowerCase(); + return isSystemctlBusUnavailable(normalized) || isGenericSystemctlIsEnabledFailure(normalized); +} + function resolveSystemctlDirectUserScopeArgs(): string[] { return ["--user"]; } @@ -470,12 +479,7 @@ export async function isSystemdServiceEnabled(args: GatewayServiceEnvArgs): Prom return true; } const detail = readSystemctlDetail(res); - if ( - isSystemctlMissing(detail) || - isSystemdUnitNotEnabled(detail) || - isSystemctlBusUnavailable(detail) || - isGenericSystemctlIsEnabledFailure(detail) - ) { + if (isSystemctlMissing(detail) || isSystemdUnitNotEnabled(detail)) { return false; } throw new Error(`systemctl is-enabled unavailable: ${detail || "unknown error"}`.trim());