diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 5ce85d95bbb..dd9c2923803 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -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 --role `. See [Devices CLI](/cli/devices) for token rotation and revocation. diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index e9f12390319..7103c47ae8a 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -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, diff --git a/ui/src/ui/connect-error.test.ts b/ui/src/ui/connect-error.test.ts new file mode 100644 index 00000000000..d57b8079d4a --- /dev/null +++ b/ui/src/ui/connect-error.test.ts @@ -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)"); + }); +}); diff --git a/ui/src/ui/connect-error.ts b/ui/src/ui/connect-error.ts index c6fb3b185eb..8f250192b1b 100644 --- a/ui/src/ui/connect-error.ts +++ b/ui/src/ui/connect-error.ts @@ -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: diff --git a/ui/src/ui/controllers/devices.ts b/ui/src/ui/controllers/devices.ts index 64095856df0..e3f0e421e0d 100644 --- a/ui/src/ui/controllers/devices.ts +++ b/ui/src/ui/controllers/devices.ts @@ -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; - paired?: Array; + paired?: Array; }>("device.pair.list", {}); state.devicesList = { pending: Array.isArray(res?.pending) ? res.pending : [], diff --git a/ui/src/ui/views/nodes.devices.test.ts b/ui/src/ui/views/nodes.devices.test.ts index 0fb2c6915dd..130331a1145 100644 --- a/ui/src/ui/views/nodes.devices.test.ts +++ b/ui/src/ui/views/nodes.devices.test.ts @@ -47,7 +47,7 @@ function baseProps(overrides: Partial = {}): 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"); }); }); diff --git a/ui/src/ui/views/nodes.ts b/ui/src/ui/views/nodes.ts index bf673ad413f..560494a0112 100644 --- a/ui/src/ui/views/nodes.ts +++ b/ui/src/ui/views/nodes.ts @@ -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`
@@ -54,7 +64,9 @@ function renderDevices(props: NodesProps) { ${pending.length > 0 ? html`
Pending
- ${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, + request: Pick, +): 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) {
${name}
${req.deviceId}${ip}
- role: ${roleValue} · scopes: ${scopesValue} · requested ${age}${repair} + ${renderPendingApprovalNote(approval.kind)} · requested ${age}${repair}
+
+ requested: ${formatAccessSummary(approval.requested)} +
+ ${approval.approved + ? html` +
+ approved now: ${formatAccessSummary(approval.approved)} +
+ ` + : nothing}
diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts index 829d1453228..8488f1e7402 100644 --- a/ui/src/ui/views/overview.ts +++ b/ui/src/ui/views/overview.ts @@ -85,6 +85,11 @@ export function renderOverview(props: OverviewProps) { return html`
${t("overview.pairing.hint")} +
+ 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> @@ -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}