mirror of https://github.com/openclaw/openclaw.git
refactor(gateway): extract node pairing reconciliation
This commit is contained in:
parent
4590ac31cc
commit
db0cea5689
|
|
@ -19,7 +19,7 @@ export function renderPendingPairingRequestsTable(params: {
|
|||
IP: r.remoteIp ?? "",
|
||||
Requested:
|
||||
typeof r.ts === "number" ? formatTimeAgo(Math.max(0, now - r.ts)) : theme.muted("unknown"),
|
||||
Repair: r.isRepair ? theme.warn("yes") : "",
|
||||
Repair: r.repairReason ? theme.warn(r.repairReason) : r.isRepair ? theme.warn("yes") : "",
|
||||
}));
|
||||
return {
|
||||
heading: theme.heading("Pending"),
|
||||
|
|
@ -30,7 +30,7 @@ export function renderPendingPairingRequestsTable(params: {
|
|||
{ key: "Node", header: "Node", minWidth: 14, flex: true },
|
||||
{ key: "IP", header: "IP", minWidth: 10 },
|
||||
{ key: "Requested", header: "Requested", minWidth: 12 },
|
||||
{ key: "Repair", header: "Repair", minWidth: 6 },
|
||||
{ key: "Repair", header: "Repair", minWidth: 12 },
|
||||
],
|
||||
rows,
|
||||
}).trimEnd(),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { createKnownNodeCatalog, getKnownNode, listKnownNodes } from "./node-catalog.js";
|
||||
import {
|
||||
createKnownNodeCatalog,
|
||||
getKnownNode,
|
||||
getKnownNodeEntry,
|
||||
listKnownNodes,
|
||||
} from "./node-catalog.js";
|
||||
|
||||
describe("gateway/node-catalog", () => {
|
||||
it("filters paired nodes by active node token instead of sticky historical roles", () => {
|
||||
|
|
@ -43,6 +48,7 @@ describe("gateway/node-catalog", () => {
|
|||
approvedAtMs: 1,
|
||||
},
|
||||
],
|
||||
pairedNodes: [],
|
||||
connectedNodes: [],
|
||||
});
|
||||
|
||||
|
|
@ -74,6 +80,22 @@ describe("gateway/node-catalog", () => {
|
|||
approvedAtMs: 99,
|
||||
},
|
||||
],
|
||||
pairedNodes: [
|
||||
{
|
||||
nodeId: "mac-1",
|
||||
token: "node-token",
|
||||
displayName: "Mac",
|
||||
platform: "darwin",
|
||||
version: "1.2.0",
|
||||
coreVersion: "1.2.0",
|
||||
uiVersion: "1.2.0",
|
||||
remoteIp: "100.0.0.9",
|
||||
caps: ["camera"],
|
||||
commands: ["system.run"],
|
||||
createdAtMs: 1,
|
||||
approvedAtMs: 100,
|
||||
},
|
||||
],
|
||||
connectedNodes: [
|
||||
{
|
||||
nodeId: "mac-1",
|
||||
|
|
@ -84,8 +106,8 @@ describe("gateway/node-catalog", () => {
|
|||
displayName: "Mac",
|
||||
platform: "darwin",
|
||||
version: "1.2.3",
|
||||
caps: ["screen"],
|
||||
commands: ["screen.snapshot"],
|
||||
caps: ["camera", "screen"],
|
||||
commands: ["screen.snapshot", "system.run"],
|
||||
remoteIp: "100.0.0.11",
|
||||
pathEnv: "/usr/bin:/bin",
|
||||
connectedAtMs,
|
||||
|
|
@ -93,6 +115,14 @@ describe("gateway/node-catalog", () => {
|
|||
],
|
||||
});
|
||||
|
||||
const entry = getKnownNodeEntry(catalog, "mac-1");
|
||||
expect(entry?.nodePairing).toEqual(
|
||||
expect.objectContaining({
|
||||
commands: ["system.run"],
|
||||
caps: ["camera"],
|
||||
approvedAtMs: 100,
|
||||
}),
|
||||
);
|
||||
expect(getKnownNode(catalog, "mac-1")).toEqual(
|
||||
expect.objectContaining({
|
||||
nodeId: "mac-1",
|
||||
|
|
@ -100,14 +130,72 @@ describe("gateway/node-catalog", () => {
|
|||
clientId: "openclaw-macos",
|
||||
clientMode: "node",
|
||||
remoteIp: "100.0.0.11",
|
||||
caps: ["screen"],
|
||||
commands: ["screen.snapshot"],
|
||||
caps: ["camera", "screen"],
|
||||
commands: ["screen.snapshot", "system.run"],
|
||||
pathEnv: "/usr/bin:/bin",
|
||||
approvedAtMs: 99,
|
||||
approvedAtMs: 100,
|
||||
connectedAtMs,
|
||||
paired: true,
|
||||
connected: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces node-pair metadata even when the node is offline", () => {
|
||||
const catalog = createKnownNodeCatalog({
|
||||
pairedDevices: [
|
||||
{
|
||||
deviceId: "mac-1",
|
||||
publicKey: "public-key",
|
||||
displayName: "Mac",
|
||||
clientId: "openclaw-macos",
|
||||
clientMode: "node",
|
||||
role: "node",
|
||||
roles: ["node"],
|
||||
tokens: {
|
||||
node: {
|
||||
token: "current-token",
|
||||
role: "node",
|
||||
scopes: [],
|
||||
createdAtMs: 1,
|
||||
},
|
||||
},
|
||||
createdAtMs: 1,
|
||||
approvedAtMs: 99,
|
||||
},
|
||||
],
|
||||
pairedNodes: [
|
||||
{
|
||||
nodeId: "mac-1",
|
||||
token: "node-token",
|
||||
platform: "darwin",
|
||||
caps: ["system"],
|
||||
commands: ["system.run"],
|
||||
createdAtMs: 1,
|
||||
approvedAtMs: 123,
|
||||
},
|
||||
],
|
||||
connectedNodes: [],
|
||||
});
|
||||
|
||||
const entry = getKnownNodeEntry(catalog, "mac-1");
|
||||
expect(entry?.live).toBeUndefined();
|
||||
expect(entry?.nodePairing).toEqual(
|
||||
expect.objectContaining({
|
||||
commands: ["system.run"],
|
||||
caps: ["system"],
|
||||
approvedAtMs: 123,
|
||||
}),
|
||||
);
|
||||
expect(getKnownNode(catalog, "mac-1")).toEqual(
|
||||
expect.objectContaining({
|
||||
nodeId: "mac-1",
|
||||
caps: ["system"],
|
||||
commands: ["system.run"],
|
||||
approvedAtMs: 123,
|
||||
paired: true,
|
||||
connected: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,44 @@
|
|||
import { hasEffectivePairedDeviceRole, type PairedDevice } from "../infra/device-pairing.js";
|
||||
import type { NodePairingPairedNode } from "../infra/node-pairing.js";
|
||||
import type { NodeListNode } from "../shared/node-list-types.js";
|
||||
import type { NodeSession } from "./node-registry.js";
|
||||
|
||||
export type KnownNodeDevicePairingSource = {
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
clientId?: string;
|
||||
clientMode?: string;
|
||||
remoteIp?: string;
|
||||
approvedAtMs?: number;
|
||||
};
|
||||
|
||||
export type KnownNodeApprovedSource = {
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
coreVersion?: string;
|
||||
uiVersion?: string;
|
||||
remoteIp?: string;
|
||||
deviceFamily?: string;
|
||||
modelIdentifier?: string;
|
||||
caps: string[];
|
||||
commands: string[];
|
||||
permissions?: Record<string, boolean>;
|
||||
approvedAtMs?: number;
|
||||
};
|
||||
|
||||
export type KnownNodeEntry = {
|
||||
nodeId: string;
|
||||
devicePairing?: KnownNodeDevicePairingSource;
|
||||
nodePairing?: KnownNodeApprovedSource;
|
||||
live?: NodeSession;
|
||||
effective: NodeListNode;
|
||||
};
|
||||
|
||||
export type KnownNodeCatalog = {
|
||||
pairedById: Map<string, NodeListNode>;
|
||||
connectedById: Map<string, NodeSession>;
|
||||
entriesById: Map<string, KnownNodeEntry>;
|
||||
};
|
||||
|
||||
function uniqueSortedStrings(...items: Array<readonly string[] | undefined>): string[] {
|
||||
|
|
@ -23,53 +57,62 @@ function uniqueSortedStrings(...items: Array<readonly string[] | undefined>): st
|
|||
return [...values].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function buildPairedNodeRecord(entry: PairedDevice): NodeListNode {
|
||||
function buildDevicePairingSource(entry: PairedDevice): KnownNodeDevicePairingSource {
|
||||
return {
|
||||
nodeId: entry.deviceId,
|
||||
displayName: entry.displayName,
|
||||
platform: entry.platform,
|
||||
version: undefined,
|
||||
coreVersion: undefined,
|
||||
uiVersion: undefined,
|
||||
clientId: entry.clientId,
|
||||
clientMode: entry.clientMode,
|
||||
deviceFamily: undefined,
|
||||
modelIdentifier: undefined,
|
||||
remoteIp: entry.remoteIp,
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: undefined,
|
||||
approvedAtMs: entry.approvedAtMs,
|
||||
paired: true,
|
||||
connected: false,
|
||||
};
|
||||
}
|
||||
|
||||
function buildKnownNodeEntry(params: {
|
||||
function buildApprovedNodeSource(entry: NodePairingPairedNode): KnownNodeApprovedSource {
|
||||
return {
|
||||
nodeId: entry.nodeId,
|
||||
displayName: entry.displayName,
|
||||
platform: entry.platform,
|
||||
version: entry.version,
|
||||
coreVersion: entry.coreVersion,
|
||||
uiVersion: entry.uiVersion,
|
||||
remoteIp: entry.remoteIp,
|
||||
deviceFamily: entry.deviceFamily,
|
||||
modelIdentifier: entry.modelIdentifier,
|
||||
caps: entry.caps ?? [],
|
||||
commands: entry.commands ?? [],
|
||||
permissions: entry.permissions,
|
||||
approvedAtMs: entry.approvedAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEffectiveKnownNode(entry: {
|
||||
nodeId: string;
|
||||
paired?: NodeListNode;
|
||||
devicePairing?: KnownNodeDevicePairingSource;
|
||||
nodePairing?: KnownNodeApprovedSource;
|
||||
live?: NodeSession;
|
||||
}): NodeListNode {
|
||||
const { nodeId, paired, live } = params;
|
||||
const { nodeId, devicePairing, nodePairing, live } = entry;
|
||||
return {
|
||||
nodeId,
|
||||
displayName: live?.displayName ?? paired?.displayName,
|
||||
platform: live?.platform ?? paired?.platform,
|
||||
version: live?.version ?? paired?.version,
|
||||
coreVersion: live?.coreVersion ?? paired?.coreVersion,
|
||||
uiVersion: live?.uiVersion ?? paired?.uiVersion,
|
||||
clientId: live?.clientId ?? paired?.clientId,
|
||||
clientMode: live?.clientMode ?? paired?.clientMode,
|
||||
deviceFamily: live?.deviceFamily ?? paired?.deviceFamily,
|
||||
modelIdentifier: live?.modelIdentifier ?? paired?.modelIdentifier,
|
||||
remoteIp: live?.remoteIp ?? paired?.remoteIp,
|
||||
caps: uniqueSortedStrings(live?.caps, paired?.caps),
|
||||
commands: uniqueSortedStrings(live?.commands, paired?.commands),
|
||||
displayName: live?.displayName ?? nodePairing?.displayName ?? devicePairing?.displayName,
|
||||
platform: live?.platform ?? nodePairing?.platform ?? devicePairing?.platform,
|
||||
version: live?.version ?? nodePairing?.version,
|
||||
coreVersion: live?.coreVersion ?? nodePairing?.coreVersion,
|
||||
uiVersion: live?.uiVersion ?? nodePairing?.uiVersion,
|
||||
clientId: live?.clientId ?? devicePairing?.clientId,
|
||||
clientMode: live?.clientMode ?? devicePairing?.clientMode,
|
||||
deviceFamily: live?.deviceFamily ?? nodePairing?.deviceFamily,
|
||||
modelIdentifier: live?.modelIdentifier ?? nodePairing?.modelIdentifier,
|
||||
remoteIp: live?.remoteIp ?? nodePairing?.remoteIp ?? devicePairing?.remoteIp,
|
||||
caps: uniqueSortedStrings(live?.caps, nodePairing?.caps),
|
||||
commands: uniqueSortedStrings(live?.commands, nodePairing?.commands),
|
||||
pathEnv: live?.pathEnv,
|
||||
permissions: live?.permissions ?? paired?.permissions,
|
||||
permissions: live?.permissions ?? nodePairing?.permissions,
|
||||
connectedAtMs: live?.connectedAtMs,
|
||||
approvedAtMs: paired?.approvedAtMs,
|
||||
paired: Boolean(paired),
|
||||
approvedAtMs: nodePairing?.approvedAtMs ?? devicePairing?.approvedAtMs,
|
||||
paired: Boolean(devicePairing ?? nodePairing),
|
||||
connected: Boolean(live),
|
||||
};
|
||||
}
|
||||
|
|
@ -91,35 +134,57 @@ function compareKnownNodes(left: NodeListNode, right: NodeListNode): number {
|
|||
|
||||
export function createKnownNodeCatalog(params: {
|
||||
pairedDevices: readonly PairedDevice[];
|
||||
pairedNodes?: readonly NodePairingPairedNode[];
|
||||
connectedNodes: readonly NodeSession[];
|
||||
}): KnownNodeCatalog {
|
||||
const pairedById = new Map(
|
||||
const devicePairingById = new Map(
|
||||
params.pairedDevices
|
||||
.filter((entry) => hasEffectivePairedDeviceRole(entry, "node"))
|
||||
.map((entry) => [entry.deviceId, buildPairedNodeRecord(entry)]),
|
||||
.map((entry) => [entry.deviceId, buildDevicePairingSource(entry)]),
|
||||
);
|
||||
const connectedById = new Map(params.connectedNodes.map((entry) => [entry.nodeId, entry]));
|
||||
return { pairedById, connectedById };
|
||||
const nodePairingById = new Map(
|
||||
(params.pairedNodes ?? []).map((entry) => [entry.nodeId, buildApprovedNodeSource(entry)]),
|
||||
);
|
||||
const liveById = new Map(params.connectedNodes.map((entry) => [entry.nodeId, entry]));
|
||||
const nodeIds = new Set<string>([
|
||||
...devicePairingById.keys(),
|
||||
...nodePairingById.keys(),
|
||||
...liveById.keys(),
|
||||
]);
|
||||
const entriesById = new Map<string, KnownNodeEntry>();
|
||||
for (const nodeId of nodeIds) {
|
||||
const devicePairing = devicePairingById.get(nodeId);
|
||||
const nodePairing = nodePairingById.get(nodeId);
|
||||
const live = liveById.get(nodeId);
|
||||
entriesById.set(nodeId, {
|
||||
nodeId,
|
||||
devicePairing,
|
||||
nodePairing,
|
||||
live,
|
||||
effective: buildEffectiveKnownNode({
|
||||
nodeId,
|
||||
devicePairing,
|
||||
nodePairing,
|
||||
live,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return { entriesById };
|
||||
}
|
||||
|
||||
export function listKnownNodes(catalog: KnownNodeCatalog): NodeListNode[] {
|
||||
const nodeIds = new Set<string>([...catalog.pairedById.keys(), ...catalog.connectedById.keys()]);
|
||||
return [...nodeIds]
|
||||
.map((nodeId) =>
|
||||
buildKnownNodeEntry({
|
||||
nodeId,
|
||||
paired: catalog.pairedById.get(nodeId),
|
||||
live: catalog.connectedById.get(nodeId),
|
||||
}),
|
||||
)
|
||||
return [...catalog.entriesById.values()]
|
||||
.map((entry) => entry.effective)
|
||||
.toSorted(compareKnownNodes);
|
||||
}
|
||||
|
||||
export function getKnownNode(catalog: KnownNodeCatalog, nodeId: string): NodeListNode | null {
|
||||
const paired = catalog.pairedById.get(nodeId);
|
||||
const live = catalog.connectedById.get(nodeId);
|
||||
if (!paired && !live) {
|
||||
return null;
|
||||
}
|
||||
return buildKnownNodeEntry({ nodeId, paired, live });
|
||||
export function getKnownNodeEntry(
|
||||
catalog: KnownNodeCatalog,
|
||||
nodeId: string,
|
||||
): KnownNodeEntry | null {
|
||||
return catalog.entriesById.get(nodeId) ?? null;
|
||||
}
|
||||
|
||||
export function getKnownNode(catalog: KnownNodeCatalog, nodeId: string): NodeListNode | null {
|
||||
return getKnownNodeEntry(catalog, nodeId)?.effective ?? null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { diffApprovedNodeCommands, normalizeDeclaredNodeCommands } from "./node-command-policy.js";
|
||||
|
||||
describe("gateway/node-command-policy", () => {
|
||||
it("normalizes declared node commands against the allowlist", () => {
|
||||
const allowlist = new Set(["canvas.snapshot", "system.run"]);
|
||||
expect(
|
||||
normalizeDeclaredNodeCommands({
|
||||
declaredCommands: [" canvas.snapshot ", "", "system.run", "system.run", "screen.record"],
|
||||
allowlist,
|
||||
}),
|
||||
).toEqual(["canvas.snapshot", "system.run"]);
|
||||
});
|
||||
|
||||
it("reports command drift against the approved node command set", () => {
|
||||
const allowlist = new Set(["canvas.snapshot", "system.run", "system.which"]);
|
||||
expect(
|
||||
diffApprovedNodeCommands({
|
||||
declaredCommands: ["canvas.snapshot", "system.run"],
|
||||
approvedCommands: ["canvas.snapshot", "system.which"],
|
||||
allowlist,
|
||||
}),
|
||||
).toEqual({
|
||||
declared: ["canvas.snapshot", "system.run"],
|
||||
approved: ["canvas.snapshot", "system.which"],
|
||||
missingApproved: ["system.run"],
|
||||
extraApproved: ["system.which"],
|
||||
effective: ["canvas.snapshot"],
|
||||
needsRepair: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -191,6 +191,69 @@ export function resolveNodeCommandAllowlist(
|
|||
return allow;
|
||||
}
|
||||
|
||||
function normalizeDeclaredCommands(commands?: readonly string[]): string[] {
|
||||
if (!Array.isArray(commands)) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
for (const value of commands) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || seen.has(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(trimmed);
|
||||
normalized.push(trimmed);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeDeclaredNodeCommands(params: {
|
||||
declaredCommands?: readonly string[];
|
||||
allowlist: Set<string>;
|
||||
}): string[] {
|
||||
return normalizeDeclaredCommands(params.declaredCommands).filter((command) =>
|
||||
params.allowlist.has(command),
|
||||
);
|
||||
}
|
||||
|
||||
export type NodeApprovedCommandDiff = {
|
||||
declared: string[];
|
||||
approved: string[];
|
||||
missingApproved: string[];
|
||||
extraApproved: string[];
|
||||
effective: string[];
|
||||
needsRepair: boolean;
|
||||
};
|
||||
|
||||
export function diffApprovedNodeCommands(params: {
|
||||
declaredCommands?: readonly string[];
|
||||
approvedCommands?: readonly string[];
|
||||
allowlist: Set<string>;
|
||||
}): NodeApprovedCommandDiff {
|
||||
const declared = normalizeDeclaredNodeCommands({
|
||||
declaredCommands: params.declaredCommands,
|
||||
allowlist: params.allowlist,
|
||||
});
|
||||
const approved = normalizeDeclaredNodeCommands({
|
||||
declaredCommands: params.approvedCommands,
|
||||
allowlist: params.allowlist,
|
||||
});
|
||||
const approvedSet = new Set(approved);
|
||||
const declaredSet = new Set(declared);
|
||||
const missingApproved = declared.filter((command) => !approvedSet.has(command));
|
||||
const extraApproved = approved.filter((command) => !declaredSet.has(command));
|
||||
const effective = declared.filter((command) => approvedSet.has(command));
|
||||
return {
|
||||
declared,
|
||||
approved,
|
||||
missingApproved,
|
||||
extraApproved,
|
||||
effective,
|
||||
needsRepair: missingApproved.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function isNodeCommandAllowed(params: {
|
||||
command: string;
|
||||
declaredCommands?: string[];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type {
|
||||
NodePairingPairedNode,
|
||||
NodePairingPendingRequest,
|
||||
NodePairingRequestInput,
|
||||
} from "../infra/node-pairing.js";
|
||||
import {
|
||||
diffApprovedNodeCommands,
|
||||
resolveNodeCommandAllowlist,
|
||||
type NodeApprovedCommandDiff,
|
||||
} from "./node-command-policy.js";
|
||||
import type { ConnectParams } from "./protocol/index.js";
|
||||
|
||||
type PendingNodePairingResult = {
|
||||
status: "pending";
|
||||
request: NodePairingPendingRequest;
|
||||
created: boolean;
|
||||
};
|
||||
|
||||
export type NodeConnectPairingReconcileResult = {
|
||||
nodeId: string;
|
||||
commandDiff: NodeApprovedCommandDiff;
|
||||
effectiveCommands: string[];
|
||||
pendingPairing?: PendingNodePairingResult;
|
||||
};
|
||||
|
||||
function buildNodePairingRequestInput(params: {
|
||||
nodeId: string;
|
||||
connectParams: ConnectParams;
|
||||
commands: string[];
|
||||
remoteIp?: string;
|
||||
repairReason?: NodePairingRequestInput["repairReason"];
|
||||
}): NodePairingRequestInput {
|
||||
return {
|
||||
nodeId: params.nodeId,
|
||||
displayName: params.connectParams.client.displayName,
|
||||
platform: params.connectParams.client.platform,
|
||||
version: params.connectParams.client.version,
|
||||
deviceFamily: params.connectParams.client.deviceFamily,
|
||||
modelIdentifier: params.connectParams.client.modelIdentifier,
|
||||
caps: params.connectParams.caps,
|
||||
commands: params.commands,
|
||||
remoteIp: params.remoteIp,
|
||||
repairReason: params.repairReason,
|
||||
};
|
||||
}
|
||||
|
||||
export async function reconcileNodePairingOnConnect(params: {
|
||||
cfg: OpenClawConfig;
|
||||
connectParams: ConnectParams;
|
||||
pairedNode: NodePairingPairedNode | null;
|
||||
reportedClientIp?: string;
|
||||
requestPairing: (input: NodePairingRequestInput) => Promise<PendingNodePairingResult>;
|
||||
}): Promise<NodeConnectPairingReconcileResult> {
|
||||
const nodeId = params.connectParams.device?.id ?? params.connectParams.client.id;
|
||||
const allowlist = resolveNodeCommandAllowlist(params.cfg, {
|
||||
platform: params.connectParams.client.platform,
|
||||
deviceFamily: params.connectParams.client.deviceFamily,
|
||||
});
|
||||
const commandDiff = diffApprovedNodeCommands({
|
||||
declaredCommands: Array.isArray(params.connectParams.commands)
|
||||
? params.connectParams.commands
|
||||
: [],
|
||||
approvedCommands: params.pairedNode?.commands,
|
||||
allowlist,
|
||||
});
|
||||
|
||||
if (!params.pairedNode) {
|
||||
const pendingPairing = await params.requestPairing(
|
||||
buildNodePairingRequestInput({
|
||||
nodeId,
|
||||
connectParams: params.connectParams,
|
||||
commands: commandDiff.declared,
|
||||
remoteIp: params.reportedClientIp,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
nodeId,
|
||||
commandDiff,
|
||||
effectiveCommands: [],
|
||||
pendingPairing,
|
||||
};
|
||||
}
|
||||
|
||||
if (commandDiff.needsRepair) {
|
||||
const pendingPairing = await params.requestPairing(
|
||||
buildNodePairingRequestInput({
|
||||
nodeId,
|
||||
connectParams: params.connectParams,
|
||||
commands: commandDiff.declared,
|
||||
remoteIp: params.reportedClientIp,
|
||||
repairReason: "approved-command-drift",
|
||||
}),
|
||||
);
|
||||
return {
|
||||
nodeId,
|
||||
commandDiff,
|
||||
effectiveCommands: commandDiff.effective,
|
||||
pendingPairing,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
commandDiff,
|
||||
effectiveCommands: commandDiff.effective,
|
||||
};
|
||||
}
|
||||
|
|
@ -51,6 +51,7 @@ export const DevicePairRequestedEventSchema = Type.Object(
|
|||
remoteIp: Type.Optional(NonEmptyString),
|
||||
silent: Type.Optional(Type.Boolean()),
|
||||
isRepair: Type.Optional(Type.Boolean()),
|
||||
repairReason: Type.Optional(NonEmptyString),
|
||||
ts: Type.Integer({ minimum: 0 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
|
|
|
|||
|
|
@ -657,9 +657,13 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
|||
return;
|
||||
}
|
||||
await respondUnavailableOnThrow(respond, async () => {
|
||||
const list = await listDevicePairing();
|
||||
const [devicePairing, nodePairing] = await Promise.all([
|
||||
listDevicePairing(),
|
||||
listNodePairing(),
|
||||
]);
|
||||
const catalog = createKnownNodeCatalog({
|
||||
pairedDevices: list.paired,
|
||||
pairedDevices: devicePairing.paired,
|
||||
pairedNodes: nodePairing.paired,
|
||||
connectedNodes: context.nodeRegistry.listConnected(),
|
||||
});
|
||||
const nodes = listKnownNodes(catalog);
|
||||
|
|
@ -682,9 +686,13 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
|||
return;
|
||||
}
|
||||
await respondUnavailableOnThrow(respond, async () => {
|
||||
const list = await listDevicePairing();
|
||||
const [devicePairing, nodePairing] = await Promise.all([
|
||||
listDevicePairing(),
|
||||
listNodePairing(),
|
||||
]);
|
||||
const catalog = createKnownNodeCatalog({
|
||||
pairedDevices: list.paired,
|
||||
pairedDevices: devicePairing.paired,
|
||||
pairedNodes: nodePairing.paired,
|
||||
connectedNodes: context.nodeRegistry.listConnected(),
|
||||
});
|
||||
const node = getKnownNode(catalog, id);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import { approveDevicePairing, listDevicePairing } from "../infra/device-pairing.js";
|
||||
import { approveNodePairing, getPairedNode, requestNodePairing } from "../infra/node-pairing.js";
|
||||
import {
|
||||
approveNodePairing,
|
||||
getPairedNode,
|
||||
listNodePairing,
|
||||
requestNodePairing,
|
||||
} from "../infra/node-pairing.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
import {
|
||||
issueOperatorToken,
|
||||
loadDeviceIdentity,
|
||||
openTrackedWs,
|
||||
pairDeviceIdentity,
|
||||
} from "./device-authz.test-helpers.js";
|
||||
import { connectGatewayClient } from "./test-helpers.e2e.js";
|
||||
import {
|
||||
|
|
@ -18,39 +23,25 @@ import {
|
|||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
async function connectNodeClientWithPairing(params: {
|
||||
async function connectNodeClient(params: {
|
||||
port: number;
|
||||
deviceIdentity: ReturnType<typeof loadDeviceIdentity>["identity"];
|
||||
commands: string[];
|
||||
}) {
|
||||
const connect = async () =>
|
||||
await connectGatewayClient({
|
||||
url: `ws://127.0.0.1:${params.port}`,
|
||||
token: "secret",
|
||||
role: "node",
|
||||
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
|
||||
clientDisplayName: "node-command-pin",
|
||||
clientVersion: "1.0.0",
|
||||
platform: "darwin",
|
||||
mode: GATEWAY_CLIENT_MODES.NODE,
|
||||
commands: params.commands,
|
||||
deviceIdentity: params.deviceIdentity,
|
||||
timeoutMessage: "timeout waiting for paired node to connect",
|
||||
});
|
||||
|
||||
try {
|
||||
return await connect();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes("pairing required")) {
|
||||
throw error;
|
||||
}
|
||||
const pairing = await listDevicePairing();
|
||||
for (const pending of pairing.pending) {
|
||||
await approveDevicePairing(pending.requestId);
|
||||
}
|
||||
return await connect();
|
||||
}
|
||||
return await connectGatewayClient({
|
||||
url: `ws://127.0.0.1:${params.port}`,
|
||||
token: "secret",
|
||||
role: "node",
|
||||
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
|
||||
clientDisplayName: "node-command-pin",
|
||||
clientVersion: "1.0.0",
|
||||
platform: "darwin",
|
||||
mode: GATEWAY_CLIENT_MODES.NODE,
|
||||
scopes: [],
|
||||
commands: params.commands,
|
||||
deviceIdentity: params.deviceIdentity,
|
||||
timeoutMessage: "timeout waiting for paired node to connect",
|
||||
});
|
||||
}
|
||||
|
||||
describe("gateway node pairing authorization", () => {
|
||||
|
|
@ -138,7 +129,13 @@ describe("gateway node pairing authorization", () => {
|
|||
|
||||
test("pins connected node commands to the approved pairing record", async () => {
|
||||
const started = await startServerWithClient("secret");
|
||||
const pairedNode = loadDeviceIdentity("node-command-pin");
|
||||
const pairedNode = await pairDeviceIdentity({
|
||||
name: "node-command-pin",
|
||||
role: "node",
|
||||
scopes: [],
|
||||
clientId: GATEWAY_CLIENT_NAMES.NODE_HOST,
|
||||
clientMode: GATEWAY_CLIENT_MODES.NODE,
|
||||
});
|
||||
|
||||
let controlWs: WebSocket | undefined;
|
||||
let firstClient: Awaited<ReturnType<typeof connectGatewayClient>> | undefined;
|
||||
|
|
@ -147,7 +144,7 @@ describe("gateway node pairing authorization", () => {
|
|||
controlWs = await openTrackedWs(started.port);
|
||||
await connectOk(controlWs, { token: "secret" });
|
||||
|
||||
firstClient = await connectNodeClientWithPairing({
|
||||
firstClient = await connectNodeClient({
|
||||
port: started.port,
|
||||
deviceIdentity: pairedNode.identity,
|
||||
commands: ["canvas.snapshot"],
|
||||
|
|
@ -161,7 +158,7 @@ describe("gateway node pairing authorization", () => {
|
|||
});
|
||||
await approveNodePairing(request.request.requestId);
|
||||
|
||||
nodeClient = await connectNodeClientWithPairing({
|
||||
nodeClient = await connectNodeClient({
|
||||
port: started.port,
|
||||
deviceIdentity: pairedNode.identity,
|
||||
commands: ["canvas.snapshot", "system.run"],
|
||||
|
|
@ -209,31 +206,29 @@ describe("gateway node pairing authorization", () => {
|
|||
}
|
||||
});
|
||||
|
||||
test("treats paired nodes without stored commands as having no approved commands", async () => {
|
||||
test("requests repair pairing and restores approved commands after reconnect", async () => {
|
||||
const started = await startServerWithClient("secret");
|
||||
const pairedNode = loadDeviceIdentity("node-command-empty");
|
||||
const pairedNode = await pairDeviceIdentity({
|
||||
name: "node-command-empty",
|
||||
role: "node",
|
||||
scopes: [],
|
||||
clientId: GATEWAY_CLIENT_NAMES.NODE_HOST,
|
||||
clientMode: GATEWAY_CLIENT_MODES.NODE,
|
||||
});
|
||||
|
||||
let controlWs: WebSocket | undefined;
|
||||
let firstClient: Awaited<ReturnType<typeof connectGatewayClient>> | undefined;
|
||||
let nodeClient: Awaited<ReturnType<typeof connectGatewayClient>> | undefined;
|
||||
try {
|
||||
controlWs = await openTrackedWs(started.port);
|
||||
await connectOk(controlWs, { token: "secret" });
|
||||
|
||||
firstClient = await connectNodeClientWithPairing({
|
||||
port: started.port,
|
||||
deviceIdentity: pairedNode.identity,
|
||||
commands: ["canvas.snapshot"],
|
||||
});
|
||||
await firstClient.stopAndWait();
|
||||
|
||||
const request = await requestNodePairing({
|
||||
const initialApproval = await requestNodePairing({
|
||||
nodeId: pairedNode.identity.deviceId,
|
||||
platform: "darwin",
|
||||
});
|
||||
await approveNodePairing(request.request.requestId);
|
||||
await approveNodePairing(initialApproval.request.requestId);
|
||||
|
||||
nodeClient = await connectNodeClientWithPairing({
|
||||
nodeClient = await connectNodeClient({
|
||||
port: started.port,
|
||||
deviceIdentity: pairedNode.identity,
|
||||
commands: ["canvas.snapshot", "system.run"],
|
||||
|
|
@ -258,9 +253,66 @@ describe("gateway node pairing authorization", () => {
|
|||
(entry) => entry.nodeId === pairedNode.identity.deviceId && entry.connected,
|
||||
);
|
||||
expect(connectedNode?.commands ?? [], JSON.stringify(lastNodes)).toEqual([]);
|
||||
|
||||
const repairDeadline = Date.now() + 2_000;
|
||||
let repairRequestId = "";
|
||||
while (Date.now() < repairDeadline) {
|
||||
const pairing = await listNodePairing();
|
||||
const repair = pairing.pending.find(
|
||||
(entry) => entry.nodeId === pairedNode.identity.deviceId,
|
||||
);
|
||||
if (repair) {
|
||||
repairRequestId = repair.requestId;
|
||||
expect(repair.isRepair).toBe(true);
|
||||
expect(repair.repairReason).toBe("approved-command-drift");
|
||||
expect(repair.commands).toEqual(["canvas.snapshot", "system.run"]);
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
expect(repairRequestId).toBeTruthy();
|
||||
|
||||
await approveNodePairing(repairRequestId);
|
||||
await nodeClient.stopAndWait();
|
||||
nodeClient = await connectNodeClient({
|
||||
port: started.port,
|
||||
deviceIdentity: pairedNode.identity,
|
||||
commands: ["canvas.snapshot", "system.run"],
|
||||
});
|
||||
|
||||
const restoredDeadline = Date.now() + 2_000;
|
||||
while (Date.now() < restoredDeadline) {
|
||||
const list = await rpcReq<{
|
||||
nodes?: Array<{ nodeId: string; connected?: boolean; commands?: string[] }>;
|
||||
}>(controlWs, "node.list", {});
|
||||
lastNodes = list.payload?.nodes ?? [];
|
||||
const node = lastNodes.find(
|
||||
(entry) => entry.nodeId === pairedNode.identity.deviceId && entry.connected,
|
||||
);
|
||||
if (
|
||||
JSON.stringify(node?.commands?.toSorted() ?? []) ===
|
||||
JSON.stringify(["canvas.snapshot", "system.run"])
|
||||
) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
|
||||
const repairedNode = lastNodes.find(
|
||||
(entry) => entry.nodeId === pairedNode.identity.deviceId && entry.connected,
|
||||
);
|
||||
expect(repairedNode?.commands?.toSorted(), JSON.stringify(lastNodes)).toEqual([
|
||||
"canvas.snapshot",
|
||||
"system.run",
|
||||
]);
|
||||
|
||||
await expect(getPairedNode(pairedNode.identity.deviceId)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
commands: ["canvas.snapshot", "system.run"],
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
controlWs?.close();
|
||||
await firstClient?.stopAndWait();
|
||||
await nodeClient?.stopAndWait();
|
||||
started.ws.close();
|
||||
await started.server.close();
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ import {
|
|||
isTrustedProxyAddress,
|
||||
resolveClientIp,
|
||||
} from "../../net.js";
|
||||
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
|
||||
import { reconcileNodePairingOnConnect } from "../../node-connect-reconcile.js";
|
||||
import { checkBrowserOrigin } from "../../origin-check.js";
|
||||
import {
|
||||
ConnectErrorDetailCodes,
|
||||
|
|
@ -992,40 +992,20 @@ export function attachGatewayWsMessageHandler(params: {
|
|||
: null;
|
||||
|
||||
if (role === "node") {
|
||||
const cfg = loadConfig();
|
||||
const nodeId = connectParams.device?.id ?? connectParams.client.id;
|
||||
const declared = Array.isArray(connectParams.commands) ? connectParams.commands : [];
|
||||
const allowlist = resolveNodeCommandAllowlist(cfg, {
|
||||
platform: connectParams.client.platform,
|
||||
deviceFamily: connectParams.client.deviceFamily,
|
||||
const reconciliation = await reconcileNodePairingOnConnect({
|
||||
cfg: loadConfig(),
|
||||
connectParams,
|
||||
pairedNode: await getPairedNode(connectParams.device?.id ?? connectParams.client.id),
|
||||
reportedClientIp,
|
||||
requestPairing: async (input) => await requestNodePairing(input),
|
||||
});
|
||||
const allowlistedDeclared = declared
|
||||
.map((cmd) => cmd.trim())
|
||||
.filter((cmd) => cmd.length > 0 && allowlist.has(cmd));
|
||||
let pairedNode = await getPairedNode(nodeId);
|
||||
if (!pairedNode) {
|
||||
const pending = await requestNodePairing({
|
||||
nodeId,
|
||||
displayName: connectParams.client.displayName,
|
||||
platform: connectParams.client.platform,
|
||||
version: connectParams.client.version,
|
||||
deviceFamily: connectParams.client.deviceFamily,
|
||||
modelIdentifier: connectParams.client.modelIdentifier,
|
||||
caps: connectParams.caps,
|
||||
commands: allowlistedDeclared,
|
||||
remoteIp: reportedClientIp,
|
||||
if (reconciliation.pendingPairing?.created) {
|
||||
const requestContext = buildRequestContext();
|
||||
requestContext.broadcast("node.pair.requested", reconciliation.pendingPairing.request, {
|
||||
dropIfSlow: true,
|
||||
});
|
||||
if (pending.status === "pending" && pending.created) {
|
||||
const requestContext = buildRequestContext();
|
||||
requestContext.broadcast("node.pair.requested", pending.request, {
|
||||
dropIfSlow: true,
|
||||
});
|
||||
}
|
||||
pairedNode = await getPairedNode(nodeId);
|
||||
}
|
||||
const pairedCommands = new Set(pairedNode?.commands ?? []);
|
||||
const filtered = allowlistedDeclared.filter((cmd) => pairedCommands.has(cmd));
|
||||
connectParams.commands = filtered;
|
||||
connectParams.commands = reconciliation.effectiveCommands;
|
||||
}
|
||||
|
||||
const shouldTrackPresence = !isGatewayCliClient(connectParams.client);
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export async function getFreeGatewayPort(): Promise<number> {
|
|||
export async function connectGatewayClient(params: {
|
||||
url: string;
|
||||
token?: string;
|
||||
deviceToken?: string;
|
||||
clientName?: GatewayClientName;
|
||||
clientDisplayName?: string;
|
||||
clientVersion?: string;
|
||||
|
|
@ -48,6 +49,7 @@ export async function connectGatewayClient(params: {
|
|||
timeoutMessage?: string;
|
||||
}) {
|
||||
const role = params.role ?? "operator";
|
||||
const scopes = params.scopes ?? (role === "node" ? [] : undefined);
|
||||
const platform = params.platform ?? process.platform;
|
||||
const identityRoot = process.env.OPENCLAW_STATE_DIR ?? process.env.HOME ?? os.tmpdir();
|
||||
const deviceIdentity =
|
||||
|
|
@ -78,6 +80,7 @@ export async function connectGatewayClient(params: {
|
|||
const client = new GatewayClient({
|
||||
url: params.url,
|
||||
token: params.token,
|
||||
deviceToken: params.deviceToken,
|
||||
connectChallengeTimeoutMs: params.connectChallengeTimeoutMs ?? 0,
|
||||
clientName: params.clientName ?? GATEWAY_CLIENT_NAMES.TEST,
|
||||
clientDisplayName: params.clientDisplayName ?? "vitest",
|
||||
|
|
@ -86,7 +89,7 @@ export async function connectGatewayClient(params: {
|
|||
deviceFamily: params.deviceFamily,
|
||||
mode: params.mode ?? GATEWAY_CLIENT_MODES.TEST,
|
||||
role,
|
||||
scopes: params.scopes,
|
||||
scopes,
|
||||
caps: params.caps,
|
||||
commands: params.commands,
|
||||
instanceId: params.instanceId,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
createAsyncLock,
|
||||
pruneExpiredPending,
|
||||
readJsonFile,
|
||||
reconcilePendingPairingRequests,
|
||||
resolvePairingPaths,
|
||||
writeJsonAtomic,
|
||||
} from "./pairing-files.js";
|
||||
|
|
@ -419,57 +420,43 @@ export async function requestDevicePairing(
|
|||
const pendingForDevice = Object.values(state.pendingById)
|
||||
.filter((pending) => pending.deviceId === deviceId)
|
||||
.toSorted((left, right) => right.ts - left.ts);
|
||||
const latestPending = pendingForDevice[0];
|
||||
if (latestPending && pendingForDevice.length === 1) {
|
||||
if (samePendingApprovalSnapshot(latestPending, req)) {
|
||||
const refreshed = refreshPendingDevicePairingRequest(latestPending, req, isRepair);
|
||||
state.pendingById[latestPending.requestId] = refreshed;
|
||||
await persistState(state, baseDir);
|
||||
return { status: "pending" as const, request: refreshed, created: false };
|
||||
}
|
||||
}
|
||||
if (pendingForDevice.length > 0) {
|
||||
const mergedRoles = mergeRoles(
|
||||
...pendingForDevice.flatMap((pending) => [pending.roles, pending.role]),
|
||||
req.roles,
|
||||
req.role,
|
||||
);
|
||||
const mergedScopes = mergeScopes(
|
||||
...pendingForDevice.map((pending) => pending.scopes),
|
||||
req.scopes,
|
||||
);
|
||||
for (const pending of pendingForDevice) {
|
||||
delete state.pendingById[pending.requestId];
|
||||
}
|
||||
const superseded = buildPendingDevicePairingRequest({
|
||||
deviceId,
|
||||
isRepair,
|
||||
req: {
|
||||
...req,
|
||||
role: normalizeRole(req.role) ?? latestPending?.role,
|
||||
roles: mergedRoles,
|
||||
scopes: mergedScopes,
|
||||
// Preserve interactive visibility when superseding pending requests:
|
||||
// if any previous pending request was interactive, keep this one interactive.
|
||||
silent: resolveSupersededPendingSilent({
|
||||
existing: pendingForDevice,
|
||||
incomingSilent: req.silent,
|
||||
}),
|
||||
},
|
||||
});
|
||||
state.pendingById[superseded.requestId] = superseded;
|
||||
await persistState(state, baseDir);
|
||||
return { status: "pending" as const, request: superseded, created: true };
|
||||
}
|
||||
|
||||
const request = buildPendingDevicePairingRequest({
|
||||
deviceId,
|
||||
isRepair,
|
||||
req,
|
||||
return await reconcilePendingPairingRequests({
|
||||
pendingById: state.pendingById,
|
||||
existing: pendingForDevice,
|
||||
incoming: req,
|
||||
canRefreshSingle: (existing, incoming) => samePendingApprovalSnapshot(existing, incoming),
|
||||
refreshSingle: (existing, incoming) =>
|
||||
refreshPendingDevicePairingRequest(existing, incoming, isRepair),
|
||||
buildReplacement: ({ existing, incoming }) => {
|
||||
const latestPending = existing[0];
|
||||
const mergedRoles = mergeRoles(
|
||||
...existing.flatMap((pending) => [pending.roles, pending.role]),
|
||||
incoming.roles,
|
||||
incoming.role,
|
||||
);
|
||||
const mergedScopes = mergeScopes(
|
||||
...existing.map((pending) => pending.scopes),
|
||||
incoming.scopes,
|
||||
);
|
||||
return buildPendingDevicePairingRequest({
|
||||
deviceId,
|
||||
isRepair,
|
||||
req: {
|
||||
...incoming,
|
||||
role: normalizeRole(incoming.role) ?? latestPending?.role,
|
||||
roles: mergedRoles,
|
||||
scopes: mergedScopes,
|
||||
// Preserve interactive visibility when superseding pending requests:
|
||||
// if any previous pending request was interactive, keep this one interactive.
|
||||
silent: resolveSupersededPendingSilent({
|
||||
existing,
|
||||
incomingSilent: incoming.silent,
|
||||
}),
|
||||
},
|
||||
});
|
||||
},
|
||||
persist: async () => await persistState(state, baseDir),
|
||||
});
|
||||
state.pendingById[request.requestId] = request;
|
||||
await persistState(state, baseDir);
|
||||
return { status: "pending" as const, request, created: true };
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,47 @@ describe("node pairing tokens", () => {
|
|||
expect(second.request.requestId).toBe(first.request.requestId);
|
||||
});
|
||||
|
||||
test("refreshes pending requests with newer commands and repair metadata", async () => {
|
||||
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-node-pairing-"));
|
||||
const first = await requestNodePairing(
|
||||
{
|
||||
nodeId: "node-1",
|
||||
platform: "darwin",
|
||||
commands: ["canvas.snapshot"],
|
||||
},
|
||||
baseDir,
|
||||
);
|
||||
await approveNodePairing(first.request.requestId, baseDir);
|
||||
|
||||
const second = await requestNodePairing(
|
||||
{
|
||||
nodeId: "node-1",
|
||||
platform: "darwin",
|
||||
displayName: "Updated Node",
|
||||
commands: ["canvas.snapshot", "system.run"],
|
||||
},
|
||||
baseDir,
|
||||
);
|
||||
const third = await requestNodePairing(
|
||||
{
|
||||
nodeId: "node-1",
|
||||
platform: "darwin",
|
||||
displayName: "Updated Node",
|
||||
commands: ["canvas.snapshot", "system.run", "system.which"],
|
||||
},
|
||||
baseDir,
|
||||
);
|
||||
|
||||
expect(second.created).toBe(true);
|
||||
expect(second.request.isRepair).toBe(true);
|
||||
expect(second.request.repairReason).toBe("paired-node-refresh");
|
||||
expect(third.created).toBe(false);
|
||||
expect(third.request.requestId).toBe(second.request.requestId);
|
||||
expect(third.request.displayName).toBe("Updated Node");
|
||||
expect(third.request.commands).toEqual(["canvas.snapshot", "system.run", "system.which"]);
|
||||
expect(third.request.repairReason).toBe("paired-node-refresh");
|
||||
});
|
||||
|
||||
test("generates base64url node tokens with 256-bit entropy output length", async () => {
|
||||
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-node-pairing-"));
|
||||
const token = await setupPairedNode(baseDir);
|
||||
|
|
|
|||
|
|
@ -5,14 +5,14 @@ import {
|
|||
createAsyncLock,
|
||||
pruneExpiredPending,
|
||||
readJsonFile,
|
||||
reconcilePendingPairingRequests,
|
||||
resolvePairingPaths,
|
||||
upsertPendingPairingRequest,
|
||||
writeJsonAtomic,
|
||||
} from "./pairing-files.js";
|
||||
import { rejectPendingPairingRequest } from "./pairing-pending.js";
|
||||
import { generatePairingToken, verifyPairingToken } from "./pairing-token.js";
|
||||
|
||||
type NodePairingNodeMetadata = {
|
||||
export type NodeDeclaredSurface = {
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
|
|
@ -27,14 +27,24 @@ type NodePairingNodeMetadata = {
|
|||
remoteIp?: string;
|
||||
};
|
||||
|
||||
export type NodePairingPendingRequest = NodePairingNodeMetadata & {
|
||||
export type NodeApprovedSurface = NodeDeclaredSurface;
|
||||
|
||||
export type NodePairingRepairReason = "approved-command-drift" | "paired-node-refresh";
|
||||
|
||||
export type NodePairingRequestInput = NodeDeclaredSurface & {
|
||||
silent?: boolean;
|
||||
repairReason?: NodePairingRepairReason;
|
||||
};
|
||||
|
||||
export type NodePairingPendingRequest = NodePairingRequestInput & {
|
||||
requestId: string;
|
||||
silent?: boolean;
|
||||
isRepair?: boolean;
|
||||
repairReason?: NodePairingRepairReason;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
export type NodePairingPairedNode = Omit<NodePairingNodeMetadata, "requestId"> & {
|
||||
export type NodePairingPairedNode = NodeApprovedSurface & {
|
||||
token: string;
|
||||
bins?: string[];
|
||||
createdAtMs: number;
|
||||
|
|
@ -59,6 +69,79 @@ const OPERATOR_ADMIN_SCOPE = "operator.admin";
|
|||
|
||||
const withLock = createAsyncLock();
|
||||
|
||||
function normalizeStringList(values?: string[]): string[] | undefined {
|
||||
if (!Array.isArray(values)) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = values.map((value) => value.trim()).filter(Boolean);
|
||||
return normalized.length > 0 ? normalized : [];
|
||||
}
|
||||
|
||||
function resolveNodePairingRepairReason(params: {
|
||||
existingPairedNode: boolean;
|
||||
requestedRepairReason?: NodePairingRepairReason;
|
||||
}): NodePairingRepairReason | undefined {
|
||||
if (params.requestedRepairReason) {
|
||||
return params.requestedRepairReason;
|
||||
}
|
||||
if (params.existingPairedNode) {
|
||||
return "paired-node-refresh";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildPendingNodePairingRequest(params: {
|
||||
requestId?: string;
|
||||
req: NodePairingRequestInput;
|
||||
}): NodePairingPendingRequest {
|
||||
const repairReason = params.req.repairReason;
|
||||
return {
|
||||
requestId: params.requestId ?? randomUUID(),
|
||||
nodeId: params.req.nodeId,
|
||||
displayName: params.req.displayName,
|
||||
platform: params.req.platform,
|
||||
version: params.req.version,
|
||||
coreVersion: params.req.coreVersion,
|
||||
uiVersion: params.req.uiVersion,
|
||||
deviceFamily: params.req.deviceFamily,
|
||||
modelIdentifier: params.req.modelIdentifier,
|
||||
caps: normalizeStringList(params.req.caps),
|
||||
commands: normalizeStringList(params.req.commands),
|
||||
permissions: params.req.permissions,
|
||||
remoteIp: params.req.remoteIp,
|
||||
silent: params.req.silent,
|
||||
repairReason,
|
||||
isRepair: Boolean(repairReason),
|
||||
ts: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function refreshPendingNodePairingRequest(
|
||||
existing: NodePairingPendingRequest,
|
||||
incoming: NodePairingRequestInput,
|
||||
): NodePairingPendingRequest {
|
||||
const repairReason = incoming.repairReason ?? existing.repairReason;
|
||||
return {
|
||||
...existing,
|
||||
displayName: incoming.displayName ?? existing.displayName,
|
||||
platform: incoming.platform ?? existing.platform,
|
||||
version: incoming.version ?? existing.version,
|
||||
coreVersion: incoming.coreVersion ?? existing.coreVersion,
|
||||
uiVersion: incoming.uiVersion ?? existing.uiVersion,
|
||||
deviceFamily: incoming.deviceFamily ?? existing.deviceFamily,
|
||||
modelIdentifier: incoming.modelIdentifier ?? existing.modelIdentifier,
|
||||
caps: normalizeStringList(incoming.caps) ?? existing.caps,
|
||||
commands: normalizeStringList(incoming.commands) ?? existing.commands,
|
||||
permissions: incoming.permissions ?? existing.permissions,
|
||||
remoteIp: incoming.remoteIp ?? existing.remoteIp,
|
||||
// Preserve interactive visibility if either request needs attention.
|
||||
silent: Boolean(existing.silent && incoming.silent),
|
||||
repairReason,
|
||||
isRepair: Boolean(repairReason),
|
||||
ts: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveNodeApprovalRequiredScope(pending: NodePairingPendingRequest): string | null {
|
||||
const commands = Array.isArray(pending.commands) ? pending.commands : [];
|
||||
if (commands.some((command) => NODE_SYSTEM_RUN_COMMANDS.some((allowed) => allowed === command))) {
|
||||
|
|
@ -122,7 +205,7 @@ export async function getPairedNode(
|
|||
}
|
||||
|
||||
export async function requestNodePairing(
|
||||
req: Omit<NodePairingPendingRequest, "requestId" | "ts" | "isRepair">,
|
||||
req: NodePairingRequestInput,
|
||||
baseDir?: string,
|
||||
): Promise<{
|
||||
status: "pending";
|
||||
|
|
@ -135,29 +218,32 @@ export async function requestNodePairing(
|
|||
if (!nodeId) {
|
||||
throw new Error("nodeId required");
|
||||
}
|
||||
|
||||
return await upsertPendingPairingRequest({
|
||||
const repairReason = resolveNodePairingRepairReason({
|
||||
existingPairedNode: Boolean(state.pairedByNodeId[nodeId]),
|
||||
requestedRepairReason: req.repairReason,
|
||||
});
|
||||
const pendingForNode = Object.values(state.pendingById)
|
||||
.filter((pending) => pending.nodeId === nodeId)
|
||||
.toSorted((left, right) => right.ts - left.ts);
|
||||
return await reconcilePendingPairingRequests({
|
||||
pendingById: state.pendingById,
|
||||
isExisting: (pending) => pending.nodeId === nodeId,
|
||||
isRepair: Boolean(state.pairedByNodeId[nodeId]),
|
||||
createRequest: (isRepair) => ({
|
||||
requestId: randomUUID(),
|
||||
existing: pendingForNode,
|
||||
incoming: {
|
||||
...req,
|
||||
nodeId,
|
||||
displayName: req.displayName,
|
||||
platform: req.platform,
|
||||
version: req.version,
|
||||
coreVersion: req.coreVersion,
|
||||
uiVersion: req.uiVersion,
|
||||
deviceFamily: req.deviceFamily,
|
||||
modelIdentifier: req.modelIdentifier,
|
||||
caps: req.caps,
|
||||
commands: req.commands,
|
||||
permissions: req.permissions,
|
||||
remoteIp: req.remoteIp,
|
||||
silent: req.silent,
|
||||
isRepair,
|
||||
ts: Date.now(),
|
||||
}),
|
||||
repairReason,
|
||||
},
|
||||
canRefreshSingle: () => true,
|
||||
refreshSingle: (existing, incoming) => refreshPendingNodePairingRequest(existing, incoming),
|
||||
buildReplacement: ({ existing, incoming }) =>
|
||||
buildPendingNodePairingRequest({
|
||||
req: {
|
||||
...incoming,
|
||||
silent: Boolean(
|
||||
incoming.silent && existing.every((pending) => pending.silent === true),
|
||||
),
|
||||
},
|
||||
}),
|
||||
persist: async () => await persistState(state, baseDir),
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import path from "node:path";
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
pruneExpiredPending,
|
||||
reconcilePendingPairingRequests,
|
||||
resolvePairingPaths,
|
||||
upsertPendingPairingRequest,
|
||||
} from "./pairing-files.js";
|
||||
|
||||
describe("pairing file helpers", () => {
|
||||
|
|
@ -30,57 +30,59 @@ describe("pairing file helpers", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("reuses existing pending requests without persisting again", async () => {
|
||||
it("refreshes a single matching pending request in place", async () => {
|
||||
const persist = vi.fn(async () => undefined);
|
||||
const existing = { requestId: "req-1", deviceId: "device-1", ts: 1 };
|
||||
const existing = { requestId: "req-1", deviceId: "device-1", ts: 1, version: 1 };
|
||||
const pendingById = { "req-1": existing };
|
||||
|
||||
await expect(
|
||||
upsertPendingPairingRequest({
|
||||
reconcilePendingPairingRequests({
|
||||
pendingById,
|
||||
isExisting: (pending) => pending.deviceId === "device-1",
|
||||
createRequest: vi.fn(() => ({ requestId: "req-2", deviceId: "device-1", ts: 2 })),
|
||||
isRepair: false,
|
||||
existing: [existing],
|
||||
incoming: { version: 2 },
|
||||
canRefreshSingle: () => true,
|
||||
refreshSingle: (pending, incoming) => ({ ...pending, version: incoming.version, ts: 2 }),
|
||||
buildReplacement: vi.fn(() => ({ requestId: "req-2", deviceId: "device-1", ts: 2 })),
|
||||
persist,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
status: "pending",
|
||||
request: existing,
|
||||
request: { requestId: "req-1", deviceId: "device-1", ts: 2, version: 2 },
|
||||
created: false,
|
||||
});
|
||||
expect(persist).not.toHaveBeenCalled();
|
||||
expect(persist).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("creates and persists new pending requests with the repair flag", async () => {
|
||||
it("replaces existing pending requests with one merged request", async () => {
|
||||
const persist = vi.fn(async () => undefined);
|
||||
const createRequest = vi.fn((isRepair: boolean) => ({
|
||||
requestId: "req-2",
|
||||
deviceId: "device-2",
|
||||
ts: 2,
|
||||
isRepair,
|
||||
}));
|
||||
const pendingById: Record<
|
||||
string,
|
||||
{ requestId: string; deviceId: string; ts: number; isRepair: boolean }
|
||||
> = {};
|
||||
const pendingById = {
|
||||
"req-1": { requestId: "req-1", deviceId: "device-2", ts: 1 },
|
||||
"req-2": { requestId: "req-2", deviceId: "device-2", ts: 2 },
|
||||
};
|
||||
|
||||
await expect(
|
||||
upsertPendingPairingRequest({
|
||||
reconcilePendingPairingRequests({
|
||||
pendingById,
|
||||
isExisting: (pending) => pending.deviceId === "device-2",
|
||||
createRequest,
|
||||
isRepair: true,
|
||||
existing: Object.values(pendingById).toSorted((left, right) => right.ts - left.ts),
|
||||
incoming: { deviceId: "device-2" },
|
||||
canRefreshSingle: () => false,
|
||||
refreshSingle: (pending) => pending,
|
||||
buildReplacement: vi.fn(() => ({
|
||||
requestId: "req-3",
|
||||
deviceId: "device-2",
|
||||
ts: 3,
|
||||
isRepair: true,
|
||||
})),
|
||||
persist,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
status: "pending",
|
||||
request: { requestId: "req-2", deviceId: "device-2", ts: 2, isRepair: true },
|
||||
request: { requestId: "req-3", deviceId: "device-2", ts: 3, isRepair: true },
|
||||
created: true,
|
||||
});
|
||||
expect(createRequest).toHaveBeenCalledWith(true);
|
||||
expect(persist).toHaveBeenCalledOnce();
|
||||
expect(pendingById).toEqual({
|
||||
"req-2": { requestId: "req-2", deviceId: "device-2", ts: 2, isRepair: true },
|
||||
"req-3": { requestId: "req-3", deviceId: "device-2", ts: 3, isRepair: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,19 +31,36 @@ export type PendingPairingRequestResult<TPending> = {
|
|||
created: boolean;
|
||||
};
|
||||
|
||||
export async function upsertPendingPairingRequest<TPending extends { requestId: string }>(params: {
|
||||
export async function reconcilePendingPairingRequests<
|
||||
TPending extends { requestId: string },
|
||||
TIncoming,
|
||||
>(params: {
|
||||
pendingById: Record<string, TPending>;
|
||||
isExisting: (pending: TPending) => boolean;
|
||||
createRequest: (isRepair: boolean) => TPending;
|
||||
isRepair: boolean;
|
||||
existing: readonly TPending[];
|
||||
incoming: TIncoming;
|
||||
canRefreshSingle: (existing: TPending, incoming: TIncoming) => boolean;
|
||||
refreshSingle: (existing: TPending, incoming: TIncoming) => TPending;
|
||||
buildReplacement: (params: { existing: readonly TPending[]; incoming: TIncoming }) => TPending;
|
||||
persist: () => Promise<void>;
|
||||
}): Promise<PendingPairingRequestResult<TPending>> {
|
||||
const existing = Object.values(params.pendingById).find(params.isExisting);
|
||||
if (existing) {
|
||||
return { status: "pending", request: existing, created: false };
|
||||
if (
|
||||
params.existing.length === 1 &&
|
||||
params.canRefreshSingle(params.existing[0], params.incoming)
|
||||
) {
|
||||
const refreshed = params.refreshSingle(params.existing[0], params.incoming);
|
||||
params.pendingById[refreshed.requestId] = refreshed;
|
||||
await params.persist();
|
||||
return { status: "pending", request: refreshed, created: false };
|
||||
}
|
||||
|
||||
const request = params.createRequest(params.isRepair);
|
||||
for (const existing of params.existing) {
|
||||
delete params.pendingById[existing.requestId];
|
||||
}
|
||||
|
||||
const request = params.buildReplacement({
|
||||
existing: params.existing,
|
||||
incoming: params.incoming,
|
||||
});
|
||||
params.pendingById[request.requestId] = request;
|
||||
await params.persist();
|
||||
return { status: "pending", request, created: true };
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export type PendingRequest = {
|
|||
uiVersion?: string;
|
||||
remoteIp?: string;
|
||||
isRepair?: boolean;
|
||||
repairReason?: string;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue