From 98a0b22e8e9f75bbd9c7c440a220c0a9d53c6e0f Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 20 Apr 2026 11:53:46 +0530 Subject: [PATCH] fix(status): show pairing recovery details --- src/commands/status.command-report-data.ts | 7 +++- src/commands/status.command-sections.test.ts | 10 ++++- src/commands/status.command-sections.ts | 19 ++++++++- src/commands/status.command.ts | 22 ++++++++++- src/commands/status.test-support.ts | 2 +- src/commands/status.test.ts | 41 ++++++++++++++++---- ui/src/ui/connect-error.node.test.ts | 29 ++++++++++++++ ui/src/ui/connect-error.ts | 8 +++- 8 files changed, 122 insertions(+), 16 deletions(-) create mode 100644 ui/src/ui/connect-error.node.test.ts diff --git a/src/commands/status.command-report-data.ts b/src/commands/status.command-report-data.ts index 79280d1b860..d5d8c72fb54 100644 --- a/src/commands/status.command-report-data.ts +++ b/src/commands/status.command-report-data.ts @@ -1,3 +1,4 @@ +import type { ConnectPairingRequiredReason } from "../gateway/protocol/connect-error-details.js"; import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js"; import type { resolveOsSummary } from "../infra/os-summary.js"; import type { PluginCompatibilityNotice } from "../plugins/status.js"; @@ -61,7 +62,11 @@ export async function buildStatusCommandReportData( memory: MemoryStatusSnapshot | null; memoryPlugin: MemoryPluginStatus; pluginCompatibility: PluginCompatibilityNotice[]; - pairingRecovery: { requestId: string | null } | null; + pairingRecovery: { + requestId: string | null; + reason: ConnectPairingRequiredReason | null; + remediationHint: string | null; + } | null; tableWidth: number; ok: (value: string) => string; warn: (value: string) => string; diff --git a/src/commands/status.command-sections.test.ts b/src/commands/status.command-sections.test.ts index 2cca1d8fa63..89b102344f4 100644 --- a/src/commands/status.command-sections.test.ts +++ b/src/commands/status.command-sections.test.ts @@ -175,13 +175,19 @@ describe("status.command-sections", () => { expect( buildStatusPairingRecoveryLines({ - pairingRecovery: { requestId: "req-123" }, + pairingRecovery: { + requestId: "req-123", + reason: "scope-upgrade", + remediationHint: "Review the requested scopes, then approve the pending upgrade.", + }, warn: (value) => `warn(${value})`, muted: (value) => `muted(${value})`, formatCliCommand: (value) => `cmd:${value}`, }), ).toEqual([ - "warn(Gateway pairing approval required.)", + "warn(Gateway scope upgrade approval required.)", + "muted(Reason: device is asking for more scopes than currently approved.)", + "muted(Hint: Review the requested scopes, then approve the pending upgrade.)", "muted(Recovery: cmd:openclaw devices approve req-123)", "muted(Fallback: cmd:openclaw devices approve --latest)", "muted(Inspect: cmd:openclaw devices list)", diff --git a/src/commands/status.command-sections.ts b/src/commands/status.command-sections.ts index a3ceffc9539..4145ab04050 100644 --- a/src/commands/status.command-sections.ts +++ b/src/commands/status.command-sections.ts @@ -1,3 +1,8 @@ +import { + buildPairingConnectRecoveryTitle, + describePairingConnectRequirement, + type ConnectPairingRequiredReason, +} from "../gateway/protocol/connect-error-details.js"; import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js"; import type { Tone } from "../memory-host-sdk/status.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; @@ -40,6 +45,8 @@ type PluginCompatibilityNoticeLike = { type PairingRecoveryLike = { requestId?: string | null; + reason?: ConnectPairingRequiredReason | null; + remediationHint?: string | null; }; export const statusHealthColumns: TableColumn[] = [ @@ -367,7 +374,17 @@ export function buildStatusPairingRecoveryLines(params: { return []; } return [ - params.warn("Gateway pairing approval required."), + params.warn(buildPairingConnectRecoveryTitle(params.pairingRecovery.reason ?? undefined)), + ...(params.pairingRecovery.reason + ? [ + params.muted( + `Reason: ${describePairingConnectRequirement(params.pairingRecovery.reason)}.`, + ), + ] + : []), + ...(params.pairingRecovery.remediationHint + ? [params.muted(`Hint: ${params.pairingRecovery.remediationHint}`)] + : []), ...(params.pairingRecovery.requestId ? [ params.muted( diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 0b265513007..840803cf080 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -1,4 +1,8 @@ import { withProgress } from "../cli/progress.js"; +import { + readPairingConnectErrorDetails, + type ConnectPairingRequiredReason, +} from "../gateway/protocol/connect-error-details.js"; import { type RuntimeEnv } from "../runtime.js"; import { runStatusJsonCommand } from "./status-json-command.ts"; import { buildStatusOverviewSurfaceFromScan } from "./status-overview-surface.ts"; @@ -59,7 +63,20 @@ function loadStatusNodeModeModule() { export function resolvePairingRecoveryContext(params: { error?: string | null; closeReason?: string | null; -}): { requestId: string | null } | null { + details?: unknown; +}): { + requestId: string | null; + reason: ConnectPairingRequiredReason | null; + remediationHint: string | null; +} | null { + const structured = readPairingConnectErrorDetails(params.details); + if (structured) { + return { + requestId: structured.requestId ?? null, + reason: structured.reason ?? null, + remediationHint: structured.remediationHint ?? null, + }; + } const sanitizeRequestId = (value: string): string | null => { const trimmed = value.trim(); if (!trimmed) { @@ -80,7 +97,7 @@ export function resolvePairingRecoveryContext(params: { const requestIdMatch = source.match(/requestId:\s*([^\s)]+)/i); const requestId = requestIdMatch && requestIdMatch[1] ? sanitizeRequestId(requestIdMatch[1]) : null; - return { requestId: requestId || null }; + return { requestId: requestId || null, reason: null, remediationHint: null }; } export async function statusCommand( @@ -247,6 +264,7 @@ export async function statusCommand( const pairingRecovery = resolvePairingRecoveryContext({ error: gatewayProbe?.error ?? null, closeReason: gatewayProbe?.close?.reason ?? null, + details: gatewayProbe?.connectErrorDetails, }); const usageLines = usage diff --git a/src/commands/status.test-support.ts b/src/commands/status.test-support.ts index 5f0616a3e42..cab3743492c 100644 --- a/src/commands/status.test-support.ts +++ b/src/commands/status.test-support.ts @@ -250,7 +250,7 @@ export function createStatusCommandReportDataParams( memory: baseStatusMemory, memoryPlugin: baseStatusMemoryPlugin, pluginCompatibility: baseStatusPluginCompatibility, - pairingRecovery: { requestId: "req-1" }, + pairingRecovery: { requestId: "req-1", reason: null, remediationHint: null }, tableWidth: 120, ...statusTestDecorators, ...statusTestFormatting, diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index fbf964a66e3..f86ee41f30f 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -105,6 +105,7 @@ type ProbeGatewayResult = { url: string; connectLatencyMs: number | null; error: string | null; + connectErrorDetails?: unknown; close: { code: number; reason: string } | null; health: unknown; status: unknown; @@ -1272,36 +1273,62 @@ describe("statusCommand", () => { error: "connect failed: pairing required (requestId: req-123)", closeReason: "pairing required (requestId: req-123)", }), - ).toEqual({ requestId: "req-123" }); + ).toEqual({ requestId: "req-123", reason: null, remediationHint: null }); expect( resolvePairingRecoveryContext({ error: "connect failed: pairing required", closeReason: "connect failed", }), - ).toEqual({ requestId: null }); + ).toEqual({ requestId: null, reason: null, 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 }); + ).toEqual({ requestId: null, reason: null, remediationHint: null }); expect( resolvePairingRecoveryContext({ error: "connect failed: pairing required", closeReason: "pairing required (requestId: req-close-456)", }), - ).toEqual({ requestId: "req-close-456" }); + ).toEqual({ requestId: "req-close-456", reason: null, remediationHint: null }); + expect( + resolvePairingRecoveryContext({ + details: { + code: "PAIRING_REQUIRED", + reason: "scope-upgrade", + requestId: "req-structured-789", + remediationHint: "Review the requested scopes, then approve the pending upgrade.", + }, + }), + ).toEqual({ + requestId: "req-structured-789", + reason: "scope-upgrade", + remediationHint: "Review the requested scopes, then approve the pending upgrade.", + }); mocks.loadConfig.mockReturnValue({ session: {}, channels: { whatsapp: { allowFrom: ["*"] } }, }); mockProbeGatewayResult({ - error: "connect failed: pairing required (requestId: req-123)", - close: { code: 1008, reason: "pairing required (requestId: req-123)" }, + error: + "connect failed: pairing required: device is asking for more scopes than currently approved", + connectErrorDetails: { + code: "PAIRING_REQUIRED", + reason: "scope-upgrade", + requestId: "req-123", + remediationHint: "Review the requested scopes, then approve the pending upgrade.", + }, + close: { + code: 1008, + reason: + "pairing required: device is asking for more scopes than currently approved (requestId: req-123)", + }, }); const joined = await runStatusAndGetJoinedLogs(); - expect(joined).toContain("Gateway pairing approval required."); + expect(joined).toContain("Gateway scope upgrade approval required."); + expect(joined).toContain("more scopes than currently approved"); expect(joined).toContain("devices approve req-123"); expect(joined).toContain("devices approve --latest"); expect(joined).toContain("devices list"); diff --git a/ui/src/ui/connect-error.node.test.ts b/ui/src/ui/connect-error.node.test.ts new file mode 100644 index 00000000000..245156f92ec --- /dev/null +++ b/ui/src/ui/connect-error.node.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { formatConnectError } from "./connect-error.ts"; + +describe("formatConnectError", () => { + it("formats pairing scope upgrades with the richer contract", () => { + expect( + formatConnectError({ + message: "pairing required: device is asking for more scopes than currently approved", + details: { + code: "PAIRING_REQUIRED", + reason: "scope-upgrade", + requestId: "req-123", + }, + }), + ).toBe("gateway pairing required: device is asking for more scopes than currently approved"); + }); + + it("formats unapproved devices with the richer contract", () => { + expect( + formatConnectError({ + message: "pairing required: device is not approved yet", + details: { + code: "PAIRING_REQUIRED", + reason: "not-paired", + }, + }), + ).toBe("gateway pairing required: device is not approved yet"); + }); +}); diff --git a/ui/src/ui/connect-error.ts b/ui/src/ui/connect-error.ts index 761e7e172cd..c6fb3b185eb 100644 --- a/ui/src/ui/connect-error.ts +++ b/ui/src/ui/connect-error.ts @@ -1,4 +1,8 @@ -import { ConnectErrorDetailCodes } from "../../../src/gateway/protocol/connect-error-details.js"; +import { + buildPairingConnectErrorMessage, + ConnectErrorDetailCodes, + readPairingConnectErrorDetails, +} from "../../../src/gateway/protocol/connect-error-details.js"; import { resolveGatewayErrorDetailCode } from "./gateway.ts"; import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; @@ -29,7 +33,7 @@ function formatErrorFromMessageAndDetails(error: ErrorWithMessageAndDetails): st case ConnectErrorDetailCodes.AUTH_RATE_LIMITED: return "too many failed authentication attempts"; case ConnectErrorDetailCodes.PAIRING_REQUIRED: - return "gateway pairing required"; + return `gateway ${buildPairingConnectErrorMessage(readPairingConnectErrorDetails(error.details)?.reason)}`; case ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED: return "device identity required (use HTTPS/localhost or allow insecure auth explicitly)"; case ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED: