From 66c1190bcc591b3f2eafb114ce0cd7607147e74f Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 20 Apr 2026 11:53:02 +0530 Subject: [PATCH] fix(control-ui): show scope upgrade pending state --- ui/src/ui/app-gateway.node.test.ts | 25 ++++++++++++ ui/src/ui/connect-error.ts | 21 ++-------- ui/src/ui/gateway.ts | 3 +- ui/src/ui/views/overview-hints.ts | 51 +++++++++++++++++++++---- ui/src/ui/views/overview.node.test.ts | 21 +++++++++- ui/src/ui/views/overview.render.test.ts | 17 +++++++++ ui/src/ui/views/overview.ts | 50 +++++++++++++++++++----- 7 files changed, 150 insertions(+), 38 deletions(-) diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index 7045ddb8e10..57a7b429181 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -433,6 +433,31 @@ describe("connectGateway", () => { expect(host.lastErrorCode).toBe("AUTH_TOKEN_MISMATCH"); }); + it("surfaces scope-upgrade approval details instead of a dead pairing error", () => { + const host = createHost(); + + connectGateway(host); + const client = gatewayClientInstances[0]; + expect(client).toBeDefined(); + + client.emitClose({ + code: 4008, + reason: "connect failed", + error: { + code: "NOT_PAIRED", + message: "scope upgrade pending approval (requestId: req-123)", + details: { + code: ConnectErrorDetailCodes.PAIRING_REQUIRED, + reason: "scope-upgrade", + requestId: "req-123", + }, + }, + }); + + expect(host.lastErrorCode).toBe(ConnectErrorDetailCodes.PAIRING_REQUIRED); + expect(host.lastError).toBe("scope upgrade pending approval (requestId: req-123)"); + }); + it("surfaces shutdown restart reasons before the socket closes", () => { const host = createHost(); diff --git a/ui/src/ui/connect-error.ts b/ui/src/ui/connect-error.ts index 8f250192b1b..6797620ffa9 100644 --- a/ui/src/ui/connect-error.ts +++ b/ui/src/ui/connect-error.ts @@ -1,6 +1,6 @@ import { ConnectErrorDetailCodes, - readPairingConnectErrorDetails, + formatConnectPairingRequiredMessage, } from "../../../src/gateway/protocol/connect-error-details.js"; import { resolveGatewayErrorDetailCode } from "./gateway.ts"; import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; @@ -31,23 +31,8 @@ function formatErrorFromMessageAndDetails(error: ErrorWithMessageAndDetails): st return "gateway auth failed"; case ConnectErrorDetailCodes.AUTH_RATE_LIMITED: return "too many failed authentication attempts"; - case ConnectErrorDetailCodes.PAIRING_REQUIRED: { - const pairing = readPairingConnectErrorDetails(error.details); - const approvedRoles = pairing?.approvedRoles?.join(", ") || "none"; - const requestedRole = pairing?.requestedRole || "none"; - const approvedScopes = pairing?.approvedScopes?.join(", ") || "none"; - const requestedScopes = pairing?.requestedScopes?.join(", ") || "none"; - switch (pairing?.reason) { - case "scope-upgrade": - return `device scope upgrade requires approval (approved: ${approvedScopes}; requested: ${requestedScopes})`; - case "role-upgrade": - return `device role upgrade requires approval (approved: ${approvedRoles}; requested: ${requestedRole})`; - case "metadata-upgrade": - return "device reconnect details changed and require approval"; - default: - return "gateway pairing required"; - } - } + case ConnectErrorDetailCodes.PAIRING_REQUIRED: + return formatConnectPairingRequiredMessage(error.details); 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: diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index d03f46df37e..dc34e84aeff 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -7,6 +7,7 @@ import { } from "../../../src/gateway/protocol/client-info.js"; import { ConnectErrorDetailCodes, + formatConnectErrorMessage, readConnectErrorRecoveryAdvice, readConnectErrorDetailCode, } from "../../../src/gateway/protocol/connect-error-details.js"; @@ -51,7 +52,7 @@ export class GatewayRequestError extends Error { readonly retryAfterMs?: number; constructor(error: GatewayErrorInfo) { - super(error.message); + super(formatConnectErrorMessage({ message: error.message, details: error.details })); this.name = "GatewayRequestError"; this.gatewayCode = error.code; this.details = error.details; diff --git a/ui/src/ui/views/overview-hints.ts b/ui/src/ui/views/overview-hints.ts index d0a2e54f5aa..ecc184ef7f7 100644 --- a/ui/src/ui/views/overview-hints.ts +++ b/ui/src/ui/views/overview-hints.ts @@ -1,4 +1,7 @@ -import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js"; +import { + ConnectErrorDetailCodes, + readConnectPairingRequiredMessage, +} from "../../../../src/gateway/protocol/connect-error-details.js"; import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; const AUTH_REQUIRED_CODES = new Set([ @@ -29,19 +32,51 @@ const INSECURE_CONTEXT_CODES = new Set([ type AuthHintKind = "required" | "failed"; +export type PairingHint = + | { + kind: "pairing-required"; + requestId: string | null; + } + | { + kind: "scope-upgrade-pending" | "role-upgrade-pending" | "metadata-upgrade-pending"; + requestId: string | null; + }; + +export function resolvePairingHint( + connected: boolean, + lastError: string | null, + lastErrorCode?: string | null, +): PairingHint | null { + if (connected || !lastError) { + return null; + } + const pairing = readConnectPairingRequiredMessage(lastError); + if (pairing) { + return { + kind: + pairing.reason === "scope-upgrade" + ? "scope-upgrade-pending" + : pairing.reason === "role-upgrade" + ? "role-upgrade-pending" + : pairing.reason === "metadata-upgrade" + ? "metadata-upgrade-pending" + : "pairing-required", + requestId: pairing.requestId ?? null, + }; + } + if (lastErrorCode === ConnectErrorDetailCodes.PAIRING_REQUIRED) { + return { kind: "pairing-required", requestId: null }; + } + return null; +} + /** Whether the overview should show device-pairing guidance for this error. */ export function shouldShowPairingHint( connected: boolean, lastError: string | null, lastErrorCode?: string | null, ): boolean { - if (connected || !lastError) { - return false; - } - if (lastErrorCode === ConnectErrorDetailCodes.PAIRING_REQUIRED) { - return true; - } - return normalizeLowercaseStringOrEmpty(lastError).includes("pairing required"); + return resolvePairingHint(connected, lastError, lastErrorCode) !== null; } /** diff --git a/ui/src/ui/views/overview.node.test.ts b/ui/src/ui/views/overview.node.test.ts index 313c2edf850..c91e6ca7f02 100644 --- a/ui/src/ui/views/overview.node.test.ts +++ b/ui/src/ui/views/overview.node.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js"; -import { resolveAuthHintKind, shouldShowPairingHint } from "./overview-hints.ts"; +import { + resolveAuthHintKind, + resolvePairingHint, + shouldShowPairingHint, +} from "./overview-hints.ts"; describe("shouldShowPairingHint", () => { it("returns true for 'pairing required' close reason", () => { @@ -38,6 +42,21 @@ describe("shouldShowPairingHint", () => { }); }); +describe("resolvePairingHint", () => { + it("detects scope-upgrade pending approval and keeps the request id", () => { + expect( + resolvePairingHint( + false, + "scope upgrade pending approval (requestId: req-123)", + ConnectErrorDetailCodes.PAIRING_REQUIRED, + ), + ).toEqual({ + kind: "scope-upgrade-pending", + requestId: "req-123", + }); + }); +}); + describe("resolveAuthHintKind", () => { it("returns required for structured auth-required codes", () => { expect( diff --git a/ui/src/ui/views/overview.render.test.ts b/ui/src/ui/views/overview.render.test.ts index d10e0960fda..58b581d64f3 100644 --- a/ui/src/ui/views/overview.render.test.ts +++ b/ui/src/ui/views/overview.render.test.ts @@ -91,4 +91,21 @@ describe("overview view rendering", () => { await i18n.setLocale("en"); }); + + it("renders a dedicated scope-upgrade approval hint with the exact approve command", async () => { + const container = document.createElement("div"); + const props = createOverviewProps({ + lastError: "scope upgrade pending approval (requestId: req-123)", + lastErrorCode: "PAIRING_REQUIRED", + }); + + render(renderOverview(props), container); + await Promise.resolve(); + + expect(container.textContent).toContain("Scope upgrade pending approval."); + expect(container.textContent).toContain( + "This device is already paired, but the requested wider scope is waiting for approval.", + ); + expect(container.textContent).toContain("openclaw devices approve req-123"); + }); }); diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts index 8488f1e7402..cc2eef18ce3 100644 --- a/ui/src/ui/views/overview.ts +++ b/ui/src/ui/views/overview.ts @@ -22,8 +22,9 @@ import { renderOverviewCards } from "./overview-cards.ts"; import { renderOverviewEventLog } from "./overview-event-log.ts"; import { resolveAuthHintKind, + type PairingHint, + resolvePairingHint, shouldShowInsecureContextHint, - shouldShowPairingHint, } from "./overview-hints.ts"; import { renderOverviewLogTail } from "./overview-log-tail.ts"; @@ -63,6 +64,33 @@ export type OverviewProps = { onRefreshLogs: () => void; }; +const PAIRING_HINT_COPY: Record< + PairingHint["kind"], + { + title: string; + summary: string | null; + } +> = { + "pairing-required": { + title: "", + summary: null, + }, + "scope-upgrade-pending": { + title: "Scope upgrade pending approval.", + summary: + "This device is already paired, but the requested wider scope is waiting for approval.", + }, + "role-upgrade-pending": { + title: "Role upgrade pending approval.", + summary: + "This device is already paired, but the requested role change is waiting for approval.", + }, + "metadata-upgrade-pending": { + title: "Device metadata change pending approval.", + summary: "This device is already paired, but the metadata change is waiting for approval.", + }, +}; + export function renderOverview(props: OverviewProps) { const snapshot = props.hello?.snapshot as | { @@ -79,20 +107,22 @@ export function renderOverview(props: OverviewProps) { const isTrustedProxy = authMode === "trusted-proxy"; const pairingHint = (() => { - if (!shouldShowPairingHint(props.connected, props.lastError, props.lastErrorCode)) { + const pairingState = resolvePairingHint(props.connected, props.lastError, props.lastErrorCode); + if (!pairingState) { return null; } + const copy = PAIRING_HINT_COPY[pairingState.kind]; + const title = copy.title || t("overview.pairing.hint"); + const approveCommand = pairingState.requestId + ? `openclaw devices approve ${pairingState.requestId}` + : "openclaw devices approve --latest"; return html`
- ${t("overview.pairing.hint")} + ${title} + ${copy.summary ? html`
${copy.summary}
` : nothing}
- If the device was already paired, this usually means it asked for more access than you - previously approved. OpenClaw keeps the old approval and creates a new pending upgrade - request instead of widening scopes silently. -
-
- openclaw devices list
- openclaw devices approve <requestId> + ${approveCommand}
+ openclaw devices list
${t("overview.pairing.mobileHint")}