fix(status): show pairing recovery details

This commit is contained in:
Ayaan Zaidi
2026-04-20 11:53:46 +05:30
parent 4bc5eab390
commit 98a0b22e8e
8 changed files with 122 additions and 16 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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