fix(gateway): surface pending pairing upgrade details

This commit is contained in:
Ayaan Zaidi
2026-04-20 11:52:51 +05:30
parent d63671fce0
commit f070a92e19
9 changed files with 228 additions and 21 deletions

View File

@@ -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();

View File

@@ -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(

View File

@@ -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();

View File

@@ -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;

View File

@@ -3,12 +3,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const gatewayClientState = vi.hoisted(() => ({
options: null as Record<string, unknown> | 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<string, unknown> | 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<string, unknown>;
@@ -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" },
});
});
});

View File

@@ -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,

View File

@@ -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)");
});
});

View File

@@ -66,6 +66,11 @@ export type PairingConnectErrorDetails = {
approvedScopes?: string[];
};
export type ConnectPairingRequiredDetails = Pick<
PairingConnectErrorDetails,
"reason" | "requestId"
>;
const CONNECT_RECOVERY_NEXT_STEP_VALUES: ReadonlySet<ConnectRecoveryNextStep> = 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<ConnectPairingRequiredReason, string>
> = {
"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";
}

View File

@@ -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();
}