From ddf9fbed348a2e22bfa0d6ced8d8aa6d310dd499 Mon Sep 17 00:00:00 2001 From: Galin Iliev Date: Tue, 19 May 2026 22:09:14 -0700 Subject: [PATCH] fix(gateway): expose runtime version in gateway status Closes #56222 --- CHANGELOG.md | 1 + docs/cli/gateway.md | 1 + src/cli/daemon-cli.coverage.test.ts | 3 +- src/cli/daemon-cli/probe.test.ts | 64 ++++++++++++++++++++++++ src/cli/daemon-cli/probe.ts | 17 ++++++- src/cli/daemon-cli/status.gather.test.ts | 21 ++++++++ src/cli/daemon-cli/status.gather.ts | 16 +++++- src/cli/daemon-cli/status.print.test.ts | 38 ++++++++++++++ src/cli/daemon-cli/status.print.ts | 2 +- 9 files changed, 159 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6a7a7e1ca8..b2f7a006ca7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai - Providers/Ollama: default unknown-capabilities models to tool-capable so discovered native Ollama models can use tools when `/api/show` omits capabilities. (#84055) Thanks @dutifulbob. - Installer/Windows: launch `install.ps1` onboarding as an attached child process so fresh native Windows installs do not freeze visibly at `Starting setup...` or corrupt the wizard's terminal rendering. - CLI/update: keep restart health checks working across one-version CLI/Gateway protocol skew and use the managed Gateway service Node for all follow-up commands even when the package root is unchanged, so `openclaw update` no longer silently switches the gateway to a different Node binary when multiple Node installations are present. Thanks @amknight. +- CLI/gateway: include the running Gateway version in `gateway status` JSON output, preserving existing server metadata while falling back to status RPC data for read probes. Fixes #56222. Thanks @galiniliev. - Memory/search: close local embedding providers when active-memory searches time out so pending local model loads and embedding contexts are aborted and released. (#83858) Thanks @brokemac79. - CLI/nodes: request pending node surface approval scopes before `openclaw nodes approve` so exec-capable node approval can use admin-scoped Gateway credentials instead of failing with `missing scope: operator.admin`. (#84392) Thanks @joshavant. diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index be402be9828..29c43c40d9d 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -299,6 +299,7 @@ openclaw gateway status --require-rpc - `gateway status` resolves configured auth SecretRefs for probe auth when possible. - If a required auth SecretRef is unresolved in this command path, `gateway status --json` reports `rpc.authWarning` when probe connectivity/auth fails; pass `--token`/`--password` explicitly or resolve the secret source first. - If the probe succeeds, unresolved auth-ref warnings are suppressed to avoid false positives. + - When probing is enabled, JSON output includes `gateway.version` when the running Gateway reports it; `--require-rpc` can fall back to the `status.runtimeVersion` RPC payload if the follow-up handshake probe cannot provide version metadata. - Use `--require-rpc` in scripts and automation when a listening service is not enough and you need read-scope RPC calls to be healthy too. - `--deep` adds a best-effort scan for extra launchd/systemd/schtasks installs. When multiple gateway-like services are detected, human output prints cleanup hints and warns that most setups should run one gateway per machine. - `--deep` also reports a recent Gateway supervisor restart handoff when the service process exited cleanly for an external supervisor restart. diff --git a/src/cli/daemon-cli.coverage.test.ts b/src/cli/daemon-cli.coverage.test.ts index b3ade826392..2c9e0154788 100644 --- a/src/cli/daemon-cli.coverage.test.ts +++ b/src/cli/daemon-cli.coverage.test.ts @@ -244,13 +244,14 @@ describe("daemon-cli coverage", () => { expect(inspectPortUsage).toHaveBeenCalledWith(19001); const parsed = parseFirstJsonRuntimeLine<{ - gateway?: { port?: number; portSource?: string; probeUrl?: string }; + gateway?: { port?: number; portSource?: string; probeUrl?: string; version?: string | null }; config?: { mismatch?: boolean }; rpc?: { url?: string; ok?: boolean }; }>(); expect(parsed.gateway?.port).toBe(19001); expect(parsed.gateway?.portSource).toBe("service args"); expect(parsed.gateway?.probeUrl).toBe("ws://127.0.0.1:19001"); + expect(parsed.gateway?.version).toBeNull(); expect(parsed.config?.mismatch).toBe(true); expect(parsed.rpc?.url).toBe("ws://127.0.0.1:19001"); expect(parsed.rpc?.ok).toBe(true); diff --git a/src/cli/daemon-cli/probe.test.ts b/src/cli/daemon-cli/probe.test.ts index 93d6615d74f..b93a81cab22 100644 --- a/src/cli/daemon-cli/probe.test.ts +++ b/src/cli/daemon-cli/probe.test.ts @@ -110,6 +110,7 @@ describe("probeGatewayStatus", () => { } expect(result.server?.version).toBe("2026.5.6"); expect(result.server?.connId).toBe("conn-1"); + expect(result.version).toBe("2026.5.6"); }); it("uses a real status RPC when requireRpc is enabled", async () => { @@ -243,6 +244,69 @@ describe("probeGatewayStatus", () => { }); }); + it("uses status.runtimeVersion when read-probe handshake metadata is unavailable", async () => { + callGatewayMock.mockReset(); + probeGatewayMock.mockReset(); + callGatewayMock.mockResolvedValueOnce({ runtimeVersion: "2026.4.24", status: "ok" }); + probeGatewayMock.mockRejectedValueOnce(new Error("probe timed out after status")); + + const result = await probeGatewayStatus({ + url: "ws://127.0.0.1:19191", + token: "temp-token", + timeoutMs: 5_000, + requireRpc: true, + }); + + expect(result).toEqual({ + ok: true, + kind: "read", + capability: "read_only", + auth: undefined, + version: "2026.4.24", + }); + }); + + it("prefers read-probe server metadata over status.runtimeVersion", async () => { + callGatewayMock.mockReset(); + probeGatewayMock.mockReset(); + callGatewayMock.mockResolvedValueOnce({ runtimeVersion: "2026.4.23", status: "ok" }); + probeGatewayMock.mockResolvedValueOnce({ + ok: true, + auth: { + role: "operator", + scopes: ["operator.read"], + capability: "read_only", + }, + server: { + version: "2026.4.24", + connId: "conn-1", + }, + }); + + const result = await probeGatewayStatus({ + url: "ws://127.0.0.1:19191", + token: "temp-token", + timeoutMs: 5_000, + requireRpc: true, + }); + + expect(result).toEqual({ + ok: true, + kind: "read", + capability: "read_only", + auth: { + role: "operator", + scopes: ["operator.read"], + capability: "read_only", + }, + server: { + version: "2026.4.24", + connId: "conn-1", + }, + version: "2026.4.24", + }); + }); + it("surfaces probe close details when the handshake fails", async () => { callGatewayMock.mockReset(); probeGatewayMock.mockReset(); diff --git a/src/cli/daemon-cli/probe.ts b/src/cli/daemon-cli/probe.ts index 3fcb72c30f1..561ddf05dbb 100644 --- a/src/cli/daemon-cli/probe.ts +++ b/src/cli/daemon-cli/probe.ts @@ -34,6 +34,16 @@ function resolveGatewayStatusProbeDetails(result: GatewayStatusProbeResult) { return "authProbe" in result ? result.authProbe : result; } +function readRuntimeVersionFromStatusPayload(payload: unknown): string | null { + if (!payload || typeof payload !== "object") { + return null; + } + const runtimeVersion = (payload as { runtimeVersion?: unknown }).runtimeVersion; + return typeof runtimeVersion === "string" && runtimeVersion.trim().length > 0 + ? runtimeVersion.trim() + : null; +} + export async function probeGatewayStatus(opts: { url: string; token?: string; @@ -48,6 +58,7 @@ export async function probeGatewayStatus(opts: { }) { const kind = (opts.requireRpc ? "read" : "connect") satisfies GatewayStatusProbeKind; try { + let statusRuntimeVersion: string | null = null; const result = await withProgress( { label: "Checking gateway status...", @@ -71,7 +82,7 @@ export async function probeGatewayStatus(opts: { }; if (opts.requireRpc) { const { callGateway } = await import("../../gateway/call.js"); - await callGateway({ + const statusPayload = await callGateway({ url: opts.url, token: opts.token, password: opts.password, @@ -81,6 +92,7 @@ export async function probeGatewayStatus(opts: { timeoutMs: opts.timeoutMs, ...(opts.configPath ? { configPath: opts.configPath } : {}), }); + statusRuntimeVersion = readRuntimeVersionFromStatusPayload(statusPayload); const authProbe = await probeGateway(probeOpts).catch(() => null); return { ok: true as const, authProbe }; } @@ -91,6 +103,7 @@ export async function probeGatewayStatus(opts: { const auth = probeDetails?.auth; const server = probeDetails?.server; const serverSummary = server ? { server } : {}; + const version = server?.version ?? ("authProbe" in result ? statusRuntimeVersion : null); if (result.ok) { return { ok: true, @@ -103,6 +116,7 @@ export async function probeGatewayStatus(opts: { : auth?.capability, auth, ...serverSummary, + ...(version != null ? { version } : {}), } as const; } return { @@ -111,6 +125,7 @@ export async function probeGatewayStatus(opts: { capability: auth?.capability, auth, ...serverSummary, + ...(version != null ? { version } : {}), error: resolveProbeFailureMessage(result), } as const; } catch (err) { diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index 749da0be57d..e1b40091791 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -17,6 +17,7 @@ const callGatewayStatusProbe = vi.fn< url?: string; error?: string | null; server?: { version?: string | null; connId?: string | null }; + version?: string | null; }> >(async (_opts?: unknown) => ({ ok: true, @@ -272,6 +273,7 @@ describe("gatherDaemonStatus", () => { expect(probeInput.token).toBe("daemon-token"); expect(status.gateway?.probeUrl).toBe("wss://127.0.0.1:19001"); expect(status.gateway?.tlsEnabled).toBe(true); + expect(status.gateway?.version).toBe("2026.5.6"); expect(status.rpc?.url).toBe("wss://127.0.0.1:19001"); expect(status.rpc?.ok).toBe(true); expect(status.rpc?.server).toEqual({ version: "2026.5.6", connId: "conn-1" }); @@ -282,6 +284,25 @@ describe("gatherDaemonStatus", () => { expect(inspectGatewayRestart).not.toHaveBeenCalled(); }); + it("falls back to probe version when server metadata is unavailable", async () => { + callGatewayStatusProbe.mockResolvedValueOnce({ + ok: true, + url: "ws://127.0.0.1:19001", + error: null, + version: "2026.5.7", + }); + + const status = await gatherDaemonStatus({ + rpc: {}, + probe: true, + deep: false, + }); + + expect(status.gateway?.version).toBe("2026.5.7"); + expect(status.rpc?.version).toBe("2026.5.7"); + expect(status.rpc?.server).toBeUndefined(); + }); + it("forwards requireRpc and configPath to the daemon probe", async () => { await gatherDaemonStatus({ rpc: {}, diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index 847c0fb63de..50fc084ca9e 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -60,6 +60,7 @@ type GatewayStatusSummary = { portSource: "service args" | "env/config"; probeUrl: string; probeNote?: string; + version?: string | null; }; type PortStatusSummary = { @@ -314,6 +315,7 @@ export type DaemonStatus = { version?: string | null; connId?: string | null; }; + version?: string | null; error?: string; url?: string; authWarning?: string; @@ -622,6 +624,11 @@ export async function gatherDaemonStatus( ) .catch(() => undefined) : undefined; + const gatewayVersion = opts.probe + ? ((rpc && "server" in rpc ? rpc.server?.version : undefined) ?? + (rpc && "version" in rpc ? rpc.version : undefined) ?? + null) + : undefined; let lastError: string | undefined; if (loaded && runtime?.status === "running" && portStatus && portStatus.status !== "busy") { @@ -647,7 +654,14 @@ export async function gatherDaemonStatus( daemon: daemonConfigSummary, ...(configMismatch ? { mismatch: true } : {}), }, - gateway, + gateway: { + ...gateway, + ...(opts.probe + ? { + version: gatewayVersion, + } + : {}), + }, port: portStatus, ...(portCliStatus ? { portCli: portCliStatus } : {}), ...(establishedClients ? { connections: establishedClients } : {}), diff --git a/src/cli/daemon-cli/status.print.test.ts b/src/cli/daemon-cli/status.print.test.ts index f87cc63cb23..7b15de5338e 100644 --- a/src/cli/daemon-cli/status.print.test.ts +++ b/src/cli/daemon-cli/status.print.test.ts @@ -321,6 +321,44 @@ describe("printDaemonStatus", () => { ); }); + it("prints gateway version from gathered gateway status when probe server metadata is absent", () => { + printDaemonStatus( + { + cli: { + version: "2026.4.23", + entrypoint: "/usr/local/bin/openclaw", + }, + service: { + label: "LaunchAgent", + loaded: true, + loadedText: "loaded", + notLoadedText: "not loaded", + runtime: { status: "running", pid: 8000 }, + }, + gateway: { + bindMode: "loopback", + bindHost: "127.0.0.1", + port: 18789, + portSource: "env/config", + probeUrl: "ws://127.0.0.1:18789", + version: "2026.5.7", + }, + rpc: { + ok: true, + kind: "read", + capability: "read_only", + url: "ws://127.0.0.1:18789", + version: "2026.5.7", + }, + extraServices: [], + }, + { json: false }, + ); + + expectMockLineContains(runtime.log, "Gateway version: 2026.5.7"); + expectMockLineContains(runtime.error, "this OpenClaw command is version 2026.4.23"); + }); + it("prints restart handoff diagnostics when deep status gathered one", () => { printDaemonStatus( { diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index e6007077aef..e58ba8bd3f6 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -220,7 +220,7 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) spacer(); } - const gatewayVersion = rpc?.server?.version?.trim(); + const gatewayVersion = rpc?.server?.version?.trim() || status.gateway?.version?.trim(); const cliVersionLine = formatCliVersionLine(status.cli); if (gatewayVersion) { if (cliVersionLine) {