diff --git a/CHANGELOG.md b/CHANGELOG.md index 56a03c0c3e8..30e6a8d5d55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Docker/Image health checks: add Dockerfile `HEALTHCHECK` that probes gateway `GET /healthz` so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc. +- Daemon/systemd checks in containers: treat missing `systemctl` invocations (including `spawn systemctl ENOENT`/`EACCES`) as unavailable service state during `is-enabled` checks, preventing container flows from failing with `Gateway service check failed` before install/status handling can continue. (#26089) Thanks @sahilsatralkar and @vincentkoc. - Android/Nodes reliability: reject `facing=both` when `deviceId` is set to avoid mislabeled duplicate captures, allow notification `open`/`reply` on non-clearable entries while still gating dismiss, trigger listener rebind before notification actions, and scale invoke-result ack timeout to invoke budget for large clip payloads. (#28260) Thanks @obviyus. - Windows/Plugin install: avoid `spawn EINVAL` on Windows npm/npx invocations by resolving to `node` + npm CLI scripts instead of spawning `.cmd` directly. Landed from contributor PR #31147 by @codertony. Thanks @codertony. - LINE/Voice transcription: classify M4A voice media as `audio/mp4` (not `video/mp4`) by checking the MPEG-4 `ftyp` major brand (`M4A ` / `M4B `), restoring voice transcription for LINE voice messages. Landed from contributor PR #31151 by @scoootscooob. Thanks @scoootscooob. diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index d31be31e720..cfaf223c91d 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -42,6 +42,56 @@ describe("systemd availability", () => { }); }); +describe("isSystemdServiceEnabled", () => { + beforeEach(() => { + execFileMock.mockClear(); + }); + + it("returns false when systemctl is not present", async () => { + const { isSystemdServiceEnabled } = await import("./systemd.js"); + 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: {} }); + expect(result).toBe(false); + }); + + it("calls systemctl is-enabled when systemctl is present", async () => { + const { isSystemdServiceEnabled } = await import("./systemd.js"); + execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => { + expect(args).toEqual(["--user", "is-enabled", "openclaw-gateway.service"]); + cb(null, "enabled", ""); + }); + const result = await isSystemdServiceEnabled({ env: {} }); + expect(result).toBe(true); + }); + + it("returns false when systemctl reports disabled", async () => { + const { isSystemdServiceEnabled } = await import("./systemd.js"); + 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: {} }); + expect(result).toBe(false); + }); + + it("throws when systemctl is-enabled fails for non-state errors", async () => { + const { isSystemdServiceEnabled } = await import("./systemd.js"); + execFileMock.mockImplementationOnce((_cmd, _args, _opts, cb) => { + const err = new Error("Failed to connect to bus") as Error & { code?: number }; + err.code = 1; + cb(err, "", "Failed to connect to bus"); + }); + await expect(isSystemdServiceEnabled({ env: {} })).rejects.toThrow( + "systemctl is-enabled unavailable: Failed to connect to bus", + ); + }); +}); + describe("systemd runtime parsing", () => { it("parses active state details", () => { const output = [ diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 0e1dc5541ba..9f073d382e6 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -142,6 +142,39 @@ async function execSystemctl( return await execFileUtf8("systemctl", args); } +function readSystemctlDetail(result: { stdout: string; stderr: string }): string { + return (result.stderr || result.stdout || "").trim(); +} + +function isSystemctlMissing(detail: string): boolean { + if (!detail) { + return false; + } + const normalized = detail.toLowerCase(); + return ( + normalized.includes("not found") || + normalized.includes("no such file or directory") || + normalized.includes("spawn systemctl enoent") || + normalized.includes("spawn systemctl eacces") + ); +} + +function isSystemdUnitNotEnabled(detail: string): boolean { + if (!detail) { + return false; + } + const normalized = detail.toLowerCase(); + return ( + normalized.includes("disabled") || + normalized.includes("static") || + normalized.includes("indirect") || + normalized.includes("masked") || + normalized.includes("not-found") || + normalized.includes("could not be found") || + normalized.includes("failed to get unit file state") + ); +} + export async function isSystemdUserServiceAvailable(): Promise { const res = await execSystemctl(["--user", "status"]); if (res.code === 0) { @@ -174,8 +207,8 @@ async function assertSystemdAvailable() { if (res.code === 0) { return; } - const detail = res.stderr || res.stdout; - if (detail.toLowerCase().includes("not found")) { + const detail = readSystemctlDetail(res); + if (isSystemctlMissing(detail)) { throw new Error("systemctl not available; systemd user services are required on Linux."); } throw new Error(`systemctl --user unavailable: ${detail || "unknown error"}`.trim()); @@ -312,11 +345,17 @@ export async function restartSystemdService({ } export async function isSystemdServiceEnabled(args: GatewayServiceEnvArgs): Promise { - await assertSystemdAvailable(); const serviceName = resolveSystemdServiceName(args.env ?? {}); const unitName = `${serviceName}.service`; const res = await execSystemctl(["--user", "is-enabled", unitName]); - return res.code === 0; + if (res.code === 0) { + return true; + } + const detail = readSystemctlDetail(res); + if (isSystemctlMissing(detail) || isSystemdUnitNotEnabled(detail)) { + return false; + } + throw new Error(`systemctl is-enabled unavailable: ${detail || "unknown error"}`.trim()); } export async function readSystemdServiceRuntime( @@ -327,7 +366,7 @@ export async function readSystemdServiceRuntime( } catch (err) { return { status: "unknown", - detail: String(err), + detail: err instanceof Error ? err.message : String(err), }; } const serviceName = resolveSystemdServiceName(env); @@ -373,8 +412,7 @@ async function isSystemctlAvailable(): Promise { if (res.code === 0) { return true; } - const detail = (res.stderr || res.stdout).toLowerCase(); - return !detail.includes("not found"); + return !isSystemctlMissing(readSystemctlDetail(res)); } export async function findLegacySystemdUnits(env: GatewayServiceEnv): Promise {