diff --git a/CHANGELOG.md b/CHANGELOG.md index a9f09e72aa2..466dbdc09d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ Docs: https://docs.openclaw.ai - Security/exec approvals: unwrap `env` dispatch wrappers inside shell-segment allowlist resolution on macOS so `env FOO=bar /path/to/bin` resolves against the effective executable instead of the wrapper token. - Security/exec approvals: treat backslash-newline as shell line continuation during macOS shell-chain parsing so line-continued `$(` substitutions fail closed instead of slipping past command-substitution checks. - Security/exec approvals: bind macOS skill auto-allow trust to both executable name and resolved path so same-basename binaries no longer inherit trust from unrelated skill bins. +- Gateway/status: add `openclaw gateway status --require-rpc` and clearer Linux non-interactive daemon-install failure reporting so automation can fail hard on probe misses instead of treating a printed RPC error as green. - Dashboard/chat UI: restore the `chat-new-messages` class on the New messages scroll pill so the button uses its existing compact styling instead of rendering as a full-screen SVG overlay. (#44856) Thanks @Astro-Han. - Build/plugin-sdk bundling: bundle plugin-sdk subpath entries in one shared build pass so published packages stop duplicating shared chunks and avoid the recent plugin-sdk memory blow-up. (#45426) Thanks @TarasShyn. - Cron/isolated sessions: route nested cron-triggered embedded runner work onto the nested lane so isolated cron jobs no longer deadlock when compaction or other queued inner work runs. Thanks @vincentkoc. diff --git a/src/cli/daemon-cli/register-service-commands.test.ts b/src/cli/daemon-cli/register-service-commands.test.ts index e249b00c835..64a1e24589b 100644 --- a/src/cli/daemon-cli/register-service-commands.test.ts +++ b/src/cli/daemon-cli/register-service-commands.test.ts @@ -67,6 +67,17 @@ describe("addGatewayServiceCommands", () => { ); }, }, + { + name: "forwards require-rpc for status", + argv: ["status", "--require-rpc"], + assert: () => { + expect(runDaemonStatus).toHaveBeenCalledWith( + expect.objectContaining({ + requireRpc: true, + }), + ); + }, + }, ])("$name", async ({ argv, assert }) => { const gateway = createGatewayParentLikeCommand(); await gateway.parseAsync(argv, { from: "user" }); diff --git a/src/cli/daemon-cli/register-service-commands.ts b/src/cli/daemon-cli/register-service-commands.ts index 5d4ce0a9c28..2690eb91d7f 100644 --- a/src/cli/daemon-cli/register-service-commands.ts +++ b/src/cli/daemon-cli/register-service-commands.ts @@ -44,12 +44,14 @@ export function addGatewayServiceCommands(parent: Command, opts?: { statusDescri .option("--password ", "Gateway password (password auth)") .option("--timeout ", "Timeout in ms", "10000") .option("--no-probe", "Skip RPC probe") + .option("--require-rpc", "Exit non-zero when the RPC probe fails", false) .option("--deep", "Scan system-level services", false) .option("--json", "Output JSON", false) .action(async (cmdOpts, command) => { await runDaemonStatus({ rpc: resolveRpcOptions(cmdOpts, command), probe: Boolean(cmdOpts.probe), + requireRpc: Boolean(cmdOpts.requireRpc), deep: Boolean(cmdOpts.deep), json: Boolean(cmdOpts.json), }); diff --git a/src/cli/daemon-cli/status.test.ts b/src/cli/daemon-cli/status.test.ts new file mode 100644 index 00000000000..d8e688044e7 --- /dev/null +++ b/src/cli/daemon-cli/status.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createCliRuntimeCapture } from "../test-runtime-capture.js"; + +const gatherDaemonStatus = vi.fn(async (_opts?: unknown) => ({ + service: { + label: "LaunchAgent", + loaded: true, + loadedText: "loaded", + notLoadedText: "not loaded", + }, + rpc: { + ok: true, + url: "ws://127.0.0.1:18789", + }, + extraServices: [], +})); +const printDaemonStatus = vi.fn(); + +const { runtimeErrors, defaultRuntime, resetRuntimeCapture } = createCliRuntimeCapture(); + +vi.mock("../../runtime.js", () => ({ + defaultRuntime, +})); + +vi.mock("../../terminal/theme.js", () => ({ + colorize: (_rich: boolean, _color: unknown, text: string) => text, + isRich: () => false, + theme: { error: "error" }, +})); + +vi.mock("./status.gather.js", () => ({ + gatherDaemonStatus: (opts: unknown) => gatherDaemonStatus(opts), +})); + +vi.mock("./status.print.js", () => ({ + printDaemonStatus: (...args: unknown[]) => printDaemonStatus(...args), +})); + +const { runDaemonStatus } = await import("./status.js"); + +describe("runDaemonStatus", () => { + beforeEach(() => { + gatherDaemonStatus.mockClear(); + printDaemonStatus.mockClear(); + resetRuntimeCapture(); + }); + + it("exits when require-rpc is set and the probe fails", async () => { + gatherDaemonStatus.mockResolvedValueOnce({ + service: { + label: "LaunchAgent", + loaded: true, + loadedText: "loaded", + notLoadedText: "not loaded", + }, + rpc: { + ok: false, + url: "ws://127.0.0.1:18789", + error: "gateway closed", + }, + extraServices: [], + }); + + await expect( + runDaemonStatus({ + rpc: {}, + probe: true, + requireRpc: true, + json: false, + }), + ).rejects.toThrow("__exit__:1"); + + expect(printDaemonStatus).toHaveBeenCalledTimes(1); + }); + + it("rejects require-rpc when probing is disabled", async () => { + await expect( + runDaemonStatus({ + rpc: {}, + probe: false, + requireRpc: true, + json: false, + }), + ).rejects.toThrow("__exit__:1"); + + expect(gatherDaemonStatus).not.toHaveBeenCalled(); + expect(runtimeErrors.join("\n")).toContain("--require-rpc cannot be used with --no-probe"); + }); +}); diff --git a/src/cli/daemon-cli/status.ts b/src/cli/daemon-cli/status.ts index 2af5a1977ec..44ae4b0a686 100644 --- a/src/cli/daemon-cli/status.ts +++ b/src/cli/daemon-cli/status.ts @@ -6,12 +6,20 @@ import type { DaemonStatusOptions } from "./types.js"; export async function runDaemonStatus(opts: DaemonStatusOptions) { try { + if (opts.requireRpc && !opts.probe) { + defaultRuntime.error("Gateway status failed: --require-rpc cannot be used with --no-probe."); + defaultRuntime.exit(1); + return; + } const status = await gatherDaemonStatus({ rpc: opts.rpc, probe: Boolean(opts.probe), deep: Boolean(opts.deep), }); printDaemonStatus(status, { json: Boolean(opts.json) }); + if (opts.requireRpc && !status.rpc?.ok) { + defaultRuntime.exit(1); + } } catch (err) { const rich = isRich(); defaultRuntime.error(colorize(rich, theme.error, `Gateway status failed: ${String(err)}`)); diff --git a/src/cli/daemon-cli/types.ts b/src/cli/daemon-cli/types.ts index 602d47e9fd1..08a6d407329 100644 --- a/src/cli/daemon-cli/types.ts +++ b/src/cli/daemon-cli/types.ts @@ -11,6 +11,7 @@ export type GatewayRpcOpts = { export type DaemonStatusOptions = { rpc: GatewayRpcOpts; probe: boolean; + requireRpc: boolean; json: boolean; } & FindExtraGatewayServicesOptions; diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index 5396b20b9d6..7b2f14e3e87 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -14,7 +14,9 @@ const gatewayClientCalls: Array<{ onClose?: (code: number, reason: string) => void; }> = []; const ensureWorkspaceAndSessionsMock = vi.fn(async (..._args: unknown[]) => {}); -const installGatewayDaemonNonInteractiveMock = vi.hoisted(() => vi.fn(async () => {})); +const installGatewayDaemonNonInteractiveMock = vi.hoisted(() => + vi.fn(async () => ({ installed: true as const })), +); const gatewayServiceMock = vi.hoisted(() => ({ label: "LaunchAgent", loadedText: "loaded", @@ -401,6 +403,84 @@ describe("onboard (non-interactive): gateway and remote auth", () => { }); }, 60_000); + it("emits a daemon-install failure when Linux user systemd is unavailable", async () => { + await withStateDir("state-local-daemon-install-json-fail-", async (stateDir) => { + installGatewayDaemonNonInteractiveMock.mockResolvedValueOnce({ + installed: false, + skippedReason: "systemd-user-unavailable", + }); + + let capturedError = ""; + const runtimeWithCapture: RuntimeEnv = { + log: () => {}, + error: (...args: unknown[]) => { + const firstArg = args[0]; + capturedError = + typeof firstArg === "string" + ? firstArg + : firstArg instanceof Error + ? firstArg.message + : (JSON.stringify(firstArg) ?? ""); + throw new Error(capturedError); + }, + exit: (_code: number) => { + throw new Error("exit should not be reached after runtime.error"); + }, + }; + + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { + configurable: true, + value: "linux", + }); + + try { + await expect( + runNonInteractiveOnboarding( + { + nonInteractive: true, + mode: "local", + workspace: path.join(stateDir, "openclaw"), + authChoice: "skip", + skipSkills: true, + skipHealth: false, + installDaemon: true, + gatewayBind: "loopback", + json: true, + }, + runtimeWithCapture, + ), + ).rejects.toThrow(/"phase": "daemon-install"/); + } finally { + Object.defineProperty(process, "platform", { + configurable: true, + value: originalPlatform, + }); + } + + const parsed = JSON.parse(capturedError) as { + ok: boolean; + phase: string; + daemonInstall?: { + requested?: boolean; + installed?: boolean; + skippedReason?: string; + }; + hints?: string[]; + }; + expect(parsed.ok).toBe(false); + expect(parsed.phase).toBe("daemon-install"); + expect(parsed.daemonInstall).toEqual({ + requested: true, + installed: false, + skippedReason: "systemd-user-unavailable", + }); + expect(parsed.hints).toContain( + "Fix: rerun without `--install-daemon` for one-shot setup, or enable a working user-systemd session and retry.", + ); + }); + }, 60_000); + it("emits structured JSON diagnostics when daemon health fails", async () => { await withStateDir("state-local-daemon-health-json-fail-", async (stateDir) => { waitForGatewayReachableMock = vi.fn(async () => ({ diff --git a/src/commands/onboard-non-interactive/local.ts b/src/commands/onboard-non-interactive/local.ts index 12e388a36d6..f62076e08e7 100644 --- a/src/commands/onboard-non-interactive/local.ts +++ b/src/commands/onboard-non-interactive/local.ts @@ -133,17 +133,57 @@ export async function runNonInteractiveOnboardingLocal(params: { skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap), }); + const daemonRuntimeRaw = opts.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME; + let daemonInstallStatus: + | { + requested: boolean; + installed: boolean; + skippedReason?: "systemd-user-unavailable"; + } + | undefined; if (opts.installDaemon) { const { installGatewayDaemonNonInteractive } = await import("./local/daemon-install.js"); - await installGatewayDaemonNonInteractive({ + const daemonInstall = await installGatewayDaemonNonInteractive({ nextConfig, opts, runtime, port: gatewayResult.port, }); + daemonInstallStatus = { + requested: true, + installed: daemonInstall.installed, + skippedReason: daemonInstall.skippedReason, + }; + if (!daemonInstall.installed && !opts.skipHealth) { + logNonInteractiveOnboardingFailure({ + opts, + runtime, + mode, + phase: "daemon-install", + message: + daemonInstall.skippedReason === "systemd-user-unavailable" + ? "Gateway service install is unavailable because systemd user services are not reachable in this Linux session." + : "Gateway service install did not complete successfully.", + installDaemon: true, + daemonInstall: { + requested: true, + installed: false, + skippedReason: daemonInstall.skippedReason, + }, + daemonRuntime: daemonRuntimeRaw, + hints: + daemonInstall.skippedReason === "systemd-user-unavailable" + ? [ + "Fix: rerun without `--install-daemon` for one-shot setup, or enable a working user-systemd session and retry.", + "If your auth profile uses env-backed refs, keep those env vars set in the shell that runs `openclaw gateway run` or `openclaw agent --local`.", + ] + : [`Run \`${formatCliCommand("openclaw gateway status --deep")}\` for more detail.`], + }); + runtime.exit(1); + return; + } } - const daemonRuntimeRaw = opts.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME; if (!opts.skipHealth) { const { healthCommand } = await import("../health.js"); const links = resolveControlUiLinks({ @@ -175,6 +215,7 @@ export async function runNonInteractiveOnboardingLocal(params: { httpUrl: links.httpUrl, }, installDaemon: Boolean(opts.installDaemon), + daemonInstall: daemonInstallStatus, daemonRuntime: opts.installDaemon ? daemonRuntimeRaw : undefined, diagnostics, hints: !opts.installDaemon @@ -206,6 +247,7 @@ export async function runNonInteractiveOnboardingLocal(params: { tailscaleMode: gatewayResult.tailscaleMode, }, installDaemon: Boolean(opts.installDaemon), + daemonInstall: daemonInstallStatus, daemonRuntime: opts.installDaemon ? daemonRuntimeRaw : undefined, skipSkills: Boolean(opts.skipSkills), skipHealth: Boolean(opts.skipHealth), diff --git a/src/commands/onboard-non-interactive/local/daemon-install.test.ts b/src/commands/onboard-non-interactive/local/daemon-install.test.ts index c3e87a1d48d..d45cf4cafad 100644 --- a/src/commands/onboard-non-interactive/local/daemon-install.test.ts +++ b/src/commands/onboard-non-interactive/local/daemon-install.test.ts @@ -6,6 +6,7 @@ const gatewayInstallErrorHint = vi.hoisted(() => vi.fn(() => "hint")); const resolveGatewayInstallToken = vi.hoisted(() => vi.fn()); const serviceInstall = vi.hoisted(() => vi.fn(async () => {})); const ensureSystemdUserLingerNonInteractive = vi.hoisted(() => vi.fn(async () => {})); +const isSystemdUserServiceAvailable = vi.hoisted(() => vi.fn(async () => true)); vi.mock("../../daemon-install-helpers.js", () => ({ buildGatewayInstallPlan, @@ -23,7 +24,7 @@ vi.mock("../../../daemon/service.js", () => ({ })); vi.mock("../../../daemon/systemd.js", () => ({ - isSystemdUserServiceAvailable: vi.fn(async () => true), + isSystemdUserServiceAvailable, })); vi.mock("../../daemon-runtime.js", () => ({ @@ -40,6 +41,7 @@ const { installGatewayDaemonNonInteractive } = await import("./daemon-install.js describe("installGatewayDaemonNonInteractive", () => { beforeEach(() => { vi.clearAllMocks(); + isSystemdUserServiceAvailable.mockResolvedValue(true); resolveGatewayInstallToken.mockResolvedValue({ token: undefined, tokenRefConfigured: true, @@ -100,4 +102,39 @@ describe("installGatewayDaemonNonInteractive", () => { expect(buildGatewayInstallPlan).not.toHaveBeenCalled(); expect(serviceInstall).not.toHaveBeenCalled(); }); + + it("returns a skipped result when Linux user systemd is unavailable", async () => { + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const originalPlatform = process.platform; + + isSystemdUserServiceAvailable.mockResolvedValue(false); + Object.defineProperty(process, "platform", { + configurable: true, + value: "linux", + }); + + try { + const result = await installGatewayDaemonNonInteractive({ + nextConfig: {} as OpenClawConfig, + opts: { installDaemon: true }, + runtime, + port: 18789, + }); + + expect(result).toEqual({ + installed: false, + skippedReason: "systemd-user-unavailable", + }); + expect(runtime.log).toHaveBeenCalledWith( + expect.stringContaining("Systemd user services are unavailable"), + ); + expect(buildGatewayInstallPlan).not.toHaveBeenCalled(); + expect(serviceInstall).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(process, "platform", { + configurable: true, + value: originalPlatform, + }); + } + }); }); diff --git a/src/commands/onboard-non-interactive/local/daemon-install.ts b/src/commands/onboard-non-interactive/local/daemon-install.ts index d3b759227d6..6236b410f75 100644 --- a/src/commands/onboard-non-interactive/local/daemon-install.ts +++ b/src/commands/onboard-non-interactive/local/daemon-install.ts @@ -13,24 +13,34 @@ export async function installGatewayDaemonNonInteractive(params: { opts: OnboardOptions; runtime: RuntimeEnv; port: number; -}) { +}): Promise< + | { + installed: true; + } + | { + installed: false; + skippedReason?: "systemd-user-unavailable"; + } +> { const { opts, runtime, port } = params; if (!opts.installDaemon) { - return; + return { installed: false }; } const daemonRuntimeRaw = opts.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME; const systemdAvailable = process.platform === "linux" ? await isSystemdUserServiceAvailable() : true; if (process.platform === "linux" && !systemdAvailable) { - runtime.log("Systemd user services are unavailable; skipping service install."); - return; + runtime.log( + "Systemd user services are unavailable; skipping service install. Use a direct shell run (`openclaw gateway run`) or rerun without --install-daemon on this session.", + ); + return { installed: false, skippedReason: "systemd-user-unavailable" }; } if (!isGatewayDaemonRuntime(daemonRuntimeRaw)) { runtime.error("Invalid --daemon-runtime (use node or bun)"); runtime.exit(1); - return; + return { installed: false }; } const service = resolveGatewayService(); @@ -50,7 +60,7 @@ export async function installGatewayDaemonNonInteractive(params: { ].join(" "), ); runtime.exit(1); - return; + return { installed: false }; } const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({ env: process.env, @@ -70,7 +80,8 @@ export async function installGatewayDaemonNonInteractive(params: { } catch (err) { runtime.error(`Gateway service install failed: ${String(err)}`); runtime.log(gatewayInstallErrorHint()); - return; + return { installed: false }; } await ensureSystemdUserLingerNonInteractive({ runtime }); + return { installed: true }; } diff --git a/src/commands/onboard-non-interactive/local/output.ts b/src/commands/onboard-non-interactive/local/output.ts index d6a45d21da8..a91df06aee6 100644 --- a/src/commands/onboard-non-interactive/local/output.ts +++ b/src/commands/onboard-non-interactive/local/output.ts @@ -29,6 +29,11 @@ export function logNonInteractiveOnboardingJson(params: { tailscaleMode: string; }; installDaemon?: boolean; + daemonInstall?: { + requested: boolean; + installed: boolean; + skippedReason?: string; + }; daemonRuntime?: string; skipSkills?: boolean; skipHealth?: boolean; @@ -45,6 +50,7 @@ export function logNonInteractiveOnboardingJson(params: { authChoice: params.authChoice, gateway: params.gateway, installDaemon: Boolean(params.installDaemon), + daemonInstall: params.daemonInstall, daemonRuntime: params.daemonRuntime, skipSkills: Boolean(params.skipSkills), skipHealth: Boolean(params.skipHealth), @@ -91,6 +97,11 @@ export function logNonInteractiveOnboardingFailure(params: { httpUrl?: string; }; installDaemon?: boolean; + daemonInstall?: { + requested: boolean; + installed: boolean; + skippedReason?: string; + }; daemonRuntime?: string; diagnostics?: GatewayHealthFailureDiagnostics; }) { @@ -108,6 +119,7 @@ export function logNonInteractiveOnboardingFailure(params: { detail: params.detail, gateway: params.gateway, installDaemon: Boolean(params.installDaemon), + daemonInstall: params.daemonInstall, daemonRuntime: params.daemonRuntime, diagnostics: params.diagnostics, hints: hints.length > 0 ? hints : undefined,