Skip to content

Commit 5c4965f

Browse files
MainframeMainframe
authored andcommitted
Gateway: treat scope-limited probe RPC as degraded
1 parent 2bfe188 commit 5c4965f

File tree

4 files changed

+127
-4
lines changed

4 files changed

+127
-4
lines changed

src/commands/gateway-status.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,54 @@ describe("gateway-status command", () => {
201201
expect(targets[0]?.summary).toBeTruthy();
202202
});
203203

204+
it("treats missing-scope RPC probe failures as degraded but reachable", async () => {
205+
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
206+
readBestEffortConfig.mockResolvedValueOnce({
207+
gateway: {
208+
mode: "local",
209+
auth: { mode: "token", token: "ltok" },
210+
},
211+
} as never);
212+
probeGateway.mockResolvedValueOnce({
213+
ok: false,
214+
url: "ws://127.0.0.1:18789",
215+
connectLatencyMs: 51,
216+
error: "missing scope: operator.read",
217+
close: null,
218+
health: null,
219+
status: null,
220+
presence: null,
221+
configSnapshot: null,
222+
});
223+
224+
await runGatewayStatus(runtime, { timeout: "1000", json: true });
225+
226+
expect(runtimeErrors).toHaveLength(0);
227+
const parsed = JSON.parse(runtimeLogs.join("\n")) as {
228+
ok?: boolean;
229+
degraded?: boolean;
230+
warnings?: Array<{ code?: string; targetIds?: string[] }>;
231+
targets?: Array<{
232+
connect?: {
233+
ok?: boolean;
234+
rpcOk?: boolean;
235+
scopeLimited?: boolean;
236+
};
237+
}>;
238+
};
239+
expect(parsed.ok).toBe(true);
240+
expect(parsed.degraded).toBe(true);
241+
expect(parsed.targets?.[0]?.connect).toMatchObject({
242+
ok: true,
243+
rpcOk: false,
244+
scopeLimited: true,
245+
});
246+
const scopeLimitedWarning = parsed.warnings?.find(
247+
(warning) => warning.code === "probe_scope_limited",
248+
);
249+
expect(scopeLimitedWarning?.targetIds).toContain("localLoopback");
250+
});
251+
204252
it("surfaces unresolved SecretRef auth diagnostics in warnings", async () => {
205253
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
206254
await withEnvAsync({ MISSING_GATEWAY_TOKEN: undefined }, async () => {

src/commands/gateway-status.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { colorize, isRich, theme } from "../terminal/theme.js";
1010
import {
1111
buildNetworkHints,
1212
extractConfigSummary,
13+
isProbeReachable,
14+
isScopeLimitedProbeFailure,
1315
type GatewayStatusTarget,
1416
parseTimeoutMs,
1517
pickGatewaySelfPresence,
@@ -193,8 +195,10 @@ export async function gatewayStatusCommand(
193195
},
194196
);
195197

196-
const reachable = probed.filter((p) => p.probe.ok);
198+
const reachable = probed.filter((p) => isProbeReachable(p.probe));
197199
const ok = reachable.length > 0;
200+
const degradedScopeLimited = probed.filter((p) => isScopeLimitedProbeFailure(p.probe));
201+
const degraded = degradedScopeLimited.length > 0;
198202
const multipleGateways = reachable.length > 1;
199203
const primary =
200204
reachable.find((p) => p.target.kind === "explicit") ??
@@ -236,12 +240,21 @@ export async function gatewayStatusCommand(
236240
});
237241
}
238242
}
243+
for (const result of degradedScopeLimited) {
244+
warnings.push({
245+
code: "probe_scope_limited",
246+
message:
247+
"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.",
248+
targetIds: [result.target.id],
249+
});
250+
}
239251

240252
if (opts.json) {
241253
runtime.log(
242254
JSON.stringify(
243255
{
244256
ok,
257+
degraded,
245258
ts: Date.now(),
246259
durationMs: Date.now() - startedAt,
247260
timeoutMs: overallTimeoutMs,
@@ -274,7 +287,9 @@ export async function gatewayStatusCommand(
274287
active: p.target.active,
275288
tunnel: p.target.tunnel ?? null,
276289
connect: {
277-
ok: p.probe.ok,
290+
ok: isProbeReachable(p.probe),
291+
rpcOk: p.probe.ok,
292+
scopeLimited: isScopeLimitedProbeFailure(p.probe),
278293
latencyMs: p.probe.connectLatencyMs,
279294
error: p.probe.error,
280295
close: p.probe.close,

src/commands/gateway-status/helpers.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { describe, expect, it } from "vitest";
22
import { withEnvAsync } from "../../test-utils/env.js";
3-
import { extractConfigSummary, resolveAuthForTarget } from "./helpers.js";
3+
import {
4+
extractConfigSummary,
5+
isProbeReachable,
6+
isScopeLimitedProbeFailure,
7+
renderProbeSummaryLine,
8+
resolveAuthForTarget,
9+
} from "./helpers.js";
410

511
describe("extractConfigSummary", () => {
612
it("marks SecretRef-backed gateway auth credentials as configured", () => {
@@ -229,3 +235,41 @@ describe("resolveAuthForTarget", () => {
229235
);
230236
});
231237
});
238+
239+
describe("probe reachability classification", () => {
240+
it("treats missing-scope RPC failures as scope-limited and reachable", () => {
241+
const probe = {
242+
ok: false,
243+
url: "ws://127.0.0.1:18789",
244+
connectLatencyMs: 51,
245+
error: "missing scope: operator.read",
246+
close: null,
247+
health: null,
248+
status: null,
249+
presence: null,
250+
configSnapshot: null,
251+
};
252+
253+
expect(isScopeLimitedProbeFailure(probe)).toBe(true);
254+
expect(isProbeReachable(probe)).toBe(true);
255+
expect(renderProbeSummaryLine(probe, false)).toContain("RPC: limited");
256+
});
257+
258+
it("keeps non-scope RPC failures as unreachable", () => {
259+
const probe = {
260+
ok: false,
261+
url: "ws://127.0.0.1:18789",
262+
connectLatencyMs: 43,
263+
error: "unknown method: status",
264+
close: null,
265+
health: null,
266+
status: null,
267+
presence: null,
268+
configSnapshot: null,
269+
};
270+
271+
expect(isScopeLimitedProbeFailure(probe)).toBe(false);
272+
expect(isProbeReachable(probe)).toBe(false);
273+
expect(renderProbeSummaryLine(probe, false)).toContain("RPC: failed");
274+
});
275+
});

src/commands/gateway-status/helpers.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { pickPrimaryTailnetIPv4 } from "../../infra/tailnet.js";
88
import { colorize, theme } from "../../terminal/theme.js";
99
import { pickGatewaySelfPresence } from "../gateway-presence.js";
1010

11+
const MISSING_SCOPE_PATTERN = /\bmissing scope:\s*[a-z0-9._-]+/i;
12+
1113
type TargetKind = "explicit" | "configRemote" | "localLoopback" | "sshTunnel";
1214

1315
export type GatewayStatusTarget = {
@@ -336,6 +338,17 @@ export function renderTargetHeader(target: GatewayStatusTarget, rich: boolean) {
336338
return `${colorize(rich, theme.heading, kindLabel)} ${colorize(rich, theme.muted, target.url)}`;
337339
}
338340

341+
export function isScopeLimitedProbeFailure(probe: GatewayProbeResult): boolean {
342+
if (probe.ok || probe.connectLatencyMs == null) {
343+
return false;
344+
}
345+
return MISSING_SCOPE_PATTERN.test(probe.error ?? "");
346+
}
347+
348+
export function isProbeReachable(probe: GatewayProbeResult): boolean {
349+
return probe.ok || isScopeLimitedProbeFailure(probe);
350+
}
351+
339352
export function renderProbeSummaryLine(probe: GatewayProbeResult, rich: boolean) {
340353
if (probe.ok) {
341354
const latency =
@@ -347,7 +360,10 @@ export function renderProbeSummaryLine(probe: GatewayProbeResult, rich: boolean)
347360
if (probe.connectLatencyMs != null) {
348361
const latency =
349362
typeof probe.connectLatencyMs === "number" ? `${probe.connectLatencyMs}ms` : "unknown";
350-
return `${colorize(rich, theme.success, "Connect: ok")} (${latency}) · ${colorize(rich, theme.error, "RPC: failed")}${detail}`;
363+
const rpcStatus = isScopeLimitedProbeFailure(probe)
364+
? colorize(rich, theme.warn, "RPC: limited")
365+
: colorize(rich, theme.error, "RPC: failed");
366+
return `${colorize(rich, theme.success, "Connect: ok")} (${latency}) · ${rpcStatus}${detail}`;
351367
}
352368

353369
return `${colorize(rich, theme.error, "Connect: failed")}${detail}`;

0 commit comments

Comments
 (0)