fix(gateway): stop paired-device duplication and redesign the Nodes page (#102810)

* fix(gateway): prune superseded silent device pairings on auto-approve

* feat(ui): unified nodes & devices inventory with dedupe and cleanup

* docs: nodes page inventory + silent pairing supersede cleanup

* refactor(gateway): run silent-pairing prune after approval broadcast

* fix(ui): surface node list errors on the nodes inventory card

* fix(gateway): restrict pairing auto-prune to same-host silent approvals

* fix(gateway): report live device connections and guard pairing prune races

* docs(ui): document bulk-cleanup name-collision tradeoff

* fix(gateway): avoid map-spread when marking live device connections

* docs: regenerate docs map
This commit is contained in:
Peter Steinberger 2026-07-09 15:40:30 +01:00 committed by GitHub
parent 743422217e
commit 8985598302
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 2112 additions and 279 deletions

View file

@ -113,12 +113,12 @@ creates a device pairing request that must be approved.
Use an already connected Control UI session with `operator.admin` access: Use an already connected Control UI session with `operator.admin` access:
1. Open the Control UI and select **Nodes**. 1. Open the Control UI and select **Nodes**.
2. In **Devices**, click **Pair mobile device**. 2. In **Nodes & devices**, click **Pair mobile device**.
3. On your phone, open the OpenClaw app → **Settings****Gateway**. 3. On your phone, open the OpenClaw app → **Settings****Gateway**.
4. Scan the QR code or paste the setup code, then connect. 4. Scan the QR code or paste the setup code, then connect.
Official OpenClaw iOS and Android apps are approved automatically when their Official OpenClaw iOS and Android apps are approved automatically when their
setup-code metadata matches. If **Devices** shows a pending request (for setup-code metadata matches. If **Pending approval** shows a request (for
example, for a non-official client or mismatched metadata), review its role and example, for a non-official client or mismatched metadata), review its role and
scopes before approving it. scopes before approving it.

View file

@ -3583,6 +3583,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Node event trust boundaries (2026.3.31+) - H2: Node event trust boundaries (2026.3.31+)
- H2: Auto-approval (macOS app) - H2: Auto-approval (macOS app)
- H2: Trusted-CIDR device auto-approval - H2: Trusted-CIDR device auto-approval
- H2: Silent pairing supersede cleanup
- H2: Metadata-upgrade auto-approval - H2: Metadata-upgrade auto-approval
- H2: QR pairing helpers - H2: QR pairing helpers
- H2: Locality and forwarded headers - H2: Locality and forwarded headers

View file

@ -176,6 +176,40 @@ Security boundary:
- Same-host loopback trusted-proxy header paths are not eligible, because that - Same-host loopback trusted-proxy header paths are not eligible, because that
path can be spoofed by local callers. path can be spoofed by local callers.
## Silent pairing supersede cleanup
Non-interactive approvals record their provenance on the paired-device row:
same-host local policy approvals as `silent`, trusted-CIDR node approvals as
`trusted-cidr`. Clients whose state directory is ephemeral (temporary homes,
containers, per-run sandboxes) mint a fresh device keypair per run, and every
run silently re-pairs as a brand-new device — without cleanup the paired list
grows one stale row per run.
When the Gateway silently approves a **local** device pairing, it retires
older `silent`-approved records that belong to the same client cluster
(matching `clientId`, `clientMode`, and display name) and are not currently
connected. Local clients run on the gateway host itself, so the cluster key
cannot match a different machine. Retired rows lose their tokens immediately;
any matching legacy node pairing entry is cleared and a `node.pair.resolved`
removal event is broadcast.
Boundaries:
- Only records whose latest approval was same-host local (`silent`) are
eligible, as trigger and as target. Trusted-CIDR pairings cross hosts where
display metadata is not a machine identity, so they are never removed
automatically — use the Control UI cleanup or `openclaw nodes remove` for
those.
- Owner-approved and QR/setup-code (bootstrap) pairings are never removed
automatically. Records approved before provenance existed stay protected,
even after a later silent re-approval of the same device id.
- Currently connected devices are skipped, so concurrent local sessions with
separate state directories keep their tokens while live. Records approved
within the last minute are also skipped, so simultaneous pairing handshakes
cannot retire each other before their connections register.
- Affected clients are local by construction, so they re-pair silently on
their next connection.
## Metadata-upgrade auto-approval ## Metadata-upgrade auto-approval
When an already-paired device reconnects with only non-sensitive metadata When an already-paired device reconnects with only non-sensitive metadata

View file

@ -42,7 +42,7 @@ Gateway has not been configured yet, run `openclaw onboard` first so setup-code
creation has a token or password auth path. creation has a token or password auth path.
2. Open the [Control UI](/web/control-ui), select **Nodes**, and click 2. Open the [Control UI](/web/control-ui), select **Nodes**, and click
**Pair mobile device** in the **Devices** card. **Pair mobile device** in the **Nodes & devices** card.
3. In the iOS app, open **Settings** -> **Gateway**, scan the QR code (or paste 3. In the iOS app, open **Settings** -> **Gateway**, scan the QR code (or paste
the setup code), and connect. the setup code), and connect.
@ -50,7 +50,7 @@ creation has a token or password auth path.
If the setup code contains both LAN and Tailscale Serve routes, the app If the setup code contains both LAN and Tailscale Serve routes, the app
probes them in order and saves the first reachable endpoint. probes them in order and saves the first reachable endpoint.
4. The official app connects automatically. If **Devices** shows a pending 4. The official app connects automatically. If **Pending approval** shows a
request, review its role and scopes before approving it. request, review its role and scopes before approving it.
The Apple Watch companion does not have a separate OpenClaw pairing approval. The Apple Watch companion does not have a separate OpenClaw pairing approval.

View file

@ -70,13 +70,13 @@ An already paired administrator can create the iOS/Android connection QR without
<Steps> <Steps>
<Step title="Open mobile pairing"> <Step title="Open mobile pairing">
Select **Nodes**, then click **Pair mobile device** in the **Devices** card. Select **Nodes**, then click **Pair mobile device** in the **Nodes & devices** card.
</Step> </Step>
<Step title="Connect the phone"> <Step title="Connect the phone">
In the OpenClaw mobile app, open **Settings****Gateway** and scan the QR code. You can copy and paste the setup code instead. In the OpenClaw mobile app, open **Settings****Gateway** and scan the QR code. You can copy and paste the setup code instead.
</Step> </Step>
<Step title="Confirm the connection"> <Step title="Confirm the connection">
The official iOS/Android app connects automatically. If **Devices** shows a pending request, review its role and scopes before approving it. The official iOS/Android app connects automatically. If **Pending approval** shows a request, review its role and scopes before approving it.
</Step> </Step>
</Steps> </Steps>
@ -147,7 +147,7 @@ A **Search** field at the top of the sidebar opens the command palette (⌘K). T
- Cron jobs: list/add/edit/run/enable/disable plus run history (`cron.*`). - Cron jobs: list/add/edit/run/enable/disable plus run history (`cron.*`).
- Tasks: live active and recent background task ledger with linked sessions and cancellation (`tasks.*`). - Tasks: live active and recent background task ledger with linked sessions and cancellation (`tasks.*`).
- Skills: status, enable/disable, install, API key updates (`skills.*`). - Skills: status, enable/disable, install, API key updates (`skills.*`).
- Nodes: list plus caps (`node.list`), create mobile setup codes, and approve device pairing (`device.pair.*`). - Nodes: one **Nodes & devices** inventory that joins paired device records with the node catalog (`node.list`, `device.pair.list`) — one entry per machine with roles, live link status, tokens, and capabilities. Duplicate pairings of the same client collapse into an expandable group, and **Clean up N stale** bulk-removes superseded pairings that are offline and were auto-approved (silent local or trusted-CIDR), so affected clients re-pair without user action. Entries can be removed (`node.pair.remove`, `device.pair.remove`), device pairing and node re-approvals handled inline (`device.pair.*`, `node.pair.approve`/`reject`), and mobile setup codes created from the same card.
- Exec approvals: edit gateway or node allowlists and ask policy for `exec host=gateway/node` (`exec.approvals.*`). - Exec approvals: edit gateway or node allowlists and ask policy for `exec host=gateway/node` (`exec.approvals.*`).
</Accordion> </Accordion>

View file

@ -560,7 +560,92 @@ async function createChatPickerScenario(): Promise<ControlUiMockGatewayScenario>
methodResponses: { methodResponses: {
"usage.cost": profileUsage.cost, "usage.cost": profileUsage.cost,
"sessions.usage": profileUsage.sessions, "sessions.usage": profileUsage.sessions,
"device.pair.list": { paired: [], pending: [] }, "device.pair.list": {
paired: [
{
deviceId: "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90",
displayName: "Mac Studio",
platform: "darwin",
clientId: "node-host",
clientMode: "node",
roles: ["operator", "node"],
scopes: ["operator.admin", "operator.read", "operator.write"],
approvedVia: "trusted-cidr",
approvedAtMs: baseTime - 3_600_000,
lastSeenAtMs: baseTime - 60_000,
tokens: [
{ role: "node", scopes: [], createdAtMs: baseTime - 3_600_000 },
{
role: "operator",
scopes: ["operator.admin", "operator.read", "operator.write"],
createdAtMs: baseTime - 3_600_000,
},
],
},
{
deviceId: "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0",
displayName: "Mac Studio",
platform: "darwin",
clientId: "node-host",
clientMode: "node",
roles: ["node"],
approvedVia: "trusted-cidr",
approvedAtMs: baseTime - 86_400_000,
lastSeenAtMs: baseTime - 82_800_000,
tokens: [{ role: "node", scopes: [], createdAtMs: baseTime - 86_400_000 }],
},
{
deviceId: "9988776655443322119988776655443322119988776655443322119988776655",
clientId: "cli",
clientMode: "cli",
platform: "darwin",
roles: ["operator"],
scopes: ["operator.admin", "operator.read", "operator.write"],
approvedVia: "silent",
approvedAtMs: baseTime - 7_200_000,
lastSeenAtMs: baseTime - 7_100_000,
tokens: [
{
role: "operator",
scopes: ["operator.admin", "operator.read", "operator.write"],
createdAtMs: baseTime - 7_200_000,
},
],
},
{
deviceId: "11223344556677889900aabbccddeeff11223344556677889900aabbccddeeff",
displayName: "iPhone",
platform: "iOS 26.4",
clientId: "openclaw-ios",
clientMode: "ui",
roles: ["operator", "node"],
scopes: ["operator.approvals", "operator.read", "operator.write"],
approvedVia: "bootstrap",
approvedAtMs: baseTime - 172_800_000,
lastSeenAtMs: baseTime - 3_600_000,
tokens: [
{ role: "node", scopes: [], createdAtMs: baseTime - 172_800_000 },
{
role: "operator",
scopes: ["operator.approvals", "operator.read", "operator.write"],
createdAtMs: baseTime - 172_800_000,
},
],
},
],
pending: [
{
requestId: "mock-pending-request",
deviceId: "feedfacecafebeef0123456789abcdeffeedfacecafebeef0123456789abcdef",
displayName: "MacBook Pro",
role: "operator",
roles: ["operator"],
scopes: ["operator.read", "operator.write"],
remoteIp: "192.168.1.20",
ts: baseTime - 30_000,
},
],
},
"device.pair.setupCode": { "device.pair.setupCode": {
auth: "token", auth: "token",
gatewayUrl: "wss://gateway.example.test", gatewayUrl: "wss://gateway.example.test",
@ -568,7 +653,54 @@ async function createChatPickerScenario(): Promise<ControlUiMockGatewayScenario>
setupCode: devicePairSetupCode, setupCode: devicePairSetupCode,
urlSource: "mock", urlSource: "mock",
}, },
"node.list": { nodes: [] }, "node.list": {
nodes: [
{
nodeId: "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90",
displayName: "Mac Studio",
platform: "darwin",
version: "2026.6.11",
connected: true,
paired: true,
approvalState: "approved",
connectedAtMs: baseTime - 60_000,
caps: ["canvas", "screen"],
commands: [
"screen.snapshot",
"system.execApprovals.get",
"system.execApprovals.set",
"system.notify",
"system.run",
"system.which",
],
},
{
nodeId: "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0",
displayName: "Mac Studio",
platform: "darwin",
version: "2026.6.10",
connected: false,
paired: true,
approvalState: "approved",
lastSeenAtMs: baseTime - 82_800_000,
caps: ["canvas", "screen"],
commands: ["screen.snapshot", "system.run"],
},
{
nodeId: "11223344556677889900aabbccddeeff11223344556677889900aabbccddeeff",
displayName: "iPhone",
platform: "iOS 26.4",
version: "2026.6.11",
connected: false,
paired: true,
approvalState: "pending-reapproval",
pendingRequestId: "mock-node-reapproval",
lastSeenAtMs: baseTime - 3_600_000,
caps: ["camera", "canvas", "contacts", "device", "location"],
commands: ["camera.list", "contacts.search", "device.info", "location.get"],
},
],
},
"agents.files.get": { "agents.files.get": {
cases: workspaceFileCases, cases: workspaceFileCases,
}, },

View file

@ -0,0 +1,170 @@
// Covers gateway-side cleanup when silent pairing supersedes stale sibling records.
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import {
approveDevicePairing,
listDevicePairing,
requestDevicePairing,
} from "../infra/device-pairing.js";
import { approveNodePairing, listNodePairing, requestNodePairing } from "../infra/node-pairing.js";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { pruneSupersededSilentPairingsAfterApproval } from "./device-pairing-prune.js";
const suiteRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-gateway-pairing-prune-" });
type BroadcastCall = { event: string; payload: Record<string, unknown> };
type PruneContext = Parameters<typeof pruneSupersededSilentPairingsAfterApproval>[0]["context"];
function createPruneContext(params?: { connectedDeviceIds?: string[] }) {
const broadcasts: BroadcastCall[] = [];
const invalidated: string[] = [];
const disconnected: string[] = [];
const logs: string[] = [];
const connected = new Set(params?.connectedDeviceIds ?? []);
const context: PruneContext = {
broadcast: (event, payload) => {
broadcasts.push({ event, payload: payload as Record<string, unknown> });
},
logGateway: {
info: (message: string) => logs.push(message),
},
hasConnectedClientsForDevice: (deviceId: string) => connected.has(deviceId),
invalidateClientsForDevice: (deviceId: string) => {
invalidated.push(deviceId);
},
disconnectClientsForDevice: (deviceId: string) => {
disconnected.push(deviceId);
},
};
return { broadcasts, invalidated, disconnected, logs, context };
}
async function pairSilentDevice(params: {
baseDir: string;
deviceId: string;
roles: string[];
clientId: string;
clientMode: string;
displayName?: string;
}) {
const request = await requestDevicePairing(
{
deviceId: params.deviceId,
publicKey: `pk-${params.deviceId}`,
clientId: params.clientId,
clientMode: params.clientMode,
displayName: params.displayName,
role: params.roles[0],
roles: params.roles,
scopes: [],
},
params.baseDir,
);
const approved = await approveDevicePairing(
request.request.requestId,
{ callerScopes: [], approvedVia: "silent" },
params.baseDir,
);
if (approved?.status !== "approved") {
throw new Error(`expected approval for ${params.deviceId}`);
}
return approved.device;
}
describe("pruneSupersededSilentPairingsAfterApproval", () => {
beforeAll(async () => {
await suiteRootTracker.setup();
});
afterAll(async () => {
await suiteRootTracker.cleanup();
});
test("retires stale node siblings across both pairing stores", async () => {
const baseDir = await suiteRootTracker.make("case");
await pairSilentDevice({
baseDir,
deviceId: "node-stale",
roles: ["node"],
clientId: "node-host",
clientMode: "node",
displayName: "megaclaw",
});
const nodeRequest = await requestNodePairing(
{ nodeId: "node-stale", displayName: "megaclaw" },
baseDir,
);
await approveNodePairing(nodeRequest.request.requestId, { callerScopes: [] }, baseDir);
const anchor = await pairSilentDevice({
baseDir,
deviceId: "node-anchor",
roles: ["node"],
clientId: "node-host",
clientMode: "node",
displayName: "megaclaw",
});
const harness = createPruneContext();
const pruned = await pruneSupersededSilentPairingsAfterApproval({
deviceId: anchor.deviceId,
context: harness.context,
baseDir,
nowMs: Date.now() + 120_000,
});
expect(pruned.map((entry) => entry.deviceId)).toEqual(["node-stale"]);
const devices = await listDevicePairing(baseDir);
expect(devices.paired.map((device) => device.deviceId)).toEqual(["node-anchor"]);
const nodes = await listNodePairing(baseDir);
expect(nodes.paired).toHaveLength(0);
expect(harness.invalidated).toEqual(["node-stale"]);
expect(harness.disconnected).toEqual(["node-stale"]);
expect(harness.broadcasts).toEqual([
{
event: "node.pair.resolved",
payload: expect.objectContaining({ nodeId: "node-stale", decision: "removed" }),
},
]);
});
test("keeps connected siblings and emits no node broadcast for operator-only prunes", async () => {
const baseDir = await suiteRootTracker.make("case");
await pairSilentDevice({
baseDir,
deviceId: "cli-stale",
roles: ["operator"],
clientId: "cli",
clientMode: "cli",
});
await pairSilentDevice({
baseDir,
deviceId: "cli-live",
roles: ["operator"],
clientId: "cli",
clientMode: "cli",
});
const anchor = await pairSilentDevice({
baseDir,
deviceId: "cli-anchor",
roles: ["operator"],
clientId: "cli",
clientMode: "cli",
});
const harness = createPruneContext({ connectedDeviceIds: ["cli-live"] });
const pruned = await pruneSupersededSilentPairingsAfterApproval({
deviceId: anchor.deviceId,
context: harness.context,
baseDir,
nowMs: Date.now() + 120_000,
});
expect(pruned.map((entry) => entry.deviceId)).toEqual(["cli-stale"]);
const devices = await listDevicePairing(baseDir);
expect(devices.paired.map((device) => device.deviceId).toSorted()).toEqual([
"cli-anchor",
"cli-live",
]);
expect(harness.broadcasts).toEqual([]);
expect(harness.disconnected).toEqual(["cli-stale"]);
});
});

View file

@ -0,0 +1,59 @@
// Prunes superseded silent device pairings after a fresh silent auto-approval.
import {
pruneSupersededSilentPairedDevices,
type PrunedSupersededPairedDevice,
} from "../infra/device-pairing.js";
import { removePairedNode } from "../infra/node-pairing.js";
import type { GatewayRequestContext } from "./server-methods/types.js";
type PruneContext = Pick<
GatewayRequestContext,
| "broadcast"
| "hasConnectedClientsForDevice"
| "invalidateClientsForDevice"
| "disconnectClientsForDevice"
> & {
logGateway: Pick<GatewayRequestContext["logGateway"], "info">;
};
/**
* After a silent auto-approval, retire older silent pairings of the same client
* cluster. Ephemeral state dirs mint a fresh deviceId per run and every run
* re-pairs silently, so without this the paired-device list grows without bound
* (dozens of stale operator/node records per host).
*/
export async function pruneSupersededSilentPairingsAfterApproval(params: {
deviceId: string;
context: PruneContext;
baseDir?: string;
nowMs?: number;
}): Promise<PrunedSupersededPairedDevice[]> {
const { context } = params;
const pruned = await pruneSupersededSilentPairedDevices({
deviceId: params.deviceId,
baseDir: params.baseDir,
nowMs: params.nowMs,
isDeviceConnected: (deviceId) => context.hasConnectedClientsForDevice?.(deviceId) ?? false,
});
for (const entry of pruned) {
context.logGateway.info(
`device pairing pruned superseded silent pairing device=${entry.deviceId} roles=${entry.roles.join(",") || "none"}`,
);
// Invalidate before disconnect so buffered frames from a racing reconnect
// fail authorization, mirroring device.pair.remove ordering.
context.invalidateClientsForDevice?.(entry.deviceId, { reason: "device-pair-removed" });
// A device-backed node may also hold a legacy nodes/paired.json row under the
// same id; retire it too. Pruned devices are offline (connected ones are
// skipped), so there is no live node session or queued action state to clear.
const removedNode = await removePairedNode(entry.deviceId, params.baseDir);
if (removedNode || entry.roles.includes("node")) {
context.broadcast(
"node.pair.resolved",
{ requestId: "", nodeId: entry.deviceId, decision: "removed", ts: Date.now() },
{ dropIfSlow: true },
);
}
context.disconnectClientsForDevice?.(entry.deviceId);
}
return pruned;
}

View file

@ -718,6 +718,7 @@ describe("deviceHandlers", () => {
approvedAtMs: 100, approvedAtMs: 100,
createdAtMs: 50, createdAtMs: 50,
tokens: undefined, tokens: undefined,
connected: false,
}, },
], ],
}, },
@ -762,6 +763,7 @@ describe("deviceHandlers", () => {
approvedAtMs: 100, approvedAtMs: 100,
createdAtMs: 50, createdAtMs: 50,
tokens: undefined, tokens: undefined,
connected: false,
}, },
{ {
deviceId: "device-2", deviceId: "device-2",
@ -769,6 +771,7 @@ describe("deviceHandlers", () => {
approvedAtMs: 200, approvedAtMs: 200,
createdAtMs: 60, createdAtMs: 60,
tokens: undefined, tokens: undefined,
connected: false,
}, },
], ],
}, },
@ -802,6 +805,7 @@ describe("deviceHandlers", () => {
approvedAtMs: 200, approvedAtMs: 200,
createdAtMs: 60, createdAtMs: 60,
tokens: undefined, tokens: undefined,
connected: false,
}, },
], ],
}, },
@ -841,6 +845,7 @@ describe("deviceHandlers", () => {
approvedAtMs: 200, approvedAtMs: 200,
createdAtMs: 60, createdAtMs: 60,
tokens: undefined, tokens: undefined,
connected: false,
}, },
], ],
}, },
@ -848,6 +853,35 @@ describe("deviceHandlers", () => {
); );
}); });
it("marks live device connections in the pairing list", async () => {
listDevicePairingMock.mockResolvedValue({
pending: [],
paired: [
{ deviceId: "device-1", publicKey: "pk-1", approvedAtMs: 100, createdAtMs: 50 },
{ deviceId: "device-2", publicKey: "pk-2", approvedAtMs: 200, createdAtMs: 60 },
],
});
const opts = createOptions(
"device.pair.list",
{},
{ client: createClient(["operator.pairing"]) },
);
(
opts.context as { hasConnectedClientsForDevice?: (deviceId: string) => boolean }
).hasConnectedClientsForDevice = (deviceId) => deviceId === "device-2";
await deviceHandlers["device.pair.list"](opts);
const respond = opts.respond as ReturnType<typeof vi.fn>;
const payload = respond.mock.calls[0]?.[1] as {
paired: Array<{ deviceId: string; connected: boolean }>;
};
expect(payload.paired.map((device) => [device.deviceId, device.connected])).toEqual([
["device-1", false],
["device-2", true],
]);
});
it("rejects approving another device from a non-admin device session", async () => { it("rejects approving another device from a non-admin device session", async () => {
getPendingDevicePairingMock.mockResolvedValue({ getPendingDevicePairingMock.mockResolvedValue({
requestId: "req-2", requestId: "req-2",

View file

@ -48,12 +48,14 @@ const DEVICE_PAIR_REJECTION_DENIED_MESSAGE = "device pairing rejection denied";
function redactPairedDevice( function redactPairedDevice(
device: { tokens?: Record<string, DeviceAuthToken> } & Record<string, unknown>, device: { tokens?: Record<string, DeviceAuthToken> } & Record<string, unknown>,
opts?: { connected?: boolean },
) { ) {
// Pairing lists are visible to operators; expose token lifecycle metadata // Pairing lists are visible to operators; expose token lifecycle metadata
// without returning raw token material or the internal approved-scope set. // without returning raw token material or the internal approved-scope set.
const { tokens, approvedScopes: _approvedScopes, ...rest } = device; const { tokens, approvedScopes: _approvedScopes, ...rest } = device;
return { return {
...rest, ...rest,
...(opts?.connected !== undefined ? { connected: opts.connected } : {}),
tokens: summarizeDeviceTokens(tokens), tokens: summarizeDeviceTokens(tokens),
}; };
} }
@ -202,7 +204,7 @@ function emitDeviceTokenLifecycleSecurityEvent(params: {
/** Gateway request handlers for device pair approval, removal, token rotation, and revocation. */ /** Gateway request handlers for device pair approval, removal, token rotation, and revocation. */
export const deviceHandlers: GatewayRequestHandlers = { export const deviceHandlers: GatewayRequestHandlers = {
"device.pair.list": async ({ params, respond, client }) => { "device.pair.list": async ({ params, respond, context, client }) => {
if (!validateDevicePairListParams(params)) { if (!validateDevicePairListParams(params)) {
respond( respond(
false, false,
@ -231,7 +233,13 @@ export const deviceHandlers: GatewayRequestHandlers = {
true, true,
{ {
pending: visibleList.pending, pending: visibleList.pending,
paired: visibleList.paired.map((device) => redactPairedDevice(device)), // Live-connection state lets clients distinguish active pairings from
// stale ones; node-role links alone do not cover operator clients.
paired: visibleList.paired.map((device) =>
redactPairedDevice(device, {
connected: context.hasConnectedClientsForDevice?.(device.deviceId.trim()) ?? false,
}),
),
}, },
undefined, undefined,
); );

View file

@ -103,6 +103,7 @@ export type GatewayRequestContext = {
deviceId: string, deviceId: string,
opts?: { role?: string; reason?: string }, opts?: { role?: string; reason?: string },
) => void; ) => void;
hasConnectedClientsForDevice?: (deviceId: string) => boolean;
disconnectClientsUsingSharedGatewayAuth?: () => void; disconnectClientsUsingSharedGatewayAuth?: () => void;
enforceSharedGatewayAuthGenerationForConfigWrite?: (nextConfig: OpenClawConfig) => void; enforceSharedGatewayAuthGenerationForConfigWrite?: (nextConfig: OpenClawConfig) => void;
nodeRegistry: NodeRegistry; nodeRegistry: NodeRegistry;

View file

@ -143,6 +143,14 @@ export function createGatewayRequestContext(
} }
return connIds; return connIds;
}, },
hasConnectedClientsForDevice: (deviceId: string) => {
for (const gatewayClient of params.clients) {
if (gatewayClient.connect.device?.id === deviceId && !gatewayClient.invalidated) {
return true;
}
}
return false;
},
invalidateClientsForDevice: (deviceId: string, opts?: { role?: string; reason?: string }) => { invalidateClientsForDevice: (deviceId: string, opts?: { role?: string; reason?: string }) => {
const reason = opts?.reason ?? "device-invalidated"; const reason = opts?.reason ?? "device-invalidated";
for (const gatewayClient of params.clients) { for (const gatewayClient of params.clients) {

View file

@ -113,6 +113,7 @@ import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js";
import { hasForwardedRequestHeaders, isLocalDirectRequest } from "../../auth.js"; import { hasForwardedRequestHeaders, isLocalDirectRequest } from "../../auth.js";
import { listControlUiPluginTabs } from "../../control-ui-plugin-tabs.js"; import { listControlUiPluginTabs } from "../../control-ui-plugin-tabs.js";
import { normalizeDeviceMetadataForAuth } from "../../device-auth.js"; import { normalizeDeviceMetadataForAuth } from "../../device-auth.js";
import { pruneSupersededSilentPairingsAfterApproval } from "../../device-pairing-prune.js";
import { ADMIN_SCOPE, APPROVALS_SCOPE } from "../../method-scopes.js"; import { ADMIN_SCOPE, APPROVALS_SCOPE } from "../../method-scopes.js";
import type { GatewayMethodRegistry } from "../../methods/registry.js"; import type { GatewayMethodRegistry } from "../../methods/registry.js";
import { import {
@ -1512,6 +1513,10 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
: await approveDevicePairing(pairing.request.requestId, { : await approveDevicePairing(pairing.request.requestId, {
callerScopes: scopes, callerScopes: scopes,
accessMetadata: clientAccessMetadata, accessMetadata: clientAccessMetadata,
// Same-host local approvals are prune-eligible "silent";
// trusted-CIDR approvals cross hosts and must never be
// auto-pruned, so they carry their own provenance.
approvedVia: allowSilentLocalPairing ? "silent" : "trusted-cidr",
}); });
if (approved?.status === "approved") { if (approved?.status === "approved") {
if (allowSetupCodeMobileBootstrapPairing && boundBootstrapProfile) { if (allowSetupCodeMobileBootstrapPairing && boundBootstrapProfile) {
@ -1530,6 +1535,20 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
}, },
{ dropIfSlow: true }, { dropIfSlow: true },
); );
if (!(allowSetupCodeMobileBootstrapPairing && boundBootstrapProfile)) {
// Best-effort retirement of stale silent siblings; a prune
// failure must never fail the fresh device's handshake.
try {
await pruneSupersededSilentPairingsAfterApproval({
deviceId: approved.device.deviceId,
context,
});
} catch (error) {
logGateway.warn(
`device pairing prune failed device=${approved.device.deviceId} error=${String(error)}`,
);
}
}
} else { } else {
resolvedByConcurrentApproval = pairingStateAllowsRequestedAccess( resolvedByConcurrentApproval = pairingStateAllowsRequestedAccess(
await getPairedDevice(device.id), await getPairedDevice(device.id),

View file

@ -0,0 +1,275 @@
// Covers silent-pairing approval provenance and superseded-record pruning.
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import {
approveBootstrapDevicePairing,
approveDevicePairing,
getPairedDevice,
listDevicePairing,
pruneSupersededSilentPairedDevices,
requestDevicePairing,
type PairedDeviceApprovalKind,
} from "./device-pairing.js";
const suiteRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-device-pairing-prune-" });
async function makeBaseDir(): Promise<string> {
return await suiteRootTracker.make("case");
}
// Ages every just-approved record past the recent-approval grace window.
function agedNowMs(): number {
return Date.now() + 120_000;
}
async function pairDevice(params: {
baseDir: string;
deviceId: string;
approvedVia?: Extract<PairedDeviceApprovalKind, "owner" | "silent" | "trusted-cidr">;
clientId?: string;
clientMode?: string;
displayName?: string;
roles?: string[];
}) {
const request = await requestDevicePairing(
{
deviceId: params.deviceId,
publicKey: `pk-${params.deviceId}`,
clientId: params.clientId ?? "cli",
clientMode: params.clientMode ?? "cli",
displayName: params.displayName,
role: params.roles?.[0] ?? "operator",
roles: params.roles ?? ["operator"],
scopes: [],
},
params.baseDir,
);
const approved = await approveDevicePairing(
request.request.requestId,
{ callerScopes: [], approvedVia: params.approvedVia },
params.baseDir,
);
if (approved?.status !== "approved") {
throw new Error(`expected approval for ${params.deviceId}`);
}
return approved.device;
}
describe("device pairing approval provenance", () => {
beforeAll(async () => {
await suiteRootTracker.setup();
});
afterAll(async () => {
await suiteRootTracker.cleanup();
});
test("records silent, owner, and bootstrap approval kinds", async () => {
const baseDir = await makeBaseDir();
const silent = await pairDevice({ baseDir, deviceId: "device-silent", approvedVia: "silent" });
expect(silent.approvedVia).toBe("silent");
const owner = await pairDevice({ baseDir, deviceId: "device-owner" });
expect(owner.approvedVia).toBe("owner");
const bootstrapRequest = await requestDevicePairing(
{
deviceId: "device-bootstrap",
publicKey: "pk-device-bootstrap",
role: "node",
roles: ["node"],
scopes: [],
},
baseDir,
);
const bootstrapApproved = await approveBootstrapDevicePairing(
bootstrapRequest.request.requestId,
{ roles: ["node"], scopes: [] },
baseDir,
);
expect(bootstrapApproved?.status === "approved" && bootstrapApproved.device.approvedVia).toBe(
"bootstrap",
);
});
test("owner approval stays sticky across a later silent re-approve", async () => {
const baseDir = await makeBaseDir();
await pairDevice({ baseDir, deviceId: "device-sticky" });
const repaired = await pairDevice({
baseDir,
deviceId: "device-sticky",
approvedVia: "silent",
});
expect(repaired.approvedVia).toBe("owner");
});
test("pre-provenance records stay unknown across a later silent re-approve", async () => {
const baseDir = await makeBaseDir();
const legacy = await pairDevice({ baseDir, deviceId: "device-legacy", approvedVia: "silent" });
// Simulate a record approved before provenance existed.
const { writeJson } = await import("./json-files.js");
const path = await import("node:path");
const { approvedVia: _approvedVia, ...legacyShape } = legacy;
await writeJson(path.join(baseDir, "devices", "paired.json"), {
[legacy.deviceId]: legacyShape,
});
const repaired = await pairDevice({
baseDir,
deviceId: "device-legacy",
approvedVia: "silent",
});
expect(repaired.approvedVia).toBeUndefined();
});
});
describe("pruneSupersededSilentPairedDevices", () => {
beforeAll(async () => {
await suiteRootTracker.setup();
});
afterAll(async () => {
await suiteRootTracker.cleanup();
});
test("removes stale silent siblings from the same client cluster only", async () => {
const baseDir = await makeBaseDir();
await pairDevice({ baseDir, deviceId: "stale-1", approvedVia: "silent" });
await pairDevice({ baseDir, deviceId: "stale-2", approvedVia: "silent" });
await pairDevice({ baseDir, deviceId: "owner-kept" });
await pairDevice({ baseDir, deviceId: "cidr-kept", approvedVia: "trusted-cidr" });
await pairDevice({
baseDir,
deviceId: "other-cluster",
approvedVia: "silent",
clientId: "node-host",
clientMode: "node",
displayName: "megaclaw",
});
const anchor = await pairDevice({ baseDir, deviceId: "anchor", approvedVia: "silent" });
const removed = await pruneSupersededSilentPairedDevices({
deviceId: anchor.deviceId,
baseDir,
nowMs: agedNowMs(),
});
expect(removed.map((entry) => entry.deviceId).toSorted()).toEqual(["stale-1", "stale-2"]);
expect(removed[0]?.roles).toEqual(["operator"]);
const remaining = (await listDevicePairing(baseDir)).paired.map((device) => device.deviceId);
expect(remaining.toSorted()).toEqual(["anchor", "cidr-kept", "other-cluster", "owner-kept"]);
});
test("trusted-cidr approvals never anchor a prune", async () => {
const baseDir = await makeBaseDir();
await pairDevice({
baseDir,
deviceId: "cidr-stale",
approvedVia: "trusted-cidr",
clientId: "node-host",
clientMode: "node",
displayName: "megaclaw",
});
const anchor = await pairDevice({
baseDir,
deviceId: "cidr-anchor",
approvedVia: "trusted-cidr",
clientId: "node-host",
clientMode: "node",
displayName: "megaclaw",
});
const removed = await pruneSupersededSilentPairedDevices({
deviceId: anchor.deviceId,
baseDir,
nowMs: agedNowMs(),
});
expect(removed).toEqual([]);
expect(await getPairedDevice("cidr-stale", baseDir)).not.toBeNull();
});
test("skips freshly approved siblings still inside the grace window", async () => {
const baseDir = await makeBaseDir();
await pairDevice({ baseDir, deviceId: "in-flight", approvedVia: "silent" });
const anchor = await pairDevice({ baseDir, deviceId: "anchor", approvedVia: "silent" });
// Real now: the sibling was approved milliseconds ago, so a concurrent
// handshake that has not registered its connection yet must survive.
const removed = await pruneSupersededSilentPairedDevices({
deviceId: anchor.deviceId,
baseDir,
});
expect(removed).toEqual([]);
expect(await getPairedDevice("in-flight", baseDir)).not.toBeNull();
});
test("skips connected devices and drops pending requests for pruned ids", async () => {
const baseDir = await makeBaseDir();
await pairDevice({ baseDir, deviceId: "stale", approvedVia: "silent" });
await pairDevice({ baseDir, deviceId: "live", approvedVia: "silent" });
const anchor = await pairDevice({ baseDir, deviceId: "anchor", approvedVia: "silent" });
// A pruned device may still have a queued repair request; it must go too.
await requestDevicePairing(
{
deviceId: "stale",
publicKey: "pk-stale-repair",
clientId: "cli",
clientMode: "cli",
role: "operator",
scopes: [],
},
baseDir,
);
const removed = await pruneSupersededSilentPairedDevices({
deviceId: anchor.deviceId,
baseDir,
nowMs: agedNowMs(),
isDeviceConnected: (deviceId) => deviceId === "live",
});
expect(removed.map((entry) => entry.deviceId)).toEqual(["stale"]);
const list = await listDevicePairing(baseDir);
expect(list.paired.map((device) => device.deviceId).toSorted()).toEqual(["anchor", "live"]);
expect(list.pending).toHaveLength(0);
});
test("does not prune when the anchor was not silent-approved", async () => {
const baseDir = await makeBaseDir();
await pairDevice({ baseDir, deviceId: "stale", approvedVia: "silent" });
const anchor = await pairDevice({ baseDir, deviceId: "anchor-owner" });
const removed = await pruneSupersededSilentPairedDevices({
deviceId: anchor.deviceId,
baseDir,
nowMs: agedNowMs(),
});
expect(removed).toEqual([]);
expect(await getPairedDevice("stale", baseDir)).not.toBeNull();
});
test("leaves legacy records without approval provenance untouched", async () => {
const baseDir = await makeBaseDir();
const legacy = await pairDevice({ baseDir, deviceId: "legacy", approvedVia: "silent" });
// Simulate a pre-provenance record by re-writing it without approvedVia.
const { writeJson } = await import("./json-files.js");
const path = await import("node:path");
const { approvedVia: _approvedVia, ...legacyShape } = legacy;
await writeJson(path.join(baseDir, "devices", "paired.json"), {
[legacy.deviceId]: legacyShape,
});
const anchor = await pairDevice({ baseDir, deviceId: "anchor", approvedVia: "silent" });
const removed = await pruneSupersededSilentPairedDevices({
deviceId: anchor.deviceId,
baseDir,
nowMs: agedNowMs(),
});
expect(removed).toEqual([]);
expect(await getPairedDevice("legacy", baseDir)).not.toBeNull();
});
});

View file

@ -107,6 +107,16 @@ export type RevokeDeviceTokenResult =
| { ok: true; entry: DeviceAuthToken } | { ok: true; entry: DeviceAuthToken }
| { ok: false; reason: RevokeDeviceTokenDenyReason; scope?: string }; | { ok: false; reason: RevokeDeviceTokenDenyReason; scope?: string };
/**
* How the latest pairing approval was granted. "silent" is a same-host local
* policy approval and the only prune-eligible kind: local clients re-pair
* silently and cannot collide with another machine's records. "trusted-cidr"
* is also non-interactive but crosses hosts, so it is never pruned
* automatically (display metadata is not a machine identity). "owner" and
* "bootstrap" approvals required a user action and are never pruned.
*/
export type PairedDeviceApprovalKind = "owner" | "silent" | "trusted-cidr" | "bootstrap";
/** Persisted approved device record, including durable approval and active role tokens. */ /** Persisted approved device record, including durable approval and active role tokens. */
export type PairedDevice = { export type PairedDevice = {
deviceId: string; deviceId: string;
@ -122,6 +132,7 @@ export type PairedDevice = {
approvedScopes?: string[]; approvedScopes?: string[];
remoteIp?: string; remoteIp?: string;
tokens?: Record<string, DeviceAuthToken>; tokens?: Record<string, DeviceAuthToken>;
approvedVia?: PairedDeviceApprovalKind;
createdAtMs: number; createdAtMs: number;
approvedAtMs: number; approvedAtMs: number;
lastSeenAtMs?: number; lastSeenAtMs?: number;
@ -537,6 +548,26 @@ function buildDeviceAuthToken(params: {
}; };
} }
// Interactive approvals must stay sticky: a later silent repair/re-approve of the
// same device id cannot downgrade an owner/bootstrap record into prune-eligible
// state. Pre-provenance records (approvedVia undefined) may have been approved by
// an owner, so a non-interactive re-approve must keep them protected (undefined).
function mergeApprovalKind(
existing: PairedDevice | undefined,
incoming: PairedDeviceApprovalKind,
): PairedDeviceApprovalKind | undefined {
if (incoming === "owner" || !existing) {
return incoming;
}
if (existing.approvedVia === undefined) {
return incoming === "bootstrap" ? "bootstrap" : undefined;
}
if (existing.approvedVia === "owner" || existing.approvedVia === "bootstrap") {
return existing.approvedVia;
}
return incoming;
}
function buildApprovedPairedDevice(params: { function buildApprovedPairedDevice(params: {
pending: DevicePairingPendingRequest; pending: DevicePairingPendingRequest;
existing: PairedDevice | undefined; existing: PairedDevice | undefined;
@ -544,6 +575,7 @@ function buildApprovedPairedDevice(params: {
approvedScopes: string[] | undefined; approvedScopes: string[] | undefined;
tokens: Record<string, DeviceAuthToken>; tokens: Record<string, DeviceAuthToken>;
now: number; now: number;
approvedVia: PairedDeviceApprovalKind;
accessMetadata?: DevicePairingAccessMetadata; accessMetadata?: DevicePairingAccessMetadata;
}): PairedDevice { }): PairedDevice {
return { return {
@ -560,6 +592,7 @@ function buildApprovedPairedDevice(params: {
approvedScopes: params.approvedScopes, approvedScopes: params.approvedScopes,
remoteIp: params.accessMetadata?.remoteIp ?? params.pending.remoteIp, remoteIp: params.accessMetadata?.remoteIp ?? params.pending.remoteIp,
tokens: params.tokens, tokens: params.tokens,
approvedVia: mergeApprovalKind(params.existing, params.approvedVia),
createdAtMs: params.existing?.createdAtMs ?? params.now, createdAtMs: params.existing?.createdAtMs ?? params.now,
approvedAtMs: params.now, approvedAtMs: params.now,
lastSeenAtMs: params.accessMetadata?.lastSeenAtMs ?? params.existing?.lastSeenAtMs, lastSeenAtMs: params.accessMetadata?.lastSeenAtMs ?? params.existing?.lastSeenAtMs,
@ -746,13 +779,21 @@ export async function approveDevicePairing(
): Promise<ApproveDevicePairingResult>; ): Promise<ApproveDevicePairingResult>;
export async function approveDevicePairing( export async function approveDevicePairing(
requestId: string, requestId: string,
options: { callerScopes?: readonly string[]; accessMetadata?: DevicePairingAccessMetadata }, options: {
callerScopes?: readonly string[];
accessMetadata?: DevicePairingAccessMetadata;
approvedVia?: Extract<PairedDeviceApprovalKind, "owner" | "silent" | "trusted-cidr">;
},
baseDir?: string, baseDir?: string,
): Promise<ApproveDevicePairingResult>; ): Promise<ApproveDevicePairingResult>;
export async function approveDevicePairing( export async function approveDevicePairing(
requestId: string, requestId: string,
optionsOrBaseDir?: optionsOrBaseDir?:
| { callerScopes?: readonly string[]; accessMetadata?: DevicePairingAccessMetadata } | {
callerScopes?: readonly string[];
accessMetadata?: DevicePairingAccessMetadata;
approvedVia?: Extract<PairedDeviceApprovalKind, "owner" | "silent" | "trusted-cidr">;
}
| string, | string,
maybeBaseDir?: string, maybeBaseDir?: string,
): Promise<ApproveDevicePairingResult> { ): Promise<ApproveDevicePairingResult> {
@ -842,6 +883,7 @@ export async function approveDevicePairing(
approvedScopes, approvedScopes,
tokens, tokens,
now, now,
approvedVia: options?.approvedVia ?? "owner",
accessMetadata: options?.accessMetadata, accessMetadata: options?.accessMetadata,
}); });
delete state.pendingById[requestId]; delete state.pendingById[requestId];
@ -941,6 +983,7 @@ export async function approveBootstrapDevicePairing(
approvedScopes: nextApprovedScopes, approvedScopes: nextApprovedScopes,
tokens, tokens,
now, now,
approvedVia: "bootstrap",
accessMetadata: options?.accessMetadata, accessMetadata: options?.accessMetadata,
}); });
delete state.pendingById[requestId]; delete state.pendingById[requestId];
@ -994,6 +1037,95 @@ export async function removePairedDevice(
}); });
} }
// Silent pairings from the same client software on the same host mint a fresh
// deviceId whenever their state dir (and thus keypair) is ephemeral. The cluster
// key groups those records so a replacement pairing can retire its predecessors.
function silentPairingClusterKey(
device: Pick<PairedDevice, "clientId" | "clientMode" | "displayName">,
): string | null {
const clientId = device.clientId?.trim().toLowerCase() ?? "";
const clientMode = device.clientMode?.trim().toLowerCase() ?? "";
const displayName = device.displayName?.trim().toLowerCase() ?? "";
if (!clientId && !clientMode && !displayName) {
return null;
}
return `${clientId}\0${clientMode}\0${displayName}`;
}
/** Superseded silent pairing removed in favor of a newer record for the same client. */
export type PrunedSupersededPairedDevice = {
deviceId: string;
roles: string[];
};
// A concurrently approved sibling may still be mid-handshake and not yet visible
// to the connected-clients check; freshly approved records are never prune
// candidates so parallel silent pairings cannot delete each other's rows.
const PRUNE_RECENT_APPROVAL_GRACE_MS = 60_000;
/**
* Remove silent-approved sibling records superseded by a newly approved silent
* pairing of the same client cluster. Only records whose latest approval was
* same-host local ("silent") are eligible, as anchor and as victim: local
* clients re-pair silently by construction and share the gateway host, so the
* metadata cluster key cannot match a different machine. Currently connected
* devices are skipped so concurrent sessions with distinct state dirs keep
* their tokens while live.
*/
export async function pruneSupersededSilentPairedDevices(params: {
deviceId: string;
baseDir?: string;
isDeviceConnected?: (deviceId: string) => boolean;
nowMs?: number;
}): Promise<PrunedSupersededPairedDevice[]> {
return await withLock(async () => {
const state = await loadState(params.baseDir);
const anchor = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)];
if (!anchor || anchor.approvedVia !== "silent") {
return [];
}
const anchorKey = silentPairingClusterKey(anchor);
if (!anchorKey) {
return [];
}
const nowMs = params.nowMs ?? Date.now();
const removed: PrunedSupersededPairedDevice[] = [];
for (const device of Object.values(state.pairedByDeviceId)) {
if (device.deviceId === anchor.deviceId) {
continue;
}
// Legacy records without approvedVia stay untouched (fail-safe).
if (device.approvedVia !== "silent") {
continue;
}
if (silentPairingClusterKey(device) !== anchorKey) {
continue;
}
if (nowMs - device.approvedAtMs < PRUNE_RECENT_APPROVAL_GRACE_MS) {
continue;
}
if (params.isDeviceConnected?.(device.deviceId)) {
continue;
}
delete state.pairedByDeviceId[device.deviceId];
for (const [requestId, pending] of Object.entries(state.pendingById)) {
if (pending.deviceId === device.deviceId) {
delete state.pendingById[requestId];
}
}
removed.push({
deviceId: device.deviceId,
roles: listApprovedPairedDeviceRoles(device),
});
}
if (removed.length === 0) {
return [];
}
await persistState(state, params.baseDir, "both");
return removed;
});
}
/** Remove one approved paired-device role while preserving unrelated role tokens. */ /** Remove one approved paired-device role while preserving unrelated role tokens. */
export async function removePairedDeviceRole(params: { export async function removePairedDeviceRole(params: {
deviceId: string; deviceId: string;

View file

@ -46,12 +46,20 @@ export type PairedDevice = {
deviceId: string; deviceId: string;
publicKey?: string; publicKey?: string;
displayName?: string; displayName?: string;
platform?: string;
clientId?: string;
clientMode?: string;
role?: string;
roles?: string[]; roles?: string[];
scopes?: string[]; scopes?: string[];
remoteIp?: string; remoteIp?: string;
tokens?: DeviceTokenSummary[]; tokens?: DeviceTokenSummary[];
approvedVia?: "owner" | "silent" | "trusted-cidr" | "bootstrap";
/** Server-computed: the device currently holds a live gateway connection. */
connected?: boolean;
createdAtMs?: number; createdAtMs?: number;
approvedAtMs?: number; approvedAtMs?: number;
lastSeenAtMs?: number;
}; };
export type DevicePairingList = { export type DevicePairingList = {
@ -299,6 +307,118 @@ export async function rejectDevicePairing(state: DevicesState, requestId: string
} }
} }
/** Entry removal request resolved from the unified inventory row. */
export type InventoryRemovalRequest = {
id: string;
name: string;
removeNode: boolean;
removeDevice: boolean;
};
type InventoryState = NodesState & DevicesState;
async function removeInventoryEntryRpc(
client: GatewayRequestClient,
entry: InventoryRemovalRequest,
) {
// Node removal first: it revokes the node role (deleting node-only device rows)
// and clears any legacy node pairing under the same id. A mixed-role record
// then loses its remaining roles via the device-level removal.
if (entry.removeNode) {
await client.request("node.pair.remove", { nodeId: entry.id });
}
if (entry.removeDevice) {
await client.request("device.pair.remove", { deviceId: entry.id });
}
}
// Reload quietly and assign the failure afterwards: a non-quiet loadDevices
// clears devicesError first, which would erase the message before it renders.
async function reloadInventory(state: InventoryState, opts?: { error?: string }) {
const quiet = opts?.error !== undefined;
await Promise.all([loadDevices(state, { quiet }), loadNodes(state, { quiet })]);
if (opts?.error !== undefined) {
state.devicesError = opts.error;
}
}
export async function removeInventoryEntry(state: InventoryState, entry: InventoryRemovalRequest) {
const client = state.client;
if (!client || !state.connected) {
return;
}
const confirmed = window.confirm(`Remove ${entry.name} (${entry.id.slice(0, 12)}…)?`);
if (!confirmed) {
return;
}
try {
await removeInventoryEntryRpc(client, entry);
await reloadInventory(state);
} catch (err) {
await reloadInventory(state, { error: String(err) });
}
}
export async function removeStaleInventoryEntries(
state: InventoryState,
entries: InventoryRemovalRequest[],
) {
const client = state.client;
if (!client || !state.connected || entries.length === 0) {
return;
}
const confirmed = window.confirm(
`Remove ${entries.length} stale pairing${entries.length === 1 ? "" : "s"}? Affected clients re-pair silently on their next connection.`,
);
if (!confirmed) {
return;
}
const failures: string[] = [];
for (const entry of entries) {
try {
await removeInventoryEntryRpc(client, entry);
} catch (err) {
failures.push(`${entry.name}: ${String(err)}`);
}
}
await reloadInventory(
state,
failures.length > 0
? {
error: `Failed to remove ${failures.length} entr${failures.length === 1 ? "y" : "ies"}: ${failures[0]}`,
}
: undefined,
);
}
export async function approveNodePairingRequest(state: InventoryState, requestId: string) {
if (!state.client || !state.connected) {
return;
}
try {
await state.client.request("node.pair.approve", { requestId });
await reloadInventory(state);
} catch (err) {
await reloadInventory(state, { error: String(err) });
}
}
export async function rejectNodePairingRequest(state: InventoryState, requestId: string) {
if (!state.client || !state.connected) {
return;
}
const confirmed = window.confirm("Reject this node pairing request?");
if (!confirmed) {
return;
}
try {
await state.client.request("node.pair.reject", { requestId });
await reloadInventory(state);
} catch (err) {
await reloadInventory(state, { error: String(err) });
}
}
export async function rotateDeviceToken( export async function rotateDeviceToken(
state: DevicesState, state: DevicesState,
params: { deviceId: string; gatewayUrl: string; role: string; scopes?: string[] }, params: { deviceId: string; gatewayUrl: string; role: string; scopes?: string[] },

View file

@ -0,0 +1,246 @@
import { describe, expect, it } from "vitest";
import type { PairedDevice } from "./index.ts";
import {
buildNodesInventory,
listStaleInventoryEntries,
resolveInventoryRemoval,
} from "./inventory.ts";
function device(overrides: Partial<PairedDevice> & { deviceId: string }): PairedDevice {
return {
publicKey: `pk-${overrides.deviceId}`,
roles: ["operator"],
...overrides,
};
}
describe("buildNodesInventory", () => {
it("joins device records with node catalog rows by id", () => {
const groups = buildNodesInventory({
paired: [
device({
deviceId: "node-1",
displayName: "megaclaw",
roles: ["operator", "node"],
lastSeenAtMs: 1_000,
}),
],
nodes: [
{
nodeId: "node-1",
displayName: "megaclaw",
connected: true,
paired: true,
caps: ["screen"],
commands: ["system.run"],
version: "2026.6.11",
},
],
});
expect(groups).toHaveLength(1);
const entry = groups[0].primary;
expect(entry.id).toBe("node-1");
expect(entry.connected).toBe(true);
expect(entry.roles).toEqual(["operator", "node"]);
expect(entry.version).toBe("2026.6.11");
expect(entry.node?.caps).toEqual(["screen"]);
});
it("groups duplicate pairings by display name with the freshest entry first", () => {
const groups = buildNodesInventory({
paired: [
device({ deviceId: "old-1", displayName: "MacBook", lastSeenAtMs: 1_000 }),
device({ deviceId: "new-1", displayName: "MacBook", lastSeenAtMs: 3_000 }),
device({ deviceId: "mid-1", displayName: "macbook", lastSeenAtMs: 2_000 }),
],
nodes: [],
});
expect(groups).toHaveLength(1);
expect(groups[0].primary.id).toBe("new-1");
expect(groups[0].duplicates.map((entry) => entry.id)).toEqual(["mid-1", "old-1"]);
});
it("prefers connected entries as group primary over fresher offline ones", () => {
const groups = buildNodesInventory({
paired: [
device({ deviceId: "offline-1", displayName: "megaclaw", lastSeenAtMs: 9_000 }),
device({
deviceId: "live-1",
displayName: "megaclaw",
roles: ["node"],
lastSeenAtMs: 1_000,
}),
],
nodes: [{ nodeId: "live-1", connected: true, paired: true }],
});
expect(groups[0].primary.id).toBe("live-1");
expect(groups[0].duplicates.map((entry) => entry.id)).toEqual(["offline-1"]);
});
it("groups anonymous records by client identity and keeps unknown ids separate", () => {
const groups = buildNodesInventory({
paired: [
device({ deviceId: "cli-1", clientId: "cli", clientMode: "cli", lastSeenAtMs: 2_000 }),
device({ deviceId: "cli-2", clientId: "cli", clientMode: "cli", lastSeenAtMs: 1_000 }),
device({ deviceId: "anon-1" }),
device({ deviceId: "anon-2" }),
],
nodes: [],
});
const keys = groups.map((group) => group.key);
expect(keys).toContain("client:cli:cli");
expect(keys).toContain("id:anon-1");
expect(keys).toContain("id:anon-2");
const cliGroup = groups.find((group) => group.key === "client:cli:cli");
expect(cliGroup?.primary.id).toBe("cli-1");
expect(cliGroup?.duplicates.map((entry) => entry.id)).toEqual(["cli-2"]);
expect(cliGroup?.name).toBe("cli");
});
it("keeps legacy node-only rows and marks them with the node role", () => {
const groups = buildNodesInventory({
paired: [],
nodes: [{ nodeId: "legacy-1", displayName: "clawmac", paired: true, connected: false }],
});
expect(groups).toHaveLength(1);
expect(groups[0].primary.roles).toEqual(["node"]);
expect(groups[0].primary.device).toBeUndefined();
});
it("flags silent and trusted-cidr pairings as auto-approved", () => {
const groups = buildNodesInventory({
paired: [
device({ deviceId: "cli-1", clientId: "cli", approvedVia: "silent" }),
device({ deviceId: "cidr-1", displayName: "megaclaw", approvedVia: "trusted-cidr" }),
device({ deviceId: "owner-1", displayName: "iPhone", approvedVia: "owner" }),
],
nodes: [],
});
const byId = new Map(groups.map((group) => [group.primary.id, group.primary]));
expect(byId.get("cli-1")?.autoApproved).toBe(true);
expect(byId.get("cidr-1")?.autoApproved).toBe(true);
expect(byId.get("owner-1")?.autoApproved).toBe(false);
});
});
describe("listStaleInventoryEntries", () => {
it("treats server-reported device connectivity as live for operator-only entries", () => {
const groups = buildNodesInventory({
paired: [
device({
deviceId: "cli-new",
clientId: "cli",
approvedVia: "silent",
lastSeenAtMs: 3_000,
}),
device({
deviceId: "cli-live",
clientId: "cli",
approvedVia: "silent",
connected: true,
lastSeenAtMs: 1_000,
}),
device({
deviceId: "cli-stale",
clientId: "cli",
approvedVia: "silent",
lastSeenAtMs: 2_000,
}),
],
nodes: [],
});
expect(groups[0].primary.id).toBe("cli-live");
expect(listStaleInventoryEntries(groups).map((entry) => entry.id)).toEqual([
"cli-new",
"cli-stale",
]);
});
it("lists offline auto-approved duplicates only", () => {
const groups = buildNodesInventory({
paired: [
device({
deviceId: "new-1",
displayName: "megaclaw",
approvedVia: "silent",
lastSeenAtMs: 3_000,
}),
device({
deviceId: "live-old",
displayName: "megaclaw",
roles: ["node"],
approvedVia: "trusted-cidr",
lastSeenAtMs: 2_000,
}),
device({
deviceId: "old-1",
displayName: "megaclaw",
approvedVia: "trusted-cidr",
lastSeenAtMs: 1_000,
}),
// Owner-approved and pre-provenance duplicates never enter the bulk sweep.
device({ deviceId: "owner-old", displayName: "megaclaw", approvedVia: "owner" }),
device({ deviceId: "legacy-old", displayName: "megaclaw" }),
],
nodes: [{ nodeId: "live-old", connected: true, paired: true }],
});
// Connected entry becomes primary; fresh offline entry stays a duplicate but
// only offline auto-approved duplicates are safe to clean up.
expect(listStaleInventoryEntries(groups).map((entry) => entry.id)).toEqual(["new-1", "old-1"]);
});
});
describe("resolveInventoryRemoval", () => {
it("routes node-role entries through node removal", () => {
const groups = buildNodesInventory({
paired: [device({ deviceId: "node-1", roles: ["node"], displayName: "megaclaw" })],
nodes: [],
});
expect(resolveInventoryRemoval(groups[0].primary)).toEqual({
removeNode: true,
removeDevice: false,
});
});
it("routes mixed-role entries through node and device removal", () => {
const groups = buildNodesInventory({
paired: [
device({ deviceId: "mixed-1", roles: ["operator", "node"], displayName: "MacBook" }),
],
nodes: [],
});
expect(resolveInventoryRemoval(groups[0].primary)).toEqual({
removeNode: true,
removeDevice: true,
});
});
it("routes operator-only entries through device removal", () => {
const groups = buildNodesInventory({
paired: [device({ deviceId: "op-1", roles: ["operator"] })],
nodes: [],
});
expect(resolveInventoryRemoval(groups[0].primary)).toEqual({
removeNode: false,
removeDevice: true,
});
});
it("routes legacy node-only rows through node removal", () => {
const groups = buildNodesInventory({
paired: [],
nodes: [{ nodeId: "legacy-1", paired: true }],
});
expect(resolveInventoryRemoval(groups[0].primary)).toEqual({
removeNode: true,
removeDevice: false,
});
});
});

View file

@ -0,0 +1,284 @@
// Builds the unified nodes/devices inventory shown on the Nodes page.
// The gateway exposes two overlapping views of the same machines: paired device
// records (roles + tokens) and the node catalog (caps + live links). This module
// joins them by id and groups duplicate pairings of the same client so the page
// renders one row per machine instead of one row per historical keypair.
import { normalizeOptionalString } from "../string-coerce.ts";
import type { PairedDevice } from "./index.ts";
export type NodeApprovalState =
| "approved"
| "pending-approval"
| "pending-reapproval"
| "unapproved";
/** Typed projection of one raw `node.list` row. */
export type NodeListEntry = {
nodeId: string;
displayName?: string;
platform?: string;
version?: string;
clientId?: string;
clientMode?: string;
remoteIp?: string;
caps: string[];
commands: string[];
approvalState?: NodeApprovalState;
pendingRequestId?: string;
connected: boolean;
paired: boolean;
connectedAtMs?: number;
lastSeenAtMs?: number;
approvedAtMs?: number;
};
export type NodesInventoryEntry = {
id: string;
name: string;
displayName?: string;
clientId?: string;
clientMode?: string;
platform?: string;
version?: string;
remoteIp?: string;
roles: string[];
scopes: string[];
connected: boolean;
autoApproved: boolean;
lastSeenAtMs?: number;
approvedAtMs?: number;
device?: PairedDevice;
node?: NodeListEntry;
};
/** One machine cluster: the freshest pairing plus superseded duplicates. */
export type NodesInventoryGroup = {
key: string;
name: string;
primary: NodesInventoryEntry;
duplicates: NodesInventoryEntry[];
};
const NODE_APPROVAL_STATES: ReadonlySet<string> = new Set([
"approved",
"pending-approval",
"pending-reapproval",
"unapproved",
]);
function optionalNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function stringList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((entry) => normalizeOptionalString(entry))
.filter((entry): entry is string => entry !== undefined);
}
export function parseNodeListEntry(raw: Record<string, unknown>): NodeListEntry | null {
const nodeId = normalizeOptionalString(raw.nodeId);
if (!nodeId) {
return null;
}
const approvalState = normalizeOptionalString(raw.approvalState);
return {
nodeId,
displayName: normalizeOptionalString(raw.displayName),
platform: normalizeOptionalString(raw.platform),
version: normalizeOptionalString(raw.version),
clientId: normalizeOptionalString(raw.clientId),
clientMode: normalizeOptionalString(raw.clientMode),
remoteIp: normalizeOptionalString(raw.remoteIp),
caps: stringList(raw.caps),
commands: stringList(raw.commands),
approvalState:
approvalState && NODE_APPROVAL_STATES.has(approvalState)
? (approvalState as NodeApprovalState)
: undefined,
pendingRequestId: normalizeOptionalString(raw.pendingRequestId),
connected: raw.connected === true,
paired: raw.paired === true,
connectedAtMs: optionalNumber(raw.connectedAtMs),
lastSeenAtMs: optionalNumber(raw.lastSeenAtMs),
approvedAtMs: optionalNumber(raw.approvedAtMs),
};
}
function deviceRoles(device: PairedDevice): string[] {
const roles = new Set<string>();
for (const role of [...(device.roles ?? []), device.role]) {
const normalized = normalizeOptionalString(role);
if (normalized) {
roles.add(normalized);
}
}
return [...roles];
}
function maxDefined(...values: Array<number | undefined>): number | undefined {
let max: number | undefined;
for (const value of values) {
if (value !== undefined && (max === undefined || value > max)) {
max = value;
}
}
return max;
}
function buildEntry(id: string, device?: PairedDevice, node?: NodeListEntry): NodesInventoryEntry {
const roles = device ? deviceRoles(device) : [];
if (node?.paired && !roles.includes("node")) {
// Legacy nodes/paired.json rows have no device record; they are still nodes.
roles.push("node");
}
const displayName =
normalizeOptionalString(device?.displayName) ?? normalizeOptionalString(node?.displayName);
const clientId = normalizeOptionalString(device?.clientId) ?? node?.clientId;
return {
id,
name: displayName ?? clientId ?? id,
displayName,
clientId,
clientMode: normalizeOptionalString(device?.clientMode) ?? node?.clientMode,
platform: normalizeOptionalString(device?.platform) ?? node?.platform,
version: node?.version,
remoteIp: normalizeOptionalString(device?.remoteIp) ?? node?.remoteIp,
roles,
scopes: stringList(device?.scopes),
// Node catalog rows and the server-computed device connection state both
// count: operator-only clients never appear in node.list.
connected: node?.connected === true || device?.connected === true,
autoApproved: device?.approvedVia === "silent" || device?.approvedVia === "trusted-cidr",
lastSeenAtMs: maxDefined(device?.lastSeenAtMs, node?.lastSeenAtMs, node?.connectedAtMs),
approvedAtMs: maxDefined(device?.approvedAtMs, node?.approvedAtMs),
device,
node,
};
}
function groupKey(entry: NodesInventoryEntry): string {
const name = entry.displayName?.trim().toLowerCase();
if (name) {
return `name:${name}`;
}
const clientId = entry.clientId?.trim().toLowerCase();
const clientMode = entry.clientMode?.trim().toLowerCase();
if (clientId || clientMode) {
return `client:${clientId ?? ""}:${clientMode ?? ""}`;
}
// No usable identity metadata: never merge with other anonymous records.
return `id:${entry.id}`;
}
function entryRecency(entry: NodesInventoryEntry): number {
return entry.lastSeenAtMs ?? entry.approvedAtMs ?? 0;
}
function compareEntries(left: NodesInventoryEntry, right: NodesInventoryEntry): number {
if (left.connected !== right.connected) {
return left.connected ? -1 : 1;
}
const recency = entryRecency(right) - entryRecency(left);
if (recency !== 0) {
return recency;
}
return left.id.localeCompare(right.id);
}
function compareGroups(left: NodesInventoryGroup, right: NodesInventoryGroup): number {
const order = compareEntries(left.primary, right.primary);
if (order !== 0) {
return order;
}
return left.name.localeCompare(right.name);
}
/** Joins paired devices with node catalog rows and groups duplicate pairings. */
export function buildNodesInventory(params: {
paired: PairedDevice[];
nodes: Array<Record<string, unknown>>;
}): NodesInventoryGroup[] {
const nodesById = new Map<string, NodeListEntry>();
for (const raw of params.nodes) {
const node = parseNodeListEntry(raw);
if (node) {
nodesById.set(node.nodeId, node);
}
}
const entries: NodesInventoryEntry[] = [];
const seen = new Set<string>();
for (const device of params.paired) {
const id = normalizeOptionalString(device.deviceId);
if (!id || seen.has(id)) {
continue;
}
seen.add(id);
entries.push(buildEntry(id, device, nodesById.get(id)));
}
for (const [id, node] of nodesById) {
if (!seen.has(id)) {
entries.push(buildEntry(id, undefined, node));
}
}
const groupsByKey = new Map<string, NodesInventoryEntry[]>();
for (const entry of entries) {
const key = groupKey(entry);
const bucket = groupsByKey.get(key);
if (bucket) {
bucket.push(entry);
} else {
groupsByKey.set(key, [entry]);
}
}
const groups: NodesInventoryGroup[] = [];
for (const [key, bucket] of groupsByKey) {
const sorted = bucket.toSorted(compareEntries);
const primary = sorted[0];
groups.push({
key,
name: primary.name,
primary,
duplicates: sorted.slice(1),
});
}
return groups.toSorted(compareGroups);
}
/**
* Duplicate entries safe to bulk-remove: superseded, not currently connected,
* and auto-approved (silent local / trusted-CIDR), so the client re-pairs
* without user action. Owner/QR-approved and pre-provenance duplicates keep
* their per-entry Remove button but never enter the bulk sweep.
*
* Deliberate tradeoff: groups key on display metadata because no machine
* identity survives a key rotation. Two distinct same-named trusted-CIDR
* machines can therefore land in one group and the offline one may be swept
* accepted because the sweep is admin-confirmed and a wrongly removed client
* is re-admitted automatically by the same auto-approve policy on reconnect.
*/
export function listStaleInventoryEntries(groups: NodesInventoryGroup[]): NodesInventoryEntry[] {
return groups.flatMap((group) =>
group.duplicates.filter((entry) => !entry.connected && entry.autoApproved),
);
}
/** Which pairing stores a removal must touch for this entry. */
export function resolveInventoryRemoval(entry: NodesInventoryEntry): {
removeNode: boolean;
removeDevice: boolean;
} {
const hasNodeRole = entry.roles.includes("node");
const nonNodeRoles = entry.roles.filter((role) => role !== "node");
return {
removeNode: hasNodeRole || entry.node?.paired === true,
// node.pair.remove deletes node-only device rows itself; only records with
// other roles (or tokenless records) need the device-level removal too.
removeDevice: Boolean(entry.device) && (nonNodeRoles.length > 0 || entry.roles.length === 0),
};
}

View file

@ -12,12 +12,16 @@ import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"
import { currentConfigObject } from "../../lib/config/index.ts"; import { currentConfigObject } from "../../lib/config/index.ts";
import { import {
approveDevicePairing, approveDevicePairing,
approveNodePairingRequest,
createInitialNodesState, createInitialNodesState,
loadDevices, loadDevices,
loadExecApprovals, loadExecApprovals,
loadNodes, loadNodes,
rejectDevicePairing, rejectDevicePairing,
rejectNodePairingRequest,
removeExecApprovalsFormValue, removeExecApprovalsFormValue,
removeInventoryEntry,
removeStaleInventoryEntries,
revokeDeviceToken, revokeDeviceToken,
rotateDeviceToken, rotateDeviceToken,
saveExecApprovals, saveExecApprovals,
@ -103,6 +107,9 @@ class NodesPage extends OpenClawLightDomElement implements NodesPageDataState {
if (event.event === "device.pair.requested" || event.event === "device.pair.resolved") { if (event.event === "device.pair.requested" || event.event === "device.pair.resolved") {
void loadDevices(this, { quiet: true }); void loadDevices(this, { quiet: true });
} }
if (event.event === "node.pair.requested" || event.event === "node.pair.resolved") {
void loadNodes(this, { quiet: true });
}
}), }),
); );
@ -257,6 +264,7 @@ class NodesPage extends OpenClawLightDomElement implements NodesPageDataState {
renderNodes({ renderNodes({
loading: this.nodesLoading, loading: this.nodesLoading,
nodes: this.nodes, nodes: this.nodes,
lastError: this.lastError,
devicesLoading: this.devicesLoading, devicesLoading: this.devicesLoading,
devicesError: this.devicesError, devicesError: this.devicesError,
devicesList: this.devicesList, devicesList: this.devicesList,
@ -274,11 +282,17 @@ class NodesPage extends OpenClawLightDomElement implements NodesPageDataState {
execApprovalsSelectedAgent: this.execApprovalsSelectedAgent, execApprovalsSelectedAgent: this.execApprovalsSelectedAgent,
execApprovalsTarget: this.execApprovalsTarget, execApprovalsTarget: this.execApprovalsTarget,
execApprovalsTargetNodeId: this.execApprovalsTargetNodeId, execApprovalsTargetNodeId: this.execApprovalsTargetNodeId,
onRefresh: () => void loadNodes(this), onRefresh: () => {
onDevicesRefresh: () => void loadDevices(this), void loadNodes(this);
void loadDevices(this);
},
onDevicePairSetupOpen: () => void this.context.overlays.openDevicePairSetup(), onDevicePairSetupOpen: () => void this.context.overlays.openDevicePairSetup(),
onDeviceApprove: (requestId) => void approveDevicePairing(this, requestId), onDeviceApprove: (requestId) => void approveDevicePairing(this, requestId),
onDeviceReject: (requestId) => void rejectDevicePairing(this, requestId), onDeviceReject: (requestId) => void rejectDevicePairing(this, requestId),
onNodeApprove: (requestId) => void approveNodePairingRequest(this, requestId),
onNodeReject: (requestId) => void rejectNodePairingRequest(this, requestId),
onInventoryRemove: (entry) => void removeInventoryEntry(this, entry),
onInventoryCleanup: (entries) => void removeStaleInventoryEntries(this, entries),
onDeviceRotate: (deviceId, role, scopes) => onDeviceRotate: (deviceId, role, scopes) =>
void rotateDeviceToken(this, { void rotateDeviceToken(this, {
deviceId, deviceId,

View file

@ -0,0 +1,362 @@
// Nodes page renders the unified paired-device / node inventory card.
import { html, nothing, type TemplateResult } from "lit";
import {
resolvePendingDeviceApprovalState,
type DevicePairingAccessSummary,
type PendingDeviceApprovalKind,
} from "../../../../src/shared/device-pairing-access.js";
import { icons } from "../../components/icons.ts";
import { t } from "../../i18n/index.ts";
import { formatList, formatRelativeTimestamp } from "../../lib/format.ts";
import type {
DeviceTokenSummary,
InventoryRemovalRequest,
PairedDevice,
PendingDevice,
} from "../../lib/nodes/index.ts";
import {
buildNodesInventory,
listStaleInventoryEntries,
resolveInventoryRemoval,
type NodesInventoryEntry,
type NodesInventoryGroup,
} from "../../lib/nodes/inventory.ts";
import { normalizeOptionalString } from "../../lib/string-coerce.ts";
import type { NodesProps } from "./view.types.ts";
const MAX_CAPABILITY_CHIPS = 16;
function toRemovalRequest(entry: NodesInventoryEntry): InventoryRemovalRequest {
const removal = resolveInventoryRemoval(entry);
return { id: entry.id, name: entry.name, ...removal };
}
function shortId(id: string): string {
return id.length > 14 ? `${id.slice(0, 12)}` : id;
}
export function renderNodesInventory(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 groups = buildNodesInventory({ paired, nodes: props.nodes });
const stale = listStaleInventoryEntries(groups);
const pairedByDeviceId = new Map(
paired
.map((device) => [normalizeOptionalString(device.deviceId), device] as const)
.filter((entry): entry is [string, PairedDevice] => Boolean(entry[0])),
);
const loading = props.loading || props.devicesLoading;
return html`
<section class="card">
<div class="row" style="justify-content: space-between; align-items: flex-start;">
<div>
<div class="card-title">Nodes & devices</div>
<div class="card-sub">One entry per paired client: roles, tokens, live links.</div>
</div>
<div class="row" style="gap: 8px; flex-wrap: wrap; justify-content: flex-end;">
${stale.length > 0
? html`
<button
class="btn btn--sm danger"
@click=${() => props.onInventoryCleanup(stale.map(toRemovalRequest))}
>
Clean up ${stale.length} stale
</button>
`
: nothing}
<button
class="btn primary"
title=${props.canPairDevice ? "" : t("nodes.pairing.adminRequired")}
?disabled=${!props.canPairDevice}
@click=${props.onDevicePairSetupOpen}
>
${icons.smartphone} ${t("nodes.pairing.button")}
</button>
<button class="btn" ?disabled=${loading} @click=${props.onRefresh}>
${loading ? t("common.loading") : t("common.refresh")}
</button>
</div>
</div>
${props.devicesError
? html`<div class="callout danger" style="margin-top: 12px;">${props.devicesError}</div>`
: nothing}
${props.lastError
? html`<div class="callout danger" style="margin-top: 12px;">${props.lastError}</div>`
: nothing}
<div class="list" style="margin-top: 16px;">
${pending.length > 0
? html`
<div class="muted" style="margin-bottom: 8px;">Pending approval</div>
${pending.map((req) =>
renderPendingDevice(req, props, lookupPairedDevice(pairedByDeviceId, req)),
)}
<div class="muted" style="margin-top: 12px; margin-bottom: 8px;">Paired</div>
`
: nothing}
${groups.length === 0 && pending.length === 0
? html` <div class="muted">No paired nodes or devices.</div> `
: groups.map((group) => renderInventoryGroup(group, props))}
</div>
</section>
`;
}
function renderInventoryGroup(group: NodesInventoryGroup, props: NodesProps) {
if (group.duplicates.length === 0) {
return renderInventoryEntry(group.primary, props);
}
return html`
<div class="nodes-group">
${renderInventoryEntry(group.primary, props)}
<details class="nodes-group__dups">
<summary>
${group.duplicates.length} older pairing${group.duplicates.length === 1 ? "" : "s"} of
${group.name}
</summary>
${group.duplicates.map((entry) => renderInventoryEntry(entry, props))}
</details>
</div>
`;
}
function entryStatusChips(entry: NodesInventoryEntry): TemplateResult[] {
const chips: TemplateResult[] = [];
// Connectivity is known for node-catalog entries and for device records with
// server-computed connection state; legacy node-only rows without either
// still report offline, which matches their live-link reality.
chips.push(
html`<span class="chip ${entry.connected ? "chip-ok" : "chip-warn"}">
${entry.connected ? "connected" : "offline"}
</span>`,
);
for (const role of entry.roles) {
chips.push(html`<span class="chip">${role}</span>`);
}
if (entry.autoApproved) {
chips.push(html`<span class="chip">auto-paired</span>`);
}
const approvalState = entry.node?.approvalState;
if (approvalState === "pending-approval" || approvalState === "pending-reapproval") {
chips.push(html`<span class="chip chip-warn">approval needed</span>`);
}
return chips;
}
function entryMetaLine(entry: NodesInventoryEntry): string {
const parts: string[] = [shortId(entry.id)];
if (entry.platform) {
parts.push(entry.platform);
}
if (entry.version) {
parts.push(entry.version);
}
if (entry.remoteIp) {
parts.push(entry.remoteIp);
}
if (entry.lastSeenAtMs) {
parts.push(`seen ${formatRelativeTimestamp(entry.lastSeenAtMs)}`);
} else if (entry.approvedAtMs) {
parts.push(`approved ${formatRelativeTimestamp(entry.approvedAtMs)}`);
}
return parts.join(" · ");
}
function renderCapabilityChips(label: string, values: string[]) {
if (values.length === 0) {
return nothing;
}
const visible = values.slice(0, MAX_CAPABILITY_CHIPS);
const overflow = values.length - visible.length;
return html`
<div class="muted" style="margin-top: 8px;">${label}</div>
<div class="chip-row" style="margin-top: 4px;">
${visible.map((value) => html`<span class="chip">${value}</span>`)}
${overflow > 0 ? html`<span class="chip">+${overflow} more</span>` : nothing}
</div>
`;
}
function renderEntryDetails(entry: NodesInventoryEntry, props: NodesProps) {
const tokens = entry.device?.tokens ?? [];
const caps = entry.node?.caps ?? [];
const commands = entry.node?.commands ?? [];
const scopes = entry.scopes;
if (tokens.length === 0 && caps.length === 0 && commands.length === 0 && scopes.length === 0) {
return nothing;
}
return html`
<details class="nodes-entry__details">
<summary>Tokens & capabilities</summary>
<div class="muted" style="margin-top: 8px; word-break: break-all;">${entry.id}</div>
${scopes.length > 0
? html`<div class="muted" style="margin-top: 8px;">scopes: ${formatList(scopes)}</div>`
: nothing}
${tokens.length > 0
? html`
<div class="muted" style="margin-top: 8px;">Tokens</div>
<div style="display: flex; flex-direction: column; gap: 8px; margin-top: 6px;">
${tokens.map((token) => renderTokenRow(entry.id, token, props))}
</div>
`
: nothing}
${renderCapabilityChips("Capabilities", caps)} ${renderCapabilityChips("Commands", commands)}
</details>
`;
}
function renderInventoryEntry(entry: NodesInventoryEntry, props: NodesProps) {
const pendingRequestId =
entry.node?.approvalState === "pending-approval" ||
entry.node?.approvalState === "pending-reapproval"
? entry.node.pendingRequestId
: undefined;
return html`
<div class="list-item nodes-entry">
<div class="list-main">
<div class="nodes-entry__head">
<span class="list-title">${entry.name}</span>
${entryStatusChips(entry)}
</div>
<div class="list-sub">${entryMetaLine(entry)}</div>
${renderEntryDetails(entry, props)}
</div>
<div class="list-meta">
<div class="row" style="justify-content: flex-end; gap: 6px; flex-wrap: wrap;">
${pendingRequestId
? html`
<button
class="btn btn--sm primary"
@click=${() => props.onNodeApprove(pendingRequestId)}
>
Approve
</button>
<button class="btn btn--sm" @click=${() => props.onNodeReject(pendingRequestId)}>
Reject
</button>
`
: nothing}
<button
class="btn btn--sm danger"
@click=${() => props.onInventoryRemove(toRemovalRequest(entry))}
>
Remove
</button>
</div>
</div>
</div>
`;
}
function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: NodesProps) {
const status = token.revokedAtMs ? "revoked" : "active";
const scopes = `scopes: ${formatList(token.scopes)}`;
const when = formatRelativeTimestamp(
token.rotatedAtMs ?? token.createdAtMs ?? token.lastUsedAtMs ?? null,
);
return html`
<div class="row" style="justify-content: space-between; gap: 8px;">
<div class="list-sub">${token.role} · ${status} · ${scopes} · ${when}</div>
<div class="row" style="justify-content: flex-end; gap: 6px; flex-wrap: wrap;">
<button
class="btn btn--sm"
@click=${() => props.onDeviceRotate(deviceId, token.role, token.scopes)}
>
Rotate
</button>
${token.revokedAtMs
? nothing
: html`
<button
class="btn btn--sm danger"
@click=${() => props.onDeviceRevoke(deviceId, token.role)}
>
Revoke
</button>
`}
</div>
</div>
`;
}
function lookupPairedDevice(
pairedByDeviceId: ReadonlyMap<string, PairedDevice>,
request: Pick<PendingDevice, "deviceId" | "publicKey">,
): PairedDevice | undefined {
const deviceId = normalizeOptionalString(request.deviceId);
if (!deviceId) {
return undefined;
}
const paired = pairedByDeviceId.get(deviceId);
if (!paired) {
return undefined;
}
const requestPublicKey = normalizeOptionalString(request.publicKey);
const pairedPublicKey = normalizeOptionalString(paired.publicKey);
if (requestPublicKey && pairedPublicKey && requestPublicKey !== pairedPublicKey) {
return undefined;
}
return paired;
}
function formatAccessSummary(access: DevicePairingAccessSummary | null): string {
if (!access) {
return "none";
}
return `roles: ${formatList(access.roles)} · scopes: ${formatList(access.scopes)}`;
}
function renderPendingApprovalNote(kind: PendingDeviceApprovalKind) {
switch (kind) {
case "scope-upgrade":
return "scope upgrade requires approval";
case "role-upgrade":
return "role upgrade requires approval";
case "re-approval":
return "reconnect details changed; approval required";
case "new-pairing":
return "new device pairing request";
}
const exhaustiveKind: never = kind;
void exhaustiveKind;
throw new Error("unsupported pending approval kind");
}
function renderPendingDevice(req: PendingDevice, props: NodesProps, paired?: PairedDevice) {
const name = normalizeOptionalString(req.displayName) || req.deviceId;
const age = typeof req.ts === "number" ? formatRelativeTimestamp(req.ts) : t("common.na");
const approval = resolvePendingDeviceApprovalState(req, paired);
const repair = req.isRepair ? " · repair" : "";
const ip = req.remoteIp ? ` · ${req.remoteIp}` : "";
return html`
<div class="list-item">
<div class="list-main">
<div class="list-title">${name}</div>
<div class="list-sub">${req.deviceId}${ip}</div>
<div class="muted" style="margin-top: 6px;">
${renderPendingApprovalNote(approval.kind)} · requested ${age}${repair}
</div>
<div class="muted" style="margin-top: 6px;">
requested: ${formatAccessSummary(approval.requested)}
</div>
${approval.approved
? html`
<div class="muted" style="margin-top: 6px;">
approved now: ${formatAccessSummary(approval.approved)}
</div>
`
: nothing}
</div>
<div class="list-meta">
<div class="row" style="justify-content: flex-end; gap: 8px; flex-wrap: wrap;">
<button class="btn btn--sm primary" @click=${() => props.onDeviceApprove(req.requestId)}>
Approve
</button>
<button class="btn btn--sm" @click=${() => props.onDeviceReject(req.requestId)}>
Reject
</button>
</div>
</div>
</div>
`;
}

View file

@ -1,12 +1,14 @@
/* @vitest-environment jsdom */ /* @vitest-environment jsdom */
import { render } from "lit"; import { render } from "lit";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { InventoryRemovalRequest } from "../../lib/nodes/index.ts";
import { renderNodes, type NodesProps } from "./view.ts"; import { renderNodes, type NodesProps } from "./view.ts";
function baseProps(overrides: Partial<NodesProps> = {}): NodesProps { function baseProps(overrides: Partial<NodesProps> = {}): NodesProps {
return { return {
loading: false, loading: false,
nodes: [], nodes: [],
lastError: null,
devicesLoading: false, devicesLoading: false,
devicesError: null, devicesError: null,
devicesList: { devicesList: {
@ -28,12 +30,15 @@ function baseProps(overrides: Partial<NodesProps> = {}): NodesProps {
execApprovalsTarget: "gateway", execApprovalsTarget: "gateway",
execApprovalsTargetNodeId: null, execApprovalsTargetNodeId: null,
onRefresh: () => undefined, onRefresh: () => undefined,
onDevicesRefresh: () => undefined,
onDevicePairSetupOpen: () => undefined, onDevicePairSetupOpen: () => undefined,
onDeviceApprove: () => undefined, onDeviceApprove: () => undefined,
onDeviceReject: () => undefined, onDeviceReject: () => undefined,
onDeviceRotate: () => undefined, onDeviceRotate: () => undefined,
onDeviceRevoke: () => undefined, onDeviceRevoke: () => undefined,
onNodeApprove: () => undefined,
onNodeReject: () => undefined,
onInventoryRemove: () => undefined,
onInventoryCleanup: () => undefined,
onLoadConfig: () => undefined, onLoadConfig: () => undefined,
onLoadExecApprovals: () => undefined, onLoadExecApprovals: () => undefined,
onBindDefault: () => undefined, onBindDefault: () => undefined,
@ -54,19 +59,20 @@ function renderNodesContainer(overrides: Partial<NodesProps>): HTMLDivElement {
return container; return container;
} }
function getDevicesCard(container: Element): Element { function getInventoryCard(container: Element): Element {
const card = Array.from(container.querySelectorAll(".card")).find( const card = Array.from(container.querySelectorAll(".card")).find(
(candidate) => candidate.querySelector(".card-title")?.textContent?.trim() === "Devices", (candidate) =>
candidate.querySelector(".card-title")?.textContent?.trim() === "Nodes & devices",
); );
expect(card).toBeInstanceOf(Element); expect(card).toBeInstanceOf(Element);
if (!(card instanceof Element)) { if (!(card instanceof Element)) {
throw new Error("Expected devices card"); throw new Error("Expected inventory card");
} }
return card; return card;
} }
function getPendingDeviceDetails(container: Element): string[] { function getPendingDeviceDetails(container: Element): string[] {
const item = getDevicesCard(container).querySelector(".list-item"); const item = getInventoryCard(container).querySelector(".list-item");
expect(item).toBeInstanceOf(Element); expect(item).toBeInstanceOf(Element);
if (!(item instanceof Element)) { if (!(item instanceof Element)) {
throw new Error("Expected pending device item"); throw new Error("Expected pending device item");
@ -76,6 +82,17 @@ function getPendingDeviceDetails(container: Element): string[] {
); );
} }
function findButton(scope: Element, label: string): HTMLButtonElement {
const button = Array.from(scope.querySelectorAll("button")).find(
(candidate) => candidate.textContent?.trim() === label,
);
expect(button).toBeInstanceOf(HTMLButtonElement);
if (!(button instanceof HTMLButtonElement)) {
throw new Error(`Expected button ${label}`);
}
return button;
}
describe("nodes devices pending rendering", () => { describe("nodes devices pending rendering", () => {
it("shows requested and approved access for a scope upgrade", () => { it("shows requested and approved access for a scope upgrade", () => {
const container = renderNodesContainer({ const container = renderNodesContainer({
@ -102,10 +119,10 @@ describe("nodes devices pending rendering", () => {
}); });
const details = getPendingDeviceDetails(container); const details = getPendingDeviceDetails(container);
expect(details[0]).toMatch(/^scope upgrade requires approval \u00b7 requested /u); expect(details[0]).toMatch(/^scope upgrade requires approval · requested /u);
expect(details.slice(1)).toEqual([ expect(details.slice(1)).toEqual([
"requested: roles: operator \u00b7 scopes: operator.admin, operator.read, operator.write", "requested: roles: operator · scopes: operator.admin, operator.read, operator.write",
"approved now: roles: operator \u00b7 scopes: operator.read", "approved now: roles: operator · scopes: operator.read",
]); ]);
}); });
@ -134,8 +151,8 @@ describe("nodes devices pending rendering", () => {
}); });
const details = getPendingDeviceDetails(container); const details = getPendingDeviceDetails(container);
expect(details[0]).toMatch(/^scope upgrade requires approval \u00b7 requested /u); expect(details[0]).toMatch(/^scope upgrade requires approval · requested /u);
expect(details.at(-1)).toBe("approved now: roles: operator \u00b7 scopes: operator.read"); expect(details.at(-1)).toBe("approved now: roles: operator · scopes: operator.read");
}); });
it("does not show upgrade context for key-mismatched pending requests", () => { it("does not show upgrade context for key-mismatched pending requests", () => {
@ -165,10 +182,10 @@ describe("nodes devices pending rendering", () => {
}); });
const details = getPendingDeviceDetails(container); const details = getPendingDeviceDetails(container);
expect(details[0]).toMatch(/^new device pairing request \u00b7 requested /u); expect(details[0]).toMatch(/^new device pairing request · requested /u);
expect(details).toEqual([ expect(details).toEqual([
details[0] ?? "", details[0] ?? "",
"requested: roles: operator \u00b7 scopes: operator.admin, operator.read, operator.write", "requested: roles: operator · scopes: operator.admin, operator.read, operator.write",
]); ]);
}); });
@ -189,7 +206,111 @@ describe("nodes devices pending rendering", () => {
}); });
const details = getPendingDeviceDetails(container); const details = getPendingDeviceDetails(container);
expect(details[1]).toBe("requested: roles: node, operator \u00b7 scopes: operator.read"); expect(details[1]).toBe("requested: roles: node, operator · scopes: operator.read");
});
});
describe("nodes inventory rendering", () => {
it("renders one row per machine with duplicates collapsed", () => {
const container = renderNodesContainer({
devicesList: {
pending: [],
paired: [
{
deviceId: "mac-new",
displayName: "MacBook",
roles: ["operator", "node"],
lastSeenAtMs: 3_000,
},
{
deviceId: "mac-old",
displayName: "MacBook",
roles: ["operator", "node"],
approvedVia: "silent",
lastSeenAtMs: 1_000,
},
],
},
nodes: [{ nodeId: "mac-new", displayName: "MacBook", connected: true, paired: true }],
});
const card = getInventoryCard(container);
const titles = Array.from(card.querySelectorAll(".list-title")).map((title) =>
title.textContent?.trim(),
);
expect(titles).toEqual(["MacBook", "MacBook"]);
const dups = card.querySelector(".nodes-group__dups");
expect(dups?.querySelector("summary")?.textContent).toContain("1 older pairing");
expect(dups?.textContent).toContain("mac-old");
expect(findButton(card, "Clean up 1 stale")).toBeInstanceOf(HTMLButtonElement);
});
it("wires Remove to the removal routing for the entry roles", () => {
const removed: InventoryRemovalRequest[] = [];
const container = renderNodesContainer({
devicesList: {
pending: [],
paired: [
{
deviceId: "op-only",
displayName: "Browser",
roles: ["operator"],
},
],
},
onInventoryRemove: (entry) => removed.push(entry),
});
findButton(getInventoryCard(container), "Remove").click();
expect(removed).toEqual([
{ id: "op-only", name: "Browser", removeNode: false, removeDevice: true },
]);
});
it("renders approve and reject actions for pending node approvals", () => {
const approvals: string[] = [];
const container = renderNodesContainer({
nodes: [
{
nodeId: "node-pending",
displayName: "clawmac",
paired: true,
connected: true,
approvalState: "pending-reapproval",
pendingRequestId: "node-req-1",
},
],
onNodeApprove: (requestId) => approvals.push(requestId),
});
const card = getInventoryCard(container);
expect(card.textContent).toContain("approval needed");
findButton(card, "Approve").click();
expect(approvals).toEqual(["node-req-1"]);
});
it("shows token rows with rotate and revoke inside entry details", () => {
const rotations: Array<{ deviceId: string; role: string }> = [];
const container = renderNodesContainer({
devicesList: {
pending: [],
paired: [
{
deviceId: "device-1",
displayName: "Device One",
roles: ["operator"],
tokens: [{ role: "operator", scopes: ["operator.read"], createdAtMs: Date.now() }],
},
],
},
onDeviceRotate: (deviceId, role) => rotations.push({ deviceId, role }),
});
const card = getInventoryCard(container);
expect(card.textContent).toContain("operator · active · scopes: operator.read");
findButton(card, "Rotate").click();
expect(rotations).toEqual([{ deviceId: "device-1", role: "operator" }]);
}); });
}); });

View file

@ -1,16 +1,8 @@
// Nodes page renders its screen content. // Nodes page renders its screen content.
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import {
resolvePendingDeviceApprovalState,
type DevicePairingAccessSummary,
type PendingDeviceApprovalKind,
} from "../../../../src/shared/device-pairing-access.js";
import { icons } from "../../components/icons.ts";
import { t } from "../../i18n/index.ts"; import { t } from "../../i18n/index.ts";
import { formatRelativeTimestamp, formatList } from "../../lib/format.ts";
import type { DeviceTokenSummary, PairedDevice, PendingDevice } from "../../lib/nodes/index.ts";
import { normalizeOptionalString } from "../../lib/string-coerce.ts";
import { renderExecApprovals, resolveExecApprovalsState } from "./view-exec-approvals.ts"; import { renderExecApprovals, resolveExecApprovalsState } from "./view-exec-approvals.ts";
import { renderNodesInventory } from "./view-inventory.ts";
import { resolveConfigAgents, resolveNodeTargets, type NodeTargetOption } from "./view-shared.ts"; import { resolveConfigAgents, resolveNodeTargets, type NodeTargetOption } from "./view-shared.ts";
export type { NodesProps } from "./view.types.ts"; export type { NodesProps } from "./view.types.ts";
import type { NodesProps } from "./view.types.ts"; import type { NodesProps } from "./view.types.ts";
@ -19,217 +11,8 @@ export function renderNodes(props: NodesProps) {
const bindingState = resolveBindingsState(props); const bindingState = resolveBindingsState(props);
const approvalsState = resolveExecApprovalsState(props); const approvalsState = resolveExecApprovalsState(props);
return html` return html`
${renderExecApprovals(approvalsState)} ${renderBindings(bindingState)} ${renderDevices(props)} ${renderNodesInventory(props)} ${renderExecApprovals(approvalsState)}
<section class="card"> ${renderBindings(bindingState)}
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Nodes</div>
<div class="card-sub">Paired devices and live links.</div>
</div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? t("common.loading") : t("common.refresh")}
</button>
</div>
<div class="list" style="margin-top: 16px;">
${props.nodes.length === 0
? html` <div class="muted">No nodes found.</div> `
: props.nodes.map((n) => renderNode(n))}
</div>
</section>
`;
}
function renderDevices(props: NodesProps) {
const list = props.devicesList ?? { pending: [], paired: [] };
const pending = Array.isArray(list.pending) ? list.pending : [];
const paired = Array.isArray(list.paired) ? list.paired : [];
const pairedByDeviceId = new Map(
paired
.map((device) => [normalizeOptionalString(device.deviceId), device] as const)
.filter((entry): entry is [string, PairedDevice] => Boolean(entry[0])),
);
return html`
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Devices</div>
<div class="card-sub">Pairing requests + role tokens.</div>
</div>
<div class="row" style="gap: 8px; flex-wrap: wrap; justify-content: flex-end;">
<button
class="btn primary"
title=${props.canPairDevice ? "" : t("nodes.pairing.adminRequired")}
?disabled=${!props.canPairDevice}
@click=${props.onDevicePairSetupOpen}
>
${icons.smartphone} ${t("nodes.pairing.button")}
</button>
<button class="btn" ?disabled=${props.devicesLoading} @click=${props.onDevicesRefresh}>
${props.devicesLoading ? t("common.loading") : t("common.refresh")}
</button>
</div>
</div>
${props.devicesError
? html`<div class="callout danger" style="margin-top: 12px;">${props.devicesError}</div>`
: nothing}
<div class="list" style="margin-top: 16px;">
${pending.length > 0
? html`
<div class="muted" style="margin-bottom: 8px;">Pending</div>
${pending.map((req) =>
renderPendingDevice(req, props, lookupPairedDevice(pairedByDeviceId, req)),
)}
`
: nothing}
${paired.length > 0
? html`
<div class="muted" style="margin-top: 12px; margin-bottom: 8px;">Paired</div>
${paired.map((device) => renderPairedDevice(device, props))}
`
: nothing}
${pending.length === 0 && paired.length === 0
? html` <div class="muted">No paired devices.</div> `
: nothing}
</div>
</section>
`;
}
function lookupPairedDevice(
pairedByDeviceId: ReadonlyMap<string, PairedDevice>,
request: Pick<PendingDevice, "deviceId" | "publicKey">,
): PairedDevice | undefined {
const deviceId = normalizeOptionalString(request.deviceId);
if (!deviceId) {
return undefined;
}
const paired = pairedByDeviceId.get(deviceId);
if (!paired) {
return undefined;
}
const requestPublicKey = normalizeOptionalString(request.publicKey);
const pairedPublicKey = normalizeOptionalString(paired.publicKey);
if (requestPublicKey && pairedPublicKey && requestPublicKey !== pairedPublicKey) {
return undefined;
}
return paired;
}
function formatAccessSummary(access: DevicePairingAccessSummary | null): string {
if (!access) {
return "none";
}
return `roles: ${formatList(access.roles)} · scopes: ${formatList(access.scopes)}`;
}
function renderPendingApprovalNote(kind: PendingDeviceApprovalKind) {
switch (kind) {
case "scope-upgrade":
return "scope upgrade requires approval";
case "role-upgrade":
return "role upgrade requires approval";
case "re-approval":
return "reconnect details changed; approval required";
case "new-pairing":
return "new device pairing request";
}
const exhaustiveKind: never = kind;
void exhaustiveKind;
throw new Error("unsupported pending approval kind");
}
function renderPendingDevice(req: PendingDevice, props: NodesProps, paired?: PairedDevice) {
const name = normalizeOptionalString(req.displayName) || req.deviceId;
const age = typeof req.ts === "number" ? formatRelativeTimestamp(req.ts) : t("common.na");
const approval = resolvePendingDeviceApprovalState(req, paired);
const repair = req.isRepair ? " · repair" : "";
const ip = req.remoteIp ? ` · ${req.remoteIp}` : "";
return html`
<div class="list-item">
<div class="list-main">
<div class="list-title">${name}</div>
<div class="list-sub">${req.deviceId}${ip}</div>
<div class="muted" style="margin-top: 6px;">
${renderPendingApprovalNote(approval.kind)} · requested ${age}${repair}
</div>
<div class="muted" style="margin-top: 6px;">
requested: ${formatAccessSummary(approval.requested)}
</div>
${approval.approved
? html`
<div class="muted" style="margin-top: 6px;">
approved now: ${formatAccessSummary(approval.approved)}
</div>
`
: nothing}
</div>
<div class="list-meta">
<div class="row" style="justify-content: flex-end; gap: 8px; flex-wrap: wrap;">
<button class="btn btn--sm primary" @click=${() => props.onDeviceApprove(req.requestId)}>
Approve
</button>
<button class="btn btn--sm" @click=${() => props.onDeviceReject(req.requestId)}>
Reject
</button>
</div>
</div>
</div>
`;
}
function renderPairedDevice(device: PairedDevice, props: NodesProps) {
const name = normalizeOptionalString(device.displayName) || device.deviceId;
const ip = device.remoteIp ? ` · ${device.remoteIp}` : "";
const roles = `roles: ${formatList(device.roles)}`;
const scopes = `scopes: ${formatList(device.scopes)}`;
const tokens = Array.isArray(device.tokens) ? device.tokens : [];
return html`
<div class="list-item">
<div class="list-main">
<div class="list-title">${name}</div>
<div class="list-sub">${device.deviceId}${ip}</div>
<div class="muted" style="margin-top: 6px;">${roles} · ${scopes}</div>
${tokens.length === 0
? html` <div class="muted" style="margin-top: 6px">Tokens: none</div> `
: html`
<div class="muted" style="margin-top: 10px;">Tokens</div>
<div style="display: flex; flex-direction: column; gap: 8px; margin-top: 6px;">
${tokens.map((token) => renderTokenRow(device.deviceId, token, props))}
</div>
`}
</div>
</div>
`;
}
function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: NodesProps) {
const status = token.revokedAtMs ? "revoked" : "active";
const scopes = `scopes: ${formatList(token.scopes)}`;
const when = formatRelativeTimestamp(
token.rotatedAtMs ?? token.createdAtMs ?? token.lastUsedAtMs ?? null,
);
return html`
<div class="row" style="justify-content: space-between; gap: 8px;">
<div class="list-sub">${token.role} · ${status} · ${scopes} · ${when}</div>
<div class="row" style="justify-content: flex-end; gap: 6px; flex-wrap: wrap;">
<button
class="btn btn--sm"
@click=${() => props.onDeviceRotate(deviceId, token.role, token.scopes)}
>
Rotate
</button>
${token.revokedAtMs
? nothing
: html`
<button
class="btn btn--sm danger"
@click=${() => props.onDeviceRevoke(deviceId, token.role)}
>
Revoke
</button>
`}
</div>
</div>
`; `;
} }
@ -447,33 +230,3 @@ function resolveAgentBindings(config: Record<string, unknown> | null): {
return { defaultBinding, agents }; return { defaultBinding, agents };
} }
function renderNode(node: Record<string, unknown>) {
const connected = Boolean(node.connected);
const paired = Boolean(node.paired);
const title =
(typeof node.displayName === "string" && node.displayName.trim()) ||
(typeof node.nodeId === "string" ? node.nodeId : "unknown");
const caps = Array.isArray(node.caps) ? (node.caps as unknown[]) : [];
const commands = Array.isArray(node.commands) ? (node.commands as unknown[]) : [];
return html`
<div class="list-item">
<div class="list-main">
<div class="list-title">${title}</div>
<div class="list-sub">
${typeof node.nodeId === "string" ? node.nodeId : ""}
${typeof node.remoteIp === "string" ? ` · ${node.remoteIp}` : ""}
${typeof node.version === "string" ? ` · ${node.version}` : ""}
</div>
<div class="chip-row" style="margin-top: 6px;">
<span class="chip">${paired ? "paired" : "unpaired"}</span>
<span class="chip ${connected ? "chip-ok" : "chip-warn"}">
${connected ? "connected" : "offline"}
</span>
${caps.slice(0, 12).map((c) => html`<span class="chip">${String(c)}</span>`)}
${commands.slice(0, 8).map((c) => html`<span class="chip">${String(c)}</span>`)}
</div>
</div>
</div>
`;
}

View file

@ -3,11 +3,13 @@ import type {
DevicePairingList, DevicePairingList,
ExecApprovalsFile, ExecApprovalsFile,
ExecApprovalsSnapshot, ExecApprovalsSnapshot,
InventoryRemovalRequest,
} from "../../lib/nodes/index.ts"; } from "../../lib/nodes/index.ts";
export type NodesProps = { export type NodesProps = {
loading: boolean; loading: boolean;
nodes: Array<Record<string, unknown>>; nodes: Array<Record<string, unknown>>;
lastError: string | null;
devicesLoading: boolean; devicesLoading: boolean;
devicesError: string | null; devicesError: string | null;
devicesList: DevicePairingList | null; devicesList: DevicePairingList | null;
@ -26,12 +28,15 @@ export type NodesProps = {
execApprovalsTarget: "gateway" | "node"; execApprovalsTarget: "gateway" | "node";
execApprovalsTargetNodeId: string | null; execApprovalsTargetNodeId: string | null;
onRefresh: () => void; onRefresh: () => void;
onDevicesRefresh: () => void;
onDevicePairSetupOpen: () => void; onDevicePairSetupOpen: () => void;
onDeviceApprove: (requestId: string) => void; onDeviceApprove: (requestId: string) => void;
onDeviceReject: (requestId: string) => void; onDeviceReject: (requestId: string) => void;
onDeviceRotate: (deviceId: string, role: string, scopes?: string[]) => void; onDeviceRotate: (deviceId: string, role: string, scopes?: string[]) => void;
onDeviceRevoke: (deviceId: string, role: string) => void; onDeviceRevoke: (deviceId: string, role: string) => void;
onNodeApprove: (requestId: string) => void;
onNodeReject: (requestId: string) => void;
onInventoryRemove: (entry: InventoryRemovalRequest) => void;
onInventoryCleanup: (entries: InventoryRemovalRequest[]) => void;
onLoadConfig: () => void; onLoadConfig: () => void;
onLoadExecApprovals: () => void; onLoadExecApprovals: () => void;
onBindDefault: (nodeId: string | null) => void; onBindDefault: (nodeId: string | null) => void;

View file

@ -7,6 +7,7 @@
@import "./styles/config.css"; @import "./styles/config.css";
@import "./styles/config-quick.css"; @import "./styles/config-quick.css";
@import "./styles/cron-quick-create.css"; @import "./styles/cron-quick-create.css";
@import "./styles/nodes.css";
@import "./styles/sessions.css"; @import "./styles/sessions.css";
@import "./styles/usage.css"; @import "./styles/usage.css";
@import "./styles/profile.css"; @import "./styles/profile.css";

54
ui/src/styles/nodes.css Normal file
View file

@ -0,0 +1,54 @@
/* Nodes page: unified paired-device / node inventory. */
.nodes-entry__head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.nodes-entry__head .chip {
padding: 2px 10px;
}
.nodes-entry__details {
margin-top: 8px;
}
.nodes-entry__details > summary {
cursor: pointer;
font-size: 12px;
color: var(--muted);
user-select: none;
}
.nodes-entry__details > summary:hover {
color: var(--fg);
}
.nodes-group {
display: flex;
flex-direction: column;
}
.nodes-group__dups {
margin: 4px 0 8px;
padding-left: 14px;
border-left: 2px solid var(--border);
}
.nodes-group__dups > summary {
cursor: pointer;
font-size: 12px;
color: var(--muted);
padding: 4px 0;
user-select: none;
}
.nodes-group__dups > summary:hover {
color: var(--fg);
}
.nodes-group__dups .list-item {
opacity: 0.75;
}