mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:00:42 +00:00
fix(daemon): surface systemd user-bus hints during gateway install (#72617)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user