mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:50:49 +00:00
fix(gateway): split probe capability from reachability
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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`),
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user