runtime: RPC replies ride mesh.rpc; an invoke subcommand (ADR 0052)

Replies go through the RPC exchange keyed by the caller's reply-queue name, not
the default exchange — so a serving module's scoped account answers with write
on mesh.rpc alone, never the default exchange (which would let it publish into
any queue). 'mesh-tools invoke <module> <tool> [args]' is the caller's side, the
sibling of emit. Verified against a real broker: a scoped account serves its
tool and is refused another module's serve queue.
This commit is contained in:
2026-09-04 21:56:04 +02:00
parent bf5339cec2
commit 4558248f16
3 changed files with 37 additions and 10 deletions
+7 -1
View File
@@ -75,6 +75,10 @@ export async function connectAmqp(
async function ensureReply(): Promise<string> {
if (replyQueue) return replyQueue;
const { queue } = await ch.assertQueue("", { exclusive: true });
// Replies come back through the RPC exchange keyed by this queue's own name, not the default
// exchange (novox/hq ADR 0052): a serving module's scoped account may write to mesh.rpc but not
// the default exchange, which would let it publish into any queue on the broker.
await ch.bindQueue(queue, RPC_EXCHANGE, queue);
replyQueue = queue;
await ch.consume(
queue,
@@ -161,7 +165,9 @@ export async function connectAmqp(
reply = { error: err instanceof Error ? err.message : String(err) };
}
if (msg.properties.replyTo) {
ch.sendToQueue(msg.properties.replyTo, Buffer.from(JSON.stringify(reply)), {
// Reply through the RPC exchange, keyed by the caller's reply-queue name, so a scoped
// account answers with write on mesh.rpc alone — never the default exchange (ADR 0052).
ch.publish(RPC_EXCHANGE, msg.properties.replyTo, Buffer.from(JSON.stringify(reply)), {
correlationId: msg.properties.correlationId,
});
}
+26
View File
@@ -17,6 +17,7 @@ import { readFileSync } from "node:fs";
import { connectAmqp } from "./broker-amqp.js";
import type { Credential } from "./broker-amqp.js";
import { runTools } from "./runtime.js";
import { invokeTool } from "@novox/mesh-sdk/tools";
import { useBroker } from "@novox/mesh-sdk/messaging";
import type { Broker } from "@novox/mesh-sdk/messaging";
import { emit } from "@novox/mesh-sdk/events";
@@ -92,8 +93,33 @@ async function emitOnce(type: string, bodyJson: string): Promise<void> {
await broker.close();
}
async function invokeOnce(module: string, tool: string, argsJson: string): Promise<void> {
let args: Record<string, unknown> = {};
if (argsJson) {
try {
args = JSON.parse(argsJson) as Record<string, unknown>;
} catch {
console.error(`mesh-tools invoke: args are not JSON: ${argsJson}`);
process.exit(1);
}
}
const broker = await connectBroker();
const result = await invokeTool(broker, module, tool, args);
process.stdout.write(JSON.stringify(result) + "\n");
await broker.close();
}
async function main(): Promise<void> {
const [command, ...rest] = process.argv.slice(2);
if (command === "invoke") {
const [module, tool] = rest;
if (!module || !tool) {
console.error("mesh-tools invoke <module> <tool> [json-args] — a module and tool are required");
process.exit(1);
}
await invokeOnce(module, tool, rest[2] ?? "");
return;
}
if (command === "emit") {
const type = rest[0];
if (!type) {