fix(daemon): surface systemd user-bus hints during gateway install (#72617)

This commit is contained in:
Vincent Koc
2026-04-26 23:30:54 -07:00
committed by GitHub
parent dcff28d285
commit b246c06fa5
6 changed files with 124 additions and 7 deletions

View File

@@ -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.

View File

@@ -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<string[] | undefined> {
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;
}

View File

@@ -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.",

View File

@@ -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")
);
}

View File

@@ -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 });

View File

@@ -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);
}
}