mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-20 22:40:58 +00:00
Hardening: refresh stale device pairing requests and pending metadata (#50695)
* Docs: clarify device pairing supersede behavior * Device pairing: supersede pending requests on auth changes
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 <requestId>
|
||||
openclaw devices reject <requestId>
|
||||
```
|
||||
|
||||
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/`:
|
||||
|
||||
@@ -346,7 +346,13 @@ curl "https://api.telegram.org/bot<bot_token>/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 <requestId>` 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).
|
||||
|
||||
|
||||
@@ -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 <deviceId>`
|
||||
|
||||
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 <requestId>
|
||||
|
||||
@@ -111,6 +111,10 @@ openclaw devices list
|
||||
openclaw devices approve <requestId>
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
|
||||
@@ -36,6 +36,10 @@ openclaw nodes status
|
||||
openclaw nodes describe --node <idOrNameOrIp>
|
||||
```
|
||||
|
||||
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 <requestId>
|
||||
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).
|
||||
|
||||
@@ -42,6 +42,10 @@ openclaw devices list
|
||||
openclaw devices approve <requestId>
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -49,6 +49,10 @@ openclaw devices list
|
||||
openclaw devices approve <requestId>
|
||||
```
|
||||
|
||||
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 <id> --role <role>`. See
|
||||
[Devices CLI](/cli/devices) for token rotation and revocation.
|
||||
|
||||
41
extensions/device-pair/notify.test.ts
Normal file
41
extensions/device-pair/notify.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" : "",
|
||||
|
||||
53
src/gateway/server.device-pair-approve-supersede.test.ts
Normal file
53
src/gateway/server.device-pair-approve-supersede.test.ts
Normal file
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -175,23 +175,59 @@ function mergeScopes(...items: Array<string[] | undefined>): 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<DevicePairingPendingRequest, "requestId" | "ts" | "isRepair">,
|
||||
): 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<DevicePairingPendingRequest, "requestId" | "ts" | "isRepair">,
|
||||
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, "requestId" | "ts" | "isRepair">;
|
||||
}): 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,
|
||||
});
|
||||
|
||||
@@ -16,6 +16,8 @@ export type PendingDevice = {
|
||||
deviceId: string;
|
||||
displayName?: string;
|
||||
role?: string;
|
||||
roles?: string[];
|
||||
scopes?: string[];
|
||||
remoteIp?: string;
|
||||
isRepair?: boolean;
|
||||
ts?: number;
|
||||
|
||||
104
ui/src/ui/views/nodes.devices.test.ts
Normal file
104
ui/src/ui/views/nodes.devices.test.ts
Normal file
@@ -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> = {}): 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");
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
<div class="list-title">${name}</div>
|
||||
<div class="list-sub">${req.deviceId}${ip}</div>
|
||||
<div class="muted" style="margin-top: 6px;">
|
||||
${role} · requested ${age}${repair}
|
||||
role: ${roleValue} · scopes: ${scopesValue} · requested ${age}${repair}
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-meta">
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user