diff --git a/CHANGELOG.md b/CHANGELOG.md index 43aff8bd18c..8de5d606931 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -139,6 +139,7 @@ Docs: https://docs.openclaw.ai - Discord: enforce strict DM component allowlist auth (#49997) Thanks @joshavant. - Stabilize plugin loader and Docker extension smoke (#50058) Thanks @joshavant. - Telegram: stabilize pairing/session/forum routing and reply formatting tests (#50155) Thanks @joshavant. +- Hardening: refresh stale device pairing requests and pending metadata (#50695) Thanks @smaeljaish771 and @joshavant. ### Fixes diff --git a/docs/channels/pairing.md b/docs/channels/pairing.md index 1ba3c6c92f2..592ced0f11d 100644 --- a/docs/channels/pairing.md +++ b/docs/channels/pairing.md @@ -67,7 +67,7 @@ If you use the `device-pair` plugin, you can do first-time device pairing entire 2. The bot replies with two messages: an instruction message and a separate **setup code** message (easy to copy/paste in Telegram). 3. On your phone, open the OpenClaw iOS app → Settings → Gateway. 4. Paste the setup code and connect. -5. Back in Telegram: `/pair approve` +5. Back in Telegram: `/pair pending` (review request IDs, role, and scopes), then approve. The setup code is a base64-encoded JSON payload that contains: @@ -84,6 +84,10 @@ openclaw devices approve openclaw devices reject ``` +If the same device retries with different auth details (for example different +role/scopes/public key), the previous pending request is superseded and a new +`requestId` is created. + ### Node pairing state storage Stored under `~/.openclaw/devices/`: diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 2758982b8d7..91ac3ec4b67 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -346,7 +346,13 @@ curl "https://api.telegram.org/bot/getUpdates" 1. `/pair` generates setup code 2. paste code in iOS app - 3. `/pair approve` approves latest pending request + 3. `/pair pending` lists pending requests (including role/scopes) + 4. approve the request: + - `/pair approve ` for explicit approval + - `/pair approve` when there is only one pending request + - `/pair approve latest` for most recent + + If a device retries with changed auth details (for example role/scopes/public key), the previous pending request is superseded and the new request uses a different `requestId`. Re-run `/pair pending` before approving. More details: [Pairing](/channels/pairing#pair-via-telegram-recommended-for-ios). diff --git a/docs/cli/devices.md b/docs/cli/devices.md index f73f30dfa1d..fa0d53a2401 100644 --- a/docs/cli/devices.md +++ b/docs/cli/devices.md @@ -21,6 +21,9 @@ openclaw devices list openclaw devices list --json ``` +Pending request output includes the requested role and scopes so approvals can +be reviewed before you approve. + ### `openclaw devices remove ` Remove one paired device entry. @@ -45,6 +48,11 @@ openclaw devices clear --yes --pending --json Approve a pending device pairing request. If `requestId` is omitted, OpenClaw automatically approves the most recent pending request. +Note: if a device retries pairing with changed auth details (role/scopes/public +key), OpenClaw supersedes the previous pending entry and issues a new +`requestId`. Run `openclaw devices list` right before approval to use the +current ID. + ``` openclaw devices approve openclaw devices approve diff --git a/docs/cli/node.md b/docs/cli/node.md index baf8c3cd45e..a1f882a7870 100644 --- a/docs/cli/node.md +++ b/docs/cli/node.md @@ -111,6 +111,10 @@ openclaw devices list openclaw devices approve ``` +If the node retries pairing with changed auth details (role/scopes/public key), +the previous pending request is superseded and a new `requestId` is created. +Run `openclaw devices list` again before approval. + The node host stores its node id, token, display name, and gateway connection info in `~/.openclaw/node.json`. diff --git a/docs/nodes/index.md b/docs/nodes/index.md index f23a2c979cf..b333708b16d 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -36,6 +36,10 @@ openclaw nodes status openclaw nodes describe --node ``` +If a node retries with changed auth details (role/scopes/public key), the prior +pending request is superseded and a new `requestId` is created. Re-run +`openclaw devices list` before approving. + Notes: - `nodes status` marks a node as **paired** when its device pairing role includes `node`. @@ -115,6 +119,9 @@ openclaw devices approve openclaw nodes status ``` +If the node retries with changed auth details, re-run `openclaw devices list` +and approve the current `requestId`. + Naming options: - `--display-name` on `openclaw node run` / `openclaw node install` (persists in `~/.openclaw/node.json` on the node). diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md index f64eba3fed0..fb37ae2d34f 100644 --- a/docs/platforms/ios.md +++ b/docs/platforms/ios.md @@ -42,6 +42,10 @@ openclaw devices list openclaw devices approve ``` +If the app retries pairing with changed auth details (role/scopes/public key), +the previous pending request is superseded and a new `requestId` is created. +Run `openclaw devices list` again before approval. + 4. Verify connection: ```bash diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 952f6f71c1d..9de259a7ef4 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -49,6 +49,10 @@ openclaw devices list openclaw devices approve ``` +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. + 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/extensions/device-pair/notify.test.ts b/extensions/device-pair/notify.test.ts new file mode 100644 index 00000000000..f3e226f49f7 --- /dev/null +++ b/extensions/device-pair/notify.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { formatPendingRequests, type PendingPairingRequest } from "./notify.ts"; + +describe("device-pair notify pending formatting", () => { + it("includes role and scopes for pending requests", () => { + const pending: PendingPairingRequest[] = [ + { + requestId: "req-1", + deviceId: "device-1", + displayName: "dev one", + platform: "ios", + role: "operator", + scopes: ["operator.admin", "operator.read"], + remoteIp: "198.51.100.2", + }, + ]; + + const text = formatPendingRequests(pending); + expect(text).toContain("Pending device pairing requests:"); + expect(text).toContain("name=dev one"); + expect(text).toContain("platform=ios"); + expect(text).toContain("role=operator"); + expect(text).toContain("scopes=operator.admin, operator.read"); + expect(text).toContain("ip=198.51.100.2"); + }); + + it("falls back to roles list and no scopes when role/scopes are absent", () => { + const pending: PendingPairingRequest[] = [ + { + requestId: "req-2", + deviceId: "device-2", + roles: ["node", "operator"], + scopes: [], + }, + ]; + + const text = formatPendingRequests(pending); + expect(text).toContain("role=node, operator"); + expect(text).toContain("scopes=none"); + }); +}); diff --git a/extensions/device-pair/notify.ts b/extensions/device-pair/notify.ts index ba45e856372..90e0e1890ab 100644 --- a/extensions/device-pair/notify.ts +++ b/extensions/device-pair/notify.ts @@ -25,10 +25,33 @@ export type PendingPairingRequest = { deviceId: string; displayName?: string; platform?: string; + role?: string; + roles?: string[]; + scopes?: string[]; remoteIp?: string; ts?: number; }; +function formatStringList(values?: readonly string[]): string { + if (!Array.isArray(values) || values.length === 0) { + return "none"; + } + const normalized = values.map((value) => value.trim()).filter((value) => value.length > 0); + return normalized.length > 0 ? normalized.join(", ") : "none"; +} + +function formatRoleList(request: PendingPairingRequest): string { + const role = request.role?.trim(); + if (role) { + return role; + } + return formatStringList(request.roles); +} + +function formatScopeList(request: PendingPairingRequest): string { + return formatStringList(request.scopes); +} + export function formatPendingRequests(pending: PendingPairingRequest[]): string { if (pending.length === 0) { return "No pending device pairing requests."; @@ -42,6 +65,8 @@ export function formatPendingRequests(pending: PendingPairingRequest[]): string `- ${req.requestId}`, label ? `name=${label}` : null, platform ? `platform=${platform}` : null, + `role=${formatRoleList(req)}`, + `scopes=${formatScopeList(req)}`, ip ? `ip=${ip}` : null, ].filter(Boolean); lines.push(parts.join(" · ")); @@ -182,11 +207,15 @@ function buildPairingRequestNotificationText(request: PendingPairingRequest): st const label = request.displayName?.trim() || request.deviceId; const platform = request.platform?.trim(); const ip = request.remoteIp?.trim(); + const role = formatRoleList(request); + const scopes = formatScopeList(request); const lines = [ "📲 New device pairing request", `ID: ${request.requestId}`, `Name: ${label}`, ...(platform ? [`Platform: ${platform}`] : []), + `Role: ${role}`, + `Scopes: ${scopes}`, ...(ip ? [`IP: ${ip}`] : []), "", `Approve: /pair approve ${request.requestId}`, diff --git a/src/cli/devices-cli.test.ts b/src/cli/devices-cli.test.ts index 7d6abba39b0..81ca7d3c37f 100644 --- a/src/cli/devices-cli.test.ts +++ b/src/cli/devices-cli.test.ts @@ -287,6 +287,30 @@ describe("devices cli local fallback", () => { }); }); +describe("devices cli list", () => { + it("renders pending scopes when present", async () => { + callGateway.mockResolvedValueOnce({ + pending: [ + { + requestId: "req-1", + deviceId: "device-1", + displayName: "Device One", + role: "operator", + scopes: ["operator.admin", "operator.read"], + ts: 1, + }, + ], + paired: [], + }); + + await runDevicesCommand(["list"]); + + const output = runtime.log.mock.calls.map((entry) => String(entry[0] ?? "")).join("\n"); + expect(output).toContain("Scopes"); + expect(output).toContain("operator.admin, operator.read"); + }); +}); + afterEach(() => { callGateway.mockClear(); buildGatewayConnectionDetails.mockClear(); diff --git a/src/cli/devices-cli.ts b/src/cli/devices-cli.ts index 143d27b20ff..96b2db3a332 100644 --- a/src/cli/devices-cli.ts +++ b/src/cli/devices-cli.ts @@ -39,6 +39,8 @@ type PendingDevice = { deviceId: string; displayName?: string; role?: string; + roles?: string[]; + scopes?: string[]; remoteIp?: string; isRepair?: boolean; ts?: number; @@ -197,6 +199,30 @@ function formatTokenSummary(tokens: DeviceTokenSummary[] | undefined) { return parts.join(", "); } +function formatPendingRoles(request: PendingDevice): string { + const role = typeof request.role === "string" ? request.role.trim() : ""; + if (role) { + return role; + } + const roles = Array.isArray(request.roles) + ? request.roles.map((item) => item.trim()).filter((item) => item.length > 0) + : []; + if (roles.length === 0) { + return ""; + } + return roles.join(", "); +} + +function formatPendingScopes(request: PendingDevice): string { + const scopes = Array.isArray(request.scopes) + ? request.scopes.map((item) => item.trim()).filter((item) => item.length > 0) + : []; + if (scopes.length === 0) { + return ""; + } + return scopes.join(", "); +} + function resolveRequiredDeviceRole( opts: DevicesRpcOpts, ): { deviceId: string; role: string } | null { @@ -235,6 +261,7 @@ export function registerDevicesCli(program: Command) { { key: "Request", header: "Request", minWidth: 10 }, { key: "Device", header: "Device", minWidth: 16, flex: true }, { key: "Role", header: "Role", minWidth: 8 }, + { key: "Scopes", header: "Scopes", minWidth: 14, flex: true }, { key: "IP", header: "IP", minWidth: 12 }, { key: "Age", header: "Age", minWidth: 8 }, { key: "Flags", header: "Flags", minWidth: 8 }, @@ -242,7 +269,8 @@ export function registerDevicesCli(program: Command) { rows: list.pending.map((req) => ({ Request: req.requestId, Device: req.displayName || req.deviceId, - Role: req.role ?? "", + Role: formatPendingRoles(req), + Scopes: formatPendingScopes(req), IP: req.remoteIp ?? "", Age: typeof req.ts === "number" ? formatTimeAgo(Date.now() - req.ts) : "", Flags: req.isRepair ? "repair" : "", diff --git a/src/gateway/server.device-pair-approve-supersede.test.ts b/src/gateway/server.device-pair-approve-supersede.test.ts new file mode 100644 index 00000000000..310598693e0 --- /dev/null +++ b/src/gateway/server.device-pair-approve-supersede.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "vitest"; +import { getPairedDevice, requestDevicePairing } from "../infra/device-pairing.js"; +import { + connectOk, + installGatewayTestHooks, + rpcReq, + startServerWithClient, +} from "./test-helpers.js"; + +installGatewayTestHooks({ scope: "suite" }); + +describe("gateway device.pair.approve superseded request ids", () => { + test("rejects approving a superseded request id", async () => { + const started = await startServerWithClient("secret"); + + try { + const first = await requestDevicePairing({ + deviceId: "supersede-device-1", + publicKey: "supersede-public-key", + role: "node", + scopes: ["node.exec"], + }); + const second = await requestDevicePairing({ + deviceId: "supersede-device-1", + publicKey: "supersede-public-key", + role: "operator", + scopes: ["operator.admin"], + }); + + expect(second.request.requestId).not.toBe(first.request.requestId); + await connectOk(started.ws); + + const staleApprove = await rpcReq(started.ws, "device.pair.approve", { + requestId: first.request.requestId, + }); + expect(staleApprove.ok).toBe(false); + expect(staleApprove.error?.message).toBe("unknown requestId"); + + const latestApprove = await rpcReq(started.ws, "device.pair.approve", { + requestId: second.request.requestId, + }); + expect(latestApprove.ok).toBe(true); + + const paired = await getPairedDevice("supersede-device-1"); + expect(paired?.role).toBe("operator"); + expect(paired?.scopes).toEqual(["operator.admin"]); + } finally { + started.ws.close(); + await started.server.close(); + started.envSnapshot.restore(); + } + }); +}); diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index 4deb04a8912..b1805145cf8 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -8,6 +8,7 @@ import { clearDevicePairing, ensureDeviceToken, getPairedDevice, + listDevicePairing, removePairedDevice, requestDevicePairing, rotateDeviceToken, @@ -124,7 +125,7 @@ describe("device pairing tokens", () => { expect(second.request.requestId).toBe(first.request.requestId); }); - test("merges pending roles/scopes for the same device before approval", async () => { + test("supersedes pending requests when requested roles/scopes change", async () => { const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); const first = await requestDevicePairing( { @@ -145,14 +146,19 @@ describe("device pairing tokens", () => { baseDir, ); - expect(second.created).toBe(false); - expect(second.request.requestId).toBe(first.request.requestId); - expect(second.request.roles).toEqual(["node", "operator"]); + expect(second.created).toBe(true); + expect(second.request.requestId).not.toBe(first.request.requestId); + expect(second.request.role).toBe("operator"); + expect(second.request.roles).toEqual(["operator"]); expect(second.request.scopes).toEqual(["operator.read", "operator.write"]); - await approveDevicePairing(first.request.requestId, baseDir); + const list = await listDevicePairing(baseDir); + expect(list.pending).toHaveLength(1); + expect(list.pending[0]?.requestId).toBe(second.request.requestId); + + await approveDevicePairing(second.request.requestId, baseDir); const paired = await getPairedDevice("device-1", baseDir); - expect(paired?.roles).toEqual(["node", "operator"]); + expect(paired?.roles).toEqual(["operator"]); expect(paired?.scopes).toEqual(["operator.read", "operator.write"]); }); diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 063834a17de..b51ae0db67a 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -175,23 +175,59 @@ function mergeScopes(...items: Array): string[] | undefine return [...scopes]; } -function mergePendingDevicePairingRequest( +function sameStringSet(left: readonly string[], right: readonly string[]): boolean { + if (left.length !== right.length) { + return false; + } + const rightSet = new Set(right); + for (const value of left) { + if (!rightSet.has(value)) { + return false; + } + } + return true; +} + +function resolveRequestedRoles(input: { role?: string; roles?: string[] }): string[] { + return mergeRoles(input.roles, input.role) ?? []; +} + +function resolveRequestedScopes(input: { scopes?: string[] }): string[] { + return normalizeDeviceAuthScopes(input.scopes); +} + +function samePendingApprovalSnapshot( + existing: DevicePairingPendingRequest, + incoming: Omit, +): boolean { + if (existing.publicKey !== incoming.publicKey) { + return false; + } + if (normalizeRole(existing.role) !== normalizeRole(incoming.role)) { + return false; + } + if ( + !sameStringSet(resolveRequestedRoles(existing), resolveRequestedRoles(incoming)) || + !sameStringSet(resolveRequestedScopes(existing), resolveRequestedScopes(incoming)) + ) { + return false; + } + return true; +} + +function refreshPendingDevicePairingRequest( existing: DevicePairingPendingRequest, incoming: Omit, isRepair: boolean, ): DevicePairingPendingRequest { - const existingRole = normalizeRole(existing.role); - const incomingRole = normalizeRole(incoming.role); return { ...existing, + publicKey: incoming.publicKey, displayName: incoming.displayName ?? existing.displayName, platform: incoming.platform ?? existing.platform, deviceFamily: incoming.deviceFamily ?? existing.deviceFamily, clientId: incoming.clientId ?? existing.clientId, clientMode: incoming.clientMode ?? existing.clientMode, - role: existingRole ?? incomingRole ?? undefined, - roles: mergeRoles(existing.roles, existing.role, incoming.role), - scopes: mergeScopes(existing.scopes, incoming.scopes), remoteIp: incoming.remoteIp ?? existing.remoteIp, // If either request is interactive, keep the pending request visible for approval. silent: Boolean(existing.silent && incoming.silent), @@ -200,6 +236,49 @@ function mergePendingDevicePairingRequest( }; } +function buildPendingDevicePairingRequest(params: { + requestId?: string; + deviceId: string; + isRepair: boolean; + req: Omit; +}): DevicePairingPendingRequest { + const role = normalizeRole(params.req.role) ?? undefined; + return { + requestId: params.requestId ?? randomUUID(), + deviceId: params.deviceId, + publicKey: params.req.publicKey, + displayName: params.req.displayName, + platform: params.req.platform, + deviceFamily: params.req.deviceFamily, + clientId: params.req.clientId, + clientMode: params.req.clientMode, + role, + roles: mergeRoles(params.req.roles, role), + scopes: mergeScopes(params.req.scopes), + remoteIp: params.req.remoteIp, + silent: params.req.silent, + isRepair: params.isRepair, + ts: Date.now(), + }; +} + +function resolvePendingApprovalRole(pending: DevicePairingPendingRequest): string | null { + const role = normalizeRole(pending.role); + if (role) { + return role; + } + if (!Array.isArray(pending.roles)) { + return null; + } + for (const candidate of pending.roles) { + const normalized = normalizeRole(candidate); + if (normalized) { + return normalized; + } + } + return null; +} + function newToken() { return generatePairingToken(); } @@ -296,33 +375,37 @@ export async function requestDevicePairing( throw new Error("deviceId required"); } const isRepair = Boolean(state.pairedByDeviceId[deviceId]); - const existing = Object.values(state.pendingById).find( - (pending) => pending.deviceId === deviceId, - ); - if (existing) { - const merged = mergePendingDevicePairingRequest(existing, req, isRepair); - state.pendingById[existing.requestId] = merged; + const pendingForDevice = Object.values(state.pendingById) + .filter((pending) => pending.deviceId === deviceId) + .toSorted((left, right) => right.ts - left.ts); + const latestPending = pendingForDevice[0]; + if (latestPending && pendingForDevice.length === 1) { + if (samePendingApprovalSnapshot(latestPending, req)) { + const refreshed = refreshPendingDevicePairingRequest(latestPending, req, isRepair); + state.pendingById[latestPending.requestId] = refreshed; + await persistState(state, baseDir); + return { status: "pending" as const, request: refreshed, created: false }; + } + } + if (pendingForDevice.length > 0) { + for (const pending of pendingForDevice) { + delete state.pendingById[pending.requestId]; + } + const superseded = buildPendingDevicePairingRequest({ + deviceId, + isRepair, + req, + }); + state.pendingById[superseded.requestId] = superseded; await persistState(state, baseDir); - return { status: "pending" as const, request: merged, created: false }; + return { status: "pending" as const, request: superseded, created: true }; } - const request: DevicePairingPendingRequest = { - requestId: randomUUID(), + const request = buildPendingDevicePairingRequest({ deviceId, - publicKey: req.publicKey, - displayName: req.displayName, - platform: req.platform, - deviceFamily: req.deviceFamily, - clientId: req.clientId, - clientMode: req.clientMode, - role: req.role, - roles: req.role ? [req.role] : undefined, - scopes: req.scopes, - remoteIp: req.remoteIp, - silent: req.silent, isRepair, - ts: Date.now(), - }; + req, + }); state.pendingById[request.requestId] = request; await persistState(state, baseDir); return { status: "pending" as const, request, created: true }; @@ -354,9 +437,10 @@ export async function approveDevicePairing( if (!pending) { return null; } - if (pending.role && options?.callerScopes) { + const approvalRole = resolvePendingApprovalRole(pending); + if (approvalRole && options?.callerScopes) { const missingScope = resolveMissingRequestedScope({ - role: pending.role, + role: approvalRole, requestedScopes: normalizeDeviceAuthScopes(pending.scopes), allowedScopes: options.callerScopes, }); diff --git a/ui/src/ui/controllers/devices.ts b/ui/src/ui/controllers/devices.ts index 16edd8afe43..64095856df0 100644 --- a/ui/src/ui/controllers/devices.ts +++ b/ui/src/ui/controllers/devices.ts @@ -16,6 +16,8 @@ export type PendingDevice = { deviceId: string; displayName?: string; role?: string; + roles?: string[]; + scopes?: string[]; remoteIp?: string; isRepair?: boolean; ts?: number; diff --git a/ui/src/ui/views/nodes.devices.test.ts b/ui/src/ui/views/nodes.devices.test.ts new file mode 100644 index 00000000000..0fb2c6915dd --- /dev/null +++ b/ui/src/ui/views/nodes.devices.test.ts @@ -0,0 +1,104 @@ +/* @vitest-environment jsdom */ +import { render } from "lit"; +import { describe, expect, it } from "vitest"; +import { renderNodes, type NodesProps } from "./nodes.ts"; + +function baseProps(overrides: Partial = {}): NodesProps { + return { + loading: false, + nodes: [], + devicesLoading: false, + devicesError: null, + devicesList: { + pending: [], + paired: [], + }, + configForm: null, + configLoading: false, + configSaving: false, + configDirty: false, + configFormMode: "form", + execApprovalsLoading: false, + execApprovalsSaving: false, + execApprovalsDirty: false, + execApprovalsSnapshot: null, + execApprovalsForm: null, + execApprovalsSelectedAgent: null, + execApprovalsTarget: "gateway", + execApprovalsTargetNodeId: null, + onRefresh: () => undefined, + onDevicesRefresh: () => undefined, + onDeviceApprove: () => undefined, + onDeviceReject: () => undefined, + onDeviceRotate: () => undefined, + onDeviceRevoke: () => undefined, + onLoadConfig: () => undefined, + onLoadExecApprovals: () => undefined, + onBindDefault: () => undefined, + onBindAgent: () => undefined, + onSaveBindings: () => undefined, + onExecApprovalsTargetChange: () => undefined, + onExecApprovalsSelectAgent: () => undefined, + onExecApprovalsPatch: () => undefined, + onExecApprovalsRemove: () => undefined, + onSaveExecApprovals: () => undefined, + ...overrides, + }; +} + +describe("nodes devices pending rendering", () => { + it("shows pending role and scopes from effective pending auth", () => { + 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: [], + }, + }), + ), + container, + ); + + const text = container.textContent ?? ""; + expect(text).toContain("role: operator"); + expect(text).toContain("scopes: operator.admin, operator.read"); + }); + + it("falls back to roles when role is absent", () => { + const container = document.createElement("div"); + render( + renderNodes( + baseProps({ + devicesList: { + pending: [ + { + requestId: "req-2", + deviceId: "device-2", + roles: ["node", "operator"], + scopes: ["operator.read"], + ts: Date.now(), + }, + ], + paired: [], + }, + }), + ), + container, + ); + + const text = container.textContent ?? ""; + expect(text).toContain("role: 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 8a8413b6d58..ad3b30e7531 100644 --- a/ui/src/ui/views/nodes.ts +++ b/ui/src/ui/views/nodes.ts @@ -128,7 +128,8 @@ function renderDevices(props: NodesProps) { function renderPendingDevice(req: PendingDevice, props: NodesProps) { const name = req.displayName?.trim() || req.deviceId; const age = typeof req.ts === "number" ? formatRelativeTimestamp(req.ts) : "n/a"; - const role = req.role?.trim() ? `role: ${req.role}` : "role: -"; + const roleValue = req.role?.trim() || formatList(req.roles); + const scopesValue = formatList(req.scopes); const repair = req.isRepair ? " · repair" : ""; const ip = req.remoteIp ? ` · ${req.remoteIp}` : ""; return html` @@ -137,7 +138,7 @@ function renderPendingDevice(req: PendingDevice, props: NodesProps) {
${name}
${req.deviceId}${ip}
- ${role} · requested ${age}${repair} + role: ${roleValue} · scopes: ${scopesValue} · requested ${age}${repair}
diff --git a/vitest.config.ts b/vitest.config.ts index 60289881975..f254bcdf0a7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -40,6 +40,7 @@ export default defineConfig({ "ui/src/ui/app-chat.test.ts", "ui/src/ui/views/agents-utils.test.ts", "ui/src/ui/views/chat.test.ts", + "ui/src/ui/views/nodes.devices.test.ts", "ui/src/ui/views/usage-render-details.test.ts", "ui/src/ui/controllers/agents.test.ts", "ui/src/ui/controllers/chat.test.ts",