diff --git a/src/cli/daemon-cli/probe.test.ts b/src/cli/daemon-cli/probe.test.ts index 39de69aab93..d1bf932aa11 100644 --- a/src/cli/daemon-cli/probe.test.ts +++ b/src/cli/daemon-cli/probe.test.ts @@ -209,6 +209,26 @@ describe("probeGatewayStatus", () => { }); }); + it("keeps actionable probe errors when the close reason stays generic", async () => { + callGatewayMock.mockReset(); + probeGatewayMock.mockReset(); + probeGatewayMock.mockResolvedValueOnce({ + ok: false, + error: "scope upgrade pending approval (requestId: req-123)", + close: { code: 1008, reason: "pairing required" }, + }); + + const result = await probeGatewayStatus({ + url: "ws://127.0.0.1:19191", + timeoutMs: 5_000, + }); + + expect(result).toEqual({ + ok: false, + error: "scope upgrade pending approval (requestId: req-123)", + }); + }); + it("surfaces status RPC errors when requireRpc is enabled", async () => { callGatewayMock.mockReset(); probeGatewayMock.mockReset(); diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 72ccf0c423b..b01ba162905 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -1,6 +1,7 @@ import { withProgress } from "../cli/progress.js"; import { normalizePairingConnectRequestId, + readConnectPairingRequiredMessage, readPairingConnectErrorDetails, type ConnectPairingRequiredReason, } from "../gateway/protocol/connect-error-details.js"; @@ -84,15 +85,15 @@ export function resolvePairingRecoveryContext(params: { const source = [params.error, params.closeReason] .filter((part) => typeof part === "string" && part.trim().length > 0) .join(" "); - if (!source || !/pairing required/i.test(source)) { + const pairing = readConnectPairingRequiredMessage(source); + if (!pairing) { return null; } - const requestIdMatch = source.match(/requestId:\s*([^\s)]+)/i); - const requestId = - requestIdMatch && requestIdMatch[1] - ? (normalizePairingConnectRequestId(requestIdMatch[1]) ?? null) - : null; - return { requestId: requestId || null, reason: null, remediationHint: null }; + return { + requestId: normalizePairingConnectRequestId(pairing.requestId) ?? null, + reason: pairing.reason ?? null, + remediationHint: null, + }; } export async function statusCommand( diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index aace9a5326e..11ec78d9b39 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -1270,28 +1270,28 @@ describe("statusCommand", () => { it("prints safe gateway pairing recovery guidance", async () => { expect( resolvePairingRecoveryContext({ - error: "connect failed: pairing required (requestId: req-123)", - closeReason: "pairing required (requestId: req-123)", + error: "scope upgrade pending approval (requestId: req-123)", + closeReason: "pairing required", }), - ).toEqual({ requestId: "req-123", reason: null, remediationHint: null }); + ).toEqual({ requestId: "req-123", reason: "scope-upgrade", remediationHint: null }); expect( resolvePairingRecoveryContext({ error: "connect failed: pairing required", closeReason: "connect failed", }), - ).toEqual({ requestId: null, reason: null, remediationHint: null }); + ).toEqual({ requestId: null, reason: "not-paired", remediationHint: null }); expect( resolvePairingRecoveryContext({ error: "connect failed: pairing required (requestId: req-123;rm -rf /)", closeReason: "pairing required (requestId: req-123;rm -rf /)", }), - ).toEqual({ requestId: null, reason: null, remediationHint: null }); + ).toEqual({ requestId: null, reason: "not-paired", remediationHint: null }); expect( resolvePairingRecoveryContext({ error: "connect failed: pairing required", closeReason: "pairing required (requestId: req-close-456)", }), - ).toEqual({ requestId: "req-close-456", reason: null, remediationHint: null }); + ).toEqual({ requestId: "req-close-456", reason: "not-paired", remediationHint: null }); expect( resolvePairingRecoveryContext({ details: { @@ -1326,8 +1326,7 @@ describe("statusCommand", () => { channels: { whatsapp: { allowFrom: ["*"] } }, }); mockProbeGatewayResult({ - error: - "connect failed: pairing required: device is asking for more scopes than currently approved", + error: "scope upgrade pending approval (requestId: req-123)", connectErrorDetails: { code: "PAIRING_REQUIRED", reason: "scope-upgrade", @@ -1336,8 +1335,7 @@ describe("statusCommand", () => { }, close: { code: 1008, - reason: - "pairing required: device is asking for more scopes than currently approved (requestId: req-123)", + reason: "pairing required", }, }); const joined = await runStatusAndGetJoinedLogs(); diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 7af388ac861..d92e90590db 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -30,6 +30,7 @@ import { resolveConnectChallengeTimeoutMs } from "./handshake-timeouts.js"; import { isLoopbackHost, isSecureWebSocketUrl } from "./net.js"; import { ConnectErrorDetailCodes, + formatConnectErrorMessage, readConnectErrorDetailCode, readConnectErrorRecoveryAdvice, type ConnectErrorRecoveryAdvice, @@ -88,7 +89,7 @@ export class GatewayClientRequestError extends Error { readonly retryAfterMs?: number; constructor(error: GatewayClientErrorShape) { - super(error.message ?? "gateway request failed"); + super(formatConnectErrorMessage({ message: error.message, details: error.details })); this.name = "GatewayClientRequestError"; this.gatewayCode = error.code ?? "UNAVAILABLE"; this.details = error.details; diff --git a/src/gateway/probe.test.ts b/src/gateway/probe.test.ts index 4d4c80cb12f..871bf51e802 100644 --- a/src/gateway/probe.test.ts +++ b/src/gateway/probe.test.ts @@ -3,12 +3,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const gatewayClientState = vi.hoisted(() => ({ options: null as Record | null, requests: [] as string[], - startMode: "hello" as "hello" | "close", + startMode: "hello" as "hello" | "close" | "connect-error-close", close: { code: 1008, reason: "pairing required" }, helloAuth: { role: "operator", scopes: ["operator.read"], } as { role?: string; scopes?: string[] } | undefined, + connectError: "scope upgrade pending approval (requestId: req-123)", + connectErrorDetails: { + code: "PAIRING_REQUIRED", + reason: "scope-upgrade", + requestId: "req-123", + } as Record | null, })); const deviceIdentityState = vi.hoisted(() => ({ @@ -16,6 +22,15 @@ const deviceIdentityState = vi.hoisted(() => ({ throwOnLoad: false, })); +class MockGatewayClientRequestError extends Error { + readonly details?: unknown; + + constructor(error: { message?: string; details?: unknown }) { + super(error.message ?? "gateway request failed"); + this.details = error.details; + } +} + class MockGatewayClient { private readonly opts: Record; @@ -35,6 +50,22 @@ class MockGatewayClient { } return; } + if (gatewayClientState.startMode === "connect-error-close") { + const onConnectError = this.opts.onConnectError; + if (typeof onConnectError === "function") { + onConnectError( + new MockGatewayClientRequestError({ + message: gatewayClientState.connectError, + details: gatewayClientState.connectErrorDetails, + }), + ); + } + const onClose = this.opts.onClose; + if (typeof onClose === "function") { + onClose(gatewayClientState.close.code, gatewayClientState.close.reason); + } + return; + } const onHelloOk = this.opts.onHelloOk; if (typeof onHelloOk === "function") { await onHelloOk({ @@ -59,6 +90,7 @@ class MockGatewayClient { vi.mock("./client.js", () => ({ GatewayClient: MockGatewayClient, + GatewayClientRequestError: MockGatewayClientRequestError, })); vi.mock("../infra/device-identity.js", () => ({ @@ -81,6 +113,12 @@ describe("probeGateway", () => { role: "operator", scopes: ["operator.read"], }; + gatewayClientState.connectError = "scope upgrade pending approval (requestId: req-123)"; + gatewayClientState.connectErrorDetails = { + code: "PAIRING_REQUIRED", + reason: "scope-upgrade", + requestId: "req-123", + }; }); it("clamps probe timeout to timer-safe bounds", () => { @@ -271,4 +309,21 @@ describe("probeGateway", () => { capability: "connected_no_operator_scope", }); }); + + it("prefers the structured connect error over the generic close reason", async () => { + gatewayClientState.startMode = "connect-error-close"; + + const result = await probeGateway({ + url: "ws://127.0.0.1:18789", + auth: { token: "secret" }, + timeoutMs: 5_000, + includeDetails: false, + }); + + expect(result).toMatchObject({ + ok: false, + error: "scope upgrade pending approval (requestId: req-123)", + close: { code: 1008, reason: "pairing required" }, + }); + }); }); diff --git a/src/gateway/probe.ts b/src/gateway/probe.ts index bffe10c0378..b39b0fec14c 100644 --- a/src/gateway/probe.ts +++ b/src/gateway/probe.ts @@ -251,7 +251,7 @@ export async function probeGateway(opts: { if (connectLatencyMs == null) { settleProbe({ ok: false, - error: formatProbeCloseError(close), + error: connectError || formatProbeCloseError(close), health: null, status: null, presence: null, diff --git a/src/gateway/protocol/connect-error-details.test.ts b/src/gateway/protocol/connect-error-details.test.ts index 65b015aee65..a5cc897a953 100644 --- a/src/gateway/protocol/connect-error-details.test.ts +++ b/src/gateway/protocol/connect-error-details.test.ts @@ -5,9 +5,13 @@ import { buildPairingConnectErrorMessage, ConnectPairingRequiredReasons, describePairingConnectRequirement, + formatConnectErrorMessage, + formatConnectPairingRequiredMessage, normalizePairingConnectRequestId, readConnectErrorDetailCode, readConnectErrorRecoveryAdvice, + readConnectPairingRequiredDetails, + readConnectPairingRequiredMessage, readPairingConnectErrorDetails, } from "./connect-error-details.js"; @@ -113,4 +117,57 @@ describe("pairing connect details", () => { remediationHint: "Review the requested scopes, then approve the pending upgrade.", }); }); + + it("reads pairing details as compact connect details", () => { + expect( + readConnectPairingRequiredDetails({ + code: "PAIRING_REQUIRED", + requestId: "req-123", + reason: "scope-upgrade", + remediationHint: "Review the requested scopes, then approve the pending upgrade.", + }), + ).toEqual({ + requestId: "req-123", + reason: "scope-upgrade", + }); + }); + + it("formats upgrade rejections with the request id", () => { + expect( + formatConnectPairingRequiredMessage({ + code: "PAIRING_REQUIRED", + requestId: "req-123", + reason: "scope-upgrade", + }), + ).toBe("scope upgrade pending approval (requestId: req-123)"); + }); + + it("parses surfaced pairing-required messages", () => { + expect( + readConnectPairingRequiredMessage("scope upgrade pending approval (requestId: req-123)"), + ).toEqual({ + requestId: "req-123", + reason: "scope-upgrade", + }); + expect( + readConnectPairingRequiredMessage( + "scope upgrade pending approval (requestId: req-123;rm -rf /)", + ), + ).toEqual({ + reason: "scope-upgrade", + }); + }); + + it("prefers pairing detail formatting over the generic message", () => { + expect( + formatConnectErrorMessage({ + message: "pairing required", + details: { + code: "PAIRING_REQUIRED", + requestId: "req-123", + reason: "scope-upgrade", + }, + }), + ).toBe("scope upgrade pending approval (requestId: req-123)"); + }); }); diff --git a/src/gateway/protocol/connect-error-details.ts b/src/gateway/protocol/connect-error-details.ts index 0f1724d88cd..24c4e27e29a 100644 --- a/src/gateway/protocol/connect-error-details.ts +++ b/src/gateway/protocol/connect-error-details.ts @@ -66,6 +66,11 @@ export type PairingConnectErrorDetails = { approvedScopes?: string[]; }; +export type ConnectPairingRequiredDetails = Pick< + PairingConnectErrorDetails, + "reason" | "requestId" +>; + const CONNECT_RECOVERY_NEXT_STEP_VALUES: ReadonlySet = new Set([ "retry_with_device_token", "update_auth_configuration", @@ -114,6 +119,15 @@ const PAIRING_CONNECT_REASON_METADATA: Readonly< }, }; +const CONNECT_PAIRING_REQUIRED_MESSAGE_BY_REASON: Readonly< + Record +> = { + "not-paired": "device pairing required", + "role-upgrade": "role upgrade pending approval", + "scope-upgrade": "scope upgrade pending approval", + "metadata-upgrade": "device metadata change pending approval", +}; + export function resolveAuthConnectErrorDetailCode( reason: string | undefined, ): ConnectErrorDetailCode { @@ -337,3 +351,64 @@ export function readPairingConnectErrorDetails( ...(approvedScopes ? { approvedScopes } : {}), }; } + +export function readConnectPairingRequiredDetails( + details: unknown, +): ConnectPairingRequiredDetails | null { + const pairing = readPairingConnectErrorDetails(details); + if (!pairing) { + return null; + } + return { + ...(pairing.requestId ? { requestId: pairing.requestId } : {}), + ...(pairing.reason ? { reason: pairing.reason } : {}), + }; +} + +export function readConnectPairingRequiredMessage( + message: string | null | undefined, +): ConnectPairingRequiredDetails | null { + const normalizedMessage = normalizeOptionalString(message); + if (!normalizedMessage) { + return null; + } + const normalized = normalizedMessage.trim().toLowerCase(); + let reason: ConnectPairingRequiredReason | undefined; + for (const [candidate, prefix] of Object.entries( + CONNECT_PAIRING_REQUIRED_MESSAGE_BY_REASON, + ) as Array<[ConnectPairingRequiredReason, string]>) { + if (normalized.includes(prefix)) { + reason = candidate; + break; + } + } + if (!reason && normalized.includes("pairing required")) { + reason = ConnectPairingRequiredReasons.NOT_PAIRED; + } + if (!reason) { + return null; + } + const requestId = normalizePairingConnectRequestId( + normalizedMessage.match(/\(requestId:\s*([^\s)]+)\)/i)?.[1], + ); + return { + ...(requestId ? { requestId } : {}), + reason, + }; +} + +export function formatConnectPairingRequiredMessage(details: unknown): string { + const pairing = readPairingConnectErrorDetails(details); + const base = + CONNECT_PAIRING_REQUIRED_MESSAGE_BY_REASON[ + pairing?.reason ?? ConnectPairingRequiredReasons.NOT_PAIRED + ]; + return pairing?.requestId ? `${base} (requestId: ${pairing.requestId})` : base; +} + +export function formatConnectErrorMessage(params: { message?: string; details?: unknown }): string { + if (readConnectErrorDetailCode(params.details) === ConnectErrorDetailCodes.PAIRING_REQUIRED) { + return formatConnectPairingRequiredMessage(params.details); + } + return normalizeOptionalString(params.message) ?? "gateway request failed"; +} diff --git a/src/gateway/server.roles-allowlist-update.test.ts b/src/gateway/server.roles-allowlist-update.test.ts index 2409250195d..837f9428aca 100644 --- a/src/gateway/server.roles-allowlist-update.test.ts +++ b/src/gateway/server.roles-allowlist-update.test.ts @@ -519,7 +519,7 @@ describe("gateway node command allowlist", () => { displayName: "node-platform-pin", deviceIdentity, }), - ).rejects.toThrow(/pairing required/i); + ).rejects.toThrow(/device metadata change pending approval/i); } finally { await iosClient?.stopAndWait(); }