feat(plugins): expose nodes runtime to cli commands

This commit is contained in:
Peter Steinberger
2026-04-25 04:12:45 +01:00
parent 3731a7c8f2
commit a126a9013d
9 changed files with 104 additions and 5 deletions

View File

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

View File

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

View File

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

View File

@@ -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<ReturnType<PluginRuntime["nodes"]["list"]>>["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,
});
},
};
}

View File

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

View File

@@ -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({

View File

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

View File

@@ -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: {

View File

@@ -95,5 +95,6 @@ export type PluginRuntime = PluginRuntimeCore & {
export type CreatePluginRuntimeOptions = {
subagent?: PluginRuntime["subagent"];
nodes?: PluginRuntime["nodes"];
allowGatewaySubagentBinding?: boolean;
};