diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index 64d515c0b4d..a9a00a96a29 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -201,6 +201,54 @@ describe("gateway-status command", () => { expect(targets[0]?.summary).toBeTruthy(); }); + it("treats missing-scope RPC probe failures as degraded but reachable", async () => { + const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture(); + readBestEffortConfig.mockResolvedValueOnce({ + gateway: { + mode: "local", + auth: { mode: "token", token: "ltok" }, + }, + } as never); + probeGateway.mockResolvedValueOnce({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: 51, + error: "missing scope: operator.read", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + await runGatewayStatus(runtime, { timeout: "1000", json: true }); + + expect(runtimeErrors).toHaveLength(0); + const parsed = JSON.parse(runtimeLogs.join("\n")) as { + ok?: boolean; + degraded?: boolean; + warnings?: Array<{ code?: string; targetIds?: string[] }>; + targets?: Array<{ + connect?: { + ok?: boolean; + rpcOk?: boolean; + scopeLimited?: boolean; + }; + }>; + }; + expect(parsed.ok).toBe(true); + expect(parsed.degraded).toBe(true); + expect(parsed.targets?.[0]?.connect).toMatchObject({ + ok: true, + rpcOk: false, + scopeLimited: true, + }); + const scopeLimitedWarning = parsed.warnings?.find( + (warning) => warning.code === "probe_scope_limited", + ); + expect(scopeLimitedWarning?.targetIds).toContain("localLoopback"); + }); + it("surfaces unresolved SecretRef auth diagnostics in warnings", async () => { const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture(); await withEnvAsync({ MISSING_GATEWAY_TOKEN: undefined }, async () => { diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts index 4ac54eca0c4..be0b9abf69a 100644 --- a/src/commands/gateway-status.ts +++ b/src/commands/gateway-status.ts @@ -10,6 +10,8 @@ import { colorize, isRich, theme } from "../terminal/theme.js"; import { buildNetworkHints, extractConfigSummary, + isProbeReachable, + isScopeLimitedProbeFailure, type GatewayStatusTarget, parseTimeoutMs, pickGatewaySelfPresence, @@ -193,8 +195,10 @@ export async function gatewayStatusCommand( }, ); - const reachable = probed.filter((p) => p.probe.ok); + const reachable = probed.filter((p) => isProbeReachable(p.probe)); const ok = reachable.length > 0; + const degradedScopeLimited = probed.filter((p) => isScopeLimitedProbeFailure(p.probe)); + const degraded = degradedScopeLimited.length > 0; const multipleGateways = reachable.length > 1; const primary = reachable.find((p) => p.target.kind === "explicit") ?? @@ -236,12 +240,21 @@ export async function gatewayStatusCommand( }); } } + for (const result of degradedScopeLimited) { + 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.", + targetIds: [result.target.id], + }); + } if (opts.json) { runtime.log( JSON.stringify( { ok, + degraded, ts: Date.now(), durationMs: Date.now() - startedAt, timeoutMs: overallTimeoutMs, @@ -274,7 +287,9 @@ export async function gatewayStatusCommand( active: p.target.active, tunnel: p.target.tunnel ?? null, connect: { - ok: p.probe.ok, + ok: isProbeReachable(p.probe), + rpcOk: p.probe.ok, + scopeLimited: isScopeLimitedProbeFailure(p.probe), latencyMs: p.probe.connectLatencyMs, error: p.probe.error, close: p.probe.close, diff --git a/src/commands/gateway-status/helpers.test.ts b/src/commands/gateway-status/helpers.test.ts index 688959f0748..e0c1ecee763 100644 --- a/src/commands/gateway-status/helpers.test.ts +++ b/src/commands/gateway-status/helpers.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; import { withEnvAsync } from "../../test-utils/env.js"; -import { extractConfigSummary, resolveAuthForTarget } from "./helpers.js"; +import { + extractConfigSummary, + isProbeReachable, + isScopeLimitedProbeFailure, + renderProbeSummaryLine, + resolveAuthForTarget, +} from "./helpers.js"; describe("extractConfigSummary", () => { it("marks SecretRef-backed gateway auth credentials as configured", () => { @@ -229,3 +235,41 @@ describe("resolveAuthForTarget", () => { ); }); }); + +describe("probe reachability classification", () => { + it("treats missing-scope RPC failures as scope-limited and reachable", () => { + const probe = { + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: 51, + error: "missing scope: operator.read", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }; + + expect(isScopeLimitedProbeFailure(probe)).toBe(true); + expect(isProbeReachable(probe)).toBe(true); + expect(renderProbeSummaryLine(probe, false)).toContain("RPC: limited"); + }); + + it("keeps non-scope RPC failures as unreachable", () => { + const probe = { + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: 43, + error: "unknown method: status", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }; + + expect(isScopeLimitedProbeFailure(probe)).toBe(false); + expect(isProbeReachable(probe)).toBe(false); + expect(renderProbeSummaryLine(probe, false)).toContain("RPC: failed"); + }); +}); diff --git a/src/commands/gateway-status/helpers.ts b/src/commands/gateway-status/helpers.ts index 7697d6af143..5f1a5e2f5ee 100644 --- a/src/commands/gateway-status/helpers.ts +++ b/src/commands/gateway-status/helpers.ts @@ -9,6 +9,8 @@ import { pickPrimaryTailnetIPv4 } from "../../infra/tailnet.js"; import { colorize, theme } from "../../terminal/theme.js"; import { pickGatewaySelfPresence } from "../gateway-presence.js"; +const MISSING_SCOPE_PATTERN = /\bmissing scope:\s*[a-z0-9._-]+/i; + type TargetKind = "explicit" | "configRemote" | "localLoopback" | "sshTunnel"; export type GatewayStatusTarget = { @@ -324,6 +326,17 @@ export function renderTargetHeader(target: GatewayStatusTarget, rich: boolean) { return `${colorize(rich, theme.heading, kindLabel)} ${colorize(rich, theme.muted, target.url)}`; } +export function isScopeLimitedProbeFailure(probe: GatewayProbeResult): boolean { + if (probe.ok || probe.connectLatencyMs == null) { + return false; + } + return MISSING_SCOPE_PATTERN.test(probe.error ?? ""); +} + +export function isProbeReachable(probe: GatewayProbeResult): boolean { + return probe.ok || isScopeLimitedProbeFailure(probe); +} + export function renderProbeSummaryLine(probe: GatewayProbeResult, rich: boolean) { if (probe.ok) { const latency = @@ -335,7 +348,10 @@ export function renderProbeSummaryLine(probe: GatewayProbeResult, rich: boolean) if (probe.connectLatencyMs != null) { const latency = typeof probe.connectLatencyMs === "number" ? `${probe.connectLatencyMs}ms` : "unknown"; - return `${colorize(rich, theme.success, "Connect: ok")} (${latency}) · ${colorize(rich, theme.error, "RPC: failed")}${detail}`; + 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}`; } return `${colorize(rich, theme.error, "Connect: failed")}${detail}`;