From 485c258aaf960e31a8d1d53a3e1937d920745d84 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 20 Apr 2026 11:18:54 +0530 Subject: [PATCH] fix(gateway): split probe capability from reachability --- src/cli/daemon-cli/probe.test.ts | 52 +++++- src/cli/daemon-cli/probe.ts | 15 +- .../daemon-cli/register-service-commands.ts | 4 +- src/cli/daemon-cli/register.ts | 2 +- src/cli/daemon-cli/status.gather.ts | 7 + src/cli/daemon-cli/status.print.test.ts | 32 ++++ src/cli/daemon-cli/status.print.ts | 24 ++- src/cli/gateway-cli/register.ts | 8 +- src/commands/gateway-status.test.ts | 34 +++- src/commands/gateway-status/helpers.test.ts | 16 +- src/commands/gateway-status/helpers.ts | 82 ++++++++- src/commands/gateway-status/output.ts | 10 +- src/gateway/probe.test.ts | 38 ++++- src/gateway/probe.ts | 156 +++++++++++++++--- 14 files changed, 431 insertions(+), 49 deletions(-) diff --git a/src/cli/daemon-cli/probe.test.ts b/src/cli/daemon-cli/probe.test.ts index 21ae436f1b4..27e3c330c2a 100644 --- a/src/cli/daemon-cli/probe.test.ts +++ b/src/cli/daemon-cli/probe.test.ts @@ -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(); diff --git a/src/cli/daemon-cli/probe.ts b/src/cli/daemon-cli/probe.ts index af18a8aad85..8b092180862 100644 --- a/src/cli/daemon-cli/probe.ts +++ b/src/cli/daemon-cli/probe.ts @@ -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; } diff --git a/src/cli/daemon-cli/register-service-commands.ts b/src/cli/daemon-cli/register-service-commands.ts index 0193c0f91e2..fc77a5afcff 100644 --- a/src/cli/daemon-cli/register-service-commands.ts +++ b/src/cli/daemon-cli/register-service-commands.ts @@ -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 ", "Gateway WebSocket URL (defaults to config/remote/local)") .option("--token ", "Gateway token (if required)") .option("--password ", "Gateway password (password auth)") diff --git a/src/cli/daemon-cli/register.ts b/src/cli/daemon-cli/register.ts index 6c2595eb44a..d0162e23323 100644 --- a/src/cli/daemon-cli/register.ts +++ b/src/cli/daemon-cli/register.ts @@ -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", }); } diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index 86985e39080..b19d0b1640e 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -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; diff --git a/src/cli/daemon-cli/status.print.test.ts b/src/cli/daemon-cli/status.print.test.ts index edb653a740e..90d52d0942a 100644 --- a/src/cli/daemon-cli/status.print.test.ts +++ b/src/cli/daemon-cli/status.print.test.ts @@ -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")); + }); }); diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index 391c26c8812..a0360211c9d 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -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(); } diff --git a/src/cli/gateway-cli/register.ts b/src/cli/gateway-cli/register.ts index 1b454beca9c..0f223278d47 100644 --- a/src/cli/gateway-cli/register.ts +++ b/src/cli/gateway-cli/register.ts @@ -144,7 +144,7 @@ export function registerGatewayCli(program: Command) { () => `\n${theme.heading("Examples:")}\n${formatHelpExamples([ ["openclaw gateway run", "Run the gateway in the foreground."], - ["openclaw gateway status", "Show service status and probe reachability."], + ["openclaw gateway status", "Show service status plus connectivity/capability."], ["openclaw gateway discover", "Find local and wide-area gateway beacons."], ["openclaw gateway call health", "Call a gateway RPC method directly."], ])}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/gateway", "docs.openclaw.ai/cli/gateway")}\n`, @@ -156,7 +156,7 @@ export function registerGatewayCli(program: Command) { ); addGatewayServiceCommands(gateway, { - statusDescription: "Show gateway service status + probe the Gateway", + statusDescription: "Show gateway service status + probe connectivity/capability", }); gatewayCallOpts( @@ -240,7 +240,9 @@ export function registerGatewayCli(program: Command) { gateway .command("probe") - .description("Show gateway reachability + discovery + health + status summary (local + remote)") + .description( + "Show gateway reachability, auth capability, and read-probe summary (local + remote)", + ) .option("--url ", "Explicit Gateway WebSocket URL (still probes localhost)") .option("--ssh ", "SSH target for remote gateway tunnel (user@host or user@host:port)") .option("--ssh-identity ", "SSH identity file path") diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index aec27fa9858..94bbfa61f92 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -55,6 +55,11 @@ const mocks = vi.hoisted(() => { connectLatencyMs: 12, error: null, close: null, + auth: { + role: "operator", + scopes: ["operator.read"], + capability: "read_only", + }, health: { ok: true }, status: { linkChannel: { @@ -93,6 +98,11 @@ const mocks = vi.hoisted(() => { connectLatencyMs: 34, error: null, close: null, + auth: { + role: "operator", + scopes: ["operator.admin"], + capability: "admin_capable", + }, health: { ok: true }, status: { linkChannel: { @@ -196,7 +206,8 @@ vi.mock("../infra/tls/gateway.js", () => ({ loadGatewayTlsRuntime: mocks.loadGatewayTlsRuntime, })); -vi.mock("../gateway/probe.js", () => ({ +vi.mock("../gateway/probe.js", async (importOriginal) => ({ + ...(await importOriginal()), probeGateway: mocks.probeGateway, })); @@ -346,6 +357,11 @@ describe("gateway-status command", () => { connectLatencyMs: 51, error: "missing scope: operator.read", close: null, + auth: { + role: "operator", + scopes: ["operator.write"], + capability: "write_capable", + }, health: null, status: null, presence: null, @@ -358,6 +374,7 @@ describe("gateway-status command", () => { const parsed = JSON.parse(runtimeLogs.join("\n")) as { ok?: boolean; degraded?: boolean; + capability?: string; warnings?: Array<{ code?: string; targetIds?: string[] }>; targets?: Array<{ connect?: { @@ -365,15 +382,20 @@ describe("gateway-status command", () => { rpcOk?: boolean; scopeLimited?: boolean; }; + auth?: { + capability?: string; + }; }>; }; expect(parsed.ok).toBe(true); expect(parsed.degraded).toBe(true); + expect(parsed.capability).toBe("write_capable"); expect(parsed.targets?.[0]?.connect).toMatchObject({ ok: true, rpcOk: false, scopeLimited: true, }); + expect(parsed.targets?.[0]?.auth?.capability).toBe("write_capable"); const scopeLimitedWarning = parsed.warnings?.find( (warning) => warning.code === "probe_scope_limited", ); @@ -415,6 +437,11 @@ describe("gateway-status command", () => { connectLatencyMs: null, error: "connection refused", close: null, + auth: { + role: null, + scopes: [], + capability: "unknown", + }, health: null, status: null, presence: null, @@ -571,6 +598,11 @@ describe("gateway-status command", () => { connectLatencyMs: 20, error: null, close: null, + auth: { + role: "operator", + scopes: ["operator.read"], + capability: "read_only", + }, health: { ok: true }, status: { linkChannel: { diff --git a/src/commands/gateway-status/helpers.test.ts b/src/commands/gateway-status/helpers.test.ts index 84eab331a96..cdf50f00c23 100644 --- a/src/commands/gateway-status/helpers.test.ts +++ b/src/commands/gateway-status/helpers.test.ts @@ -230,6 +230,11 @@ describe("probe reachability classification", () => { connectLatencyMs: 51, error: "missing scope: operator.read", close: null, + auth: { + role: "operator", + scopes: ["operator.write"], + capability: "write_capable" as const, + }, health: null, status: null, presence: null, @@ -238,7 +243,8 @@ describe("probe reachability classification", () => { expect(isScopeLimitedProbeFailure(probe)).toBe(true); expect(isProbeReachable(probe)).toBe(true); - expect(renderProbeSummaryLine(probe, false)).toContain("RPC: limited"); + expect(renderProbeSummaryLine(probe, false)).toContain("Capability: write-capable"); + expect(renderProbeSummaryLine(probe, false)).toContain("Read probe: limited"); }); it("keeps non-scope RPC failures as unreachable", () => { @@ -248,6 +254,11 @@ describe("probe reachability classification", () => { connectLatencyMs: 43, error: "unknown method: status", close: null, + auth: { + role: "operator", + scopes: [], + capability: "connected_no_operator_scope" as const, + }, health: null, status: null, presence: null, @@ -256,7 +267,8 @@ describe("probe reachability classification", () => { expect(isScopeLimitedProbeFailure(probe)).toBe(false); expect(isProbeReachable(probe)).toBe(false); - expect(renderProbeSummaryLine(probe, false)).toContain("RPC: failed"); + expect(renderProbeSummaryLine(probe, false)).toContain("Capability: connect-only"); + expect(renderProbeSummaryLine(probe, false)).toContain("Read probe: failed"); }); }); describe("gateway-status local target scheme", () => { diff --git a/src/commands/gateway-status/helpers.ts b/src/commands/gateway-status/helpers.ts index e77ead2480a..2a0fc3ffa1b 100644 --- a/src/commands/gateway-status/helpers.ts +++ b/src/commands/gateway-status/helpers.ts @@ -4,7 +4,7 @@ import type { OpenClawConfig, ConfigFileSnapshot } from "../../config/types.js"; import { hasConfiguredSecretInput } from "../../config/types.secrets.js"; import { resolveGatewayProbeSurfaceAuth } from "../../gateway/auth-surface-resolution.js"; import { isLoopbackHost } from "../../gateway/net.js"; -import type { GatewayProbeResult } from "../../gateway/probe.js"; +import { type GatewayProbeCapability, type GatewayProbeResult } from "../../gateway/probe.js"; import { inspectBestEffortPrimaryTailnetIPv4 } from "../../infra/network-discovery-display.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { colorize, theme } from "../../terminal/theme.js"; @@ -280,22 +280,90 @@ export function isProbeReachable(probe: GatewayProbeResult): boolean { return probe.ok || isScopeLimitedProbeFailure(probe); } +function getGatewayProbeCapability(probe: GatewayProbeResult): GatewayProbeCapability { + return probe.auth.capability; +} + +export function summarizeGatewayProbeCapability( + probes: GatewayProbeResult[], +): GatewayProbeCapability { + const priority: GatewayProbeCapability[] = [ + "admin_capable", + "write_capable", + "read_only", + "connected_no_operator_scope", + "pairing_pending", + "unknown", + ]; + for (const capability of priority) { + if (probes.some((probe) => getGatewayProbeCapability(probe) === capability)) { + return capability; + } + } + return "unknown"; +} + +function formatGatewayProbeCapabilityLabel(capability: GatewayProbeCapability) { + switch (capability) { + case "admin_capable": + return "Capability: admin-capable"; + case "write_capable": + return "Capability: write-capable"; + case "read_only": + return "Capability: read-only"; + case "connected_no_operator_scope": + return "Capability: connect-only"; + case "pairing_pending": + return "Capability: pairing pending"; + default: + return "Capability: unknown"; + } +} + +function colorForGatewayProbeCapability(capability: GatewayProbeCapability) { + switch (capability) { + case "admin_capable": + case "write_capable": + case "read_only": + return theme.info; + case "connected_no_operator_scope": + case "pairing_pending": + return theme.warn; + default: + return theme.muted; + } +} + +export function renderProbeCapabilityLine(probe: GatewayProbeResult, rich: boolean) { + const capability = getGatewayProbeCapability(probe); + return colorize( + rich, + colorForGatewayProbeCapability(capability), + formatGatewayProbeCapabilityLabel(capability), + ); +} + export function renderProbeSummaryLine(probe: GatewayProbeResult, rich: boolean) { + const capability = renderProbeCapabilityLine(probe, rich); if (probe.ok) { const latency = typeof probe.connectLatencyMs === "number" ? `${probe.connectLatencyMs}ms` : "unknown"; - return `${colorize(rich, theme.success, "Connect: ok")} (${latency}) · ${colorize(rich, theme.success, "RPC: ok")}`; + return `${colorize(rich, theme.success, "Connect: ok")} (${latency}) · ${capability} · ${colorize(rich, theme.success, "Read probe: ok")}`; } const detail = probe.error ? ` - ${probe.error}` : ""; if (probe.connectLatencyMs != null) { const latency = typeof probe.connectLatencyMs === "number" ? `${probe.connectLatencyMs}ms` : "unknown"; - const rpcStatus = isScopeLimitedProbeFailure(probe) - ? colorize(rich, theme.warn, "RPC: limited") - : colorize(rich, theme.error, "RPC: failed"); - return `${colorize(rich, theme.success, "Connect: ok")} (${latency}) · ${rpcStatus}${detail}`; + const readStatus = isScopeLimitedProbeFailure(probe) + ? colorize(rich, theme.warn, "Read probe: limited") + : colorize(rich, theme.error, "Read probe: failed"); + return `${colorize(rich, theme.success, "Connect: ok")} (${latency}) · ${capability} · ${readStatus}${detail}`; } - return `${colorize(rich, theme.error, "Connect: failed")}${detail}`; + if (getGatewayProbeCapability(probe) === "pairing_pending") { + return `${colorize(rich, theme.warn, "Connect: blocked")}${detail} · ${capability}`; + } + + return `${colorize(rich, theme.error, "Connect: failed")}${detail} · ${capability}`; } diff --git a/src/commands/gateway-status/output.ts b/src/commands/gateway-status/output.ts index 266795a01e6..1b51603c5a4 100644 --- a/src/commands/gateway-status/output.ts +++ b/src/commands/gateway-status/output.ts @@ -5,6 +5,7 @@ import { serializeGatewayDiscoveryBeacon } from "./discovery.js"; import { isProbeReachable, isScopeLimitedProbeFailure, + summarizeGatewayProbeCapability, renderProbeSummaryLine, renderTargetHeader, } from "./helpers.js"; @@ -78,7 +79,7 @@ export function buildGatewayStatusWarnings(params: { warnings.push({ code: "probe_scope_limited", message: - "Probe diagnostics are limited by gateway scopes (missing operator.read). Connection succeeded, but status details may be incomplete. Hint: pair device identity or use credentials with operator.read.", + "Read-probe diagnostics are limited by gateway scopes (missing operator.read). Connection succeeded, but read-only status calls are incomplete. Hint: pair device identity or use credentials with operator.read.", targetIds: [result.target.id], }); } @@ -98,9 +99,11 @@ export function writeGatewayStatusJson(params: { }) { const reachable = params.probed.filter((entry) => isProbeReachable(entry.probe)); const degraded = params.probed.some((entry) => isScopeLimitedProbeFailure(entry.probe)); + const capability = summarizeGatewayProbeCapability(params.probed.map((entry) => entry.probe)); writeRuntimeJson(params.runtime, { ok: reachable.length > 0, degraded, + capability, ts: Date.now(), durationMs: Date.now() - params.startedAt, timeoutMs: params.overallTimeoutMs, @@ -126,6 +129,7 @@ export function writeGatewayStatusJson(params: { error: entry.probe.error, close: entry.probe.close, }, + auth: entry.probe.auth, self: entry.self, config: entry.configSummary, health: entry.probe.health, @@ -149,12 +153,16 @@ export function writeGatewayStatusText(params: { }) { const reachable = params.probed.filter((entry) => isProbeReachable(entry.probe)); const ok = reachable.length > 0; + const capability = summarizeGatewayProbeCapability(params.probed.map((entry) => entry.probe)); params.runtime.log(colorize(params.rich, theme.heading, "Gateway Status")); params.runtime.log( ok ? `${colorize(params.rich, theme.success, "Reachable")}: yes` : `${colorize(params.rich, theme.error, "Reachable")}: no`, ); + params.runtime.log( + `${colorize(params.rich, theme.info, "Capability")}: ${capability.replaceAll("_", "-")}`, + ); params.runtime.log( colorize(params.rich, theme.muted, `Probe budget: ${params.overallTimeoutMs}ms`), ); diff --git a/src/gateway/probe.test.ts b/src/gateway/probe.test.ts index 74a76cbca00..edea954a156 100644 --- a/src/gateway/probe.test.ts +++ b/src/gateway/probe.test.ts @@ -5,6 +5,10 @@ const gatewayClientState = vi.hoisted(() => ({ requests: [] as string[], startMode: "hello" as "hello" | "close", close: { code: 1008, reason: "pairing required" }, + helloAuth: { + role: "operator", + scopes: ["operator.read"], + }, })); const deviceIdentityState = vi.hoisted(() => ({ @@ -33,7 +37,10 @@ class MockGatewayClient { } const onHelloOk = this.opts.onHelloOk; if (typeof onHelloOk === "function") { - await onHelloOk(); + await onHelloOk({ + type: "hello-ok", + auth: gatewayClientState.helloAuth, + }); } }) .catch(() => {}); @@ -70,6 +77,10 @@ describe("probeGateway", () => { deviceIdentityState.throwOnLoad = false; gatewayClientState.startMode = "hello"; gatewayClientState.close = { code: 1008, reason: "pairing required" }; + gatewayClientState.helloAuth = { + role: "operator", + scopes: ["operator.read"], + }; }); it("clamps probe timeout to timer-safe bounds", () => { @@ -93,6 +104,11 @@ describe("probeGateway", () => { "config.get", ]); expect(result.ok).toBe(true); + expect(result.auth).toMatchObject({ + role: "operator", + scopes: ["operator.read"], + capability: "read_only", + }); }); it("keeps device identity enabled for remote probes", async () => { @@ -198,7 +214,27 @@ describe("probeGateway", () => { ok: false, error: "gateway closed (1008): pairing required", close: { code: 1008, reason: "pairing required" }, + auth: { capability: "pairing_pending" }, }); expect(gatewayClientState.requests).toEqual([]); }); + + it("reports write-capable auth when hello-ok scopes include operator.write", async () => { + gatewayClientState.helloAuth = { + role: "operator", + scopes: ["operator.write"], + }; + + const result = await probeGateway({ + url: "ws://127.0.0.1:18789", + auth: { token: "secret" }, + timeoutMs: 1_000, + includeDetails: false, + }); + + expect(result.auth).toMatchObject({ + scopes: ["operator.write"], + capability: "write_capable", + }); + }); }); diff --git a/src/gateway/probe.ts b/src/gateway/probe.ts index 06864c59c08..d832e156bdc 100644 --- a/src/gateway/probe.ts +++ b/src/gateway/probe.ts @@ -17,12 +17,27 @@ export type GatewayProbeClose = { hint?: string; }; +export type GatewayProbeCapability = + | "unknown" + | "pairing_pending" + | "connected_no_operator_scope" + | "read_only" + | "write_capable" + | "admin_capable"; + +export type GatewayProbeAuthSummary = { + role: string | null; + scopes: string[]; + capability: GatewayProbeCapability; +}; + export type GatewayProbeResult = { ok: boolean; url: string; connectLatencyMs: number | null; error: string | null; close: GatewayProbeClose | null; + auth: GatewayProbeAuthSummary; health: unknown; status: unknown; presence: SystemPresence[] | null; @@ -31,6 +46,10 @@ export type GatewayProbeResult = { export const MIN_PROBE_TIMEOUT_MS = 250; export const MAX_TIMER_DELAY_MS = 2_147_483_647; +const PAIRING_REQUIRED_PATTERN = /\bpairing required\b/i; +const OPERATOR_READ_SCOPE = "operator.read"; +const OPERATOR_WRITE_SCOPE = "operator.write"; +const OPERATOR_ADMIN_SCOPE = "operator.admin"; export function clampProbeTimeoutMs(timeoutMs: number): number { return Math.min(MAX_TIMER_DELAY_MS, Math.max(MIN_PROBE_TIMEOUT_MS, timeoutMs)); @@ -40,6 +59,69 @@ function formatProbeCloseError(close: GatewayProbeClose): string { return `gateway closed (${close.code}): ${close.reason}`; } +function emptyProbeAuth(): GatewayProbeAuthSummary { + return { + role: null, + scopes: [], + capability: "unknown", + }; +} + +function resolveProbeAuthSummary(params: { + role?: string | null; + scopes?: string[]; + error?: string | null; + close?: GatewayProbeClose | null; + verifiedRead?: boolean; + connectLatencyMs?: number | null; +}): GatewayProbeAuthSummary { + const scopes = Array.isArray(params.scopes) ? params.scopes : []; + return { + role: params.role ?? null, + scopes, + capability: resolveGatewayProbeCapability({ + auth: { scopes }, + error: params.error, + close: params.close, + verifiedRead: params.verifiedRead, + connectLatencyMs: params.connectLatencyMs, + }), + }; +} + +export function isPairingPendingProbeFailure(params: { + error?: string | null; + close?: GatewayProbeClose | null; +}): boolean { + return PAIRING_REQUIRED_PATTERN.test(params.close?.reason ?? params.error ?? ""); +} + +export function resolveGatewayProbeCapability(params: { + auth?: Pick | null; + error?: string | null; + close?: GatewayProbeClose | null; + verifiedRead?: boolean; + connectLatencyMs?: number | null; +}): GatewayProbeCapability { + if (isPairingPendingProbeFailure(params)) { + return "pairing_pending"; + } + const scopes = Array.isArray(params.auth?.scopes) ? params.auth.scopes : []; + if (scopes.includes(OPERATOR_ADMIN_SCOPE)) { + return "admin_capable"; + } + if (scopes.includes(OPERATOR_WRITE_SCOPE)) { + return "write_capable"; + } + if (scopes.includes(OPERATOR_READ_SCOPE) || params.verifiedRead === true) { + return "read_only"; + } + if (params.connectLatencyMs != null) { + return "connected_no_operator_scope"; + } + return "unknown"; +} + export async function probeGateway(opts: { url: string; auth?: GatewayProbeAuth; @@ -53,6 +135,7 @@ export async function probeGateway(opts: { let connectLatencyMs: number | null = null; let connectError: string | null = null; let close: GatewayProbeClose | null = null; + let auth = emptyProbeAuth(); const detailLevel = opts.includeDetails === false ? "none" : (opts.detailLevel ?? "full"); @@ -100,6 +183,34 @@ export async function probeGateway(opts: { client.stop(); resolve({ url: opts.url, ...result }); }; + const settleProbe = (params: { + ok: boolean; + error: string | null; + verifiedRead?: boolean; + health: unknown; + status: unknown; + presence: SystemPresence[] | null; + configSnapshot: unknown; + }) => { + settle({ + ok: params.ok, + connectLatencyMs, + error: params.error, + close, + auth: resolveProbeAuthSummary({ + role: auth.role, + scopes: auth.scopes, + error: params.error, + close, + verifiedRead: params.verifiedRead, + connectLatencyMs, + }), + health: params.health, + status: params.status, + presence: params.presence, + configSnapshot: params.configSnapshot, + }); + }; const client = new GatewayClient({ url: opts.url, @@ -118,11 +229,9 @@ export async function probeGateway(opts: { onClose: (code, reason) => { close = { code, reason }; if (connectLatencyMs == null) { - settle({ + settleProbe({ ok: false, - connectLatencyMs, error: formatProbeCloseError(close), - close, health: null, status: null, presence: null, @@ -130,14 +239,19 @@ export async function probeGateway(opts: { }); } }, - onHelloOk: async () => { + onHelloOk: async (hello) => { connectLatencyMs = Date.now() - startedAt; + auth = resolveProbeAuthSummary({ + role: typeof hello?.auth?.role === "string" ? hello.auth.role : null, + scopes: Array.isArray(hello?.auth?.scopes) + ? hello.auth.scopes.filter((scope): scope is string => typeof scope === "string") + : [], + }); if (detailLevel === "none") { - settle({ + settleProbe({ ok: true, - connectLatencyMs, error: null, - close, + verifiedRead: false, health: null, status: null, presence: null, @@ -148,11 +262,9 @@ export async function probeGateway(opts: { // Once the gateway has accepted the session, a slow follow-up RPC should no longer // downgrade the probe to "unreachable". Give detail fetching its own budget. armProbeTimer(() => { - settle({ + settleProbe({ ok: false, - connectLatencyMs, error: "timeout", - close, health: null, status: null, presence: null, @@ -162,11 +274,10 @@ export async function probeGateway(opts: { try { if (detailLevel === "presence") { const presence = await client.request("system-presence"); - settle({ + settleProbe({ ok: true, - connectLatencyMs, error: null, - close, + verifiedRead: true, health: null, status: null, presence: Array.isArray(presence) ? (presence as SystemPresence[]) : null, @@ -180,22 +291,20 @@ export async function probeGateway(opts: { client.request("system-presence"), client.request("config.get", {}), ]); - settle({ + settleProbe({ ok: true, - connectLatencyMs, error: null, - close, + verifiedRead: true, health, status, presence: Array.isArray(presence) ? (presence as SystemPresence[]) : null, configSnapshot, }); } catch (err) { - settle({ + const error = formatErrorMessage(err); + settleProbe({ ok: false, - connectLatencyMs, - error: formatErrorMessage(err), - close, + error, health: null, status: null, presence: null, @@ -206,11 +315,10 @@ export async function probeGateway(opts: { }); armProbeTimer(() => { - settle({ + const error = connectError ? `connect failed: ${connectError}` : "timeout"; + settleProbe({ ok: false, - connectLatencyMs, - error: connectError ? `connect failed: ${connectError}` : "timeout", - close, + error, health: null, status: null, presence: null,