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

View File

@@ -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 <url>", "Explicit Gateway WebSocket URL (still probes localhost)")
.option("--ssh <target>", "SSH target for remote gateway tunnel (user@host or user@host:port)")
.option("--ssh-identity <path>", "SSH identity file path")

View File

@@ -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<typeof import("../gateway/probe.js")>()),
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: {

View File

@@ -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", () => {

View File

@@ -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}`;
}

View File

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

View File

@@ -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",
});
});
});

View File

@@ -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<GatewayProbeAuthSummary, "scopes"> | 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,