mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:00:42 +00:00
fix(status): show pairing recovery details
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
29
ui/src/ui/connect-error.node.test.ts
Normal file
29
ui/src/ui/connect-error.node.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user