fix(control-ui): explain pairing access upgrades

This commit is contained in:
Ayaan Zaidi
2026-04-20 13:06:01 +05:30
parent 67d2026e22
commit 41a01cdae5
8 changed files with 231 additions and 25 deletions

View File

@@ -58,6 +58,11 @@ If the browser retries pairing with changed auth details (role/scopes/public
key), the previous pending request is superseded and a new `requestId` is
created. Re-run `openclaw devices list` before approval.
If the browser is already paired and you change it from read access to
write/admin access, this is treated as an approval upgrade, not a silent
reconnect. OpenClaw keeps the old approval active, blocks the broader reconnect,
and asks you to approve the new scope set explicitly.
Once approved, the device is remembered and won't require re-approval unless
you revoke it with `openclaw devices revoke --device <id> --role <role>`. See
[Devices CLI](/cli/devices) for token rotation and revocation.

View File

@@ -2,6 +2,7 @@ import {
GATEWAY_EVENT_UPDATE_AVAILABLE,
type GatewayUpdateAvailableEventPayload,
} from "../../../src/gateway/events.js";
import { ConnectErrorDetailCodes } from "../../../src/gateway/protocol/connect-error-details.js";
import {
CHAT_SESSIONS_ACTIVE_MINUTES,
clearPendingQueueItemsForRun,
@@ -307,7 +308,9 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
if (code !== 1012) {
if (error?.message) {
host.lastError =
host.lastErrorCode && isGenericBrowserFetchFailure(error.message)
host.lastErrorCode &&
(host.lastErrorCode === ConnectErrorDetailCodes.PAIRING_REQUIRED ||
isGenericBrowserFetchFailure(error.message))
? formatConnectError({
message: error.message,
details: error.details,

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { ConnectErrorDetailCodes } from "../../../src/gateway/protocol/connect-error-details.js";
import { formatConnectError } from "./connect-error.ts";
describe("formatConnectError", () => {
it("explains scope upgrades that require approval", () => {
expect(
formatConnectError({
message: "pairing required",
details: {
code: ConnectErrorDetailCodes.PAIRING_REQUIRED,
reason: "scope-upgrade",
approvedScopes: ["operator.read"],
requestedScopes: ["operator.admin", "operator.read"],
},
}),
).toBe(
"device scope upgrade requires approval (approved: operator.read; requested: operator.admin, operator.read)",
);
});
it("explains role upgrades that require approval", () => {
expect(
formatConnectError({
message: "pairing required",
details: {
code: ConnectErrorDetailCodes.PAIRING_REQUIRED,
reason: "role-upgrade",
approvedRoles: ["operator"],
requestedRole: "node",
},
}),
).toBe("device role upgrade requires approval (approved: operator; requested: node)");
});
});

View File

@@ -1,5 +1,4 @@
import {
buildPairingConnectErrorMessage,
ConnectErrorDetailCodes,
readPairingConnectErrorDetails,
} from "../../../src/gateway/protocol/connect-error-details.js";
@@ -32,8 +31,23 @@ function formatErrorFromMessageAndDetails(error: ErrorWithMessageAndDetails): st
return "gateway auth failed";
case ConnectErrorDetailCodes.AUTH_RATE_LIMITED:
return "too many failed authentication attempts";
case ConnectErrorDetailCodes.PAIRING_REQUIRED:
return `gateway ${buildPairingConnectErrorMessage(readPairingConnectErrorDetails(error.details)?.reason)}`;
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.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

@@ -14,6 +14,7 @@ export type DeviceTokenSummary = {
export type PendingDevice = {
requestId: string;
deviceId: string;
publicKey?: string;
displayName?: string;
role?: string;
roles?: string[];
@@ -25,6 +26,7 @@ export type PendingDevice = {
export type PairedDevice = {
deviceId: string;
publicKey?: string;
displayName?: string;
roles?: string[];
scopes?: string[];
@@ -61,7 +63,7 @@ export async function loadDevices(state: DevicesState, opts?: { quiet?: boolean
try {
const res = await state.client.request<{
pending?: Array<PendingDevice>;
paired?: Array<PendingDevice>;
paired?: Array<PairedDevice>;
}>("device.pair.list", {});
state.devicesList = {
pending: Array.isArray(res?.pending) ? res.pending : [],

View File

@@ -47,7 +47,7 @@ function baseProps(overrides: Partial<NodesProps> = {}): NodesProps {
}
describe("nodes devices pending rendering", () => {
it("shows pending role and scopes from effective pending auth", () => {
it("shows requested and approved access for a scope upgrade", () => {
const container = document.createElement("div");
render(
renderNodes(
@@ -63,7 +63,14 @@ describe("nodes devices pending rendering", () => {
ts: Date.now(),
},
],
paired: [],
paired: [
{
deviceId: "device-1",
displayName: "Device One",
roles: ["operator"],
scopes: ["operator.read"],
},
],
},
}),
),
@@ -71,8 +78,83 @@ describe("nodes devices pending rendering", () => {
);
const text = container.textContent ?? "";
expect(text).toContain("role: operator");
expect(text).toContain("scopes: operator.admin, operator.read");
expect(text).toContain("scope upgrade requires approval");
expect(text).toContain("requested: roles: operator");
expect(text).toContain("approved now: roles: operator");
expect(text).toContain("operator.admin, operator.read");
});
it("normalizes pending device ids before matching paired access", () => {
const container = document.createElement("div");
render(
renderNodes(
baseProps({
devicesList: {
pending: [
{
requestId: "req-1",
deviceId: " device-1 ",
displayName: "Device One",
role: "operator",
scopes: ["operator.admin", "operator.read"],
ts: Date.now(),
},
],
paired: [
{
deviceId: "device-1",
displayName: "Device One",
roles: ["operator"],
scopes: ["operator.read"],
},
],
},
}),
),
container,
);
const text = container.textContent ?? "";
expect(text).toContain("scope upgrade requires approval");
expect(text).toContain("approved now: roles: operator");
});
it("does not show upgrade context for key-mismatched pending requests", () => {
const container = document.createElement("div");
render(
renderNodes(
baseProps({
devicesList: {
pending: [
{
requestId: "req-1",
deviceId: "device-1",
publicKey: "new-key",
displayName: "Device One",
role: "operator",
scopes: ["operator.admin"],
ts: Date.now(),
},
],
paired: [
{
deviceId: "device-1",
publicKey: "old-key",
displayName: "Device One",
roles: ["operator"],
scopes: ["operator.read"],
},
],
},
}),
),
container,
);
const text = container.textContent ?? "";
expect(text).toContain("new device pairing request");
expect(text).not.toContain("scope upgrade requires approval");
expect(text).not.toContain("approved now:");
});
it("falls back to roles when role is absent", () => {
@@ -98,7 +180,7 @@ describe("nodes devices pending rendering", () => {
);
const text = container.textContent ?? "";
expect(text).toContain("role: node, operator");
expect(text).toContain("requested: roles: node, operator");
expect(text).toContain("scopes: operator.read");
});
});

View File

@@ -1,4 +1,9 @@
import { html, nothing } from "lit";
import {
resolvePendingDeviceApprovalState,
type DevicePairingAccessSummary,
type PendingDeviceApprovalKind,
} from "../../../../src/shared/device-pairing-access.js";
import { t } from "../../i18n/index.ts";
import type { DeviceTokenSummary, PairedDevice, PendingDevice } from "../controllers/devices.ts";
import { formatRelativeTimestamp, formatList } from "../format.ts";
@@ -36,6 +41,11 @@ function renderDevices(props: NodesProps) {
const list = props.devicesList ?? { pending: [], paired: [] };
const pending = Array.isArray(list.pending) ? list.pending : [];
const paired = Array.isArray(list.paired) ? list.paired : [];
const pairedByDeviceId = new Map(
paired
.map((device) => [normalizeOptionalString(device.deviceId), device] as const)
.filter((entry): entry is [string, PairedDevice] => Boolean(entry[0])),
);
return html`
<section class="card">
<div class="row" style="justify-content: space-between;">
@@ -54,7 +64,9 @@ function renderDevices(props: NodesProps) {
${pending.length > 0
? html`
<div class="muted" style="margin-bottom: 8px;">Pending</div>
${pending.map((req) => renderPendingDevice(req, props))}
${pending.map((req) =>
renderPendingDevice(req, props, lookupPairedDevice(pairedByDeviceId, req)),
)}
`
: nothing}
${paired.length > 0
@@ -71,11 +83,53 @@ function renderDevices(props: NodesProps) {
`;
}
function renderPendingDevice(req: PendingDevice, props: NodesProps) {
function lookupPairedDevice(
pairedByDeviceId: ReadonlyMap<string, PairedDevice>,
request: Pick<PendingDevice, "deviceId" | "publicKey">,
): PairedDevice | undefined {
const deviceId = normalizeOptionalString(request.deviceId);
if (!deviceId) {
return undefined;
}
const paired = pairedByDeviceId.get(deviceId);
if (!paired) {
return undefined;
}
const requestPublicKey = normalizeOptionalString(request.publicKey);
const pairedPublicKey = normalizeOptionalString(paired.publicKey);
if (requestPublicKey && pairedPublicKey && requestPublicKey !== pairedPublicKey) {
return undefined;
}
return paired;
}
function formatAccessSummary(access: DevicePairingAccessSummary | null): string {
if (!access) {
return "none";
}
return `roles: ${formatList(access.roles)} · scopes: ${formatList(access.scopes)}`;
}
function renderPendingApprovalNote(kind: PendingDeviceApprovalKind) {
switch (kind) {
case "scope-upgrade":
return "scope upgrade requires approval";
case "role-upgrade":
return "role upgrade requires approval";
case "re-approval":
return "reconnect details changed; approval required";
case "new-pairing":
return "new device pairing request";
}
const exhaustiveKind: never = kind;
void exhaustiveKind;
throw new Error("unsupported pending approval kind");
}
function renderPendingDevice(req: PendingDevice, props: NodesProps, paired?: PairedDevice) {
const name = normalizeOptionalString(req.displayName) || req.deviceId;
const age = typeof req.ts === "number" ? formatRelativeTimestamp(req.ts) : t("common.na");
const roleValue = normalizeOptionalString(req.role) || formatList(req.roles);
const scopesValue = formatList(req.scopes);
const approval = resolvePendingDeviceApprovalState(req, paired);
const repair = req.isRepair ? " · repair" : "";
const ip = req.remoteIp ? ` · ${req.remoteIp}` : "";
return html`
@@ -84,8 +138,18 @@ function renderPendingDevice(req: PendingDevice, props: NodesProps) {
<div class="list-title">${name}</div>
<div class="list-sub">${req.deviceId}${ip}</div>
<div class="muted" style="margin-top: 6px;">
role: ${roleValue} · scopes: ${scopesValue} · requested ${age}${repair}
${renderPendingApprovalNote(approval.kind)} · requested ${age}${repair}
</div>
<div class="muted" style="margin-top: 6px;">
requested: ${formatAccessSummary(approval.requested)}
</div>
${approval.approved
? html`
<div class="muted" style="margin-top: 6px;">
approved now: ${formatAccessSummary(approval.approved)}
</div>
`
: nothing}
</div>
<div class="list-meta">
<div class="row" style="justify-content: flex-end; gap: 8px; flex-wrap: wrap;">

View File

@@ -85,6 +85,11 @@ export function renderOverview(props: OverviewProps) {
return html`
<div class="muted" style="margin-top: 8px">
${t("overview.pairing.hint")}
<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>
@@ -260,11 +265,9 @@ export function renderOverview(props: OverviewProps) {
type="button"
class="btn btn--icon ${props.showGatewayToken ? "active" : ""}"
style="flex-shrink: 0; width: 36px; height: 36px; box-sizing: border-box;"
title=${
props.showGatewayToken
? t("overview.access.hideToken")
: t("overview.access.showToken")
}
title=${props.showGatewayToken
? t("overview.access.hideToken")
: t("overview.access.showToken")}
aria-label=${t("overview.access.toggleTokenVisibility")}
aria-pressed=${props.showGatewayToken}
@click=${props.onToggleGatewayTokenVisibility}
@@ -291,11 +294,9 @@ export function renderOverview(props: OverviewProps) {
type="button"
class="btn btn--icon ${props.showGatewayPassword ? "active" : ""}"
style="flex-shrink: 0; width: 36px; height: 36px; box-sizing: border-box;"
title=${
props.showGatewayPassword
? t("overview.access.hidePassword")
: t("overview.access.showPassword")
}
title=${props.showGatewayPassword
? t("overview.access.hidePassword")
: t("overview.access.showPassword")}
aria-label=${t("overview.access.togglePasswordVisibility")}
aria-pressed=${props.showGatewayPassword}
@click=${props.onToggleGatewayPasswordVisibility}