diff --git a/CHANGELOG.md b/CHANGELOG.md index 51a17bc3d5e..b62c1d95f81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - macOS Gateway: detect installed-but-unloaded LaunchAgent split-brain states during status, doctor, and restart, and re-bootstrap launchd supervision before falling back to unmanaged listener restarts. Fixes #67335, #53475, and #71060; refs #58890, #60885, and #70801. Thanks @ze1tgeist88, @dafacto, and @vishutdhar. - Plugins/install: stage bundled plugin runtime dependencies before Gateway startup, drain update restarts, and materialize plugin-owned root chunks in external mirrors so staged deps resolve under native ESM. Fixes #72058; supersedes #72084. Thanks @amnesia106 and @drvoss. - TTS/SecretRef: resolve `messages.tts.providers.*.apiKey` from the active runtime snapshot so SecretRef-backed MiniMax and other TTS provider keys work in runtime reply/audio paths. Fixes #68690. Thanks @joshavant. +- Gateway/install: surface systemd user-bus recovery hints during Linux service activation and retry via the machine user scope when `systemctl --user` reports no-medium bus failures. Fixes #39673; refs #44417 and #63561. Thanks @Arbor4, @myrsu, and @mssteuer. - CLI/startup: read generated startup metadata from the bundled `dist` layout before falling back to live help rendering, so root/browser help and channel-option bootstrap stay on the fast path. Thanks @vincentkoc. - CLI/help: treat positional `help` invocations like `openclaw channels help` as help paths for startup gating, avoiding model/auth warmup while preserving positional arguments such as `openclaw docs help`. Thanks @gumadeiras. - Web search: route plugin-scoped web_search SecretRefs through the active runtime config snapshot so provider execution receives resolved credentials across app/runtime paths, including `plugins.entries.brave.config.webSearch.apiKey`. Fixes #68690. Thanks @VACInc. diff --git a/src/cli/daemon-cli/response.ts b/src/cli/daemon-cli/response.ts index fe77f1d00d7..e2ea3566974 100644 --- a/src/cli/daemon-cli/response.ts +++ b/src/cli/daemon-cli/response.ts @@ -1,5 +1,11 @@ import { Writable } from "node:stream"; import type { GatewayService } from "../../daemon/service.js"; +import { + isSystemdUnavailableDetail, + renderSystemdUnavailableHints, +} from "../../daemon/systemd-hints.js"; +import { classifySystemdUnavailableDetail } from "../../daemon/systemd-unavailable.js"; +import { isWSL } from "../../infra/wsl.js"; import { defaultRuntime } from "../../runtime.js"; export type DaemonAction = "install" | "uninstall" | "start" | "stop" | "restart"; @@ -132,6 +138,17 @@ export function createDaemonActionContext(params: { action: DaemonAction; json: return { stdout, warnings, emit, fail }; } +async function buildInstallFailureHints(error: unknown): Promise { + const detail = String(error); + if (process.platform !== "linux" || !isSystemdUnavailableDetail(detail)) { + return undefined; + } + return renderSystemdUnavailableHints({ + wsl: await isWSL(), + kind: classifySystemdUnavailableDetail(detail), + }); +} + export async function installDaemonServiceAndEmit(params: { serviceNoun: string; service: GatewayService; @@ -143,7 +160,10 @@ export async function installDaemonServiceAndEmit(params: { try { await params.install(); } catch (err) { - params.fail(`${params.serviceNoun} install failed: ${String(err)}`); + params.fail( + `${params.serviceNoun} install failed: ${String(err)}`, + await buildInstallFailureHints(err), + ); return; } diff --git a/src/daemon/systemd-hints.test.ts b/src/daemon/systemd-hints.test.ts index 08aef124e58..02fa7c1cdad 100644 --- a/src/daemon/systemd-hints.test.ts +++ b/src/daemon/systemd-hints.test.ts @@ -7,6 +7,7 @@ describe("isSystemdUnavailableDetail", () => { expect( isSystemdUnavailableDetail("systemctl --user unavailable: Failed to connect to bus"), ).toBe(true); + expect(isSystemdUnavailableDetail("systemctl --user unavailable: ENOMEDIUM")).toBe(true); expect( isSystemdUnavailableDetail( "systemctl not available; systemd user services are required on Linux.", diff --git a/src/daemon/systemd-unavailable.ts b/src/daemon/systemd-unavailable.ts index 1ce3ef1a751..196e980cc46 100644 --- a/src/daemon/systemd-unavailable.ts +++ b/src/daemon/systemd-unavailable.ts @@ -27,6 +27,7 @@ export function isSystemdUserBusUnavailableDetail(detail?: string): boolean { normalized.includes("failed to connect to user scope bus") || normalized.includes("dbus_session_bus_address") || normalized.includes("xdg_runtime_dir") || + normalized.includes("enomedium") || normalized.includes("no medium found") ); } diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index 4c53a81df88..110da192f60 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -862,6 +862,92 @@ describe("systemd service install and uninstall", () => { }); }); + it("falls back to machine user scope when install activation hits a no-medium user bus failure", async () => { + await withNodeSystemdFixture(async ({ env }) => { + const installEnv = { ...env, USER: "debian" }; + execFileMock + .mockImplementationOnce((_cmd, args, _opts, cb) => { + assertUserSystemctlArgs(args, "status"); + cb(null, "", ""); + }) + .mockImplementationOnce((_cmd, args, _opts, cb) => { + assertUserSystemctlArgs(args, "daemon-reload"); + cb(null, "", ""); + }) + .mockImplementationOnce((_cmd, args, _opts, cb) => { + assertUserSystemctlArgs(args, "enable", NODE_SERVICE); + cb( + createExecFileError("Failed to connect to bus: No medium found", { + stderr: "Failed to connect to bus: No medium found", + }), + "", + "", + ); + }) + .mockImplementationOnce((_cmd, args, _opts, cb) => { + assertMachineUserSystemctlArgs(args, "debian", "enable", NODE_SERVICE); + cb(null, "", ""); + }) + .mockImplementationOnce((_cmd, args, _opts, cb) => { + assertUserSystemctlArgs(args, "restart", NODE_SERVICE); + cb(null, "", ""); + }); + + await installSystemdService({ + env: installEnv, + stdout: { write: vi.fn() } as unknown as NodeJS.WritableStream, + programArguments: ["/usr/bin/openclaw", "node", "run"], + workingDirectory: "/tmp", + environment: { + OPENCLAW_SYSTEMD_UNIT: "openclaw-node", + }, + }); + + expect(execFileMock).toHaveBeenCalledTimes(5); + }); + }); + + it("surfaces install activation user-bus failures as systemd unavailable errors", async () => { + await withNodeSystemdFixture(async ({ env }) => { + vi.spyOn(os, "userInfo").mockImplementation(() => { + throw new Error("no user info"); + }); + execFileMock + .mockImplementationOnce((_cmd, args, _opts, cb) => { + assertUserSystemctlArgs(args, "status"); + cb(null, "", ""); + }) + .mockImplementationOnce((_cmd, args, _opts, cb) => { + assertUserSystemctlArgs(args, "daemon-reload"); + cb(null, "", ""); + }) + .mockImplementationOnce((_cmd, args, _opts, cb) => { + assertUserSystemctlArgs(args, "enable", NODE_SERVICE); + cb( + createExecFileError("Failed to connect to bus: No medium found", { + stderr: "Failed to connect to bus: No medium found", + }), + "", + "", + ); + }); + + await expect( + installSystemdService({ + env, + stdout: { write: vi.fn() } as unknown as NodeJS.WritableStream, + programArguments: ["/usr/bin/openclaw", "node", "run"], + workingDirectory: "/tmp", + environment: { + OPENCLAW_SYSTEMD_UNIT: "openclaw-node", + }, + }), + ).rejects.toThrow("systemctl --user unavailable: Failed to connect to bus: No medium found"); + + expect(execFileMock).toHaveBeenCalledTimes(3); + }); + }); + it("disables the OPENCLAW_SYSTEMD_UNIT override during uninstall", async () => { await withNodeSystemdFixture(async ({ env, unitPath }) => { await fs.mkdir(path.dirname(unitPath), { recursive: true }); diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index ad75ed6f599..26edba280e2 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -554,9 +554,19 @@ async function activateSystemdService(params: { env: GatewayServiceEnv }) { const serviceName = resolveSystemdServiceName(params.env); const unitName = `${serviceName}.service`; const reloadSystemd = async () => await execSystemctlUser(params.env, ["daemon-reload"]); + const throwActivationFailure = ( + action: "daemon-reload" | "enable" | "restart", + result: { stdout: string; stderr: string }, + ): never => { + const detail = readSystemctlDetail(result); + if (isSystemdUserScopeUnavailable(detail)) { + throw new Error(`systemctl --user unavailable: ${detail || "unknown error"}`.trim()); + } + throw new Error(`systemctl ${action} failed: ${detail || "unknown error"}`.trim()); + }; const reload = await reloadSystemd(); if (reload.code !== 0) { - throw new Error(`systemctl daemon-reload failed: ${reload.stderr || reload.stdout}`.trim()); + throwActivationFailure("daemon-reload", reload); } const runAfterReloadRetry = async (action: "enable" | "restart") => { @@ -566,21 +576,19 @@ async function activateSystemdService(params: { env: GatewayServiceEnv }) { } const retryReload = await reloadSystemd(); if (retryReload.code !== 0) { - throw new Error( - `systemctl daemon-reload failed: ${retryReload.stderr || retryReload.stdout}`.trim(), - ); + throwActivationFailure("daemon-reload", retryReload); } return await execSystemctlUser(params.env, [action, unitName]); }; const enable = await runAfterReloadRetry("enable"); if (enable.code !== 0) { - throw new Error(`systemctl enable failed: ${enable.stderr || enable.stdout}`.trim()); + throwActivationFailure("enable", enable); } const restart = await runAfterReloadRetry("restart"); if (restart.code !== 0) { - throw new Error(`systemctl restart failed: ${restart.stderr || restart.stdout}`.trim()); + throwActivationFailure("restart", restart); } }