diff --git a/docs/tools/code-mode.md b/docs/tools/code-mode.md index 2b0052f77291..ffff45e704e1 100644 --- a/docs/tools/code-mode.md +++ b/docs/tools/code-mode.md @@ -565,6 +565,23 @@ type ToolCatalog = { }; ``` +Paired Gateway nodes are available through the `nodes` global: + +```typescript +const available = await nodes.list(); +const node = await nodes.get(available[0].id); +const status = await node.invoke("device.status"); +``` + +`nodes.list()` returns paired node ids, names, platforms, connection state, and +advertised commands. `nodes.get(idOrName)` resolves an exact id before a display +name and returns a handle with `id`, `name`, and `invoke(command, params?)`. +Invocation uses the normal `nodes` tool path, so pairing, command policy, scopes, +approvals, timeouts, hooks, and telemetry are unchanged. A handle includes +`listDir(path)` only when the node advertises `fs.listDir`. It does not include +`exec`: the generic nodes surface reserves `system.run` for the normal shell +`exec` tool with a node host. + Convenience tool functions are installed only for unambiguous safe names: ```typescript diff --git a/src/agents/code-mode-bridge.ts b/src/agents/code-mode-bridge.ts index 5c77487d36a3..108561a24d8f 100644 --- a/src/agents/code-mode-bridge.ts +++ b/src/agents/code-mode-bridge.ts @@ -1,6 +1,9 @@ import { createHash, randomUUID } from "node:crypto"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { NODE_FS_LIST_DIR_COMMAND } from "../infra/node-commands.js"; import { emitSessionLifecycleEvent } from "../sessions/session-lifecycle-events.js"; +import { parseNodeList } from "../shared/node-list-parse.js"; +import type { NodeListNode } from "../shared/node-list-types.js"; import { toCodeModeJsonSafe } from "./code-mode-json.js"; import type { CodeModeNamespaceRuntime } from "./code-mode-namespaces.js"; import { @@ -23,6 +26,7 @@ import { type CollectorCompletionResult, } from "./tools/agents-wait-tool.js"; import { ToolInputError } from "./tools/common.js"; +import { resolveEligibleNodeFromList } from "./tools/nodes-utils.js"; import { resolveInternalSessionKey, resolveMainSessionAlias } from "./tools/sessions-helpers.js"; type CodeModeSwarmDeps = { @@ -39,6 +43,124 @@ const defaultCodeModeSwarmDeps: CodeModeSwarmDeps = { waitForCollectorCompletion, }; +const CODE_MODE_NODES_TOOL_ID = "openclaw:core:nodes"; + +type CodeModeNode = { + id: string; + name: string; + platform?: string; + connected: boolean; + commands: string[]; +}; + +function projectCodeModeNode(node: NodeListNode): CodeModeNode { + return { + id: node.nodeId, + name: node.displayName?.trim() || node.nodeId, + ...(node.platform ? { platform: node.platform } : {}), + connected: node.connected === true, + commands: Array.isArray(node.commands) + ? node.commands.filter((command): command is string => typeof command === "string") + : [], + }; +} + +async function callNodesTool(params: { + runtime: ToolSearchRuntime; + parentToolCallId: string; + signal?: AbortSignal; + onUpdate?: AgentToolUpdateCallback; + input: Record; +}): Promise { + return await params.runtime.callValue(CODE_MODE_NODES_TOOL_ID, params.input, { + includeMcp: false, + parentToolCallId: params.parentToolCallId, + signal: params.signal, + onUpdate: params.onUpdate, + recoverySurface: "tools", + }); +} + +async function listCodeModeNodes(params: { + runtime: ToolSearchRuntime; + parentToolCallId: string; + signal?: AbortSignal; + onUpdate?: AgentToolUpdateCallback; +}): Promise { + return parseNodeList( + await callNodesTool({ + ...params, + input: { action: "status" }, + }), + ); +} + +async function runNodesBridge(params: { + runtime: ToolSearchRuntime; + parentToolCallId: string; + request: PendingBridgeRequest; + signal?: AbortSignal; + onUpdate?: AgentToolUpdateCallback; +}): Promise { + const values = params.request.args; + const action = values[0]; + if (action === "list") { + return (await listCodeModeNodes(params)) + .filter((node) => node.paired === true) + .map(projectCodeModeNode); + } + if (action === "get") { + const query = values[1]; + if (typeof query !== "string" || !query.trim()) { + throw new ToolInputError("nodes.get id or name must be a non-empty string."); + } + const node = resolveEligibleNodeFromList( + await listCodeModeNodes(params), + query, + (candidate) => candidate.paired === true, + { + ineligibleExact: (id, eligibleIds) => + `node "${id}" is not paired (paired node ids: ${eligibleIds})`, + nameResolveFailed: (reason, eligibleIds) => `${reason} (paired node ids: ${eligibleIds})`, + noneEligible: () => "no paired nodes", + multipleEligible: (eligible) => + `multiple nodes paired: ${eligible + .map((candidate) => candidate.nodeId) + .toSorted() + .join(", ")}`, + }, + ); + const projected = projectCodeModeNode(node); + return { + id: projected.id, + name: projected.name, + ...(projected.commands.includes(NODE_FS_LIST_DIR_COMMAND) + ? { listDirCommand: NODE_FS_LIST_DIR_COMMAND } + : {}), + }; + } + if (action === "invoke") { + const node = values[1]; + const command = values[2]; + if (typeof node !== "string" || !node.trim()) { + throw new ToolInputError("nodes.invoke node id must be a non-empty string."); + } + if (typeof command !== "string" || !command.trim()) { + throw new ToolInputError("nodes.invoke command must be a non-empty string."); + } + return await callNodesTool({ + ...params, + input: { + action: "invoke", + node, + invokeCommand: command, + invokeParamsJson: JSON.stringify(values[3] ?? {}), + }, + }); + } + throw new ToolInputError("unsupported nodes bridge action."); +} + let codeModeSwarmDeps = defaultCodeModeSwarmDeps; export function codeModeReplayIdForToolCall( @@ -324,6 +446,10 @@ export async function runBridgeRequest(params: { }); break; } + case "nodes": { + value = await runNodesBridge(params); + break; + } case "yield": { value = { status: "yielded", reason: values[0] ?? null }; break; diff --git a/src/agents/code-mode-nodes.test.ts b/src/agents/code-mode-nodes.test.ts new file mode 100644 index 000000000000..14ba71402b28 --- /dev/null +++ b/src/agents/code-mode-nodes.test.ts @@ -0,0 +1,284 @@ +/** Tests the first-class Code Mode nodes API through the real generic nodes tool. */ + +import { expectDefined } from "@openclaw/normalization-core"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ToolSearchCatalogToolExecutor } from "./tool-search.js"; +import type { AnyAgentTool } from "./tools/common.js"; + +const gatewayMocks = vi.hoisted(() => ({ + callGatewayTool: vi.fn(), + readGatewayCallOptions: vi.fn(() => ({})), +})); + +vi.mock("./tools/gateway.js", () => ({ + callGatewayTool: gatewayMocks.callGatewayTool, + readGatewayCallOptions: gatewayMocks.readGatewayCallOptions, +})); + +const nodeSnapshot = [ + { + nodeId: "node-1", + displayName: "Desk", + platform: "darwin", + paired: true, + connected: true, + commands: ["device.status", "fs.listDir", "computer.act", "danger.read"], + }, + { + nodeId: "shadow-id", + displayName: "Offline", + platform: "linux", + paired: false, + connected: false, + commands: ["device.status"], + }, + { + nodeId: "node-2", + displayName: "shadow-id", + paired: true, + connected: false, + commands: ["device.status"], + }, +]; + +let applyCodeModeCatalog: typeof import("./code-mode.js").applyCodeModeCatalog; +let createCodeModeTools: typeof import("./code-mode.js").createCodeModeTools; +let createToolSearchCatalogRef: typeof import("./tool-search.js").createToolSearchCatalogRef; +let createNodesTool: typeof import("./tools/nodes-tool.js").createNodesTool; +let testing: typeof import("./code-mode.test-support.js").testing; + +function resultDetails(result: { details?: unknown }): Record { + expect(result.details).toBeDefined(); + expect(typeof result.details).toBe("object"); + return result.details as Record; +} + +function createHarness() { + const catalogRef = createToolSearchCatalogRef(); + const config = { tools: { codeMode: true } } as never; + const nestedCalls: Parameters[0][] = []; + const executeTool: ToolSearchCatalogToolExecutor = async (params) => { + nestedCalls.push(params); + const result = await params.tool.execute( + params.toolCallId, + params.input, + params.signal, + params.onUpdate, + undefined as never, + ); + return await params.acceptResultBeforeProjection(result); + }; + const ctx = { + config, + runtimeConfig: config, + sessionId: "session-code-mode-nodes", + sessionKey: "agent:main:main", + runId: "run-code-mode-nodes", + catalogRef, + executeTool, + }; + const codeModeTools = createCodeModeTools(ctx); + const nodesTool = createNodesTool({ agentSessionKey: ctx.sessionKey }); + applyCodeModeCatalog({ + tools: [...codeModeTools, nodesTool], + config, + sessionId: ctx.sessionId, + sessionKey: ctx.sessionKey, + runId: ctx.runId, + catalogRef, + }); + return { + execTool: expectDefined(codeModeTools[0], "code mode exec tool"), + waitTool: expectDefined(codeModeTools[1], "code mode wait tool"), + nestedCalls, + }; +} + +async function runUntilCompleted(params: { + execTool: AnyAgentTool; + waitTool: AnyAgentTool; + code: string; +}): Promise> { + let details = resultDetails( + await params.execTool.execute("code-nodes-call", { code: params.code }), + ); + for (let index = 0; index < 8 && details.status === "waiting"; index += 1) { + details = resultDetails( + await params.waitTool.execute(`code-nodes-wait-${index}`, { runId: details.runId }), + ); + } + return details; +} + +describe("Code Mode nodes", () => { + beforeAll(async () => { + vi.resetModules(); + ({ applyCodeModeCatalog, createCodeModeTools } = await import("./code-mode.js")); + ({ createToolSearchCatalogRef } = await import("./tool-search.js")); + ({ createNodesTool } = await import("./tools/nodes-tool.js")); + ({ testing } = await import("./code-mode.test-support.js")); + }); + + beforeEach(() => { + gatewayMocks.callGatewayTool.mockReset(); + gatewayMocks.readGatewayCallOptions.mockReset(); + gatewayMocks.readGatewayCallOptions.mockReturnValue({}); + gatewayMocks.callGatewayTool.mockImplementation(async (method, _options, input) => { + if (method === "node.list") { + return { nodes: nodeSnapshot }; + } + if (method === "node.invoke") { + const invocation = input as { + nodeId: string; + command: string; + params: unknown; + }; + if (invocation.command === "danger.read") { + throw new Error( + 'node command not allowed: "danger.read" is blocked by gateway.nodes.commands.deny', + ); + } + return { + payload: { + nodeId: invocation.nodeId, + command: invocation.command, + params: invocation.params, + }, + }; + } + throw new Error(`unexpected gateway method: ${String(method)}`); + }); + }); + + afterEach(() => { + testing.activeRuns.clear(); + testing.resumingRunIds.clear(); + }); + + it("lists nodes and returns a callable handle with conditional directory sugar", async () => { + const harness = createHarness(); + const details = await runUntilCompleted({ + ...harness, + code: ` + const listed = await nodes.list(); + const node = await nodes.get("Desk"); + const invoked = await node.invoke("device.status", { detail: true }); + const directory = await node.listDir("/tmp"); + return { + listed, + id: node.id, + name: node.name, + methods: Object.keys(node).sort(), + invoked, + directory, + hasExec: "exec" in node, + }; + `, + }); + + expect(details.status).toBe("completed"); + expect(details.value).toEqual({ + listed: [ + { + id: "node-1", + name: "Desk", + platform: "darwin", + connected: true, + commands: ["device.status", "fs.listDir", "computer.act", "danger.read"], + }, + { + id: "node-2", + name: "shadow-id", + connected: false, + commands: ["device.status"], + }, + ], + id: "node-1", + name: "Desk", + methods: ["id", "invoke", "listDir", "name"], + invoked: { + payload: { nodeId: "node-1", command: "device.status", params: { detail: true } }, + }, + directory: { + payload: { nodeId: "node-1", command: "fs.listDir", params: { path: "/tmp" } }, + }, + hasExec: false, + }); + expect(harness.nestedCalls).not.toHaveLength(0); + expect(harness.nestedCalls.every((call) => call.parentToolCallId === "code-nodes-call")).toBe( + true, + ); + }); + + it("omits listDir when the node does not advertise fs.listDir", async () => { + const harness = createHarness(); + const details = await runUntilCompleted({ + ...harness, + code: `const node = await nodes.get("node-2"); return Object.keys(node).sort();`, + }); + + expect(details.value).toEqual(["id", "invoke", "name"]); + }); + + it("surfaces the generic nodes policy refusal with the command name", async () => { + const harness = createHarness(); + const details = await runUntilCompleted({ + ...harness, + code: ` + const node = await nodes.get("Desk"); + try { + await node.invoke("danger.read"); + return "unexpected success"; + } catch (error) { + return error.message; + } + `, + }); + + expect(details.value).toContain('node command not allowed: "danger.read"'); + expect(details.value).toContain("blocked by gateway.nodes.commands.deny"); + }); + + it("keeps computer.act blocked by the generic nodes surface", async () => { + const harness = createHarness(); + const details = await runUntilCompleted({ + ...harness, + code: ` + const node = await nodes.get("Desk"); + try { + await node.invoke("computer.act", { action: "left_click", x: 1, y: 1 }); + return "unexpected success"; + } catch (error) { + return error.message; + } + `, + }); + + expect(details.value).toContain('invokeCommand "computer.act"'); + expect(details.value).toContain("use the dedicated computer tool"); + expect( + gatewayMocks.callGatewayTool.mock.calls.some( + ([method, , input]) => + method === "node.invoke" && + (input as { command?: string } | undefined)?.command === "computer.act", + ), + ).toBe(false); + }); + + it("rejects an ineligible exact id before matching an eligible node name", async () => { + const harness = createHarness(); + const details = await runUntilCompleted({ + ...harness, + code: ` + try { + await nodes.get("shadow-id"); + return "unexpected success"; + } catch (error) { + return error.message; + } + `, + }); + + expect(details.value).toBe('node "shadow-id" is not paired (paired node ids: node-1, node-2)'); + }); +}); diff --git a/src/agents/code-mode-runtime.ts b/src/agents/code-mode-runtime.ts index 50731526bace..3c967228aabf 100644 --- a/src/agents/code-mode-runtime.ts +++ b/src/agents/code-mode-runtime.ts @@ -55,6 +55,7 @@ type CodeModeBridgeMethod = | "describe" | "call" | "callValue" + | "nodes" | "yield" | "namespace" | "agentSpawn" diff --git a/src/agents/code-mode-worker-types.ts b/src/agents/code-mode-worker-types.ts index cfa4c239a7d0..3d1c01a13d1e 100644 --- a/src/agents/code-mode-worker-types.ts +++ b/src/agents/code-mode-worker-types.ts @@ -6,6 +6,7 @@ type CodeModeBridgeMethod = | "describe" | "call" | "callValue" + | "nodes" | "yield" | "namespace" | "agentSpawn" diff --git a/src/agents/code-mode.test.ts b/src/agents/code-mode.test.ts index d1c98709dfa9..7189f4985113 100644 --- a/src/agents/code-mode.test.ts +++ b/src/agents/code-mode.test.ts @@ -439,6 +439,12 @@ describe("Code Mode", () => { expect(execTool.description).toContain('"javascript" or "typescript"'); expect(execTool.description).toContain("never a shell command"); expect(execTool.description).toContain("do not retry failed shell source"); + const nodesGuidance = + "- nodes: paired Gateway nodes; nodes.list(), (await nodes.get(id)).invoke(command, params)"; + expect(execTool.description).toContain(nodesGuidance); + expect(execTool.description.indexOf(nodesGuidance)).toBe( + execTool.description.lastIndexOf(nodesGuidance), + ); expect(parameters.properties?.code?.description).toContain( "`tools.search` takes a query string, not an object", diff --git a/src/agents/code-mode.ts b/src/agents/code-mode.ts index 14ad2ea04377..0dab27ae17e7 100644 --- a/src/agents/code-mode.ts +++ b/src/agents/code-mode.ts @@ -144,12 +144,15 @@ function createCodeModeExecDescription( const swarmGuidance = swarmEnabled ? " Swarm globals `agents.run`, `phase`, and `log` are available; read `agents.d.ts` for types and orchestration idioms." : ""; + const nodesGuidance = + "\n- nodes: paired Gateway nodes; nodes.list(), (await nodes.get(id)).invoke(command, params)\n"; const catalogIndex = catalog ? formatCodeModeCatalogIndex(catalog) : ""; return ( "Run JavaScript or TypeScript in OpenClaw code mode. Use `return` to pass the final value back; otherwise the result is `null`. Quick-index arrows show trusted declared output hints; `-> ?` means never guess result field names. For declared fields, process them in the first exec; do not spend another exec inspecting them. Perform dependent reads, checks, and follow-up calls in order; parallelize independent work only. For an unknown output, including a final dependent call after declared-output calls, return the raw tool value unchanged; do not wrap it in the requested answer shape or guess fields; filter or map it only in a later exec. Nested calls enforce normal tool policy and approvals. `ALL_TOOLS` is the complete compact catalog. Select exact ids directly or with `tools.search(query: string, options?)`; use `tools.describe(id: string)` only when needed. Never invent or transform a tool id. `tools.callValue(id: string, args?)` returns its JSON value directly; `tools.call(id: string, args?)` preserves `{ tool, result }`. Example: `const hit = ALL_TOOLS.find((entry) => entry.description.includes('weather')) ?? (await tools.search('weather'))[0]; return await tools.callValue(hit.id, {});`. Node.js modules and `require`/`import` are NOT available; use enabled catalog tools allowed by policy for shell, file, network, or external actions." + apiGuidance + mcpGuidance + swarmGuidance + + nodesGuidance + ' The `language` field accepts only "javascript" or "typescript"; do not pass "bash", "shell", or other values.' + " Both `code` and `command` contain JavaScript or TypeScript, never a shell command. " + "For shell or file operations, call the exact catalog tool from guest JavaScript; do not retry failed shell source." + diff --git a/src/agents/code-mode.worker.ts b/src/agents/code-mode.worker.ts index c5805942ef84..8b5c693eff68 100644 --- a/src/agents/code-mode.worker.ts +++ b/src/agents/code-mode.worker.ts @@ -188,6 +188,30 @@ const CONTROLLER_SOURCE = String.raw` return true; } + function nodeHandle(descriptor) { + const handle = Object.create(null); + Object.defineProperties(handle, { + id: { value: descriptor.id, enumerable: true }, + name: { value: descriptor.name, enumerable: true }, + invoke: { + value: (command, params) => request("nodes", ["invoke", descriptor.id, command, params]), + enumerable: true, + }, + }); + if (typeof descriptor.listDirCommand === "string") { + Object.defineProperty(handle, "listDir", { + value: (path) => request("nodes", ["invoke", descriptor.id, descriptor.listDirCommand, { path }]), + enumerable: true, + }); + } + return Object.freeze(handle); + } + + const nodes = Object.freeze({ + list: () => request("nodes", ["list"]), + get: async (idOrName) => nodeHandle(await request("nodes", ["get", idOrName])), + }); + const baseTools = Object.create(null); Object.defineProperties(baseTools, { search: { value: (query, options) => request("search", [query, options]), enumerable: true }, @@ -291,6 +315,7 @@ const CONTROLLER_SOURCE = String.raw` Object.defineProperties(globalThis, { ALL_TOOLS: { value: Object.freeze(catalog.slice()), enumerable: true }, API: { value: api, enumerable: true }, + nodes: { value: nodes, enumerable: true }, namespaces: { value: Object.freeze(namespaceGlobals), enumerable: true }, tools: { value: Object.freeze(baseTools), enumerable: true }, text: { value: (value) => output.push({ type: "text", text: asText(value) }), enumerable: true }, @@ -326,6 +351,7 @@ function createHostRequestHandler(params: { method !== "describe" && method !== "call" && method !== "callValue" && + method !== "nodes" && method !== "yield" && method !== "namespace" && method !== "agentSpawn" &&