diff --git a/src/commands/configure.daemon.test.ts b/src/commands/configure.daemon.test.ts index 800f5d940f8..f24be6c326b 100644 --- a/src/commands/configure.daemon.test.ts +++ b/src/commands/configure.daemon.test.ts @@ -122,4 +122,19 @@ describe("maybeInstallDaemon", () => { expect(serviceInstall).toHaveBeenCalledTimes(1); }); + + 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"), + ); + + await expect( + maybeInstallDaemon({ + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + port: 18789, + }), + ).resolves.toBeUndefined(); + + expect(serviceInstall).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/daemon/systemd-hints.test.ts b/src/daemon/systemd-hints.test.ts new file mode 100644 index 00000000000..314b48b75b8 --- /dev/null +++ b/src/daemon/systemd-hints.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { isSystemdUnavailableDetail, renderSystemdUnavailableHints } from "./systemd-hints.js"; + +describe("isSystemdUnavailableDetail", () => { + it("matches systemd unavailable error details", () => { + expect( + isSystemdUnavailableDetail("systemctl --user unavailable: Failed to connect to bus"), + ).toBe(true); + expect( + isSystemdUnavailableDetail( + "systemctl not available; systemd user services are required on Linux.", + ), + ).toBe(true); + expect(isSystemdUnavailableDetail("permission denied")).toBe(false); + }); +}); + +describe("renderSystemdUnavailableHints", () => { + it("renders WSL2-specific recovery hints", () => { + expect(renderSystemdUnavailableHints({ wsl: true })).toEqual([ + "WSL2 needs systemd enabled: edit /etc/wsl.conf with [boot]\\nsystemd=true", + "Then run: wsl --shutdown (from PowerShell) and reopen your distro.", + "Verify: systemctl --user status", + ]); + }); + + it("renders generic Linux recovery hints outside WSL", () => { + expect(renderSystemdUnavailableHints()).toEqual([ + "systemd user services are unavailable; install/enable systemd or run the gateway under your supervisor.", + "If you're in a container, run the gateway in the foreground instead of `openclaw gateway`.", + ]); + }); +}); diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index b080302a644..984f0111b0c 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import os from "node:os"; import { beforeEach, describe, expect, it, vi } from "vitest"; const execFileMock = vi.hoisted(() => vi.fn()); @@ -150,6 +151,79 @@ describe("isSystemdServiceEnabled", () => { expect(result).toBe(false); }); + it("returns false for the WSL2 Ubuntu 24.04 wrapper-only is-enabled failure", async () => { + const { isSystemdServiceEnabled } = await import("./systemd.js"); + mockManagedUnitPresent(); + execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => { + expect(args).toEqual(["--user", "is-enabled", "openclaw-gateway.service"]); + const err = new Error( + "Command failed: systemctl --user is-enabled openclaw-gateway.service", + ) as Error & { code?: number }; + err.code = 1; + cb(err, "", ""); + }); + + const result = await isSystemdServiceEnabled({ env: { HOME: "/tmp/openclaw-test-home" } }); + expect(result).toBe(false); + }); + + it("returns false when is-enabled cannot connect to the user bus without machine fallback", async () => { + const { isSystemdServiceEnabled } = await import("./systemd.js"); + mockManagedUnitPresent(); + vi.spyOn(os, "userInfo").mockImplementationOnce(() => { + throw new Error("no user info"); + }); + execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => { + expect(args).toEqual(["--user", "is-enabled", "openclaw-gateway.service"]); + cb( + createExecFileError("Failed to connect to bus", { stderr: "Failed to connect to bus" }), + "", + "", + ); + }); + + const result = await isSystemdServiceEnabled({ + env: { HOME: "/tmp/openclaw-test-home", USER: "", LOGNAME: "" }, + }); + expect(result).toBe(false); + }); + + it("returns false when both direct and machine-scope is-enabled checks report bus unavailability", async () => { + const { isSystemdServiceEnabled } = await import("./systemd.js"); + mockManagedUnitPresent(); + execFileMock + .mockImplementationOnce((_cmd, args, _opts, cb) => { + expect(args).toEqual(["--user", "is-enabled", "openclaw-gateway.service"]); + cb( + createExecFileError("Failed to connect to bus", { stderr: "Failed to connect to bus" }), + "", + "", + ); + }) + .mockImplementationOnce((_cmd, args, _opts, cb) => { + expect(args).toEqual([ + "--machine", + "debian@", + "--user", + "is-enabled", + "openclaw-gateway.service", + ]); + cb( + createExecFileError("Failed to connect to user scope bus via local transport", { + stderr: + "Failed to connect to user scope bus via local transport: $DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR not defined", + }), + "", + "", + ); + }); + + const result = await isSystemdServiceEnabled({ + env: { HOME: "/tmp/openclaw-test-home", USER: "debian" }, + }); + expect(result).toBe(false); + }); + it("throws when systemctl is-enabled fails for non-state errors", async () => { const { isSystemdServiceEnabled } = await import("./systemd.js"); mockManagedUnitPresent(); diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 9d8849a2ba5..20d3508f611 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -179,6 +179,34 @@ function isSystemdUnitNotEnabled(detail: string): boolean { ); } +function isSystemctlBusUnavailable(detail: string): boolean { + if (!detail) { + return false; + } + const normalized = detail.toLowerCase(); + return ( + normalized.includes("failed to connect to bus") || + normalized.includes("failed to connect to user scope bus") || + normalized.includes("dbus_session_bus_address") || + normalized.includes("xdg_runtime_dir") || + normalized.includes("no medium found") || + normalized.includes("connection refused") + ); +} + +function isGenericSystemctlIsEnabledFailure(detail: string): boolean { + if (!detail) { + return false; + } + const normalized = detail.toLowerCase().trim(); + return ( + normalized.startsWith("command failed: systemctl") && + normalized.includes(" is-enabled ") && + !normalized.includes("permission denied") && + !normalized.includes("access denied") + ); +} + function resolveSystemctlDirectUserScopeArgs(): string[] { return ["--user"]; } @@ -439,7 +467,12 @@ export async function isSystemdServiceEnabled(args: GatewayServiceEnvArgs): Prom return true; } const detail = readSystemctlDetail(res); - if (isSystemctlMissing(detail) || isSystemdUnitNotEnabled(detail)) { + if ( + isSystemctlMissing(detail) || + isSystemdUnitNotEnabled(detail) || + isSystemctlBusUnavailable(detail) || + isGenericSystemctlIsEnabledFailure(detail) + ) { return false; } throw new Error(`systemctl is-enabled unavailable: ${detail || "unknown error"}`.trim()); diff --git a/src/infra/wsl.test.ts b/src/infra/wsl.test.ts new file mode 100644 index 00000000000..63b7b9544b0 --- /dev/null +++ b/src/infra/wsl.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; + +const readFileSyncMock = vi.hoisted(() => vi.fn()); +const readFileMock = vi.hoisted(() => vi.fn()); + +vi.mock("node:fs", () => ({ + readFileSync: readFileSyncMock, +})); + +vi.mock("node:fs/promises", () => ({ + default: { + readFile: readFileMock, + }, +})); + +const { isWSLEnv, isWSLSync, isWSL2Sync, isWSL, resetWSLStateForTests } = await import("./wsl.js"); + +const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + value: platform, + configurable: true, + }); +} + +describe("wsl detection", () => { + let envSnapshot: ReturnType; + + beforeEach(() => { + envSnapshot = captureEnv(["WSL_INTEROP", "WSL_DISTRO_NAME", "WSLENV"]); + readFileSyncMock.mockReset(); + readFileMock.mockReset(); + resetWSLStateForTests(); + setPlatform("linux"); + }); + + afterEach(() => { + envSnapshot.restore(); + resetWSLStateForTests(); + if (originalPlatformDescriptor) { + Object.defineProperty(process, "platform", originalPlatformDescriptor); + } + }); + + it.each([ + ["WSL_DISTRO_NAME", "Ubuntu"], + ["WSL_INTEROP", "/run/WSL/123_interop"], + ["WSLENV", "PATH/l"], + ])("detects WSL from %s", (key, value) => { + process.env[key] = value; + expect(isWSLEnv()).toBe(true); + }); + + it("reads /proc/version for sync WSL detection when env vars are absent", () => { + readFileSyncMock.mockReturnValueOnce("Linux version 6.6.0-1-microsoft-standard-WSL2"); + expect(isWSLSync()).toBe(true); + expect(readFileSyncMock).toHaveBeenCalledWith("/proc/version", "utf8"); + }); + + it.each(["Linux version 6.6.0-1-microsoft-standard-WSL2", "Linux version 6.6.0-1-wsl2"])( + "detects WSL2 sync from kernel version: %s", + (kernelVersion) => { + readFileSyncMock.mockReturnValueOnce(kernelVersion); + readFileSyncMock.mockReturnValueOnce(kernelVersion); + expect(isWSL2Sync()).toBe(true); + }, + ); + + it("returns false for sync detection on non-linux platforms", () => { + setPlatform("darwin"); + expect(isWSLSync()).toBe(false); + expect(isWSL2Sync()).toBe(false); + expect(readFileSyncMock).not.toHaveBeenCalled(); + }); + + it("caches async WSL detection until reset", async () => { + readFileMock.mockResolvedValue("6.6.0-1-microsoft-standard-WSL2"); + + await expect(isWSL()).resolves.toBe(true); + await expect(isWSL()).resolves.toBe(true); + + expect(readFileMock).toHaveBeenCalledTimes(1); + + resetWSLStateForTests(); + await expect(isWSL()).resolves.toBe(true); + expect(readFileMock).toHaveBeenCalledTimes(2); + }); + + it("returns false when async WSL detection cannot read osrelease", async () => { + readFileMock.mockRejectedValueOnce(new Error("ENOENT")); + await expect(isWSL()).resolves.toBe(false); + }); + + it("returns false for async detection on non-linux platforms without reading osrelease", async () => { + setPlatform("win32"); + await expect(isWSL()).resolves.toBe(false); + expect(readFileMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/infra/wsl.ts b/src/infra/wsl.ts index 25820d611cd..6517ae97a6f 100644 --- a/src/infra/wsl.ts +++ b/src/infra/wsl.ts @@ -3,6 +3,10 @@ import fs from "node:fs/promises"; let wslCached: boolean | null = null; +export function resetWSLStateForTests(): void { + wslCached = null; +} + export function isWSLEnv(): boolean { if (process.env.WSL_INTEROP || process.env.WSL_DISTRO_NAME || process.env.WSLENV) { return true; @@ -48,6 +52,10 @@ export async function isWSL(): Promise { if (wslCached !== null) { return wslCached; } + if (process.platform !== "linux") { + wslCached = false; + return wslCached; + } if (isWSLEnv()) { wslCached = true; return wslCached;