fix(gateway): enrich pairing connect errors

This commit is contained in:
Ayaan Zaidi
2026-04-20 11:52:48 +05:30
parent 2ad17098fe
commit 4bc5eab390
6 changed files with 269 additions and 23 deletions

View File

@@ -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 <requestId>`. |
| 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 <requestId>`. Scope/role upgrades use the same flow after you review the requested access. |
Device auth v2 migration check:

View File

@@ -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<GatewayProbeResult, "url">) => {
const settle = (
result: Omit<GatewayProbeResult, "url" | "connectErrorDetails"> & {
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 };

View File

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

View File

@@ -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<ConnectRecoveryNextStep> = new Set([
"retry_with_device_token",
"update_auth_configuration",
@@ -52,6 +69,45 @@ const CONNECT_RECOVERY_NEXT_STEP_VALUES: ReadonlySet<ConnectRecoveryNextStep> =
"review_auth_configuration",
]);
const CONNECT_PAIRING_REQUIRED_REASON_VALUES: ReadonlySet<ConnectPairingRequiredReason> = 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 } : {}),
};
}

View File

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

View File

@@ -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<ReturnType<typeof getPairedDevice>> | 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;