From 9bcc44301bcbc6d97143425b2b47201460de57e6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Jul 2026 22:28:32 -0400 Subject: [PATCH] fix(nodes): preserve gateway connections and local inference (#114857) --- extensions/ollama/src/node-inference.test.ts | 106 +++++++++++++++++++ extensions/ollama/src/node-inference.ts | 16 +-- src/cli/node-cli/daemon.test.ts | 25 +++++ src/cli/node-cli/daemon.ts | 42 ++------ src/cli/node-cli/gateway-options.ts | 38 +++++++ src/cli/node-cli/register.test.ts | 56 ++++++++++ src/cli/node-cli/register.ts | 40 ++----- src/cli/nodes-cli.coverage.test.ts | 15 +++ src/cli/nodes-cli/register.pairing.ts | 4 +- src/cli/nodes-cli/register.ts | 2 +- src/node-host/runner.test.ts | 58 ++++++++-- src/node-host/runner.ts | 3 - 12 files changed, 320 insertions(+), 85 deletions(-) create mode 100644 src/cli/node-cli/gateway-options.ts diff --git a/extensions/ollama/src/node-inference.test.ts b/extensions/ollama/src/node-inference.test.ts index 43a43a915942..e68f9723574a 100644 --- a/extensions/ollama/src/node-inference.test.ts +++ b/extensions/ollama/src/node-inference.test.ts @@ -42,6 +42,16 @@ async function withOllamaServer( remote_host: "https://ollama.com", details: {}, }, + { + name: "tagged-only:cloud", + size: 1, + details: {}, + }, + { + name: "tagged-only:120b-cloud", + size: 1, + details: {}, + }, { name: "chat:small", size: 500, @@ -204,6 +214,16 @@ describe("Ollama node host inference", () => { JSON.stringify({ model: "remote:cloud", prompt: "hello" }), ), ).rejects.toThrow("is not a local chat model"); + await expect( + commandByName(baseUrl, OLLAMA_CHAT_COMMAND).handle( + JSON.stringify({ model: "tagged-only:cloud", prompt: "hello" }), + ), + ).rejects.toThrow("is not a local chat model"); + await expect( + commandByName(baseUrl, OLLAMA_CHAT_COMMAND).handle( + JSON.stringify({ model: "tagged-only:120b-cloud", prompt: "hello" }), + ), + ).rejects.toThrow("is not a local chat model"); await expect( commandByName(baseUrl, OLLAMA_CHAT_COMMAND).handle( JSON.stringify({ model: "embedding:latest", prompt: "hello" }), @@ -237,6 +257,15 @@ describe("Ollama node host inference", () => { }); describe("node_inference agent tool", () => { + it("uses a flat action enum supported by local model providers", () => { + const tool = createOllamaNodeInferenceTool(createTestPluginApi()); + const action = (tool.parameters as { properties: { action: unknown } }).properties.action; + + expect(action).toMatchObject({ type: "string", enum: ["discover", "run"] }); + expect(action).not.toHaveProperty("anyOf"); + expect(action).not.toHaveProperty("oneOf"); + }); + it("discovers models through the connected node runtime", async () => { const invoke = vi.fn(async () => ({ payload: { provider: "ollama", models: [{ name: "chat:small", loaded: true }] }, @@ -283,6 +312,83 @@ describe("node_inference agent tool", () => { }); }); + it("discovers only nodes authorized for local model discovery", async () => { + const invoke = vi.fn(async () => ({ + payload: { provider: "ollama", models: [{ name: "chat:small" }] }, + })); + const api = createTestPluginApi({ + runtime: { + nodes: { + list: async () => ({ + nodes: [ + { + nodeId: "denied-node", + connected: true, + commands: [OLLAMA_MODELS_COMMAND, OLLAMA_CHAT_COMMAND], + invocableCommands: [], + }, + { + nodeId: "allowed-node", + connected: true, + commands: [OLLAMA_MODELS_COMMAND, OLLAMA_CHAT_COMMAND], + invocableCommands: [OLLAMA_MODELS_COMMAND], + }, + ], + }), + invoke, + }, + } as never, + }); + + const result = await createOllamaNodeInferenceTool(api).execute("call-discover", { + action: "discover", + }); + + expect(invoke).toHaveBeenCalledOnce(); + expect(invoke).toHaveBeenCalledWith(expect.objectContaining({ nodeId: "allowed-node" })); + expect(result.details).toMatchObject({ nodes: [{ nodeId: "allowed-node", ok: true }] }); + }); + + it("routes inference to the node authorized for model discovery and chat", async () => { + const invoke = vi.fn(async () => ({ + payload: { provider: "ollama", model: "chat:small", response: "done" }, + })); + const api = createTestPluginApi({ + runtime: { + nodes: { + list: async () => ({ + nodes: [ + { + nodeId: "denied-node", + connected: true, + commands: [OLLAMA_MODELS_COMMAND, OLLAMA_CHAT_COMMAND], + invocableCommands: [OLLAMA_MODELS_COMMAND], + }, + { + nodeId: "allowed-node", + connected: true, + commands: [OLLAMA_MODELS_COMMAND, OLLAMA_CHAT_COMMAND], + invocableCommands: [OLLAMA_MODELS_COMMAND, OLLAMA_CHAT_COMMAND], + }, + ], + }), + invoke, + }, + } as never, + }); + + await createOllamaNodeInferenceTool(api).execute("call-authorized", { + action: "run", + model: "chat:small", + prompt: "answer fast", + }); + + expect(invoke).toHaveBeenCalledOnce(); + expect(invoke).toHaveBeenCalledWith( + expect.objectContaining({ nodeId: "allowed-node", command: OLLAMA_CHAT_COMMAND }), + ); + }); + it("routes a run to the sole capable node", async () => { const invoke = vi.fn(async () => ({ payload: { provider: "ollama", model: "chat:small", response: "done" }, diff --git a/extensions/ollama/src/node-inference.ts b/extensions/ollama/src/node-inference.ts index 55505696ed34..953fc0444951 100644 --- a/extensions/ollama/src/node-inference.ts +++ b/extensions/ollama/src/node-inference.ts @@ -1,4 +1,4 @@ -import { jsonResult } from "openclaw/plugin-sdk/channel-actions"; +import { jsonResult, stringEnum } from "openclaw/plugin-sdk/channel-actions"; import { formatErrorMessage as errorMessage } from "openclaw/plugin-sdk/error-runtime"; // Ollama node inference exposes local models to agents through paired node hosts. import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; @@ -24,6 +24,7 @@ import { buildOllamaBaseUrlSsrFPolicy, enrichOllamaModelsWithContext, fetchOllamaModels, + isOllamaCloudModel, resolveOllamaApiBase, } from "./provider-models.js"; @@ -185,7 +186,7 @@ async function discoverOllamaNodeModels( throw new Error(`Ollama is not running at ${apiBase}`); } const localModels = discovered.models - .filter((model) => !model.remote_host?.trim()) + .filter((model) => !model.remote_host?.trim() && !isOllamaCloudModel(model.name)) .slice(0, MAX_DISCOVERED_MODELS); const [models, loadedNames] = await Promise.all([ enrichOllamaModelsWithContext(apiBase, localModels), @@ -247,7 +248,8 @@ async function runOllamaNodeChat(params: { const apiBase = resolveOllamaApiBase(params.baseUrl); const discovered = await fetchOllamaModels(apiBase); const localModel = discovered.models.find( - (model) => model.name === params.model && !model.remote_host?.trim(), + (model) => + model.name === params.model && !model.remote_host?.trim() && !isOllamaCloudModel(model.name), ); const [model] = localModel ? await enrichOllamaModelsWithContext(apiBase, [localModel]) : []; if (!discovered.reachable || model?.capabilities?.includes("completion") !== true) { @@ -424,7 +426,7 @@ const ollamaNodeInferenceToolDefinition = { "Discover and run chat-capable Ollama models installed on paired desktop/server nodes. Use action=discover first, then action=run with a node and model from that result. Inference stays on the selected node.", parameters: Type.Object( { - action: Type.Union([Type.Literal("discover"), Type.Literal("run")]), + action: stringEnum(["discover", "run"] as const), node: Type.Optional( Type.String({ description: "Connected node id or display name. Required when ambiguous." }), ), @@ -450,7 +452,7 @@ export function createOllamaNodeInferenceTool(api: OpenClawPluginApi): AnyAgentT const nodeQuery = readStringParam(params, "node"); const listed = await api.runtime.nodes.list({ connected: true }); const modelNodes = listed.nodes.filter((node) => - node.commands?.includes(OLLAMA_MODELS_COMMAND), + (node.invocableCommands ?? node.commands)?.includes(OLLAMA_MODELS_COMMAND), ); if (action === "discover") { @@ -494,7 +496,9 @@ export function createOllamaNodeInferenceTool(api: OpenClawPluginApi): AnyAgentT if (action !== "run") { throw new Error("action must be discover or run"); } - const chatNodes = modelNodes.filter((node) => node.commands?.includes(OLLAMA_CHAT_COMMAND)); + const chatNodes = modelNodes.filter((node) => + (node.invocableCommands ?? node.commands)?.includes(OLLAMA_CHAT_COMMAND), + ); const node = nodeQuery ? findNode(chatNodes, nodeQuery) : chatNodes.length === 1 diff --git a/src/cli/node-cli/daemon.test.ts b/src/cli/node-cli/daemon.test.ts index a5f4344137c2..00f71eb66500 100644 --- a/src/cli/node-cli/daemon.test.ts +++ b/src/cli/node-cli/daemon.test.ts @@ -121,6 +121,7 @@ describe("runNodeDaemonInstall", () => { expect(mocks.buildNodeInstallPlan).toHaveBeenCalledWith( expect.objectContaining({ + contextPath: undefined, tls: false, tlsFingerprint: undefined, }), @@ -149,11 +150,35 @@ describe("runNodeDaemonInstall", () => { expect(mocks.buildNodeInstallPlan).toHaveBeenCalledWith( expect.objectContaining({ + contextPath: "/saved", tls: true, tlsFingerprint: "saved-fingerprint", }), ); }); + + it("installs an explicitly plaintext node for a saved TLS gateway", async () => { + await runNodeDaemonInstall({ force: true, tls: false }); + + expect(mocks.buildNodeInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ + host: "saved-gateway.local", + port: 18789, + contextPath: "/saved", + tls: false, + tlsFingerprint: undefined, + }), + ); + }); + + it("rejects a TLS fingerprint when installing an explicitly plaintext node", async () => { + await runNodeDaemonInstall({ force: true, tls: false, tlsFingerprint: "new-fingerprint" }); + + expect(mocks.buildNodeInstallPlan).not.toHaveBeenCalled(); + expect(mocks.runtime.error).toHaveBeenCalledWith( + expect.stringContaining("--no-tls cannot be combined with --tls-fingerprint"), + ); + }); }); describe("runNodeDaemonStatus", () => { diff --git a/src/cli/node-cli/daemon.ts b/src/cli/node-cli/daemon.ts index b10e8be159e5..741446bb4a1d 100644 --- a/src/cli/node-cli/daemon.ts +++ b/src/cli/node-cli/daemon.ts @@ -1,5 +1,4 @@ // Node-host daemon lifecycle commands for install, status, start, stop, and restart. -import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { colorize } from "../../../packages/terminal-core/src/theme.js"; import { DEFAULT_GATEWAY_DAEMON_RUNTIME, @@ -33,10 +32,10 @@ import { failIfNixDaemonInstallMode, filterDaemonEnv, formatRuntimeStatus, - parsePort, resolveRuntimeStatusColor, } from "../daemon-cli/shared.js"; import { formatInvalidConfigPort, formatInvalidPortOption } from "../error-format.js"; +import { resolveNodeGatewayOptions } from "./gateway-options.js"; type NodeDaemonInstallOptions = { host?: string; @@ -78,30 +77,6 @@ function buildNodeRuntimeHints(env: NodeJS.ProcessEnv = process.env): string[] { }); } -function resolveNodeDefaults( - opts: NodeDaemonInstallOptions, - config: Awaited>, -) { - // CLI flags override node-host config; missing values fall back to loopback Gateway defaults. - const savedHost = config?.gateway?.host || "127.0.0.1"; - const host = normalizeOptionalString(opts.host) || savedHost; - const retargeted = opts.host !== undefined || opts.port !== undefined; - const portOverride = parsePort(opts.port); - if (opts.port !== undefined && portOverride === null) { - return { host, port: null, retargeted, endpointChanged: false }; - } - const savedPort = config?.gateway?.port ?? 18789; - const port = portOverride ?? savedPort; - const endpointChanged = - (opts.host !== undefined && host !== savedHost) || - (opts.port !== undefined && port !== savedPort); - const explicitContextPath = opts.contextPath !== undefined; - const contextPath = - normalizeOptionalString(opts.contextPath) || - (explicitContextPath || retargeted ? undefined : config?.gateway?.contextPath); - return { host, port, contextPath, retargeted, endpointChanged }; -} - export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) { const { json, stdout, warnings, emit, fail } = createDaemonInstallActionContext(opts.json); if (failIfNixDaemonInstallMode(fail)) { @@ -109,7 +84,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) { } const config = await loadNodeHostConfig(); - const { host, port, contextPath, endpointChanged } = resolveNodeDefaults(opts, config); + const { host, port, contextPath, tls, tlsFingerprint } = resolveNodeGatewayOptions(opts, config); if (!Number.isFinite(port ?? Number.NaN) || (port ?? 0) <= 0 || (port ?? 0) > 65_535) { fail( opts.port !== undefined @@ -118,6 +93,10 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) { ); return; } + if (opts.tls === false && opts.tlsFingerprint !== undefined) { + fail("--no-tls cannot be combined with --tls-fingerprint"); + return; + } const runtimeRaw = opts.runtime ? opts.runtime : DEFAULT_GATEWAY_DAEMON_RUNTIME; if (!isGatewayDaemonRuntime(runtimeRaw)) { @@ -148,19 +127,14 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) { return; } - const tlsFingerprint = - normalizeOptionalString(opts.tlsFingerprint) || - (endpointChanged ? undefined : config?.gateway?.tlsFingerprint); - const inheritedTls = endpointChanged ? undefined : config?.gateway?.tls; - const tls = Boolean(opts.tls) || Boolean(tlsFingerprint) || Boolean(inheritedTls); const { programArguments, workingDirectory, environment, environmentValueSources, description } = await buildNodeInstallPlan({ env: process.env, host, port: port ?? 18789, contextPath, - tls, - tlsFingerprint: tlsFingerprint || undefined, + tls: Boolean(tls), + tlsFingerprint, nodeId: opts.nodeId, displayName: opts.displayName, installedAppsSharing: opts.shareInstalledApps, diff --git a/src/cli/node-cli/gateway-options.ts b/src/cli/node-cli/gateway-options.ts new file mode 100644 index 000000000000..f4970240803d --- /dev/null +++ b/src/cli/node-cli/gateway-options.ts @@ -0,0 +1,38 @@ +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import type { NodeHostConfig } from "../../node-host/config.js"; +import { parsePort } from "../daemon-cli/shared.js"; + +type NodeGatewayOptions = { + host?: string; + port?: string | number; + contextPath?: string; + tls?: boolean; + tlsFingerprint?: string; +}; + +export function resolveNodeGatewayOptions( + options: NodeGatewayOptions, + config: NodeHostConfig | null, +) { + const savedHost = config?.gateway?.host || "127.0.0.1"; + const savedPort = config?.gateway?.port ?? 18789; + const host = normalizeOptionalString(options.host) || savedHost; + const port = options.port === undefined ? savedPort : parsePort(options.port); + const endpointChanged = host !== savedHost || (port !== null && port !== savedPort); + const tlsFingerprint = + options.tls === false + ? undefined + : (normalizeOptionalString(options.tlsFingerprint) ?? + (endpointChanged ? undefined : config?.gateway?.tlsFingerprint)); + const tls = + typeof options.tls === "boolean" + ? options.tls + : Boolean(tlsFingerprint) || (endpointChanged ? undefined : config?.gateway?.tls); + const contextPath = + normalizeOptionalString(options.contextPath) ?? + (options.contextPath !== undefined || endpointChanged + ? undefined + : config?.gateway?.contextPath); + + return { host, port, contextPath, tls, tlsFingerprint }; +} diff --git a/src/cli/node-cli/register.test.ts b/src/cli/node-cli/register.test.ts index 669af7e5b18c..ce1eda947167 100644 --- a/src/cli/node-cli/register.test.ts +++ b/src/cli/node-cli/register.test.ts @@ -106,6 +106,62 @@ describe("registerNodeCli", () => { ); }); + it.each([ + ["host", ["--host", "10.0.0.2"]], + ["port", ["--port", "19001"]], + ])("preserves saved gateway settings when the explicit %s is unchanged", async (_name, args) => { + daemonMocks.loadNodeHostConfig.mockResolvedValue({ + version: 1, + nodeId: "node-existing", + gateway: { + host: "10.0.0.2", + port: 19001, + tls: true, + tlsFingerprint: "saved-fingerprint", + contextPath: "/saved", + }, + }); + + await createProgram().parseAsync(["node", "run", ...args], { from: "user" }); + + expect(daemonMocks.runNodeHost).toHaveBeenCalledWith( + expect.objectContaining({ + gatewayHost: "10.0.0.2", + gatewayPort: 19001, + gatewayTls: true, + gatewayTlsFingerprint: "saved-fingerprint", + gatewayContextPath: "/saved", + }), + ); + }); + + it.each([ + ["host", ["--host", "10.0.0.3"]], + ["port", ["--port", "19002"]], + ])("clears saved gateway settings when the explicit %s changes", async (_name, args) => { + daemonMocks.loadNodeHostConfig.mockResolvedValue({ + version: 1, + nodeId: "node-existing", + gateway: { + host: "10.0.0.2", + port: 19001, + tls: true, + tlsFingerprint: "saved-fingerprint", + contextPath: "/saved", + }, + }); + + await createProgram().parseAsync(["node", "run", ...args], { from: "user" }); + + expect(daemonMocks.runNodeHost).toHaveBeenCalledWith( + expect.objectContaining({ + gatewayTls: undefined, + gatewayTlsFingerprint: undefined, + gatewayContextPath: undefined, + }), + ); + }); + it("inherits saved TLS settings only when using the saved gateway endpoint", async () => { daemonMocks.loadNodeHostConfig.mockResolvedValue({ version: 1, diff --git a/src/cli/node-cli/register.ts b/src/cli/node-cli/register.ts index c45ebde39767..e53c70e9422e 100644 --- a/src/cli/node-cli/register.ts +++ b/src/cli/node-cli/register.ts @@ -1,5 +1,4 @@ // Commander registration for foreground node host and node service lifecycle commands. -import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { Command } from "commander"; import { formatDocsLink } from "../../../packages/terminal-core/src/links.js"; import { theme } from "../../../packages/terminal-core/src/theme.js"; @@ -7,7 +6,6 @@ import { loadNodeHostConfig } from "../../node-host/config.js"; import { runNodeHost } from "../../node-host/runner.js"; import { runNodeHostWorker } from "../../node-host/worker.js"; import { defaultRuntime } from "../../runtime.js"; -import { parsePort } from "../daemon-cli/shared.js"; import { formatInvalidPortOption } from "../error-format.js"; import { formatHelpExamples } from "../help-format.js"; import { @@ -18,16 +16,9 @@ import { runNodeDaemonStop, runNodeDaemonUninstall, } from "./daemon.js"; +import { resolveNodeGatewayOptions } from "./gateway-options.js"; import { runNodeIdentityShow } from "./identity.js"; -function parsePortOption(value: unknown, fallback: number): number | null { - // Undefined keeps config/default port; invalid explicit input returns null for CLI errors. - if (value === undefined) { - return fallback; - } - return parsePort(value); -} - export function registerNodeCli(program: Command) { const node = program .command("node") @@ -69,38 +60,26 @@ export function registerNodeCli(program: Command) { .option("--no-share-installed-apps", "Disable installed application sharing") .action(async (opts) => { const existing = await loadNodeHostConfig(); - const host = - normalizeOptionalString(opts.host as string | undefined) || - existing?.gateway?.host || - "127.0.0.1"; - const port = parsePortOption(opts.port, existing?.gateway?.port ?? 18789); + const { host, port, contextPath, tls, tlsFingerprint } = resolveNodeGatewayOptions( + opts, + existing, + ); if (port === null) { defaultRuntime.error(formatInvalidPortOption("--port")); defaultRuntime.exit(1); return; } - const retargetedGateway = opts.host !== undefined || opts.port !== undefined; - const explicitContextPath = opts.contextPath !== undefined; - const explicitTlsDisabled = opts.tls === false; - if (explicitTlsDisabled && opts.tlsFingerprint !== undefined) { + if (opts.tls === false && opts.tlsFingerprint !== undefined) { defaultRuntime.error("--no-tls cannot be combined with --tls-fingerprint"); defaultRuntime.exit(1); return; } - const tlsFingerprint = - explicitTlsDisabled || retargetedGateway - ? opts.tlsFingerprint - : (opts.tlsFingerprint ?? existing?.gateway?.tlsFingerprint); - const inheritedTls = retargetedGateway ? undefined : existing?.gateway?.tls; await runNodeHost({ gatewayHost: host, gatewayPort: port, - gatewayTls: - typeof opts.tls === "boolean" ? opts.tls : Boolean(tlsFingerprint) || inheritedTls, + gatewayTls: tls, gatewayTlsFingerprint: tlsFingerprint, - gatewayContextPath: - normalizeOptionalString(opts.contextPath as string | undefined) ?? - (explicitContextPath || retargetedGateway ? undefined : existing?.gateway?.contextPath), + gatewayContextPath: contextPath, nodeId: opts.nodeId, displayName: opts.displayName, installedAppsSharing: opts.shareInstalledApps, @@ -129,7 +108,8 @@ export function registerNodeCli(program: Command) { .option("--host ", "Gateway host") .option("--port ", "Gateway port") .option("--context-path ", "Gateway WebSocket context path (e.g. /openclaw-gw)") - .option("--tls", "Use TLS for the gateway connection", false) + .option("--tls", "Use TLS for the gateway connection") + .option("--no-tls", "Disable TLS for the gateway connection") .option("--tls-fingerprint ", "Expected TLS certificate fingerprint (sha256)") .option("--node-id ", "Override the generated node instance id") .option("--display-name ", "Override node display name") diff --git a/src/cli/nodes-cli.coverage.test.ts b/src/cli/nodes-cli.coverage.test.ts index e261725f2925..fea4e50afd80 100644 --- a/src/cli/nodes-cli.coverage.test.ts +++ b/src/cli/nodes-cli.coverage.test.ts @@ -123,6 +123,21 @@ describe("nodes-cli coverage", () => { }); }); + it("shows the registered pending command in node pairing help", () => { + const nodes = sharedProgram.commands.find((command) => command.name() === "nodes"); + const output: string[] = []; + + expect(nodes).toBeDefined(); + nodes?.configureOutput({ + writeOut: (value) => output.push(value), + writeErr: (value) => output.push(value), + }); + nodes?.outputHelp(); + + expect(output.join("")).toContain("openclaw nodes pending"); + expect(output.join("")).not.toContain("openclaw nodes pairing pending"); + }); + it("explains unknown nodes approve request ids with the current pending requests", async () => { callGateway.mockResolvedValueOnce({ pending: [{ requestId: "current-request", nodeId: "n1", ts: Date.now() }], diff --git a/src/cli/nodes-cli/register.pairing.ts b/src/cli/nodes-cli/register.pairing.ts index 472ae9e43654..d5304ab1a640 100644 --- a/src/cli/nodes-cli/register.pairing.ts +++ b/src/cli/nodes-cli/register.pairing.ts @@ -200,7 +200,7 @@ export function registerNodesPairingCommands(nodes: Command) { const nodeId = await resolveNodeId(opts, normalizeOptionalString(opts.node) ?? ""); if (!nodeId) { defaultRuntime.error( - `--node is required. Run ${formatCliCommand("openclaw nodes pairing pending")} to choose a node request.`, + `--node is required. Run ${formatCliCommand("openclaw nodes pending")} to choose a node request.`, ); defaultRuntime.exit(1); return; @@ -228,7 +228,7 @@ export function registerNodesPairingCommands(nodes: Command) { const name = normalizeOptionalString(opts.name) ?? ""; if (!nodeId || !name) { defaultRuntime.error( - `--node and --name are required. Run ${formatCliCommand("openclaw nodes pairing pending")} to choose a node, then rerun with --name .`, + `--node and --name are required. Run ${formatCliCommand("openclaw nodes pending")} to choose a node, then rerun with --name .`, ); defaultRuntime.exit(1); return; diff --git a/src/cli/nodes-cli/register.ts b/src/cli/nodes-cli/register.ts index 561c8f9ae510..37feb9098b80 100644 --- a/src/cli/nodes-cli/register.ts +++ b/src/cli/nodes-cli/register.ts @@ -26,7 +26,7 @@ export async function registerNodesCli(program: Command, argv: readonly string[] () => `\n${theme.heading("Examples:")}\n${formatHelpExamples([ ["openclaw nodes status", "List known nodes with live status."], - ["openclaw nodes pairing pending", "Show pending node pairing requests."], + ["openclaw nodes pending", "Show pending node pairing requests."], ["openclaw nodes remove --node ", "Remove a stale paired node entry."], [ 'openclaw nodes invoke --node --command system.which --params \'{"bins":["uname"]}\'', diff --git a/src/node-host/runner.test.ts b/src/node-host/runner.test.ts index 742e0a154c2b..b0ac5b1f7892 100644 --- a/src/node-host/runner.test.ts +++ b/src/node-host/runner.test.ts @@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({ }>, mcpConfiguredServerCount: 0, mcpDescriptors: [] as Array>, + nodePluginTools: [] as Array>, nodeSkillDescriptors: [] as Array>, runtimeSteps: [] as string[], useFakeRuntime: false, @@ -116,15 +117,7 @@ vi.mock("./plugin-node-host.js", () => ({ return { commands: [...mocks.nodeHostCommands], caps: [...mocks.nodeHostCaps], - nodePluginTools: [ - { - pluginId: "test-plugin", - name: "remote_echo", - description: "Echo from node host", - command: "test.echo", - parameters: { type: "object", properties: {} }, - }, - ], + nodePluginTools: [...mocks.nodePluginTools], }; }), watchRegisteredNodeHostCommandAvailability: vi.fn((_context: unknown, onChange: () => void) => { @@ -183,6 +176,15 @@ describe("runNodeHost", () => { mocks.capturedGatewayClients.length = 0; mocks.mcpConfiguredServerCount = 0; mocks.mcpDescriptors = []; + mocks.nodePluginTools = [ + { + pluginId: "test-plugin", + name: "remote_echo", + description: "Echo from node host", + command: "test.echo", + parameters: { type: "object", properties: {} }, + }, + ]; mocks.nodeSkillDescriptors = []; mocks.runtimeSteps = []; mocks.useFakeRuntime = false; @@ -492,6 +494,44 @@ describe("runNodeHost", () => { }); }); + it("clears gateway plugin tools when the final node-hosted tool disappears", async () => { + mocks.startGatewayClientWhenEventLoopReady.mockResolvedValueOnce({ + ready: true, + aborted: false, + elapsedMs: 0, + }); + const processOnceSpy = vi.spyOn(process, "once"); + const previousExitCode = process.exitCode; + try { + const running = runNodeHost({ gatewayHost: "127.0.0.1", gatewayPort: 18789 }); + await vi.waitFor(() => expect(mocks.availabilityChanged).toBeDefined()); + const client = mocks.capturedGatewayClients[0]; + lastCapturedOptions()?.onHelloOk?.({ + protocol: 1, + features: { methods: [], events: [] }, + } as unknown as Parameters>[0]); + expect(client?.request).toHaveBeenCalledWith("node.pluginTools.update", { + tools: [expect.objectContaining({ name: "remote_echo" })], + }); + + mocks.nodePluginTools = []; + mocks.availabilityChanged?.(); + + expect(client?.request).toHaveBeenLastCalledWith("node.pluginTools.update", { tools: [] }); + const onSigterm = processOnceSpy.mock.calls.find(([event]) => event === "SIGTERM")?.[1]; + onSigterm?.("SIGTERM"); + await running; + } finally { + for (const [event, listener] of processOnceSpy.mock.calls) { + if ((event === "SIGINT" || event === "SIGTERM") && typeof listener === "function") { + process.off(event, listener); + } + } + process.exitCode = previousExitCode; + processOnceSpy.mockRestore(); + } + }); + it("publishes node-hosted skills after gateway hello succeeds", async () => { mocks.nodeSkillDescriptors = [ { diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index b32366a49693..9165ffaa3b0f 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -124,9 +124,6 @@ function isUnsupportedNodeSkillsUpdateError(error: unknown): boolean { } async function publishNodePluginTools(client: GatewayClient, tools: unknown[]): Promise { - if (tools.length === 0) { - return; - } try { await client.request("node.pluginTools.update", { tools }); } catch (error) {