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:
Josh Avant
2026-03-19 18:26:06 -05:00
committed by GitHub
parent 9486f6e379
commit 8e132aed6e
19 changed files with 452 additions and 41 deletions

View File

@@ -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

View File

@@ -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/`:

View File

@@ -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).

View File

@@ -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>

View File

@@ -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`.

View File

@@ -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).

View File

@@ -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

View File

@@ -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.

View 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");
});
});

View File

@@ -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}`,

View File

@@ -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();

View File

@@ -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" : "",

View 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();
}
});
});

View File

@@ -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"]);
});

View File

@@ -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,
});

View File

@@ -16,6 +16,8 @@ export type PendingDevice = {
deviceId: string;
displayName?: string;
role?: string;
roles?: string[];
scopes?: string[];
remoteIp?: string;
isRepair?: boolean;
ts?: number;

View 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");
});
});

View File

@@ -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">

View File

@@ -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",