mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
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:
parent
743422217e
commit
8985598302
25 changed files with 2112 additions and 279 deletions
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
170
src/gateway/device-pairing-prune.test.ts
Normal file
170
src/gateway/device-pairing-prune.test.ts
Normal 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
59
src/gateway/device-pairing-prune.ts
Normal file
59
src/gateway/device-pairing-prune.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
275
src/infra/device-pairing-prune.test.ts
Normal file
275
src/infra/device-pairing-prune.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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[] },
|
||||||
|
|
|
||||||
246
ui/src/lib/nodes/inventory.test.ts
Normal file
246
ui/src/lib/nodes/inventory.test.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
284
ui/src/lib/nodes/inventory.ts
Normal file
284
ui/src/lib/nodes/inventory.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
362
ui/src/pages/nodes/view-inventory.ts
Normal file
362
ui/src/pages/nodes/view-inventory.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
@ -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" }]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
54
ui/src/styles/nodes.css
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue