mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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>
This commit is contained in:
@@ -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 `<pre>` rendering so malformed recursive markdown no longer crashes Control UI session rendering on load. (#36445) Thanks @BinHPdev.
|
||||
|
||||
@@ -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" },
|
||||
{
|
||||
|
||||
@@ -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}` : ""}`;
|
||||
})();
|
||||
|
||||
|
||||
@@ -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<DaemonStatusSummary> {
|
||||
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<DaemonStatusSummary> {
|
||||
return await buildDaemonStatusSummary(resolveGatewayService(), "Daemon");
|
||||
return await buildDaemonStatusSummary("gateway");
|
||||
}
|
||||
|
||||
export async function getNodeDaemonStatusSummary(): Promise<DaemonStatusSummary> {
|
||||
return await buildDaemonStatusSummary(resolveNodeService(), "Node");
|
||||
return await buildDaemonStatusSummary("node");
|
||||
}
|
||||
|
||||
60
src/commands/status.service-summary.test.ts
Normal file
60
src/commands/status.service-summary.test.ts
Normal file
@@ -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>): 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");
|
||||
});
|
||||
});
|
||||
52
src/commands/status.service-summary.ts
Normal file
52
src/commands/status.service-summary.ts
Normal file
@@ -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<ServiceStatusSummary> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user