fix(gateway): expose runtime version in gateway status

Closes #56222
This commit is contained in:
Galin Iliev
2026-05-19 22:09:14 -07:00
committed by GitHub
parent 29f8715f05
commit ddf9fbed34
9 changed files with 159 additions and 4 deletions

View File

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

View File

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

View File

@@ -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<GatewayStatusProbeResult>(
{
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) {

View File

@@ -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: {},

View File

@@ -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 } : {}),

View File

@@ -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(
{

View File

@@ -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) {