From 4bc5eab3900d117ff167199f12401ea83d24875f Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 20 Apr 2026 11:52:48 +0530 Subject: [PATCH] fix(gateway): enrich pairing connect errors --- docs/gateway/troubleshooting.md | 12 +- src/gateway/probe.ts | 21 ++- .../protocol/connect-error-details.test.ts | 57 +++++++ src/gateway/protocol/connect-error-details.ts | 147 ++++++++++++++++++ ...silent-scope-upgrade-reconnect.poc.test.ts | 26 +++- .../server/ws-connection/message-handler.ts | 29 +++- 6 files changed, 269 insertions(+), 23 deletions(-) diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index 61a18dc8059..c4c16fe5726 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -193,12 +193,12 @@ Common signatures: Use `error.details.code` from the failed `connect` response to pick the next action: -| Detail code | Meaning | Recommended action | -| ---------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `AUTH_TOKEN_MISSING` | Client did not send a required shared token. | Paste/set token in the client and retry. For dashboard paths: `openclaw config get gateway.auth.token` then paste into Control UI settings. | -| `AUTH_TOKEN_MISMATCH` | Shared token did not match gateway auth token. | If `canRetryWithDeviceToken=true`, allow one trusted retry. Cached-token retries reuse stored approved scopes; explicit `deviceToken` / `scopes` callers keep requested scopes. If still failing, run the [token drift recovery checklist](/cli/devices#token-drift-recovery-checklist). | -| `AUTH_DEVICE_TOKEN_MISMATCH` | Cached per-device token is stale or revoked. | Rotate/re-approve device token using [devices CLI](/cli/devices), then reconnect. | -| `PAIRING_REQUIRED` | Device identity is known but not approved for this role. | Approve pending request: `openclaw devices list` then `openclaw devices approve `. | +| Detail code | Meaning | Recommended action | +| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `AUTH_TOKEN_MISSING` | Client did not send a required shared token. | Paste/set token in the client and retry. For dashboard paths: `openclaw config get gateway.auth.token` then paste into Control UI settings. | +| `AUTH_TOKEN_MISMATCH` | Shared token did not match gateway auth token. | If `canRetryWithDeviceToken=true`, allow one trusted retry. Cached-token retries reuse stored approved scopes; explicit `deviceToken` / `scopes` callers keep requested scopes. If still failing, run the [token drift recovery checklist](/cli/devices#token-drift-recovery-checklist). | +| `AUTH_DEVICE_TOKEN_MISMATCH` | Cached per-device token is stale or revoked. | Rotate/re-approve device token using [devices CLI](/cli/devices), then reconnect. | +| `PAIRING_REQUIRED` | Device identity needs approval. Check `error.details.reason` for `not-paired`, `scope-upgrade`, `role-upgrade`, or `metadata-upgrade`, and use `requestId` / `remediationHint` when present. | Approve pending request: `openclaw devices list` then `openclaw devices approve `. Scope/role upgrades use the same flow after you review the requested access. | Device auth v2 migration check: diff --git a/src/gateway/probe.ts b/src/gateway/probe.ts index 9704da78d1a..bffe10c0378 100644 --- a/src/gateway/probe.ts +++ b/src/gateway/probe.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { formatErrorMessage } from "../infra/errors.js"; import type { SystemPresence } from "../infra/system-presence.js"; -import { GatewayClient } from "./client.js"; +import { GatewayClient, GatewayClientRequestError } from "./client.js"; import { READ_SCOPE } from "./method-scopes.js"; import { isLoopbackHost } from "./net.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "./protocol/client-info.js"; @@ -36,6 +36,7 @@ export type GatewayProbeResult = { url: string; connectLatencyMs: number | null; error: string | null; + connectErrorDetails?: unknown; close: GatewayProbeClose | null; auth: GatewayProbeAuthSummary; health: unknown; @@ -137,6 +138,7 @@ export async function probeGateway(opts: { const instanceId = randomUUID(); let connectLatencyMs: number | null = null; let connectError: string | null = null; + let connectErrorDetails: unknown = null; let close: GatewayProbeClose | null = null; let auth = emptyProbeAuth(); let authMetadataPresent = false; @@ -178,14 +180,25 @@ export async function probeGateway(opts: { clearProbeTimer(); timer = setTimeout(onTimeout, clampProbeTimeoutMs(opts.timeoutMs)); }; - const settle = (result: Omit) => { + const settle = ( + result: Omit & { + connectErrorDetails?: unknown; + }, + ) => { if (settled) { return; } settled = true; clearProbeTimer(); client.stop(); - resolve({ url: opts.url, ...result }); + const { connectErrorDetails: resultConnectErrorDetails, ...rest } = result; + resolve({ + url: opts.url, + ...rest, + ...(resultConnectErrorDetails != null + ? { connectErrorDetails: resultConnectErrorDetails } + : {}), + }); }; const settleProbe = (params: { ok: boolean; @@ -200,6 +213,7 @@ export async function probeGateway(opts: { ok: params.ok, connectLatencyMs, error: params.error, + connectErrorDetails, close, auth: resolveProbeAuthSummary({ role: auth.role, @@ -230,6 +244,7 @@ export async function probeGateway(opts: { deviceIdentity, onConnectError: (err) => { connectError = formatErrorMessage(err); + connectErrorDetails = err instanceof GatewayClientRequestError ? err.details : null; }, onClose: (code, reason) => { close = { code, reason }; diff --git a/src/gateway/protocol/connect-error-details.test.ts b/src/gateway/protocol/connect-error-details.test.ts index 2a7a2c53979..c15d9a12002 100644 --- a/src/gateway/protocol/connect-error-details.test.ts +++ b/src/gateway/protocol/connect-error-details.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from "vitest"; import { + buildPairingConnectCloseReason, + buildPairingConnectErrorDetails, + buildPairingConnectErrorMessage, + ConnectPairingRequiredReasons, + describePairingConnectRequirement, readConnectErrorDetailCode, readConnectErrorRecoveryAdvice, + readPairingConnectErrorDetails, } from "./connect-error-details.js"; describe("readConnectErrorDetailCode", () => { @@ -40,3 +46,54 @@ describe("readConnectErrorRecoveryAdvice", () => { ).toEqual({ canRetryWithDeviceToken: true, recommendedNextStep: undefined }); }); }); + +describe("pairing connect details", () => { + it("builds reason-specific pairing messages", () => { + expect(buildPairingConnectErrorMessage(ConnectPairingRequiredReasons.SCOPE_UPGRADE)).toBe( + "pairing required: device is asking for more scopes than currently approved", + ); + expect(describePairingConnectRequirement(ConnectPairingRequiredReasons.NOT_PAIRED)).toBe( + "device is not approved yet", + ); + }); + + it("builds structured pairing details with remediation", () => { + expect( + buildPairingConnectErrorDetails({ + reason: ConnectPairingRequiredReasons.NOT_PAIRED, + requestId: "req-123", + }), + ).toEqual({ + code: "PAIRING_REQUIRED", + reason: "not-paired", + requestId: "req-123", + remediationHint: "Approve this device from the pending pairing requests.", + }); + }); + + it("reads pairing details and backfills missing remediation hints", () => { + expect( + readPairingConnectErrorDetails({ + code: "PAIRING_REQUIRED", + reason: "scope-upgrade", + requestId: "req-456", + }), + ).toEqual({ + code: "PAIRING_REQUIRED", + reason: "scope-upgrade", + requestId: "req-456", + remediationHint: "Review the requested scopes, then approve the pending upgrade.", + }); + }); + + it("includes request ids in close reasons when available", () => { + expect( + buildPairingConnectCloseReason({ + reason: ConnectPairingRequiredReasons.ROLE_UPGRADE, + requestId: "req-789", + }), + ).toBe( + "pairing required: device is asking for a higher role than currently approved (requestId: req-789)", + ); + }); +}); diff --git a/src/gateway/protocol/connect-error-details.ts b/src/gateway/protocol/connect-error-details.ts index 8075fca09d8..2c740f209b6 100644 --- a/src/gateway/protocol/connect-error-details.ts +++ b/src/gateway/protocol/connect-error-details.ts @@ -32,6 +32,16 @@ export const ConnectErrorDetailCodes = { export type ConnectErrorDetailCode = (typeof ConnectErrorDetailCodes)[keyof typeof ConnectErrorDetailCodes]; +export const ConnectPairingRequiredReasons = { + NOT_PAIRED: "not-paired", + ROLE_UPGRADE: "role-upgrade", + SCOPE_UPGRADE: "scope-upgrade", + METADATA_UPGRADE: "metadata-upgrade", +} as const; + +export type ConnectPairingRequiredReason = + (typeof ConnectPairingRequiredReasons)[keyof typeof ConnectPairingRequiredReasons]; + export type ConnectRecoveryNextStep = | "retry_with_device_token" | "update_auth_configuration" @@ -44,6 +54,13 @@ export type ConnectErrorRecoveryAdvice = { recommendedNextStep?: ConnectRecoveryNextStep; }; +export type PairingConnectErrorDetails = { + code: typeof ConnectErrorDetailCodes.PAIRING_REQUIRED; + reason?: ConnectPairingRequiredReason; + requestId?: string; + remediationHint?: string; +}; + const CONNECT_RECOVERY_NEXT_STEP_VALUES: ReadonlySet = new Set([ "retry_with_device_token", "update_auth_configuration", @@ -52,6 +69,45 @@ const CONNECT_RECOVERY_NEXT_STEP_VALUES: ReadonlySet = "review_auth_configuration", ]); +const CONNECT_PAIRING_REQUIRED_REASON_VALUES: ReadonlySet = new Set([ + "not-paired", + "role-upgrade", + "scope-upgrade", + "metadata-upgrade", +]); + +const PAIRING_CONNECT_REASON_METADATA: Readonly< + Record< + ConnectPairingRequiredReason, + { + requirement: string; + remediationHint: string; + recoveryTitle: string; + } + > +> = { + "not-paired": { + requirement: "device is not approved yet", + remediationHint: "Approve this device from the pending pairing requests.", + recoveryTitle: "Gateway pairing approval required.", + }, + "role-upgrade": { + requirement: "device is asking for a higher role than currently approved", + remediationHint: "Review the requested role upgrade, then approve the pending request.", + recoveryTitle: "Gateway role upgrade approval required.", + }, + "scope-upgrade": { + requirement: "device is asking for more scopes than currently approved", + remediationHint: "Review the requested scopes, then approve the pending upgrade.", + recoveryTitle: "Gateway scope upgrade approval required.", + }, + "metadata-upgrade": { + requirement: "device identity changed and must be re-approved", + remediationHint: "Review the refreshed device details, then approve the pending request.", + recoveryTitle: "Gateway device refresh approval required.", + }, +}; + export function resolveAuthConnectErrorDetailCode( reason: string | undefined, ): ConnectErrorDetailCode { @@ -139,3 +195,94 @@ export function readConnectErrorRecoveryAdvice(details: unknown): ConnectErrorRe recommendedNextStep, }; } + +function normalizePairingConnectReason(value: unknown): ConnectPairingRequiredReason | undefined { + const normalized = normalizeOptionalString(value) ?? ""; + return CONNECT_PAIRING_REQUIRED_REASON_VALUES.has(normalized as ConnectPairingRequiredReason) + ? (normalized as ConnectPairingRequiredReason) + : undefined; +} + +export function describePairingConnectRequirement( + reason: ConnectPairingRequiredReason | undefined, +): string { + return reason + ? PAIRING_CONNECT_REASON_METADATA[reason].requirement + : "device approval is required"; +} + +export function buildPairingConnectErrorMessage( + reason: ConnectPairingRequiredReason | undefined, +): string { + return reason + ? `pairing required: ${describePairingConnectRequirement(reason)}` + : "pairing required"; +} + +export function buildPairingConnectRemediationHint( + reason: ConnectPairingRequiredReason | undefined, +): string { + return reason + ? PAIRING_CONNECT_REASON_METADATA[reason].remediationHint + : "Approve the pending device request before retrying."; +} + +export function buildPairingConnectRecoveryTitle( + reason: ConnectPairingRequiredReason | undefined, +): string { + return reason + ? PAIRING_CONNECT_REASON_METADATA[reason].recoveryTitle + : "Gateway pairing approval required."; +} + +export function buildPairingConnectErrorDetails(params: { + reason: ConnectPairingRequiredReason | undefined; + requestId?: string; + remediationHint?: string; +}): PairingConnectErrorDetails { + const requestId = normalizeOptionalString(params.requestId) ?? undefined; + const remediationHint = + normalizeOptionalString(params.remediationHint) ?? + buildPairingConnectRemediationHint(params.reason); + return { + code: ConnectErrorDetailCodes.PAIRING_REQUIRED, + ...(params.reason ? { reason: params.reason } : {}), + ...(requestId ? { requestId } : {}), + ...(remediationHint ? { remediationHint } : {}), + }; +} + +export function buildPairingConnectCloseReason(params: { + reason: ConnectPairingRequiredReason | undefined; + requestId?: string; +}): string { + const requestId = normalizeOptionalString(params.requestId) ?? undefined; + const message = buildPairingConnectErrorMessage(params.reason); + return requestId ? `${message} (requestId: ${requestId})` : message; +} + +export function readPairingConnectErrorDetails( + details: unknown, +): PairingConnectErrorDetails | null { + if (readConnectErrorDetailCode(details) !== ConnectErrorDetailCodes.PAIRING_REQUIRED) { + return null; + } + if (!details || typeof details !== "object" || Array.isArray(details)) { + return { code: ConnectErrorDetailCodes.PAIRING_REQUIRED }; + } + const raw = details as { + reason?: unknown; + requestId?: unknown; + remediationHint?: unknown; + }; + const reason = normalizePairingConnectReason(raw.reason); + const requestId = normalizeOptionalString(raw.requestId) ?? undefined; + const remediationHint = + normalizeOptionalString(raw.remediationHint) ?? buildPairingConnectRemediationHint(reason); + return { + code: ConnectErrorDetailCodes.PAIRING_REQUIRED, + ...(reason ? { reason } : {}), + ...(requestId ? { requestId } : {}), + ...(remediationHint ? { remediationHint } : {}), + }; +} diff --git a/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts b/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts index af503a615ae..425fe761934 100644 --- a/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts +++ b/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts @@ -31,8 +31,18 @@ async function expectRejectedScopeUpgradeAttempt({ }) { const pending = await devicePairingModule.listDevicePairing(); expect(pending.pending).toHaveLength(1); - expect(((attempt.error?.details ?? {}) as { requestId?: unknown }).requestId).toBe( - pending.pending[0]?.requestId, + expect( + ( + (attempt.error?.details ?? {}) as { + requestId?: unknown; + reason?: unknown; + remediationHint?: unknown; + } + ).requestId, + ).toBe(pending.pending[0]?.requestId); + expect(((attempt.error?.details ?? {}) as { reason?: unknown }).reason).toBe("scope-upgrade"); + expect(((attempt.error?.details ?? {}) as { remediationHint?: unknown }).remediationHint).toBe( + "Review the requested scopes, then approve the pending upgrade.", ); const requested = (await requestedEvent) as { @@ -76,7 +86,9 @@ describe("gateway silent scope-upgrade reconnect", () => { scopes: ["operator.admin"], }); expect(sharedAuthUpgradeAttempt.ok).toBe(false); - expect(sharedAuthUpgradeAttempt.error?.message).toBe("pairing required"); + expect(sharedAuthUpgradeAttempt.error?.message).toBe( + "pairing required: device is asking for more scopes than currently approved", + ); await expectRejectedScopeUpgradeAttempt({ attempt: sharedAuthUpgradeAttempt, @@ -137,7 +149,9 @@ describe("gateway silent scope-upgrade reconnect", () => { scopes: ["operator.admin"], }); expect(reconnectAttempt.ok).toBe(false); - expect(reconnectAttempt.error?.message).toBe("pairing required"); + expect(reconnectAttempt.error?.message).toBe( + "pairing required: device is asking for more scopes than currently approved", + ); await expectRejectedScopeUpgradeAttempt({ attempt: reconnectAttempt, @@ -230,7 +244,7 @@ describe("gateway silent scope-upgrade reconnect", () => { }); expect(res.ok).toBe(false); - expect(res.error?.message).toBe("pairing required"); + expect(res.error?.message).toBe("pairing required: device is not approved yet"); expect( (res.error?.details as { requestId?: unknown; code?: string } | undefined)?.requestId, ).toBeUndefined(); @@ -283,7 +297,7 @@ describe("gateway silent scope-upgrade reconnect", () => { }); expect(res.ok).toBe(false); - expect(res.error?.message).toBe("pairing required"); + expect(res.error?.message).toBe("pairing required: device is not approved yet"); expect(replacementRequestId).toBeTruthy(); expect( (res.error?.details as { requestId?: unknown; code?: string } | undefined)?.requestId, diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 77f0a5a77a2..6c16fd095b4 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -67,7 +67,11 @@ import { import { reconcileNodePairingOnConnect } from "../../node-connect-reconcile.js"; import { checkBrowserOrigin } from "../../origin-check.js"; import { + buildPairingConnectCloseReason, + buildPairingConnectErrorDetails, + buildPairingConnectErrorMessage, ConnectErrorDetailCodes, + type ConnectPairingRequiredReason, resolveDeviceAuthConnectErrorDetailCode, resolveAuthConnectErrorDetailCode, } from "../../protocol/connect-error-details.js"; @@ -860,7 +864,7 @@ export function attachGatewayWsMessageHandler(params: { remoteIp: reportedClientIp, }; const requirePairing = async ( - reason: "not-paired" | "role-upgrade" | "scope-upgrade" | "metadata-upgrade", + reason: ConnectPairingRequiredReason, existingPairedDevice: Awaited> | null = null, ) => { const pairingStateAllowsRequestedAccess = ( @@ -1002,6 +1006,11 @@ export function attachGatewayWsMessageHandler(params: { (approved?.status === "approved" || resolvedByConcurrentApproval) ) ) { + const pairingErrorDetails = buildPairingConnectErrorDetails({ + reason, + requestId: recoveryRequestId, + }); + const pairingErrorMessage = buildPairingConnectErrorMessage(reason); setHandshakeState("failed"); setCloseCause("pairing-required", { deviceId: device.id, @@ -1012,15 +1021,19 @@ export function attachGatewayWsMessageHandler(params: { type: "res", id: frame.id, ok: false, - error: errorShape(ErrorCodes.NOT_PAIRED, "pairing required", { - details: { - code: ConnectErrorDetailCodes.PAIRING_REQUIRED, - ...(recoveryRequestId ? { requestId: recoveryRequestId } : {}), - reason, - }, + error: errorShape(ErrorCodes.NOT_PAIRED, pairingErrorMessage, { + details: pairingErrorDetails, }), }); - close(1008, "pairing required"); + close( + 1008, + truncateCloseReason( + buildPairingConnectCloseReason({ + reason, + requestId: recoveryRequestId, + }), + ), + ); return false; } return true;