From a56841b98c8d55787408ac8ca5de371141fee872 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 7 Mar 2026 19:43:19 -0500 Subject: [PATCH] Daemon: harden WSL2 systemctl install checks (#39294) * Daemon: harden WSL2 systemctl install checks * Changelog: note WSL2 daemon install hardening * Daemon: tighten systemctl failure classification --- CHANGELOG.md | 1 + src/commands/configure.daemon.test.ts | 15 ++++ src/daemon/systemd-hints.test.ts | 33 +++++++++ src/daemon/systemd.test.ts | 91 +++++++++++++++++++++++ src/daemon/systemd.ts | 38 +++++++++- src/infra/wsl.test.ts | 101 ++++++++++++++++++++++++++ src/infra/wsl.ts | 8 ++ 7 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 src/daemon/systemd-hints.test.ts create mode 100644 src/infra/wsl.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 79e5814d490..3dc40220915 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,7 @@ Docs: https://docs.openclaw.ai - Agents/schema cleaning: detect Venice + Grok model IDs as xAI-proxied targets so unsupported JSON Schema keywords are stripped before requests, preventing Venice/Grok `Invalid arguments` failures. (openclaw#35355) thanks @Sid-Qin. - Skills/native command deduplication: centralize skill command dedupe by canonical `skillName` in `listSkillCommandsForAgents` so duplicate suffixed variants (for example `_2`) are no longer surfaced across interfaces outside Discord. (#27521) thanks @shivama205. - Agents/xAI tool-call argument decoding: decode HTML-entity encoded xAI/Grok tool-call argument values (`&`, `"`, `<`, `>`, numeric entities) before tool execution so commands with shell operators and quotes no longer fail with parse errors. (#35276) Thanks @Sid-Qin. +- Linux/WSL2 daemon install hardening: add regression coverage for WSL environment detection, WSL-specific systemd guidance, and `systemctl --user is-enabled` failure paths so WSL2/headless onboarding keeps treating bus-unavailable probes as non-fatal while preserving real permission errors. Related: #36495. Thanks @vincentkoc. - Agents/thinking-tag promotion hardening: guard `promoteThinkingTagsToBlocks` against malformed assistant content entries (`null`/`undefined`) before `block.type` reads so malformed provider payloads no longer crash session processing while preserving pass-through behavior. (#35143) thanks @Sid-Qin. - Gateway/Control UI version reporting: align runtime and browser client version metadata to avoid `dev` placeholders, wait for bootstrap version before first UI websocket connect, and only forward bootstrap `serverVersion` to same-origin gateway targets to prevent cross-target version leakage. (from #35230, #30928, #33928) Thanks @Sid-Qin, @joelnishanth, and @MoerAI. - Control UI/markdown parser crash fallback: catch `marked.parse()` failures and fall back to escaped plain-text `
` rendering so malformed recursive markdown no longer crashes Control UI session rendering on load. (#36445) Thanks @BinHPdev.
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 f5927dab83a..5262ed02cf5 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());
@@ -164,6 +165,96 @@ 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 generic wrapper errors report infrastructure failures", 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, "", "read-only file system");
+    });
+
+    await expect(
+      isSystemdServiceEnabled({ env: { HOME: "/tmp/openclaw-test-home" } }),
+    ).rejects.toThrow("systemctl is-enabled unavailable: read-only file system");
+  });
+
   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 eb649785084..e242a360c70 100644
--- a/src/daemon/systemd.ts
+++ b/src/daemon/systemd.ts
@@ -278,6 +278,37 @@ 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")
+  );
+}
+
+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") &&
+    !normalized.includes("no space left") &&
+    !normalized.includes("read-only file system") &&
+    !normalized.includes("out of memory") &&
+    !normalized.includes("cannot allocate memory")
+  );
+}
+
 function resolveSystemctlDirectUserScopeArgs(): string[] {
   return ["--user"];
 }
@@ -538,7 +569,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;