mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 10:57:40 +00:00
Daemon: harden WSL2 systemctl install checks (#39294)
* Daemon: harden WSL2 systemctl install checks * Changelog: note WSL2 daemon install hardening * Daemon: tighten systemctl failure classification
This commit is contained in:
33
src/daemon/systemd-hints.test.ts
Normal file
33
src/daemon/systemd-hints.test.ts
Normal file
@@ -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`.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user