fix(gateway): hide unapproved node surfaces

Co-authored-by: samzong <samzong.lu@gmail.com>
This commit is contained in:
Peter Steinberger
2026-05-13 14:31:21 +01:00
parent 53d007bc87
commit 6160e7a411
11 changed files with 433 additions and 7 deletions

View File

@@ -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.

View File

@@ -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,
},

View File

@@ -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");
});

View File

@@ -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,
};
}

View File

@@ -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,
};

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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",
{

View File

@@ -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,

View File

@@ -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(),

View File

@@ -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;