diff --git a/src/cli/daemon-cli/probe.test.ts b/src/cli/daemon-cli/probe.test.ts index 27e3c330c2a..39de69aab93 100644 --- a/src/cli/daemon-cli/probe.test.ts +++ b/src/cli/daemon-cli/probe.test.ts @@ -63,6 +63,14 @@ describe("probeGatewayStatus", () => { callGatewayMock.mockReset(); probeGatewayMock.mockReset(); callGatewayMock.mockResolvedValueOnce({ status: "ok" }); + probeGatewayMock.mockResolvedValueOnce({ + ok: true, + auth: { + role: "operator", + scopes: ["operator.admin"], + capability: "admin_capable", + }, + }); const result = await probeGatewayStatus({ url: "ws://127.0.0.1:19191", @@ -77,10 +85,23 @@ describe("probeGatewayStatus", () => { expect(result).toEqual({ ok: true, kind: "read", - capability: "read_only", - auth: undefined, + capability: "admin_capable", + auth: { + role: "operator", + scopes: ["operator.admin"], + capability: "admin_capable", + }, + }); + expect(probeGatewayMock).toHaveBeenCalledWith({ + url: "ws://127.0.0.1:19191", + auth: { + token: "temp-token", + password: undefined, + }, + tlsFingerprint: "abc123", + timeoutMs: 5_000, + includeDetails: false, }); - expect(probeGatewayMock).not.toHaveBeenCalled(); expect(callGatewayMock).toHaveBeenCalledWith({ url: "ws://127.0.0.1:19191", token: "temp-token", @@ -92,6 +113,38 @@ describe("probeGatewayStatus", () => { }); }); + it("falls back to read-only when the status RPC succeeds but the auth probe is inconclusive", async () => { + callGatewayMock.mockReset(); + probeGatewayMock.mockReset(); + callGatewayMock.mockResolvedValueOnce({ status: "ok" }); + probeGatewayMock.mockResolvedValueOnce({ + ok: true, + auth: { + role: null, + scopes: [], + capability: "unknown", + }, + }); + + const result = await probeGatewayStatus({ + url: "ws://127.0.0.1:19191", + token: "temp-token", + timeoutMs: 5_000, + requireRpc: true, + }); + + expect(result).toEqual({ + ok: true, + kind: "read", + capability: "read_only", + auth: { + role: null, + scopes: [], + capability: "unknown", + }, + }); + }); + it("surfaces probe close details when the handshake fails", async () => { callGatewayMock.mockReset(); probeGatewayMock.mockReset(); diff --git a/src/cli/daemon-cli/probe.ts b/src/cli/daemon-cli/probe.ts index 8b092180862..09ef347efcb 100644 --- a/src/cli/daemon-cli/probe.ts +++ b/src/cli/daemon-cli/probe.ts @@ -46,7 +46,18 @@ export async function probeGatewayStatus(opts: { timeoutMs: opts.timeoutMs, ...(opts.configPath ? { configPath: opts.configPath } : {}), }); - return { ok: true } as const; + const { probeGateway } = await import("../../gateway/probe.js"); + const authProbe = await probeGateway({ + url: opts.url, + auth: { + token: opts.token, + password: opts.password, + }, + tlsFingerprint: opts.tlsFingerprint, + timeoutMs: opts.timeoutMs, + includeDetails: false, + }).catch(() => null); + return { ok: true as const, authProbe }; } const { probeGateway } = await import("../../gateway/probe.js"); return await probeGateway({ @@ -61,12 +72,20 @@ export async function probeGatewayStatus(opts: { }); }, ); - const auth = "auth" in result ? result.auth : undefined; + const auth = + "auth" in result ? result.auth : "authProbe" in result ? result.authProbe?.auth : undefined; if (result.ok) { return { ok: true, kind, - capability: opts.requireRpc ? "read_only" : auth?.capability, + capability: + kind === "read" + ? auth?.capability && auth.capability !== "unknown" + ? auth.capability + : // The status RPC proves read access even when a follow-up hello probe + // cannot recover richer scope metadata. + "read_only" + : auth?.capability, auth, } as const; } diff --git a/src/commands/gateway-status/output.test.ts b/src/commands/gateway-status/output.test.ts new file mode 100644 index 00000000000..181b99ab6a3 --- /dev/null +++ b/src/commands/gateway-status/output.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { GatewayProbeResult } from "../../gateway/probe.js"; +import type { RuntimeEnv } from "../../runtime.js"; +import type { GatewayStatusProbedTarget } from "./probe-run.js"; + +const writeRuntimeJson = vi.fn(); + +vi.mock("../../runtime.js", () => ({ + writeRuntimeJson: (...args: unknown[]) => writeRuntimeJson(...args), +})); + +vi.mock("../../terminal/theme.js", async () => { + const actual = + await vi.importActual("../../terminal/theme.js"); + return { + ...actual, + colorize: (_rich: boolean, _theme: unknown, text: string) => text, + }; +}); + +const { writeGatewayStatusJson, writeGatewayStatusText } = await import("./output.js"); + +function createRuntimeCapture(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`__exit__:${code}`); + }), + } as unknown as RuntimeEnv; +} + +function createProbe( + capability: GatewayProbeResult["auth"]["capability"], + params: { + ok: boolean; + connectLatencyMs: number | null; + error?: string | null; + }, +): GatewayProbeResult { + return { + ok: params.ok, + url: "ws://127.0.0.1:18789", + connectLatencyMs: params.connectLatencyMs, + error: params.error ?? null, + close: null, + auth: { + role: "operator", + scopes: capability === "admin_capable" ? ["operator.admin"] : ["operator.read"], + capability, + }, + health: null, + status: null, + presence: null, + configSnapshot: null, + }; +} + +function createTarget(id: string, probe: GatewayProbeResult): GatewayStatusProbedTarget { + return { + target: { + id, + kind: "explicit", + url: probe.url, + active: true, + }, + probe, + configSummary: null, + self: null, + authDiagnostics: [], + }; +} + +describe("gateway status output", () => { + beforeEach(() => { + writeRuntimeJson.mockReset(); + }); + + it("derives summary capability from reachable probes only in json output", () => { + const runtime = createRuntimeCapture(); + writeGatewayStatusJson({ + runtime, + startedAt: Date.now() - 50, + overallTimeoutMs: 5_000, + discoveryTimeoutMs: 500, + network: { + localLoopbackUrl: "ws://127.0.0.1:18789", + localTailnetUrl: null, + tailnetIPv4: null, + }, + discovery: [], + probed: [ + createTarget( + "unreachable-admin", + createProbe("admin_capable", { + ok: false, + connectLatencyMs: 40, + error: "unknown method: status", + }), + ), + createTarget( + "reachable-read", + createProbe("read_only", { + ok: true, + connectLatencyMs: 20, + }), + ), + ], + warnings: [], + primaryTargetId: "reachable-read", + }); + + expect(writeRuntimeJson).toHaveBeenCalledWith( + runtime, + expect.objectContaining({ + ok: true, + capability: "read_only", + }), + ); + }); + + it("derives summary capability from reachable probes only in text output", () => { + const runtime = createRuntimeCapture(); + writeGatewayStatusText({ + runtime, + rich: false, + overallTimeoutMs: 5_000, + discovery: [], + probed: [ + createTarget( + "unreachable-admin", + createProbe("admin_capable", { + ok: false, + connectLatencyMs: 40, + error: "unknown method: status", + }), + ), + createTarget( + "reachable-read", + createProbe("read_only", { + ok: false, + connectLatencyMs: 20, + error: "missing scope: operator.read", + }), + ), + ], + warnings: [], + }); + + expect(runtime.log).toHaveBeenCalledWith("Capability: read-only"); + }); +}); diff --git a/src/commands/gateway-status/output.ts b/src/commands/gateway-status/output.ts index 1b51603c5a4..8ce7d38aa88 100644 --- a/src/commands/gateway-status/output.ts +++ b/src/commands/gateway-status/output.ts @@ -99,7 +99,7 @@ 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)); + const capability = summarizeGatewayProbeCapability(reachable.map((entry) => entry.probe)); writeRuntimeJson(params.runtime, { ok: reachable.length > 0, degraded, @@ -153,7 +153,7 @@ 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)); + const capability = summarizeGatewayProbeCapability(reachable.map((entry) => entry.probe)); params.runtime.log(colorize(params.rich, theme.heading, "Gateway Status")); params.runtime.log( ok diff --git a/src/gateway/probe.test.ts b/src/gateway/probe.test.ts index edea954a156..4d4c80cb12f 100644 --- a/src/gateway/probe.test.ts +++ b/src/gateway/probe.test.ts @@ -8,7 +8,7 @@ const gatewayClientState = vi.hoisted(() => ({ helloAuth: { role: "operator", scopes: ["operator.read"], - }, + } as { role?: string; scopes?: string[] } | undefined, })); const deviceIdentityState = vi.hoisted(() => ({ @@ -237,4 +237,38 @@ describe("probeGateway", () => { capability: "write_capable", }); }); + + it("keeps capability unknown when hello-ok omits auth metadata", async () => { + gatewayClientState.helloAuth = undefined; + + const result = await probeGateway({ + url: "ws://127.0.0.1:18789", + auth: { token: "secret" }, + timeoutMs: 1_000, + includeDetails: false, + }); + + expect(result.auth).toMatchObject({ + role: null, + scopes: [], + capability: "unknown", + }); + }); + + it("reports connect-only only when hello-ok explicitly includes empty auth metadata", async () => { + gatewayClientState.helloAuth = {}; + + const result = await probeGateway({ + url: "ws://127.0.0.1:18789", + auth: { token: "secret" }, + timeoutMs: 1_000, + includeDetails: false, + }); + + expect(result.auth).toMatchObject({ + role: null, + scopes: [], + capability: "connected_no_operator_scope", + }); + }); }); diff --git a/src/gateway/probe.ts b/src/gateway/probe.ts index d832e156bdc..9704da78d1a 100644 --- a/src/gateway/probe.ts +++ b/src/gateway/probe.ts @@ -70,6 +70,7 @@ function emptyProbeAuth(): GatewayProbeAuthSummary { function resolveProbeAuthSummary(params: { role?: string | null; scopes?: string[]; + authMetadataPresent?: boolean; error?: string | null; close?: GatewayProbeClose | null; verifiedRead?: boolean; @@ -81,6 +82,7 @@ function resolveProbeAuthSummary(params: { scopes, capability: resolveGatewayProbeCapability({ auth: { scopes }, + authMetadataPresent: params.authMetadataPresent, error: params.error, close: params.close, verifiedRead: params.verifiedRead, @@ -98,6 +100,7 @@ export function isPairingPendingProbeFailure(params: { export function resolveGatewayProbeCapability(params: { auth?: Pick | null; + authMetadataPresent?: boolean; error?: string | null; close?: GatewayProbeClose | null; verifiedRead?: boolean; @@ -116,7 +119,7 @@ export function resolveGatewayProbeCapability(params: { if (scopes.includes(OPERATOR_READ_SCOPE) || params.verifiedRead === true) { return "read_only"; } - if (params.connectLatencyMs != null) { + if (params.connectLatencyMs != null && params.authMetadataPresent === true) { return "connected_no_operator_scope"; } return "unknown"; @@ -136,6 +139,7 @@ export async function probeGateway(opts: { let connectError: string | null = null; let close: GatewayProbeClose | null = null; let auth = emptyProbeAuth(); + let authMetadataPresent = false; const detailLevel = opts.includeDetails === false ? "none" : (opts.detailLevel ?? "full"); @@ -200,6 +204,7 @@ export async function probeGateway(opts: { auth: resolveProbeAuthSummary({ role: auth.role, scopes: auth.scopes, + authMetadataPresent, error: params.error, close, verifiedRead: params.verifiedRead, @@ -241,11 +246,13 @@ export async function probeGateway(opts: { }, onHelloOk: async (hello) => { connectLatencyMs = Date.now() - startedAt; + authMetadataPresent = typeof hello?.auth === "object" && hello.auth !== null; 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") : [], + authMetadataPresent, }); if (detailLevel === "none") { settleProbe({