fix(gateway): split probe capability from reachability

This commit is contained in:
Ayaan Zaidi
2026-04-20 11:18:54 +05:30
parent a4130ae8ed
commit 485c258aaf
14 changed files with 431 additions and 49 deletions

View File

@@ -19,7 +19,14 @@ vi.mock("../progress.js", () => ({
describe("probeGatewayStatus", () => {
it("uses lightweight token-only probing for daemon status", async () => {
callGatewayMock.mockReset();
probeGatewayMock.mockResolvedValueOnce({ ok: true });
probeGatewayMock.mockResolvedValueOnce({
ok: true,
auth: {
role: "operator",
scopes: ["operator.write"],
capability: "write_capable",
},
});
const result = await probeGatewayStatus({
url: "ws://127.0.0.1:19191",
@@ -29,7 +36,16 @@ describe("probeGatewayStatus", () => {
json: true,
});
expect(result).toEqual({ ok: true });
expect(result).toEqual({
ok: true,
kind: "connect",
capability: "write_capable",
auth: {
role: "operator",
scopes: ["operator.write"],
capability: "write_capable",
},
});
expect(callGatewayMock).not.toHaveBeenCalled();
expect(probeGatewayMock).toHaveBeenCalledWith({
url: "ws://127.0.0.1:19191",
@@ -58,7 +74,12 @@ describe("probeGatewayStatus", () => {
configPath: "/tmp/openclaw-daemon/openclaw.json",
});
expect(result).toEqual({ ok: true });
expect(result).toEqual({
ok: true,
kind: "read",
capability: "read_only",
auth: undefined,
});
expect(probeGatewayMock).not.toHaveBeenCalled();
expect(callGatewayMock).toHaveBeenCalledWith({
url: "ws://127.0.0.1:19191",
@@ -78,6 +99,11 @@ describe("probeGatewayStatus", () => {
ok: false,
error: null,
close: { code: 1008, reason: "pairing required" },
auth: {
role: null,
scopes: [],
capability: "pairing_pending",
},
});
const result = await probeGatewayStatus({
@@ -87,6 +113,13 @@ describe("probeGatewayStatus", () => {
expect(result).toEqual({
ok: false,
kind: "connect",
capability: "pairing_pending",
auth: {
role: null,
scopes: [],
capability: "pairing_pending",
},
error: "gateway closed (1008): pairing required",
});
});
@@ -98,6 +131,11 @@ describe("probeGatewayStatus", () => {
ok: false,
error: "timeout",
close: { code: 1008, reason: "pairing required" },
auth: {
role: null,
scopes: [],
capability: "pairing_pending",
},
});
const result = await probeGatewayStatus({
@@ -107,6 +145,13 @@ describe("probeGatewayStatus", () => {
expect(result).toEqual({
ok: false,
kind: "connect",
capability: "pairing_pending",
auth: {
role: null,
scopes: [],
capability: "pairing_pending",
},
error: "gateway closed (1008): pairing required",
});
});
@@ -125,6 +170,7 @@ describe("probeGatewayStatus", () => {
expect(result).toEqual({
ok: false,
kind: "read",
error: "missing scope: operator.admin",
});
expect(probeGatewayMock).not.toHaveBeenCalled();

View File

@@ -1,6 +1,8 @@
import { formatErrorMessage } from "../../infra/errors.js";
import { withProgress } from "../progress.js";
type GatewayStatusProbeKind = "connect" | "read";
function resolveProbeFailureMessage(result: {
error?: string | null;
close?: { code: number; reason: string } | null;
@@ -24,6 +26,7 @@ export async function probeGatewayStatus(opts: {
requireRpc?: boolean;
configPath?: string;
}) {
const kind = (opts.requireRpc ? "read" : "connect") satisfies GatewayStatusProbeKind;
try {
const result = await withProgress(
{
@@ -58,16 +61,26 @@ export async function probeGatewayStatus(opts: {
});
},
);
const auth = "auth" in result ? result.auth : undefined;
if (result.ok) {
return { ok: true } as const;
return {
ok: true,
kind,
capability: opts.requireRpc ? "read_only" : auth?.capability,
auth,
} as const;
}
return {
ok: false,
kind,
capability: auth?.capability,
auth,
error: resolveProbeFailureMessage(result),
} as const;
} catch (err) {
return {
ok: false,
kind,
error: formatErrorMessage(err),
} as const;
}

View File

@@ -49,7 +49,9 @@ function resolveRpcOptions(cmdOpts: GatewayRpcOpts, command?: Command): GatewayR
export function addGatewayServiceCommands(parent: Command, opts?: { statusDescription?: string }) {
parent
.command("status")
.description(opts?.statusDescription ?? "Show gateway service status + probe the Gateway")
.description(
opts?.statusDescription ?? "Show gateway service status + probe connectivity/capability",
)
.option("--url <url>", "Gateway WebSocket URL (defaults to config/remote/local)")
.option("--token <token>", "Gateway token (if required)")
.option("--password <password>", "Gateway password (password auth)")

View File

@@ -14,6 +14,6 @@ export function registerDaemonCli(program: Command) {
);
addGatewayServiceCommands(daemon, {
statusDescription: "Show service install status + probe the Gateway",
statusDescription: "Show service install status + probe connectivity/capability",
});
}

View File

@@ -165,6 +165,13 @@ export type DaemonStatus = {
lastError?: string;
rpc?: {
ok: boolean;
kind?: "connect" | "read";
capability?: string;
auth?: {
role?: string | null;
scopes?: string[];
capability?: string;
};
error?: string;
url?: string;
authWarning?: string;

View File

@@ -120,4 +120,36 @@ describe("printDaemonStatus", () => {
expect.stringContaining(formatCliCommand("openclaw gateway restart")),
);
});
it("prints probe kind and capability separately", () => {
printDaemonStatus(
{
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",
},
rpc: {
ok: true,
kind: "connect",
capability: "write_capable",
url: "ws://127.0.0.1:18789",
},
extraServices: [],
},
{ json: false },
);
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("Connectivity probe: ok"));
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("Capability: write-capable"));
});
});

View File

@@ -50,6 +50,17 @@ function sanitizeDaemonStatusForJson(status: DaemonStatus): DaemonStatus {
};
}
function formatProbeKindLabel(kind?: "connect" | "read") {
return kind === "read" ? "Read probe:" : "Connectivity probe:";
}
function formatCapabilityLabel(capability?: string) {
if (!capability) {
return null;
}
return capability.replaceAll("_", "-");
}
export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
if (opts.json) {
const sanitized = sanitizeDaemonStatusForJson(status);
@@ -175,21 +186,26 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
);
}
if (rpc) {
const probeLabel = formatProbeKindLabel(rpc.kind);
if (rpc.ok) {
defaultRuntime.log(`${label("RPC probe:")} ${okText("ok")}`);
defaultRuntime.log(`${label(probeLabel)} ${okText("ok")}`);
} else {
defaultRuntime.error(`${label("RPC probe:")} ${errorText("failed")}`);
defaultRuntime.error(`${label(probeLabel)} ${errorText("failed")}`);
if (rpc.authWarning) {
defaultRuntime.error(`${label("RPC auth:")} ${warnText(rpc.authWarning)}`);
defaultRuntime.error(`${label("Probe auth:")} ${warnText(rpc.authWarning)}`);
}
if (rpc.url) {
defaultRuntime.error(`${label("RPC target:")} ${rpc.url}`);
defaultRuntime.error(`${label("Probe target:")} ${rpc.url}`);
}
const lines = (rpc.error ?? "unknown").split(/\r?\n/).filter(Boolean);
for (const line of lines.slice(0, 12)) {
defaultRuntime.error(` ${errorText(line)}`);
}
}
const capability = formatCapabilityLabel(rpc.capability);
if (capability) {
defaultRuntime.log(`${label("Capability:")} ${infoText(capability)}`);
}
spacer();
}