From 556a74d25914d434ceec892733b5d71a32f03998 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 7 Mar 2026 20:30:48 -0500 Subject: [PATCH] Daemon: handle degraded systemd status checks (#39325) * Daemon: handle degraded systemd status checks * Changelog: note systemd status handling * Update src/commands/status.service-summary.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + src/commands/status-all.ts | 23 ++++---- src/commands/status.command.ts | 4 +- src/commands/status.daemon.ts | 40 ++++++-------- src/commands/status.service-summary.test.ts | 60 +++++++++++++++++++++ src/commands/status.service-summary.ts | 52 ++++++++++++++++++ src/daemon/systemd.test.ts | 48 +++++++++++++++++ src/daemon/systemd.ts | 38 +++++++------ 8 files changed, 211 insertions(+), 55 deletions(-) create mode 100644 src/commands/status.service-summary.test.ts create mode 100644 src/commands/status.service-summary.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fd2a094d248..a49cfd97ff6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,7 @@ Docs: https://docs.openclaw.ai - 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. +- Linux/systemd status and degraded-session handling: treat degraded-but-reachable `systemctl --user status` results as available, preserve early errors for truly unavailable user-bus cases, and report externally managed running services as running instead of `not installed`. 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/status-all.ts b/src/commands/status-all.ts
index 285e0884a43..5adc26327af 100644
--- a/src/commands/status-all.ts
+++ b/src/commands/status-all.ts
@@ -30,6 +30,7 @@ import { buildChannelsTable } from "./status-all/channels.js";
 import { formatDurationPrecise, formatGatewayAuthUsed } from "./status-all/format.js";
 import { pickGatewaySelfPresence } from "./status-all/gateway.js";
 import { buildStatusAllReportLines } from "./status-all/report-lines.js";
+import { readServiceStatusSummary } from "./status.service-summary.js";
 import { formatUpdateOneLiner } from "./status.update.js";
 
 export async function statusAllCommand(
@@ -135,18 +136,14 @@ export async function statusAllCommand(
     progress.setLabel("Checking services…");
     const readServiceSummary = async (service: GatewayService) => {
       try {
-        const [loaded, runtimeInfo, command] = await Promise.all([
-          service.isLoaded({ env: process.env }).catch(() => false),
-          service.readRuntime(process.env).catch(() => undefined),
-          service.readCommand(process.env).catch(() => null),
-        ]);
-        const installed = command != null;
+        const summary = await readServiceStatusSummary(service, service.label);
         return {
-          label: service.label,
-          installed,
-          loaded,
-          loadedText: loaded ? service.loadedText : service.notLoadedText,
-          runtime: runtimeInfo,
+          label: summary.label,
+          installed: summary.installed,
+          managedByOpenClaw: summary.managedByOpenClaw,
+          loaded: summary.loaded,
+          loadedText: summary.loadedText,
+          runtime: summary.runtime,
         };
       } catch {
         return null;
@@ -310,7 +307,7 @@ export async function statusAllCommand(
             Item: "Gateway service",
             Value: !daemon.installed
               ? `${daemon.label} not installed`
-              : `${daemon.label} ${daemon.installed ? "installed · " : ""}${daemon.loadedText}${daemon.runtime?.status ? ` · ${daemon.runtime.status}` : ""}${daemon.runtime?.pid ? ` (pid ${daemon.runtime.pid})` : ""}`,
+              : `${daemon.label} ${daemon.managedByOpenClaw ? "installed · " : ""}${daemon.loadedText}${daemon.runtime?.status ? ` · ${daemon.runtime.status}` : ""}${daemon.runtime?.pid ? ` (pid ${daemon.runtime.pid})` : ""}`,
           }
         : { Item: "Gateway service", Value: "unknown" },
       nodeService
@@ -318,7 +315,7 @@ export async function statusAllCommand(
             Item: "Node service",
             Value: !nodeService.installed
               ? `${nodeService.label} not installed`
-              : `${nodeService.label} ${nodeService.installed ? "installed · " : ""}${nodeService.loadedText}${nodeService.runtime?.status ? ` · ${nodeService.runtime.status}` : ""}${nodeService.runtime?.pid ? ` (pid ${nodeService.runtime.pid})` : ""}`,
+              : `${nodeService.label} ${nodeService.managedByOpenClaw ? "installed · " : ""}${nodeService.loadedText}${nodeService.runtime?.status ? ` · ${nodeService.runtime.status}` : ""}${nodeService.runtime?.pid ? ` (pid ${nodeService.runtime.pid})` : ""}`,
           }
         : { Item: "Node service", Value: "unknown" },
       {
diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts
index 688ddd726dd..97be8c9e412 100644
--- a/src/commands/status.command.ts
+++ b/src/commands/status.command.ts
@@ -302,14 +302,14 @@ export async function statusCommand(
     if (daemon.installed === false) {
       return `${daemon.label} not installed`;
     }
-    const installedPrefix = daemon.installed === true ? "installed · " : "";
+    const installedPrefix = daemon.managedByOpenClaw ? "installed · " : "";
     return `${daemon.label} ${installedPrefix}${daemon.loadedText}${daemon.runtimeShort ? ` · ${daemon.runtimeShort}` : ""}`;
   })();
   const nodeDaemonValue = (() => {
     if (nodeDaemon.installed === false) {
       return `${nodeDaemon.label} not installed`;
     }
-    const installedPrefix = nodeDaemon.installed === true ? "installed · " : "";
+    const installedPrefix = nodeDaemon.managedByOpenClaw ? "installed · " : "";
     return `${nodeDaemon.label} ${installedPrefix}${nodeDaemon.loadedText}${nodeDaemon.runtimeShort ? ` · ${nodeDaemon.runtimeShort}` : ""}`;
   })();
 
diff --git a/src/commands/status.daemon.ts b/src/commands/status.daemon.ts
index af6ee25c120..dcf5487e8ce 100644
--- a/src/commands/status.daemon.ts
+++ b/src/commands/status.daemon.ts
@@ -1,43 +1,37 @@
 import { resolveNodeService } from "../daemon/node-service.js";
-import type { GatewayService } from "../daemon/service.js";
 import { resolveGatewayService } from "../daemon/service.js";
 import { formatDaemonRuntimeShort } from "./status.format.js";
+import { readServiceStatusSummary } from "./status.service-summary.js";
 
 type DaemonStatusSummary = {
   label: string;
   installed: boolean | null;
+  managedByOpenClaw: boolean;
+  externallyManaged: boolean;
   loadedText: string;
   runtimeShort: string | null;
 };
 
 async function buildDaemonStatusSummary(
-  service: GatewayService,
-  fallbackLabel: string,
+  serviceLabel: "gateway" | "node",
 ): Promise {
-  try {
-    const [loaded, runtime, command] = await Promise.all([
-      service.isLoaded({ env: process.env }).catch(() => false),
-      service.readRuntime(process.env).catch(() => undefined),
-      service.readCommand(process.env).catch(() => null),
-    ]);
-    const installed = command != null;
-    const loadedText = loaded ? service.loadedText : service.notLoadedText;
-    const runtimeShort = formatDaemonRuntimeShort(runtime);
-    return { label: service.label, installed, loadedText, runtimeShort };
-  } catch {
-    return {
-      label: fallbackLabel,
-      installed: null,
-      loadedText: "unknown",
-      runtimeShort: null,
-    };
-  }
+  const service = serviceLabel === "gateway" ? resolveGatewayService() : resolveNodeService();
+  const fallbackLabel = serviceLabel === "gateway" ? "Daemon" : "Node";
+  const summary = await readServiceStatusSummary(service, fallbackLabel);
+  return {
+    label: summary.label,
+    installed: summary.installed,
+    managedByOpenClaw: summary.managedByOpenClaw,
+    externallyManaged: summary.externallyManaged,
+    loadedText: summary.loadedText,
+    runtimeShort: formatDaemonRuntimeShort(summary.runtime),
+  };
 }
 
 export async function getDaemonStatusSummary(): Promise {
-  return await buildDaemonStatusSummary(resolveGatewayService(), "Daemon");
+  return await buildDaemonStatusSummary("gateway");
 }
 
 export async function getNodeDaemonStatusSummary(): Promise {
-  return await buildDaemonStatusSummary(resolveNodeService(), "Node");
+  return await buildDaemonStatusSummary("node");
 }
diff --git a/src/commands/status.service-summary.test.ts b/src/commands/status.service-summary.test.ts
new file mode 100644
index 00000000000..fb51d8036e4
--- /dev/null
+++ b/src/commands/status.service-summary.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it, vi } from "vitest";
+import type { GatewayService } from "../daemon/service.js";
+import { readServiceStatusSummary } from "./status.service-summary.js";
+
+function createService(overrides: Partial): GatewayService {
+  return {
+    label: "systemd",
+    loadedText: "enabled",
+    notLoadedText: "disabled",
+    install: vi.fn(async () => {}),
+    uninstall: vi.fn(async () => {}),
+    stop: vi.fn(async () => {}),
+    restart: vi.fn(async () => {}),
+    isLoaded: vi.fn(async () => false),
+    readCommand: vi.fn(async () => null),
+    readRuntime: vi.fn(async () => ({ status: "stopped" as const })),
+    ...overrides,
+  };
+}
+
+describe("readServiceStatusSummary", () => {
+  it("marks OpenClaw-managed services as installed", async () => {
+    const summary = await readServiceStatusSummary(
+      createService({
+        isLoaded: vi.fn(async () => true),
+        readCommand: vi.fn(async () => ({ programArguments: ["openclaw", "gateway", "run"] })),
+        readRuntime: vi.fn(async () => ({ status: "running" })),
+      }),
+      "Daemon",
+    );
+
+    expect(summary.installed).toBe(true);
+    expect(summary.managedByOpenClaw).toBe(true);
+    expect(summary.externallyManaged).toBe(false);
+    expect(summary.loadedText).toBe("enabled");
+  });
+
+  it("marks running unmanaged services as externally managed", async () => {
+    const summary = await readServiceStatusSummary(
+      createService({
+        readRuntime: vi.fn(async () => ({ status: "running" })),
+      }),
+      "Daemon",
+    );
+
+    expect(summary.installed).toBe(true);
+    expect(summary.managedByOpenClaw).toBe(false);
+    expect(summary.externallyManaged).toBe(true);
+    expect(summary.loadedText).toBe("running (externally managed)");
+  });
+
+  it("keeps missing services as not installed when nothing is running", async () => {
+    const summary = await readServiceStatusSummary(createService({}), "Daemon");
+
+    expect(summary.installed).toBe(false);
+    expect(summary.managedByOpenClaw).toBe(false);
+    expect(summary.externallyManaged).toBe(false);
+    expect(summary.loadedText).toBe("disabled");
+  });
+});
diff --git a/src/commands/status.service-summary.ts b/src/commands/status.service-summary.ts
new file mode 100644
index 00000000000..6cfe519222e
--- /dev/null
+++ b/src/commands/status.service-summary.ts
@@ -0,0 +1,52 @@
+import type { GatewayServiceRuntime } from "../daemon/service-runtime.js";
+import type { GatewayService } from "../daemon/service.js";
+
+export type ServiceStatusSummary = {
+  label: string;
+  installed: boolean | null;
+  loaded: boolean;
+  managedByOpenClaw: boolean;
+  externallyManaged: boolean;
+  loadedText: string;
+  runtime: GatewayServiceRuntime | undefined;
+};
+
+export async function readServiceStatusSummary(
+  service: GatewayService,
+  fallbackLabel: string,
+): Promise {
+  try {
+    const [loaded, runtime, command] = await Promise.all([
+      service.isLoaded({ env: process.env }).catch(() => false),
+      service.readRuntime(process.env).catch(() => undefined),
+      service.readCommand(process.env).catch(() => null),
+    ]);
+    const managedByOpenClaw = command != null;
+    const externallyManaged = !managedByOpenClaw && runtime?.status === "running";
+const installed = managedByOpenClaw || externallyManaged;
+    const loadedText = externallyManaged
+      ? "running (externally managed)"
+      : loaded
+        ? service.loadedText
+        : service.notLoadedText;
+    return {
+      label: service.label,
+      installed,
+      loaded,
+      managedByOpenClaw,
+      externallyManaged,
+      loadedText,
+      runtime,
+    };
+  } catch {
+    return {
+      label: fallbackLabel,
+      installed: null,
+      loaded: false,
+      managedByOpenClaw: false,
+      externallyManaged: false,
+      loadedText: "unknown",
+      runtime: undefined,
+    };
+  }
+}
diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts
index 761ac376165..485b2fc5301 100644
--- a/src/daemon/systemd.test.ts
+++ b/src/daemon/systemd.test.ts
@@ -90,6 +90,14 @@ describe("systemd availability", () => {
     await expect(isSystemdUserServiceAvailable()).resolves.toBe(false);
   });
 
+  it("returns true when systemd is degraded but still reachable", async () => {
+    execFileMock.mockImplementation((_cmd, _args, _opts, cb) => {
+      cb(createExecFileError("degraded", { stderr: "degraded\nsome-unit.service failed" }), "", "");
+    });
+
+    await expect(isSystemdUserServiceAvailable()).resolves.toBe(true);
+  });
+
   it("falls back to machine user scope when --user bus is unavailable", async () => {
     execFileMock
       .mockImplementationOnce((_cmd, args, _opts, cb) => {
@@ -631,6 +639,26 @@ describe("systemd service control", () => {
     expect(String(write.mock.calls[0]?.[0])).toContain("Stopped systemd service");
   });
 
+  it("allows stop when systemd status is degraded but available", async () => {
+    execFileMock
+      .mockImplementationOnce((_cmd, _args, _opts, cb) =>
+        cb(
+          createExecFileError("degraded", { stderr: "degraded\nsome-unit.service failed" }),
+          "",
+          "",
+        ),
+      )
+      .mockImplementationOnce((_cmd, args, _opts, cb) => {
+        expect(args).toEqual(["--user", "stop", "openclaw-gateway.service"]);
+        cb(null, "", "");
+      });
+
+    await stopSystemdService({
+      stdout: { write: vi.fn() } as unknown as NodeJS.WritableStream,
+      env: {},
+    });
+  });
+
   it("restarts a profile-specific user unit", async () => {
     execFileMock
       .mockImplementationOnce((_cmd, _args, _opts, cb) => cb(null, "", ""))
@@ -658,6 +686,26 @@ describe("systemd service control", () => {
     ).rejects.toThrow("systemctl stop failed: permission denied");
   });
 
+  it("throws the user-bus error before stop when systemd is unavailable", async () => {
+    vi.spyOn(os, "userInfo").mockImplementationOnce(() => {
+      throw new Error("no user info");
+    });
+    execFileMock.mockImplementationOnce((_cmd, _args, _opts, cb) => {
+      cb(
+        createExecFileError("Failed to connect to bus", { stderr: "Failed to connect to bus" }),
+        "",
+        "",
+      );
+    });
+
+    await expect(
+      stopSystemdService({
+        stdout: { write: vi.fn() } as unknown as NodeJS.WritableStream,
+        env: { USER: "", LOGNAME: "" },
+      }),
+    ).rejects.toThrow("systemctl --user unavailable: Failed to connect to bus");
+  });
+
   it("targets the sudo caller's user scope when SUDO_USER is set", async () => {
     execFileMock
       .mockImplementationOnce((_cmd, args, _opts, cb) => {
diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts
index 889db4a07fc..22161ae3b17 100644
--- a/src/daemon/systemd.ts
+++ b/src/daemon/systemd.ts
@@ -306,6 +306,19 @@ function isSystemctlBusUnavailable(detail: string): boolean {
   );
 }
 
+function isSystemdUserScopeUnavailable(detail: string): boolean {
+  if (!detail) {
+    return false;
+  }
+  const normalized = detail.toLowerCase();
+  return (
+    isSystemctlMissing(normalized) ||
+    isSystemctlBusUnavailable(normalized) ||
+    normalized.includes("not been booted") ||
+    normalized.includes("not supported")
+  );
+}
+
 function isGenericSystemctlIsEnabledFailure(detail: string): boolean {
   if (!detail) {
     return false;
@@ -409,26 +422,11 @@ export async function isSystemdUserServiceAvailable(
   if (res.code === 0) {
     return true;
   }
-  const detail = `${res.stderr} ${res.stdout}`.toLowerCase();
+  const detail = `${res.stderr} ${res.stdout}`.trim();
   if (!detail) {
     return false;
   }
-  if (detail.includes("not found")) {
-    return false;
-  }
-  if (detail.includes("failed to connect")) {
-    return false;
-  }
-  if (detail.includes("not been booted")) {
-    return false;
-  }
-  if (detail.includes("no such file or directory")) {
-    return false;
-  }
-  if (detail.includes("not supported")) {
-    return false;
-  }
-  return false;
+  return !isSystemdUserScopeUnavailable(detail);
 }
 
 async function assertSystemdAvailable(env: GatewayServiceEnv = process.env as GatewayServiceEnv) {
@@ -440,6 +438,12 @@ async function assertSystemdAvailable(env: GatewayServiceEnv = process.env as Ga
   if (isSystemctlMissing(detail)) {
     throw new Error("systemctl not available; systemd user services are required on Linux.");
   }
+  if (!detail) {
+    throw new Error("systemctl --user unavailable: unknown error");
+  }
+  if (!isSystemdUserScopeUnavailable(detail)) {
+    return;
+  }
   throw new Error(`systemctl --user unavailable: ${detail || "unknown error"}`.trim());
 }