From a126a9013d9ff597de8c4a5824e7f675a651df53 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 04:12:45 +0100 Subject: [PATCH] feat(plugins): expose nodes runtime to cli commands --- CHANGELOG.md | 1 + docs/plugins/google-meet.md | 3 +- docs/plugins/sdk-runtime.md | 8 +++-- src/plugins/cli-gateway-nodes-runtime.ts | 45 ++++++++++++++++++++++++ src/plugins/cli-registry-loader.ts | 4 +++ src/plugins/cli.test.ts | 15 ++++++++ src/plugins/runtime/index.test.ts | 30 ++++++++++++++++ src/plugins/runtime/index.ts | 2 +- src/plugins/runtime/types.ts | 1 + 9 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 src/plugins/cli-gateway-nodes-runtime.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f26b566a95b..4a66ab61595 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,6 +104,7 @@ Docs: https://docs.openclaw.ai - Agents/TTS: suppress successful spoken transcripts from verbose chat tool output when structured voice media is already queued, while preserving text output for non-builtin tool-name collisions. Fixes #71282. Thanks @neeravmakwana. - Plugins/Google Meet: reuse existing Meet tabs and active sessions across harmless URL query differences, avoiding duplicate Chrome windows when agents retry a join. Thanks @steipete. - Plugins/Google Meet: tell agents to recover already-open Meet tabs after browser timeouts, and make the dev CLI release its build lock if compiler spawning fails. Thanks @steipete. +- Plugins/CLI: provide Gateway-backed node inspection to plugin commands, so `googlemeet recover-tab` can inspect paired browser nodes from the terminal. Thanks @steipete. - Gateway/sessions: recover main-agent turns interrupted by a gateway restart from stale transcript-lock evidence, avoiding stuck `status: "running"` sessions without broad post-boot transcript scans. Fixes #70555. Thanks @bitloi. - Codex approvals: keep command approval responses within Codex app-server `availableDecisions`, including deny/cancel fallbacks for prompts that do not offer `decline`. (#71338) Thanks @Lucenx9. - Codex harness: reject same-thread app-server notifications without `turnId` or `turn.id` after a bound turn starts, preventing unscoped events from mutating or completing the active reply. (#71317) Thanks @Lucenx9. diff --git a/docs/plugins/google-meet.md b/docs/plugins/google-meet.md index c225c5bc341..24c98aa528d 100644 --- a/docs/plugins/google-meet.md +++ b/docs/plugins/google-meet.md @@ -931,7 +931,8 @@ openclaw googlemeet recover-tab https://meet.google.com/abc-defg-hij The equivalent tool action is `recover_current_tab`. It focuses and inspects an existing Meet tab on the configured Chrome node. It does not open a new tab or create a new session; it reports the current blocker, such as login, admission, -permissions, or audio-choice state. +permissions, or audio-choice state. The CLI command talks to the configured +Gateway, so the Gateway must be running and the Chrome node must be connected. ### Twilio setup checks fail diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md index 0c60f98e6b3..eb1ea20e5d0 100644 --- a/docs/plugins/sdk-runtime.md +++ b/docs/plugins/sdk-runtime.md @@ -122,8 +122,8 @@ await api.runtime.subagent.deleteSession({ ### `api.runtime.nodes` List connected nodes and invoke a node-host command from Gateway-loaded plugin -code. Use this when a plugin owns local work on a paired device, for example a -browser or audio bridge on another Mac. +code or from plugin CLI commands. Use this when a plugin owns local work on a +paired device, for example a browser or audio bridge on another Mac. ```typescript const { nodes } = await api.runtime.nodes.list({ connected: true }); @@ -136,7 +136,9 @@ const result = await api.runtime.nodes.invoke({ }); ``` -This runtime is only available inside the Gateway. Node commands still go +Inside the Gateway this runtime is in-process. In plugin CLI commands it calls +the configured Gateway over RPC, so commands such as `openclaw googlemeet +recover-tab` can inspect paired nodes from the terminal. Node commands still go through normal Gateway node pairing, command allowlists, and node-local command handling. diff --git a/src/plugins/cli-gateway-nodes-runtime.ts b/src/plugins/cli-gateway-nodes-runtime.ts new file mode 100644 index 00000000000..b98273ccabb --- /dev/null +++ b/src/plugins/cli-gateway-nodes-runtime.ts @@ -0,0 +1,45 @@ +import { randomUUID } from "node:crypto"; +import { callGateway } from "../gateway/call.js"; +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../gateway/protocol/client-info.js"; +import type { PluginRuntime } from "./runtime/types.js"; + +export function createPluginCliGatewayNodesRuntime(): PluginRuntime["nodes"] { + return { + async list(params) { + const payload = await callGateway({ + method: "node.list", + params: {}, + clientName: GATEWAY_CLIENT_NAMES.CLI, + mode: GATEWAY_CLIENT_MODES.CLI, + }); + const nodes = Array.isArray(payload?.nodes) ? payload.nodes : []; + const filteredNodes = + params?.connected === true + ? nodes.filter( + (node) => + node !== null && + typeof node === "object" && + (node as { connected?: unknown }).connected === true, + ) + : nodes; + return { + nodes: filteredNodes as Awaited>["nodes"], + }; + }, + async invoke(params) { + return await callGateway({ + method: "node.invoke", + params: { + nodeId: params.nodeId, + command: params.command, + ...(params.params !== undefined && { params: params.params }), + timeoutMs: params.timeoutMs, + idempotencyKey: params.idempotencyKey || randomUUID(), + }, + timeoutMs: params.timeoutMs ? params.timeoutMs + 5_000 : undefined, + clientName: GATEWAY_CLIENT_NAMES.CLI, + mode: GATEWAY_CLIENT_MODES.CLI, + }); + }, + }; +} diff --git a/src/plugins/cli-registry-loader.ts b/src/plugins/cli-registry-loader.ts index e0e146bfc4f..44235298bd4 100644 --- a/src/plugins/cli-registry-loader.ts +++ b/src/plugins/cli-registry-loader.ts @@ -1,6 +1,7 @@ import { collectUniqueCommandDescriptors } from "../cli/program/command-descriptor-utils.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveManifestActivationPluginIds } from "./activation-planner.js"; +import { createPluginCliGatewayNodesRuntime } from "./cli-gateway-nodes-runtime.js"; import type { PluginLoadOptions } from "./loader.js"; import { loadOpenClawPluginCliRegistry, loadOpenClawPlugins } from "./loader.js"; import type { PluginRegistry } from "./registry.js"; @@ -117,6 +118,9 @@ export async function loadPluginCliCommandRegistryWithContext(params: { ...(onlyPluginIds.length > 0 ? { onlyPluginIds } : {}), activate: false, cache: false, + runtimeOptions: { + nodes: createPluginCliGatewayNodesRuntime(), + }, }), ), }; diff --git a/src/plugins/cli.test.ts b/src/plugins/cli.test.ts index 3b372dea011..e90d359b143 100644 --- a/src/plugins/cli.test.ts +++ b/src/plugins/cli.test.ts @@ -181,6 +181,21 @@ describe("registerPluginCliCommands", () => { ); }); + it("injects gateway-backed node runtime into plugin CLI commands", async () => { + await registerPluginCliCommands(createProgram(), {} as OpenClawConfig); + + expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + runtimeOptions: { + nodes: { + list: expect.any(Function), + invoke: expect.any(Function), + }, + }, + }), + ); + }); + it("loads plugin CLI commands from the auto-enabled config snapshot", async () => { const { rawConfig, autoEnabledConfig } = createAutoEnabledCliFixture(); mocks.applyPluginAutoEnable.mockReturnValue({ diff --git a/src/plugins/runtime/index.test.ts b/src/plugins/runtime/index.test.ts index 86e0b7f9d70..23a75dd1065 100644 --- a/src/plugins/runtime/index.test.ts +++ b/src/plugins/runtime/index.test.ts @@ -8,6 +8,7 @@ import { VERSION } from "../../version.js"; import { clearGatewaySubagentRuntime, createPluginRuntime, + setGatewayNodesRuntime, setGatewaySubagentRuntime, } from "./index.js"; @@ -267,4 +268,33 @@ describe("plugin runtime command execution", () => { }); expect(run).toHaveBeenCalledWith({ sessionKey: "s-2", message: "hello" }); }); + + it("uses explicit nodes runtime when provided", async () => { + const nodes = { + list: vi.fn().mockResolvedValue({ nodes: [] }), + invoke: vi.fn().mockResolvedValue({ ok: true }), + }; + const runtime = createPluginRuntime({ nodes }); + + await expect(runtime.nodes.list({ connected: true })).resolves.toEqual({ nodes: [] }); + await expect( + runtime.nodes.invoke({ nodeId: "node-1", command: "browser.proxy" }), + ).resolves.toEqual({ ok: true }); + expect(nodes.list).toHaveBeenCalledWith({ connected: true }); + expect(nodes.invoke).toHaveBeenCalledWith({ nodeId: "node-1", command: "browser.proxy" }); + }); + + it("late-binds to gateway nodes when explicitly enabled", async () => { + const nodes = { + list: vi.fn().mockResolvedValue({ nodes: [{ nodeId: "node-1" }] }), + invoke: vi.fn().mockResolvedValue({ ok: true }), + }; + const runtime = createPluginRuntime({ allowGatewaySubagentBinding: true }); + setGatewayNodesRuntime(nodes); + + await expect(runtime.nodes.list({ connected: true })).resolves.toEqual({ + nodes: [{ nodeId: "node-1" }], + }); + expect(nodes.list).toHaveBeenCalledWith({ connected: true }); + }); }); diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index f0b270b5f43..05dba934b4b 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -216,7 +216,7 @@ export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}): _options.subagent, _options.allowGatewaySubagentBinding === true, ), - nodes: createLateBindingNodes(_options.allowGatewaySubagentBinding === true), + nodes: _options.nodes ?? createLateBindingNodes(_options.allowGatewaySubagentBinding === true), system: createRuntimeSystem(), media: createRuntimeMedia(), webSearch: { diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 4068ed59016..8c94d5200e9 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -95,5 +95,6 @@ export type PluginRuntime = PluginRuntimeCore & { export type CreatePluginRuntimeOptions = { subagent?: PluginRuntime["subagent"]; + nodes?: PluginRuntime["nodes"]; allowGatewaySubagentBinding?: boolean; };