mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:50:43 +00:00
fix(gateway): enrich pairing connect errors
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user