Skip to content

Commit a2da03e

Browse files
committed
fix(gateway): resolve macOS App node shell command probing timeouts
1 parent 91266fa commit a2da03e

5 files changed

Lines changed: 104 additions & 8 deletions

File tree

src/gateway/node-command-policy.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,10 +260,21 @@ export function isForegroundRestrictedPluginNodeCommand(command: string): boolea
260260
}
261261

262262
type NodeCommandPolicyNode = Pick<NodeSession, "platform" | "deviceFamily"> &
263-
Partial<Pick<NodeSession, "caps" | "commands" | "connId" | "nodeId">> & {
263+
Partial<Pick<NodeSession, "caps" | "commands" | "connId" | "nodeId" | "clientId" | "clientMode">> & {
264264
approvedCommands?: readonly string[];
265265
};
266266

267+
function isAppNode(node?: NodeCommandPolicyNode): boolean {
268+
if (!node) {
269+
return false;
270+
}
271+
return (
272+
node.clientMode === "app" ||
273+
node.clientId === "openclaw-macos" ||
274+
node.clientId === "openclaw-windows"
275+
);
276+
}
277+
267278
function isDesktopPlatformId(platformId: PlatformId): boolean {
268279
return platformId === "macos" || platformId === "windows" || platformId === "linux";
269280
}
@@ -337,6 +348,13 @@ function resolveNodeCommandAllowlistInternal(
337348
.map((cmd) => cmd.trim())
338349
.filter((cmd) => cmd && !dangerousPluginCommands.has(cmd)),
339350
);
351+
352+
if (isAppNode(node)) {
353+
for (const cmd of NODE_SYSTEM_RUN_COMMANDS) {
354+
allow.delete(cmd);
355+
}
356+
}
357+
340358
for (const cmd of extra) {
341359
const trimmed = cmd.trim();
342360
if (trimmed) {
@@ -363,8 +381,13 @@ export function resolveNodePairingCommandAllowlist(
363381
cfg: OpenClawConfig,
364382
node?: NodeCommandPolicyNode,
365383
): Set<string> {
384+
// App nodes must never get system.run even in the pairing allowlist, because
385+
// the declared commands from this allowlist are stored in the pairing record
386+
// and later treated as approved on reconnect. Passing includeDesktopHostCommands
387+
// to resolveNodeCommandAllowlistInternal is still correct for non-app desktop
388+
// nodes that legitimately need system.run at pairing time.
366389
return resolveNodeCommandAllowlistInternal(cfg, node, {
367-
includeDesktopHostCommands: true,
390+
includeDesktopHostCommands: !isAppNode(node),
368391
});
369392
}
370393

src/gateway/node-connect-reconcile.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ export async function reconcileNodePairingOnConnect(params: {
150150
deviceFamily: params.connectParams.client.deviceFamily,
151151
caps: params.connectParams.caps,
152152
commands: params.connectParams.commands,
153+
clientId: params.connectParams.client.id,
154+
clientMode: params.connectParams.client.mode,
153155
};
154156
const pairingAllowlist = resolveNodePairingCommandAllowlist(params.cfg, policyNode);
155157
const declared = normalizeDeclaredNodeCommands({

src/gateway/server-methods/nodes.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,8 @@ function resolveAllowedPendingNodeActions(params: {
346346
deviceFamily: connect?.client?.deviceFamily,
347347
caps: connect?.caps,
348348
commands: declaredCommands,
349+
clientId: connect?.client?.id,
350+
clientMode: connect?.client?.mode,
349351
});
350352
const allowed = pending.filter((entry) => {
351353
const result = isNodeCommandAllowed({
@@ -775,6 +777,8 @@ export const nodeHandlers: GatewayRequestHandlers = {
775777
deviceFamily: approvedNode.deviceFamily,
776778
caps: approvedNode.caps,
777779
commands: approvedNode.commands,
780+
clientId: approvedNode.clientId,
781+
clientMode: approvedNode.clientMode,
778782
approvedCommands: approvedNode.commands,
779783
});
780784
const currentAllowedCommands = normalizeDeclaredNodeCommands({

test/app-node-probe.e2e.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { afterAll, describe, expect, it } from "vitest";
2+
import { GatewayClient } from "../src/gateway/client.js";
3+
import {
4+
type GatewayInstance,
5+
connectNode,
6+
spawnGatewayInstance,
7+
stopGatewayInstance,
8+
waitForNodeStatus,
9+
} from "./helpers/gateway-e2e-harness.js";
10+
11+
const E2E_TIMEOUT_MS = 30_000;
12+
13+
describe("gateway app node probe", () => {
14+
const instances: GatewayInstance[] = [];
15+
const nodeClients: GatewayClient[] = [];
16+
17+
afterAll(async () => {
18+
for (const client of nodeClients) {
19+
client.stop();
20+
}
21+
for (const inst of instances) {
22+
await stopGatewayInstance(inst);
23+
}
24+
});
25+
26+
it(
27+
"does not timeout probing an app node",
28+
{ timeout: E2E_TIMEOUT_MS },
29+
async () => {
30+
const gw = await spawnGatewayInstance("app-probe-test");
31+
instances.push(gw);
32+
33+
// Connect an app node with no declared commands: gateway policy must not
34+
// assign system.run to app nodes, so probing should be skipped entirely.
35+
const startTime = Date.now();
36+
const node = await connectNode(gw, "macos-app", {
37+
platform: "macos",
38+
deviceFamily: "Mac",
39+
mode: "node",
40+
clientId: "openclaw-macos",
41+
commands: [],
42+
});
43+
nodeClients.push(node.client);
44+
45+
// waitForNodeStatus returns the matched node entry from node.list once
46+
// connected and paired — use it directly to verify commands.
47+
const nodeStatus = await waitForNodeStatus(gw, node.nodeId);
48+
49+
const duration = Date.now() - startTime;
50+
51+
// Without probing suppression this would take ~10–15 s per timed-out
52+
// probe. With the fix it should complete well within 5 s.
53+
expect(duration).toBeLessThan(5000);
54+
55+
// App nodes must not have system.run assigned by the gateway policy.
56+
expect((nodeStatus as any).commands ?? []).not.toContain("system.run");
57+
},
58+
);
59+
});

test/helpers/gateway-e2e-harness.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,22 +87,30 @@ export async function postJson(
8787
export async function connectNode(
8888
inst: GatewayInstance,
8989
label: string,
90+
opts?: {
91+
platform?: string;
92+
mode?: string;
93+
clientId?: string;
94+
deviceFamily?: string;
95+
commands?: string[];
96+
}
9097
): Promise<{ client: GatewayClient; nodeId: string }> {
9198
const identityPath = path.join(inst.homeDir, `${label}-device.json`);
9299
const deviceIdentity = loadOrCreateDeviceIdentity(identityPath);
93100
const nodeId = deviceIdentity.deviceId;
94101
const client = await connectGatewayClient({
95102
url: `ws://127.0.0.1:${inst.port}`,
96103
token: inst.gatewayToken,
97-
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
104+
clientName: (opts?.clientId as any) ?? GATEWAY_CLIENT_NAMES.NODE_HOST,
98105
clientDisplayName: label,
99106
clientVersion: "1.0.0",
100-
platform: "ios",
101-
mode: GATEWAY_CLIENT_MODES.NODE,
107+
platform: opts?.platform ?? "ios",
108+
deviceFamily: opts?.deviceFamily,
109+
mode: (opts?.mode as any) ?? GATEWAY_CLIENT_MODES.NODE,
102110
role: "node",
103111
scopes: [],
104112
caps: ["system"],
105-
commands: ["system.run"],
113+
commands: opts?.commands ?? [],
106114
deviceIdentity,
107115
timeoutMessage: `timeout waiting for ${label} to connect`,
108116
});
@@ -171,9 +179,9 @@ export async function waitForNodeStatus(
171179
try {
172180
while (Date.now() < deadline) {
173181
const list = await client.request("node.list", {});
174-
const match = list.nodes?.find((n) => n.nodeId === nodeId);
182+
const match = list.nodes?.find((n: any) => n.nodeId === nodeId);
175183
if (match?.connected && match?.paired) {
176-
return;
184+
return match;
177185
}
178186
await sleep(GATEWAY_NODE_STATUS_POLL_MS);
179187
}

0 commit comments

Comments
 (0)