From dc59ec0e1c8b9c746cab9843bb8adc23930bb28f Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 3 Oct 2026 21:05:38 +0200 Subject: [PATCH] A launched bundle serves under the module the runtime names (hq ADR 0193) serveRegisteredOverStdio lists the registrations of the module named by MESH_SERVED_MODULE by their own names and every other registration as .. Before, the first registration was taken for the module's own, so a bundle registering its seat first listed the seat's verbs as its own tools. Contributors are given the process's environment, the bundle's own. 0.1.4. --- package.json | 2 +- src/stdio/index.ts | 17 ++++++++++++----- test/fixtures/stdio-seat-first.mjs | 6 ++++++ test/stdio.test.ts | 29 +++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 6 deletions(-) create mode 100644 test/fixtures/stdio-seat-first.mjs diff --git a/package.json b/package.json index 71c3592..29e8145 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@novox/mesh-sdk", - "version": "0.1.3", + "version": "0.1.4", "description": "The stable spine a Novox Mesh module's own code builds against.", "type": "module", "exports": { diff --git a/src/stdio/index.ts b/src/stdio/index.ts index 214fbfc..5ffb913 100644 --- a/src/stdio/index.ts +++ b/src/stdio/index.ts @@ -89,13 +89,20 @@ export async function serveStdio(name: string, tools: ToolDefinition[]): Promise } } +/** The word the node's runtime sets for a bundle it launches: the module it serves the bundle as + * (novox/hq ADR 0193). */ +export const SERVED_AS = "MESH_SERVED_MODULE"; + /** - * Serve everything registered with `registerModuleTools` over stdio: the one-line entry for a bundle - * that already registers the way the in-process shortcut expects, so one source serves both ways. + * Serve everything registered with `registerModuleTools` over stdio — the launcher every TypeScript + * bundle the runtime serves is started through (novox/hq ADR 0193). What the module the runtime + * serves this bundle as registered is listed by its own name; anything registered under another + * name — a seat this module implements — is listed `.`, whichever was registered first. + * Every contributor is given this process's environment, which is the bundle's own. */ -export async function serveRegisteredOverStdio(): Promise { - const groups = collectTools(); - const name = groups[0]?.module ?? "bundle"; +export async function serveRegisteredOverStdio(as: string | undefined = process.env[SERVED_AS]): Promise { + const groups = collectTools(process.env); + const name = as ?? groups[0]?.module ?? "bundle"; const tools: ToolDefinition[] = []; for (const g of groups) { for (const t of g.tools) tools.push(g.module === name ? t : { ...t, name: `${g.module}.${t.name}` }); diff --git a/test/fixtures/stdio-seat-first.mjs b/test/fixtures/stdio-seat-first.mjs new file mode 100644 index 0000000..2f34180 --- /dev/null +++ b/test/fixtures/stdio-seat-first.mjs @@ -0,0 +1,6 @@ +// A bundle that registers its seat's verbs before its own tools, as the packet filter does. +import { registerModuleTools } from "../../dist/tools/index.js"; +import { serveRegisteredOverStdio } from "../../dist/stdio/index.js"; +registerModuleTools("node-packet-filter", () => [{ name: "rules", description: "the ruleset", input: {}, run: async () => "r" }]); +registerModuleTools("nftables", (env) => [{ name: "firewall_rules", description: "the table", input: {}, run: async () => env.NFT_WORD ?? null }]); +await serveRegisteredOverStdio(); diff --git a/test/stdio.test.ts b/test/stdio.test.ts index 17aa3a0..32b1b5f 100644 --- a/test/stdio.test.ts +++ b/test/stdio.test.ts @@ -39,3 +39,32 @@ test("a bundle on stdio lists its tools and answers a call, a seat's verb among child.kill(); } }); + +test("a bundle served as its module lists the seat's verbs as the seat's, whichever registered first (ADR 0193)", async () => { + const child = spawn(process.execPath, [fileURLToPath(new URL("./fixtures/stdio-seat-first.mjs", import.meta.url))], + { stdio: ["pipe", "pipe", "inherit"], env: { ...process.env, MESH_SERVED_MODULE: "nftables", NFT_WORD: "given" } }); + const replies: any[] = []; + let buffered = ""; + child.stdout.on("data", (d) => { + buffered += d.toString(); + let at: number; + while ((at = buffered.indexOf("\n")) >= 0) { + replies.push(JSON.parse(buffered.slice(0, at))); + buffered = buffered.slice(at + 1); + } + }); + const ask = async (id: number, method: string, params?: unknown) => { + child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); + for (let i = 0; i < 100 && !replies.some((r) => r.id === id); i++) await new Promise((r) => setTimeout(r, 20)); + return replies.find((r) => r.id === id); + }; + try { + assert.equal((await ask(1, "initialize", { protocolVersion: "2025-03-26", capabilities: {} })).result.serverInfo.name, "nftables"); + const listed = (await ask(2, "tools/list")).result.tools; + assert.deepEqual(listed.map((t: any) => t.name), ["node-packet-filter.rules", "firewall_rules"]); + const called = (await ask(3, "tools/call", { name: "firewall_rules", arguments: {} })).result; + assert.equal(called.content[0].text, JSON.stringify("given")); + } finally { + child.kill(); + } +});