mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:30:43 +00:00
fix(gateway): surface pending pairing upgrade details
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user