fix(control-ui): show scope upgrade pending state

This commit is contained in:
Ayaan Zaidi
2026-04-20 11:53:02 +05:30
parent f070a92e19
commit 66c1190bcc
7 changed files with 150 additions and 38 deletions

View File

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

View File

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

View File

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

View File

@@ -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<string>([
@@ -29,19 +32,51 @@ const INSECURE_CONTEXT_CODES = new Set<string>([
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;
}
/**

View File

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

View File

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

View File

@@ -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`
<div class="muted" style="margin-top: 8px">
${t("overview.pairing.hint")}
${title}
${copy.summary ? html`<div style="margin-top: 6px">${copy.summary}</div>` : nothing}
<div style="margin-top: 6px">
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.
</div>
<div style="margin-top: 6px">
<span class="mono">openclaw devices list</span><br />
<span class="mono">openclaw devices approve &lt;requestId&gt;</span>
<span class="mono">${approveCommand}</span><br />
<span class="mono">openclaw devices list</span>
</div>
<div style="margin-top: 6px; font-size: 12px;">${t("overview.pairing.mobileHint")}</div>
<div style="margin-top: 6px">