Files
openclaw/src/infra/node-pairing.test.ts
Peter Steinberger f5806d08e2 refactor(gateway): retire the standalone node pairing store (#103120)
* refactor(gateway): fold node pairing store into device records

* docs+cli: retire standalone node pairing store references

* test(infra): respec node pairing as device-backed surface approvals

* test(infra): assemble migration token fixtures dynamically

* fix(pairing): preserve node surface across device re-approval, strip legacy tokens in CLI JSON

* test(gateway): drop retired node token from pairing fixtures

* chore(protocol): regenerate Swift models, drop dead pairing-pending module
2026-07-09 22:58:03 +01:00

636 lines
21 KiB
TypeScript

// Tests node capability-surface approvals stored on paired device records.
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { approveDevicePairing, requestDevicePairing } from "./device-pairing.js";
import {
approveNodePairing,
beginNodePairingConnect,
finalizeNodePairingCleanupClaim,
listNodePairing,
releaseNodePairingCleanupClaim,
renamePairedNode,
requestNodePairing,
reusePendingNodePairingForReconnect,
updatePairedNodeMetadata,
} from "./node-pairing.js";
const tempDirs = createSuiteTempRootTracker({ prefix: "openclaw-node-pairing-" });
async function withNodePairingDir<T>(run: (baseDir: string) => Promise<T>): Promise<T> {
return await run(await tempDirs.make("case"));
}
async function seedNodeDevice(baseDir: string, nodeId: string): Promise<void> {
const request = await requestDevicePairing(
{ deviceId: nodeId, publicKey: `pk-${nodeId}`, role: "node", roles: ["node"], scopes: [] },
baseDir,
);
await approveDevicePairing(request.request.requestId, { callerScopes: [] }, baseDir);
}
async function setupPairedNode(baseDir: string): Promise<void> {
await seedNodeDevice(baseDir, "node-1");
const request = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
commands: ["system.run"],
},
baseDir,
);
await approveNodePairing(
request.request.requestId,
{ callerScopes: ["operator.pairing", "operator.admin"] },
baseDir,
);
const paired = await findPairedNode("node-1", baseDir);
expect(paired?.nodeId).toBe("node-1");
}
async function findPairedNode(nodeId: string, baseDir: string) {
const pairing = await listNodePairing(baseDir);
return pairing.paired.find((node) => node.nodeId === nodeId) ?? null;
}
function requireRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Expected a non-array record");
}
return value as Record<string, unknown>;
}
function findRecordByField<T extends Record<string, unknown>>(
records: T[],
field: string,
value: unknown,
): T {
const record = records.find((entry) => entry[field] === value);
if (!record) {
throw new Error(`Expected record with ${field}=${String(value)}`);
}
return record;
}
describe("node surface approvals", () => {
beforeAll(async () => {
await tempDirs.setup();
});
afterAll(async () => {
await tempDirs.cleanup();
});
test("requires a paired device before accepting surface requests", async () => {
await withNodePairingDir(async (baseDir) => {
await expect(
requestNodePairing({ nodeId: "node-unpaired", platform: "darwin" }, baseDir),
).rejects.toThrow(/paired device/);
});
});
test("reuses pending requests for metadata refreshes", async () => {
await withNodePairingDir(async (baseDir) => {
await seedNodeDevice(baseDir, "node-1");
const first = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
},
baseDir,
);
const second = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
},
baseDir,
);
expect(first.created).toBe(true);
expect(second.created).toBe(false);
expect(second.request.requestId).toBe(first.request.requestId);
expect("revision" in first.request).toBe(false);
expect("revision" in second.request).toBe(false);
await seedNodeDevice(baseDir, "node-2");
const commandFirst = await requestNodePairing(
{
nodeId: "node-2",
platform: "darwin",
commands: ["canvas.snapshot"],
},
baseDir,
);
const commandSecond = await requestNodePairing(
{
nodeId: "node-2",
platform: "darwin",
displayName: "Updated Node",
commands: ["canvas.snapshot"],
},
baseDir,
);
expect(commandSecond.created).toBe(false);
expect(commandSecond.superseded).toBeUndefined();
expect(commandSecond.request.requestId).toBe(commandFirst.request.requestId);
expect(commandSecond.request.displayName).toBe("Updated Node");
expect(commandSecond.request.commands).toEqual(["canvas.snapshot"]);
await seedNodeDevice(baseDir, "node-3");
const reorderedFirst = await requestNodePairing(
{
nodeId: "node-3",
platform: "darwin",
caps: ["camera", "screen"],
commands: ["canvas.snapshot", "system.run"],
},
baseDir,
);
const reorderedSecond = await requestNodePairing(
{
nodeId: "node-3",
platform: "darwin",
caps: ["screen", "camera"],
commands: ["system.run", "canvas.snapshot"],
},
baseDir,
);
expect(reorderedSecond.created).toBe(false);
expect(reorderedSecond.superseded).toBeUndefined();
expect(reorderedSecond.request.requestId).toBe(reorderedFirst.request.requestId);
await seedNodeDevice(baseDir, "node-4");
await requestNodePairing(
{
nodeId: "node-4",
platform: "darwin",
commands: ["canvas.present"],
},
baseDir,
);
const pairing = await listNodePairing(baseDir);
const pendingNode = findRecordByField(pairing.pending, "nodeId", "node-4");
expect(pendingNode.commands).toEqual(["canvas.present"]);
expect(pendingNode.requiredApproveScopes).toEqual(["operator.pairing", "operator.write"]);
expect("revision" in pendingNode).toBe(false);
expect(pairing.paired).toEqual([]);
});
});
test("supersedes pending requests when the approval surface changes", async () => {
await withNodePairingDir(async (baseDir) => {
await seedNodeDevice(baseDir, "node-1");
const first = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
caps: ["camera"],
commands: ["canvas.snapshot"],
permissions: { camera: true },
},
baseDir,
);
const second = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
commands: ["canvas.snapshot", "system.run"],
},
baseDir,
);
expect(second.created).toBe(true);
expect(second.superseded).toEqual([{ requestId: first.request.requestId, nodeId: "node-1" }]);
expect(second.request.requestId).not.toBe(first.request.requestId);
const list = await listNodePairing(baseDir);
expect(list.pending).toHaveLength(1);
expect(list.pending[0]?.requestId).toBe(second.request.requestId);
expect(list.pending[0]?.commands).toEqual(["canvas.snapshot", "system.run"]);
await expect(
approveNodePairing(
first.request.requestId,
{ callerScopes: ["operator.pairing", "operator.admin"] },
baseDir,
),
).resolves.toBeNull();
const approved = await approveNodePairing(
second.request.requestId,
{ callerScopes: ["operator.pairing", "operator.admin"] },
baseDir,
);
const approvedRecord = requireRecord(approved);
const approvedNode = requireRecord(approvedRecord.node);
expect(approvedRecord.requestId).toBe(second.request.requestId);
expect(approvedNode.commands).toEqual(["canvas.snapshot", "system.run"]);
await seedNodeDevice(baseDir, "node-2");
const capsFirst = await requestNodePairing(
{
nodeId: "node-2",
platform: "darwin",
caps: ["camera"],
},
baseDir,
);
const capsSecond = await requestNodePairing(
{
nodeId: "node-2",
platform: "darwin",
caps: ["camera", "screen"],
},
baseDir,
);
expect(capsSecond.created).toBe(true);
expect(capsSecond.superseded).toEqual([
{ requestId: capsFirst.request.requestId, nodeId: "node-2" },
]);
expect(capsSecond.request.requestId).not.toBe(capsFirst.request.requestId);
await seedNodeDevice(baseDir, "node-3");
const permissionsFirst = await requestNodePairing(
{
nodeId: "node-3",
platform: "darwin",
permissions: { camera: true },
},
baseDir,
);
const permissionsSecond = await requestNodePairing(
{
nodeId: "node-3",
platform: "darwin",
permissions: { camera: true, screen: true },
},
baseDir,
);
expect(permissionsSecond.created).toBe(true);
expect(permissionsSecond.superseded).toEqual([
{ requestId: permissionsFirst.request.requestId, nodeId: "node-3" },
]);
expect(permissionsSecond.request.requestId).not.toBe(permissionsFirst.request.requestId);
});
});
test("rejects every pending request for one node without removing its approval", async () => {
await withNodePairingDir(async (baseDir) => {
await setupPairedNode(baseDir);
const pending = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
commands: ["system.run", "canvas.snapshot"],
},
baseDir,
);
const snapshot = await beginNodePairingConnect("node-1", baseDir);
expect(snapshot.cleanupClaim).toBeDefined();
await expect(finalizeNodePairingCleanupClaim(snapshot.cleanupClaim!)).resolves.toEqual([
{ requestId: pending.request.requestId, nodeId: "node-1" },
]);
await expect(finalizeNodePairingCleanupClaim(snapshot.cleanupClaim!)).resolves.toEqual([]);
const pairing = await listNodePairing(baseDir);
expect(pairing.pending).toEqual([]);
expect(pairing.paired).toHaveLength(1);
expect(pairing.paired[0]?.nodeId).toBe("node-1");
await expect(findPairedNode("node-1", baseDir)).resolves.toMatchObject({
commands: ["system.run"],
});
});
});
test("preserves a pending request refreshed after the connect snapshot", async () => {
await withNodePairingDir(async (baseDir) => {
await setupPairedNode(baseDir);
const pending = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
commands: ["system.run", "canvas.snapshot"],
},
baseDir,
);
const snapshot = await beginNodePairingConnect("node-1", baseDir);
expect(snapshot.cleanupClaim).toBeDefined();
const refreshed = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
commands: ["system.run", "canvas.snapshot"],
},
baseDir,
);
expect(refreshed.request.requestId).toBe(pending.request.requestId);
await expect(finalizeNodePairingCleanupClaim(snapshot.cleanupClaim!)).resolves.toEqual([]);
expect((await listNodePairing(baseDir)).pending).toHaveLength(1);
});
});
test("reuses an unchanged reconnect request without leaving stale cleanup ownership", async () => {
await withNodePairingDir(async (baseDir) => {
await setupPairedNode(baseDir);
const pending = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
commands: ["system.run", "canvas.snapshot"],
},
baseDir,
);
const snapshot = await beginNodePairingConnect("node-1", baseDir);
expect(snapshot.cleanupClaim).toBeDefined();
await expect(
reusePendingNodePairingForReconnect(
{
nodeId: "node-1",
platform: "darwin",
commands: ["system.run", "canvas.snapshot"],
},
snapshot.cleanupClaim,
baseDir,
),
).resolves.toMatchObject({
request: { requestId: pending.request.requestId },
created: false,
});
await expect(finalizeNodePairingCleanupClaim(snapshot.cleanupClaim!)).resolves.toEqual([]);
await expect(
approveNodePairing(
pending.request.requestId,
{ callerScopes: ["operator.pairing", "operator.admin"] },
baseDir,
),
).resolves.toMatchObject({ requestId: pending.request.requestId });
});
});
test("does not reuse a reconnect request when pending metadata changed", async () => {
await withNodePairingDir(async (baseDir) => {
await setupPairedNode(baseDir);
await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
displayName: "Old Name",
commands: ["system.run", "canvas.snapshot"],
},
baseDir,
);
const snapshot = await beginNodePairingConnect("node-1", baseDir);
await expect(
reusePendingNodePairingForReconnect(
{
nodeId: "node-1",
platform: "darwin",
displayName: "New Name",
commands: ["system.run", "canvas.snapshot"],
},
snapshot.cleanupClaim,
baseDir,
),
).resolves.toBeNull();
if (snapshot.cleanupClaim) {
await releaseNodePairingCleanupClaim(snapshot.cleanupClaim);
}
});
});
test("preserves newer cleanup ownership after an older reconnect reuses pending state", async () => {
await withNodePairingDir(async (baseDir) => {
await setupPairedNode(baseDir);
const pending = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
commands: ["system.run", "canvas.snapshot"],
},
baseDir,
);
const matchingReconnect = await beginNodePairingConnect("node-1", baseDir);
const changedReconnect = await beginNodePairingConnect("node-1", baseDir);
expect(matchingReconnect.cleanupClaim).toBeDefined();
expect(changedReconnect.cleanupClaim).toBeDefined();
await reusePendingNodePairingForReconnect(
{
nodeId: "node-1",
platform: "darwin",
commands: ["system.run", "canvas.snapshot"],
},
matchingReconnect.cleanupClaim,
baseDir,
);
await expect(
finalizeNodePairingCleanupClaim(changedReconnect.cleanupClaim!),
).resolves.toEqual([{ requestId: pending.request.requestId, nodeId: "node-1" }]);
expect((await listNodePairing(baseDir)).pending).toEqual([]);
});
});
test("preserves a replacement pending request created after the connect snapshot", async () => {
await withNodePairingDir(async (baseDir) => {
await setupPairedNode(baseDir);
const pending = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
commands: ["system.run", "canvas.snapshot"],
},
baseDir,
);
const snapshot = await beginNodePairingConnect("node-1", baseDir);
expect(snapshot.cleanupClaim).toBeDefined();
const replacement = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
commands: ["system.run", "canvas.present"],
},
baseDir,
);
expect(replacement.request.requestId).not.toBe(pending.request.requestId);
await expect(finalizeNodePairingCleanupClaim(snapshot.cleanupClaim!)).resolves.toEqual([]);
const remaining = (await listNodePairing(baseDir)).pending;
expect(remaining).toHaveLength(1);
expect(remaining[0]?.requestId).toBe(replacement.request.requestId);
});
});
test("blocks approval until a reconnect cleanup claim is released", async () => {
await withNodePairingDir(async (baseDir) => {
await setupPairedNode(baseDir);
const pending = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
commands: ["system.run", "canvas.snapshot"],
},
baseDir,
);
const firstSnapshot = await beginNodePairingConnect("node-1", baseDir);
const secondSnapshot = await beginNodePairingConnect("node-1", baseDir);
expect(firstSnapshot.cleanupClaim).toBeDefined();
expect(secondSnapshot.cleanupClaim?.generation).toBeGreaterThan(
firstSnapshot.cleanupClaim!.generation,
);
await expect(
approveNodePairing(
pending.request.requestId,
{ callerScopes: ["operator.pairing", "operator.admin"] },
baseDir,
),
).resolves.toBeNull();
await releaseNodePairingCleanupClaim(firstSnapshot.cleanupClaim!);
await expect(
approveNodePairing(
pending.request.requestId,
{ callerScopes: ["operator.pairing", "operator.admin"] },
baseDir,
),
).resolves.toBeNull();
await releaseNodePairingCleanupClaim(secondSnapshot.cleanupClaim!);
await expect(
approveNodePairing(
pending.request.requestId,
{ callerScopes: ["operator.pairing", "operator.admin"] },
baseDir,
),
).resolves.toMatchObject({ requestId: pending.request.requestId });
});
});
test("requires the right scopes to approve node requests", async () => {
await withNodePairingDir(async (baseDir) => {
await seedNodeDevice(baseDir, "node-1");
const systemRunRequest = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
commands: ["system.run"],
},
baseDir,
);
await expect(
approveNodePairing(
systemRunRequest.request.requestId,
{ callerScopes: ["operator.pairing"] },
baseDir,
),
).resolves.toEqual({
status: "forbidden",
missingScope: "operator.admin",
});
await expect(findPairedNode("node-1", baseDir)).resolves.toBeNull();
await seedNodeDevice(baseDir, "node-2");
const commandlessRequest = await requestNodePairing(
{
nodeId: "node-2",
platform: "darwin",
},
baseDir,
);
await expect(
approveNodePairing(commandlessRequest.request.requestId, { callerScopes: [] }, baseDir),
).resolves.toEqual({
status: "forbidden",
missingScope: "operator.pairing",
});
const approved = await approveNodePairing(
commandlessRequest.request.requestId,
{ callerScopes: ["operator.pairing"] },
baseDir,
);
const approvedRecord = requireRecord(approved);
const approvedNode = requireRecord(approvedRecord.node);
expect(approvedRecord.requestId).toBe(commandlessRequest.request.requestId);
expect(approvedNode.nodeId).toBe("node-2");
expect(approvedNode.commands).toBeUndefined();
});
});
test("updates node runtime metadata and reports missing nodes", async () => {
await withNodePairingDir(async (baseDir) => {
await setupPairedNode(baseDir);
await expect(
updatePairedNodeMetadata("node-1", { lastConnectedAtMs: 1234, bins: ["ffmpeg"] }, baseDir),
).resolves.toBe(true);
await expect(
updatePairedNodeMetadata("missing", { lastConnectedAtMs: 1 }, baseDir),
).resolves.toBe(false);
const pairedNode = await findPairedNode("node-1", baseDir);
expect(pairedNode?.lastConnectedAtMs).toBe(1234);
expect(pairedNode?.bins).toEqual(["ffmpeg"]);
});
});
test("keeps the approved node surface across a device pairing re-approval", async () => {
await withNodePairingDir(async (baseDir) => {
await setupPairedNode(baseDir);
const pendingSurface = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
commands: ["system.run", "canvas.snapshot"],
},
baseDir,
);
// A device repair (same id, fresh keypair) rebuilds the paired record;
// approved and pending node surfaces must survive that rebuild.
const repair = await requestDevicePairing(
{
deviceId: "node-1",
publicKey: "pk-node-1-rotated",
role: "node",
roles: ["node"],
scopes: [],
},
baseDir,
);
await approveDevicePairing(repair.request.requestId, { callerScopes: [] }, baseDir);
const paired = await findPairedNode("node-1", baseDir);
expect(paired?.commands).toEqual(["system.run"]);
const pending = (await listNodePairing(baseDir)).pending;
expect(pending).toHaveLength(1);
expect(pending[0]?.requestId).toBe(pendingSurface.request.requestId);
});
});
test("renames the operator-facing node name without touching approval state", async () => {
await withNodePairingDir(async (baseDir) => {
await setupPairedNode(baseDir);
const renamed = await renamePairedNode("node-1", "Living Room iPad", baseDir);
expect(renamed?.displayName).toBe("Living Room iPad");
await expect(renamePairedNode("missing", "Nope", baseDir)).resolves.toBeNull();
const pairedNode = await findPairedNode("node-1", baseDir);
expect(pairedNode?.displayName).toBe("Living Room iPad");
expect(pairedNode?.commands).toEqual(["system.run"]);
});
});
});