diff --git a/src/cli/nodes-cli.coverage.test.ts b/src/cli/nodes-cli.coverage.test.ts index 1800b727049..e261725f292 100644 --- a/src/cli/nodes-cli.coverage.test.ts +++ b/src/cli/nodes-cli.coverage.test.ts @@ -208,6 +208,19 @@ describe("nodes-cli coverage", () => { expect(runtimeErrors.at(-1)).toContain('command "system.run" is reserved for shell execution'); }); + it("rejects malformed nodes invoke params before opening a gateway call", async () => { + await expect( + sharedProgram.parseAsync( + ["nodes", "invoke", "--node", "mac-1", "--command", "canvas.eval", "--params", "not-json"], + { from: "user" }, + ), + ).rejects.toThrow("__exit__:1"); + + expect(runtimeErrors.at(-1)).toContain("--params must be valid JSON."); + expect(callGateway).not.toHaveBeenCalled(); + expect(lastNodeInvokeCall).toBeNull(); + }); + it("invokes system.notify with provided fields", async () => { const invoke = await runNodesCommand([ "nodes", diff --git a/src/cli/nodes-cli/register.invoke.ts b/src/cli/nodes-cli/register.invoke.ts index 1115cc411db..5fbbed1c984 100644 --- a/src/cli/nodes-cli/register.invoke.ts +++ b/src/cli/nodes-cli/register.invoke.ts @@ -17,6 +17,14 @@ import type { NodesRpcOpts } from "./types.js"; const BLOCKED_NODE_INVOKE_COMMANDS = new Set(["system.run", "system.run.prepare"]); +function parseNodeInvokeParams(value = "{}"): unknown { + try { + return JSON.parse(value) as unknown; + } catch { + throw new Error("--params must be valid JSON."); + } +} + /** Register direct node command invocation. */ export function registerNodesInvokeCommands(nodes: Command) { nodesCallOpts( @@ -30,9 +38,9 @@ export function registerNodesInvokeCommands(nodes: Command) { .option("--idempotency-key ", "Idempotency key (optional)") .action(async (opts: NodesRpcOpts) => { await runNodesCommand("invoke", async () => { - const nodeId = await resolveNodeId(opts, normalizeOptionalString(opts.node) ?? ""); + const nodeQuery = normalizeOptionalString(opts.node) ?? ""; const command = normalizeOptionalString(opts.command) ?? ""; - if (!nodeId || !command) { + if (!nodeQuery || !command) { const { error } = getNodesTheme(); defaultRuntime.error(error("--node and --command required")); defaultRuntime.exit(1); @@ -43,7 +51,8 @@ export function registerNodesInvokeCommands(nodes: Command) { `command "${command}" is reserved for shell execution; use the exec tool with host=node instead`, ); } - const params = JSON.parse(opts.params ?? "{}") as unknown; + const params = parseNodeInvokeParams(opts.params); + const nodeId = await resolveNodeId(opts, nodeQuery); const timeoutMs = parseOptionalNodePositiveInteger( opts.invokeTimeout, "--invoke-timeout",