mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:50:42 +00:00
fix(control-ui): show scope upgrade pending state
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <requestId></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">
|
||||
|
||||
Reference in New Issue
Block a user