mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 14:40:43 +00:00
fix(control-ui): explain pairing access upgrades
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
35
ui/src/ui/connect-error.test.ts
Normal file
35
ui/src/ui/connect-error.test.ts
Normal 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)");
|
||||
});
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -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 : [],
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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 <requestId></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}
|
||||
|
||||
Reference in New Issue
Block a user