mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 15:47:28 +00:00
fix(gateway): hide unapproved node surfaces
Co-authored-by: samzong <samzong.lu@gmail.com>
This commit is contained in:
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Gateway: hide pending Node pairing commands, capabilities, and permissions until approval, and refresh the live approved surface when pairings change. (#80741) Thanks @samzong.
|
||||
- Plugins/Feishu/WhatsApp/Line: enforce inbound media size caps while reading download streams, avoiding full buffering of oversized attachments. (#81044, #81050) Thanks @samzong.
|
||||
- Config: serialize and retry semantic config mutations centrally, so concurrent commands can rebase safe changes instead of clobbering or hand-rolling command-local retry loops. (#76601)
|
||||
- Require approval for setup-code device pairing [AI]. (#81292) Thanks @pgondhi987.
|
||||
|
||||
@@ -106,7 +106,9 @@ describe("gateway/node-catalog", () => {
|
||||
displayName: "Mac",
|
||||
platform: "darwin",
|
||||
version: "1.2.3",
|
||||
declaredCaps: ["camera", "screen"],
|
||||
caps: ["camera", "screen"],
|
||||
declaredCommands: ["screen.snapshot", "system.run"],
|
||||
commands: ["screen.snapshot", "system.run"],
|
||||
remoteIp: "100.0.0.11",
|
||||
pathEnv: "/usr/bin:/bin",
|
||||
@@ -257,7 +259,9 @@ describe("gateway/node-catalog", () => {
|
||||
client: {} as never,
|
||||
displayName: "Mac",
|
||||
platform: "darwin",
|
||||
declaredCaps: ["canvas"],
|
||||
caps: ["canvas"],
|
||||
declaredCommands: ["canvas.snapshot"],
|
||||
commands: ["canvas.snapshot"],
|
||||
connectedAtMs: 1,
|
||||
},
|
||||
|
||||
@@ -59,6 +59,45 @@ describe("reconcileNodePairingOnConnect", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps first-time pending node surfaces declared but not effective", async () => {
|
||||
const requestPairing = vi.fn(async (input: NodePairingRequestInput) => ({
|
||||
status: "pending" as const,
|
||||
request: { ...input, requestId: "req-pending", ts: 1 },
|
||||
created: true,
|
||||
}));
|
||||
|
||||
const result = await reconcileNodePairingOnConnect({
|
||||
cfg: {} as never,
|
||||
connectParams: makeNodeConnectParams({
|
||||
client: {
|
||||
id: "openclaw-macos",
|
||||
version: "test",
|
||||
platform: "darwin",
|
||||
mode: "node",
|
||||
},
|
||||
caps: ["talk"],
|
||||
commands: ["system.run"],
|
||||
permissions: { camera: true },
|
||||
}),
|
||||
pairedNode: null,
|
||||
requestPairing,
|
||||
});
|
||||
|
||||
expect(result.declaredCaps).toEqual(["talk"]);
|
||||
expect(result.effectiveCaps).toEqual([]);
|
||||
expect(result.declaredCommands).toEqual(["system.run"]);
|
||||
expect(result.effectiveCommands).toEqual([]);
|
||||
expect(result.declaredPermissions).toEqual({ camera: true });
|
||||
expect(result.effectivePermissions).toBeUndefined();
|
||||
expect(requestPairing).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
caps: ["talk"],
|
||||
commands: ["system.run"],
|
||||
permissions: { camera: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a fresh pairing request when paired node capabilities change", async () => {
|
||||
const requestPairing = vi.fn(async (input: NodePairingRequestInput) => ({
|
||||
status: "pending" as const,
|
||||
@@ -93,6 +132,7 @@ describe("reconcileNodePairingOnConnect", () => {
|
||||
});
|
||||
expect(result.effectiveCaps).toEqual(["camera"]);
|
||||
expect(result.effectiveCommands).toEqual([]);
|
||||
expect(result.declaredCaps).toEqual(["camera", "screen"]);
|
||||
expect(result.pendingPairing?.request.requestId).toBe("req-caps");
|
||||
});
|
||||
|
||||
|
||||
@@ -13,8 +13,11 @@ import type { ConnectParams } from "./protocol/index.js";
|
||||
|
||||
export type NodeConnectPairingReconcileResult = {
|
||||
nodeId: string;
|
||||
declaredCaps: string[];
|
||||
effectiveCaps: string[];
|
||||
declaredCommands: string[];
|
||||
effectiveCommands: string[];
|
||||
declaredPermissions?: Record<string, boolean>;
|
||||
effectivePermissions?: Record<string, boolean>;
|
||||
pendingPairing?: RequestNodePairingResult;
|
||||
};
|
||||
@@ -169,9 +172,12 @@ export async function reconcileNodePairingOnConnect(params: {
|
||||
);
|
||||
return {
|
||||
nodeId,
|
||||
effectiveCaps: declaredCaps,
|
||||
effectiveCommands: declared,
|
||||
effectivePermissions: declaredPermissions,
|
||||
declaredCaps,
|
||||
effectiveCaps: [],
|
||||
declaredCommands: declared,
|
||||
effectiveCommands: [],
|
||||
declaredPermissions,
|
||||
effectivePermissions: undefined,
|
||||
pendingPairing,
|
||||
};
|
||||
}
|
||||
@@ -211,8 +217,11 @@ export async function reconcileNodePairingOnConnect(params: {
|
||||
);
|
||||
return {
|
||||
nodeId,
|
||||
declaredCaps,
|
||||
effectiveCaps: effectiveApprovedDeclaredCaps,
|
||||
declaredCommands: declared,
|
||||
effectiveCommands: effectiveApprovedDeclaredCommands,
|
||||
declaredPermissions,
|
||||
effectivePermissions: effectiveApprovedDeclaredPermissions,
|
||||
pendingPairing,
|
||||
};
|
||||
@@ -220,8 +229,11 @@ export async function reconcileNodePairingOnConnect(params: {
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
declaredCaps,
|
||||
effectiveCaps: declaredCaps,
|
||||
declaredCommands: declared,
|
||||
effectiveCommands: declared,
|
||||
declaredPermissions,
|
||||
effectivePermissions: declaredPermissions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ function createNodeSession(): NodeSession {
|
||||
nodeId: "node-1",
|
||||
connId: "conn-1",
|
||||
client: {} as NodeSession["client"],
|
||||
declaredCaps: [],
|
||||
caps: [],
|
||||
declaredCommands: ["demo.read"],
|
||||
commands: ["demo.read"],
|
||||
connectedAtMs: 0,
|
||||
};
|
||||
|
||||
@@ -6,7 +6,17 @@ function makeClient(
|
||||
connId: string,
|
||||
nodeId: string,
|
||||
sent: string[] = [],
|
||||
opts: { clientId?: string; platform?: string; version?: string } = {},
|
||||
opts: {
|
||||
clientId?: string;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
caps?: string[];
|
||||
commands?: string[];
|
||||
permissions?: Record<string, boolean>;
|
||||
declaredCaps?: string[];
|
||||
declaredCommands?: string[];
|
||||
declaredPermissions?: Record<string, boolean>;
|
||||
} = {},
|
||||
): GatewayWsClient {
|
||||
return {
|
||||
connId,
|
||||
@@ -19,6 +29,8 @@ function makeClient(
|
||||
},
|
||||
} as unknown as GatewayWsClient["socket"],
|
||||
connect: {
|
||||
minProtocol: 1,
|
||||
maxProtocol: 1,
|
||||
client: {
|
||||
id: opts.clientId ?? "openclaw-macos",
|
||||
version: opts.version ?? "1.0.0",
|
||||
@@ -32,7 +44,13 @@ function makeClient(
|
||||
signedAt: 1,
|
||||
nonce: "nonce",
|
||||
},
|
||||
} as GatewayWsClient["connect"],
|
||||
caps: opts.caps ?? [],
|
||||
commands: opts.commands ?? [],
|
||||
permissions: opts.permissions,
|
||||
declaredCaps: opts.declaredCaps,
|
||||
declaredCommands: opts.declaredCommands,
|
||||
declaredPermissions: opts.declaredPermissions,
|
||||
} as unknown as GatewayWsClient["connect"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -433,4 +451,51 @@ describe("gateway/node-registry", () => {
|
||||
'{"type":"event","event":"heartbeat"}',
|
||||
]);
|
||||
});
|
||||
|
||||
it("refreshes effective live surface within the declared surface", () => {
|
||||
const registry = new NodeRegistry();
|
||||
const client = makeClient("conn-1", "node-1", [], {
|
||||
caps: [],
|
||||
commands: [],
|
||||
declaredCaps: ["talk"],
|
||||
declaredCommands: ["talk.ptt.start"],
|
||||
declaredPermissions: { microphone: true, camera: false },
|
||||
});
|
||||
|
||||
const session = registry.register(client, {});
|
||||
expect(session.caps).toEqual([]);
|
||||
expect(session.commands).toEqual([]);
|
||||
|
||||
const updated = registry.updateSurface("node-1", {
|
||||
caps: ["talk", "screen"],
|
||||
commands: ["talk.ptt.start", "system.run"],
|
||||
permissions: { microphone: true, camera: true },
|
||||
});
|
||||
|
||||
expect(updated?.caps).toEqual(["talk"]);
|
||||
expect(updated?.commands).toEqual(["talk.ptt.start"]);
|
||||
expect(updated?.permissions).toEqual({ microphone: true, camera: false });
|
||||
expect(client.connect.caps).toEqual(["talk"]);
|
||||
expect((client.connect as { commands?: string[] }).commands).toEqual(["talk.ptt.start"]);
|
||||
});
|
||||
|
||||
it("clears effective permissions when explicitly removed", () => {
|
||||
const registry = new NodeRegistry();
|
||||
const client = makeClient("conn-1", "node-1", [], {
|
||||
permissions: { camera: false },
|
||||
declaredPermissions: { camera: false },
|
||||
});
|
||||
|
||||
registry.register(client, {});
|
||||
const updated = registry.updateSurface("node-1", {
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: undefined,
|
||||
});
|
||||
|
||||
expect(updated?.permissions).toBeUndefined();
|
||||
expect(
|
||||
(client.connect as { permissions?: Record<string, boolean> }).permissions,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,8 +15,11 @@ export type NodeSession = {
|
||||
deviceFamily?: string;
|
||||
modelIdentifier?: string;
|
||||
remoteIp?: string;
|
||||
declaredCaps: string[];
|
||||
caps: string[];
|
||||
declaredCommands: string[];
|
||||
commands: string[];
|
||||
declaredPermissions?: Record<string, boolean>;
|
||||
permissions?: Record<string, boolean>;
|
||||
pathEnv?: string;
|
||||
connectedAtMs: number;
|
||||
@@ -138,13 +141,27 @@ export class NodeRegistry {
|
||||
const connect = client.connect;
|
||||
const nodeId = connect.device?.id ?? connect.client.id;
|
||||
const caps = Array.isArray(connect.caps) ? connect.caps : [];
|
||||
const declaredCaps = Array.isArray((connect as { declaredCaps?: string[] }).declaredCaps)
|
||||
? ((connect as { declaredCaps?: string[] }).declaredCaps ?? [])
|
||||
: caps;
|
||||
const commands = Array.isArray((connect as { commands?: string[] }).commands)
|
||||
? ((connect as { commands?: string[] }).commands ?? [])
|
||||
: [];
|
||||
const declaredCommands = Array.isArray(
|
||||
(connect as { declaredCommands?: string[] }).declaredCommands,
|
||||
)
|
||||
? ((connect as { declaredCommands?: string[] }).declaredCommands ?? [])
|
||||
: commands;
|
||||
const permissions =
|
||||
typeof (connect as { permissions?: Record<string, boolean> }).permissions === "object"
|
||||
? ((connect as { permissions?: Record<string, boolean> }).permissions ?? undefined)
|
||||
: undefined;
|
||||
const declaredPermissions =
|
||||
typeof (connect as { declaredPermissions?: Record<string, boolean> }).declaredPermissions ===
|
||||
"object"
|
||||
? ((connect as { declaredPermissions?: Record<string, boolean> }).declaredPermissions ??
|
||||
undefined)
|
||||
: permissions;
|
||||
const pathEnv =
|
||||
typeof (connect as { pathEnv?: string }).pathEnv === "string"
|
||||
? (connect as { pathEnv?: string }).pathEnv
|
||||
@@ -163,8 +180,11 @@ export class NodeRegistry {
|
||||
deviceFamily: connect.client.deviceFamily,
|
||||
modelIdentifier: connect.client.modelIdentifier,
|
||||
remoteIp: opts.remoteIp,
|
||||
declaredCaps,
|
||||
caps,
|
||||
declaredCommands,
|
||||
commands,
|
||||
declaredPermissions,
|
||||
permissions,
|
||||
pathEnv,
|
||||
connectedAtMs: Date.now(),
|
||||
@@ -208,6 +228,66 @@ export class NodeRegistry {
|
||||
return this.nodesById.get(nodeId);
|
||||
}
|
||||
|
||||
updateCommands(nodeId: string, commands: readonly string[]): NodeSession | null {
|
||||
return this.updateSurface(nodeId, { commands });
|
||||
}
|
||||
|
||||
updateSurface(
|
||||
nodeId: string,
|
||||
surface: {
|
||||
caps?: readonly string[];
|
||||
commands: readonly string[];
|
||||
permissions?: Record<string, boolean> | undefined;
|
||||
},
|
||||
): NodeSession | null {
|
||||
const node = this.nodesById.get(nodeId);
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const declaredCommands = new Set(node.declaredCommands);
|
||||
const nextCommands = surface.commands.filter((command) => declaredCommands.has(command));
|
||||
node.commands = nextCommands;
|
||||
(node.client.connect as { commands?: string[] }).commands = nextCommands;
|
||||
|
||||
if ("caps" in surface) {
|
||||
const declaredCaps = new Set(node.declaredCaps);
|
||||
const nextCaps = (surface.caps ?? []).filter((capability) => declaredCaps.has(capability));
|
||||
node.caps = nextCaps;
|
||||
(node.client.connect as { caps?: string[] }).caps = nextCaps;
|
||||
}
|
||||
|
||||
if ("permissions" in surface) {
|
||||
if (surface.permissions === undefined) {
|
||||
node.permissions = undefined;
|
||||
(node.client.connect as { permissions?: Record<string, boolean> }).permissions = undefined;
|
||||
return node;
|
||||
}
|
||||
const declared = node.declaredPermissions ?? {};
|
||||
const nextEntries: Array<[string, boolean]> = [];
|
||||
for (const [key, declaredValue] of Object.entries(declared)) {
|
||||
if (!declaredValue) {
|
||||
nextEntries.push([key, false]);
|
||||
continue;
|
||||
}
|
||||
const approvedValue = surface.permissions?.[key];
|
||||
if (approvedValue === true) {
|
||||
nextEntries.push([key, true]);
|
||||
continue;
|
||||
}
|
||||
if (approvedValue === false) {
|
||||
nextEntries.push([key, false]);
|
||||
}
|
||||
}
|
||||
const nextPermissions = nextEntries.length > 0 ? Object.fromEntries(nextEntries) : undefined;
|
||||
node.permissions = nextPermissions;
|
||||
(node.client.connect as { permissions?: Record<string, boolean> }).permissions =
|
||||
nextPermissions;
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
async invoke(params: {
|
||||
nodeId: string;
|
||||
command: string;
|
||||
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
resolveApnsAuthConfigFromEnv,
|
||||
resolveApnsRelayConfigFromEnv,
|
||||
} from "../../infra/push-apns.js";
|
||||
import {
|
||||
recordRemoteNodeInfo,
|
||||
refreshRemoteNodeBins,
|
||||
removeRemoteNodeInfo,
|
||||
} from "../../infra/skills-remote.js";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
@@ -29,10 +34,12 @@ import { createKnownNodeCatalog, getKnownNode, listKnownNodes } from "../node-ca
|
||||
import {
|
||||
isForegroundRestrictedPluginNodeCommand,
|
||||
isNodeCommandAllowed,
|
||||
normalizeDeclaredNodeCommands,
|
||||
resolveNodeCommandAllowlist,
|
||||
} from "../node-command-policy.js";
|
||||
import { applyPluginNodeInvokePolicy } from "../node-invoke-plugin-policy.js";
|
||||
import { sanitizeNodeInvokeParamsForForwarding } from "../node-invoke-sanitize.js";
|
||||
import type { NodeSession } from "../node-registry.js";
|
||||
import { refreshClientPluginNodeCapability } from "../plugin-node-capability.js";
|
||||
import {
|
||||
type ConnectParams,
|
||||
@@ -295,6 +302,34 @@ function listPendingNodeActions(nodeId: string): PendingNodeAction[] {
|
||||
return prunePendingNodeActions(nodeId, Date.now());
|
||||
}
|
||||
|
||||
function refreshConnectedNodeSurfaceCaches(params: {
|
||||
context: GatewayRequestContext;
|
||||
nodeSession: NodeSession;
|
||||
cfg?: OpenClawConfig;
|
||||
}) {
|
||||
const cfg = params.cfg ?? params.context.getRuntimeConfig();
|
||||
const { nodeSession } = params;
|
||||
recordRemoteNodeInfo({
|
||||
nodeId: nodeSession.nodeId,
|
||||
displayName: nodeSession.displayName,
|
||||
platform: nodeSession.platform,
|
||||
deviceFamily: nodeSession.deviceFamily,
|
||||
commands: nodeSession.commands,
|
||||
remoteIp: nodeSession.remoteIp,
|
||||
});
|
||||
void refreshRemoteNodeBins({
|
||||
nodeId: nodeSession.nodeId,
|
||||
platform: nodeSession.platform,
|
||||
deviceFamily: nodeSession.deviceFamily,
|
||||
commands: nodeSession.commands,
|
||||
cfg,
|
||||
}).catch((err) =>
|
||||
params.context.logGateway.warn(
|
||||
`remote bin probe failed for ${nodeSession.nodeId}: ${formatErrorMessage(err)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAllowedPendingNodeActions(params: {
|
||||
nodeId: string;
|
||||
client: { connect?: ConnectParams | null } | null;
|
||||
@@ -734,6 +769,25 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
const approvedNode = approved.node;
|
||||
const cfg = context.getRuntimeConfig();
|
||||
const currentAllowlist = resolveNodeCommandAllowlist(cfg, {
|
||||
platform: approvedNode.platform,
|
||||
deviceFamily: approvedNode.deviceFamily,
|
||||
caps: approvedNode.caps,
|
||||
commands: approvedNode.commands,
|
||||
});
|
||||
const currentAllowedCommands = normalizeDeclaredNodeCommands({
|
||||
declaredCommands: approvedNode.commands ?? [],
|
||||
allowlist: currentAllowlist,
|
||||
});
|
||||
const updatedNode = context.nodeRegistry.updateSurface(approvedNode.nodeId, {
|
||||
caps: approvedNode.caps ?? [],
|
||||
commands: currentAllowedCommands,
|
||||
permissions: approvedNode.permissions,
|
||||
});
|
||||
if (updatedNode) {
|
||||
refreshConnectedNodeSurfaceCaches({ context, nodeSession: updatedNode, cfg });
|
||||
}
|
||||
context.broadcast(
|
||||
"node.pair.resolved",
|
||||
{
|
||||
@@ -792,6 +846,13 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId"));
|
||||
return;
|
||||
}
|
||||
pendingNodeActionsById.delete(removed.nodeId);
|
||||
context.nodeRegistry.updateSurface(removed.nodeId, {
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: undefined,
|
||||
});
|
||||
removeRemoteNodeInfo(removed.nodeId);
|
||||
context.broadcast(
|
||||
"node.pair.resolved",
|
||||
{
|
||||
|
||||
@@ -8,7 +8,9 @@ function registryWith(nodes: Array<Partial<NodeSession>>): NodeRegistry {
|
||||
nodes.map((node, index) => ({
|
||||
nodeId: `node-${index}`,
|
||||
connId: `conn-${index}`,
|
||||
declaredCaps: [],
|
||||
caps: [],
|
||||
declaredCommands: [],
|
||||
commands: [],
|
||||
connectedAtMs: 0,
|
||||
...node,
|
||||
|
||||
@@ -215,6 +215,14 @@ async function expectPendingPairingCommands(nodeId: string, commands: string[])
|
||||
expect(pending?.commands).toEqual(commands);
|
||||
}
|
||||
|
||||
async function getPendingNodePairing(nodeId: string) {
|
||||
const pairingList = await rpcReq<{
|
||||
pending?: Array<{ requestId?: string; nodeId?: string; commands?: string[] }>;
|
||||
}>(ws, "node.pair.list", {});
|
||||
expect(pairingList.ok).toBe(true);
|
||||
return (pairingList.payload?.pending ?? []).find((entry) => entry.nodeId === nodeId);
|
||||
}
|
||||
|
||||
describe("gateway role enforcement", () => {
|
||||
test("enforces operator and node permissions", async () => {
|
||||
let nodeClient: GatewayClient | undefined;
|
||||
@@ -463,7 +471,7 @@ describe("gateway node command allowlist", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps allowlisted declared commands available before node pairing exists", async () => {
|
||||
test("hides allowlisted declared commands before node pairing is approved", async () => {
|
||||
const displayName = "node-device-paired-only";
|
||||
let nodeClient: GatewayClient | undefined;
|
||||
|
||||
@@ -481,17 +489,160 @@ describe("gateway node command allowlist", () => {
|
||||
const node = await findConnectedNodeByDisplayName(displayName);
|
||||
return node?.commands?.toSorted() ?? [];
|
||||
}, FAST_WAIT_OPTS)
|
||||
.toEqual(["canvas.snapshot", "system.run"]);
|
||||
.toEqual([]);
|
||||
|
||||
const node = await findConnectedNodeByDisplayName(displayName);
|
||||
const nodeId = requireNodeId(node?.nodeId, displayName);
|
||||
|
||||
await expectPendingPairingCommands(nodeId, ["canvas.snapshot", "system.run"]);
|
||||
|
||||
const canvasRes = await rpcReq(ws, "node.invoke", {
|
||||
nodeId,
|
||||
command: "canvas.snapshot",
|
||||
params: { format: "png" },
|
||||
idempotencyKey: "pending-node-canvas",
|
||||
});
|
||||
expect(canvasRes.ok).toBe(false);
|
||||
expect(canvasRes.error?.message ?? "").toContain("node command not allowed");
|
||||
} finally {
|
||||
await nodeClient?.stopAndWait();
|
||||
}
|
||||
});
|
||||
|
||||
test("refreshes live commands when pending node pairing is approved", async () => {
|
||||
const displayName = "node-approve-live-commands";
|
||||
let nodeClient: GatewayClient | undefined;
|
||||
let resolveInvoke: ((payload: { id?: string; nodeId?: string }) => void) | null = null;
|
||||
const waitForInvoke = () =>
|
||||
new Promise<{ id?: string; nodeId?: string }>((resolve) => {
|
||||
resolveInvoke = resolve;
|
||||
});
|
||||
|
||||
try {
|
||||
nodeClient = await connectNodeClientWithPairing({
|
||||
port,
|
||||
commands: ["canvas.snapshot"],
|
||||
platform: "darwin",
|
||||
instanceId: displayName,
|
||||
displayName,
|
||||
onEvent: (evt) => {
|
||||
if (evt.event === "node.invoke.request") {
|
||||
resolveInvoke?.(evt.payload as { id?: string; nodeId?: string });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const node = await findConnectedNodeByDisplayName(displayName);
|
||||
return node?.commands?.toSorted() ?? [];
|
||||
}, FAST_WAIT_OPTS)
|
||||
.toEqual([]);
|
||||
|
||||
const node = await findConnectedNodeByDisplayName(displayName);
|
||||
const nodeId = requireNodeId(node?.nodeId, displayName);
|
||||
const pairingList = await rpcReq<{
|
||||
pending?: Array<{ requestId?: string; nodeId?: string; commands?: string[] }>;
|
||||
}>(ws, "node.pair.list", {});
|
||||
const pending = (pairingList.payload?.pending ?? []).find((entry) => entry.nodeId === nodeId);
|
||||
expect(pending?.commands).toEqual(["canvas.snapshot"]);
|
||||
|
||||
const approveRes = await rpcReq(ws, "node.pair.approve", { requestId: pending?.requestId });
|
||||
expect(approveRes.ok).toBe(true);
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const refreshed = await findConnectedNodeByDisplayName(displayName);
|
||||
return refreshed?.commands?.toSorted() ?? [];
|
||||
}, FAST_WAIT_OPTS)
|
||||
.toEqual(["canvas.snapshot"]);
|
||||
|
||||
const invokeResP = rpcReq(ws, "node.invoke", {
|
||||
nodeId,
|
||||
command: "canvas.snapshot",
|
||||
params: { format: "png" },
|
||||
idempotencyKey: "approved-live-node-command",
|
||||
});
|
||||
const payload = await waitForInvoke();
|
||||
await nodeClient.request("node.invoke.result", {
|
||||
id: payload.id ?? "",
|
||||
nodeId: payload.nodeId ?? nodeId,
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify({ ok: true }),
|
||||
});
|
||||
const invokeRes = await invokeResP;
|
||||
expect(invokeRes.ok).toBe(true);
|
||||
} finally {
|
||||
await nodeClient?.stopAndWait();
|
||||
}
|
||||
});
|
||||
|
||||
test("rechecks current allowlist before exposing approved live commands", async () => {
|
||||
const displayName = "node-approve-live-commands-current-allowlist";
|
||||
let nodeClient: GatewayClient | undefined;
|
||||
let configPath: string | undefined;
|
||||
|
||||
try {
|
||||
const deviceIdentity = loadOrCreateDeviceIdentity(
|
||||
path.join(
|
||||
os.tmpdir(),
|
||||
`openclaw-node-current-allowlist-${Date.now()}-${Math.random()}.json`,
|
||||
),
|
||||
);
|
||||
nodeClient = await connectNodeClientWithPairing({
|
||||
port,
|
||||
commands: ["canvas.snapshot", "system.run"],
|
||||
platform: "darwin",
|
||||
instanceId: displayName,
|
||||
displayName,
|
||||
deviceIdentity,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const node = await findConnectedNodeByDisplayName(displayName);
|
||||
return node?.commands?.toSorted() ?? [];
|
||||
}, FAST_WAIT_OPTS)
|
||||
.toEqual([]);
|
||||
|
||||
const node = await findConnectedNodeByDisplayName(displayName);
|
||||
const nodeId = requireNodeId(node?.nodeId, displayName);
|
||||
const pending = await getPendingNodePairing(nodeId);
|
||||
expect(pending?.commands).toEqual(["canvas.snapshot", "system.run"]);
|
||||
|
||||
configPath = getGatewayTestConfigPath();
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify({ gateway: { nodes: { denyCommands: ["system.run"] } } }, null, 2),
|
||||
);
|
||||
|
||||
const approveRes = await rpcReq(ws, "node.pair.approve", { requestId: pending?.requestId });
|
||||
expect(approveRes.ok).toBe(true);
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const refreshed = await findConnectedNodeByDisplayName(displayName);
|
||||
return refreshed?.commands?.toSorted() ?? [];
|
||||
}, FAST_WAIT_OPTS)
|
||||
.toEqual(["canvas.snapshot"]);
|
||||
|
||||
const invokeRes = await rpcReq(ws, "node.invoke", {
|
||||
nodeId,
|
||||
command: "system.run",
|
||||
params: { command: ["echo", "stale"] },
|
||||
idempotencyKey: "stale-allowlist-system-run",
|
||||
});
|
||||
expect(invokeRes.ok).toBe(false);
|
||||
expect(invokeRes.error?.message ?? "").toContain("node command not allowed");
|
||||
} finally {
|
||||
if (configPath) {
|
||||
await fs.writeFile(configPath, "{}\n");
|
||||
}
|
||||
await nodeClient?.stopAndWait();
|
||||
}
|
||||
});
|
||||
|
||||
test("records only allowlisted commands in pending node pairing requests", async () => {
|
||||
const deviceIdentityPath = path.join(
|
||||
os.tmpdir(),
|
||||
|
||||
@@ -1283,6 +1283,14 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
|
||||
dropIfSlow: true,
|
||||
});
|
||||
}
|
||||
const nodeConnectParams = connectParams as ConnectParams & {
|
||||
declaredCaps?: string[];
|
||||
declaredCommands?: string[];
|
||||
declaredPermissions?: Record<string, boolean>;
|
||||
};
|
||||
nodeConnectParams.declaredCaps = reconciliation.declaredCaps;
|
||||
nodeConnectParams.declaredCommands = reconciliation.declaredCommands;
|
||||
nodeConnectParams.declaredPermissions = reconciliation.declaredPermissions;
|
||||
connectParams.caps = reconciliation.effectiveCaps;
|
||||
connectParams.commands = reconciliation.effectiveCommands;
|
||||
connectParams.permissions = reconciliation.effectivePermissions;
|
||||
|
||||
Reference in New Issue
Block a user