From 7152148410f863cf6a65204824608a70d457cb42 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 3 Oct 2026 12:44:21 +0200 Subject: [PATCH] One SDK per runtime: a bundle's SDK import resolves to the runtime's copy (hq issue 209) A bundle carries its dependencies, the SDK among them; imported in-process that copy was a second SDK with its own tool registry, so a bundle registered its tools into a list the runtime never read and served nothing, silently. A resolve hook (module.registerHooks, in thread; module.register is deprecated from Node 26) now sends every import of @novox/mesh-sdk, from whichever bundle, to the runtime's own copy: one registry, one broker. The test loads a bundle from a directory holding its own SDK copy and sees its tool served. --- node-tools/src/runtime.ts | 69 +++++++++++++++++++++++++++- node-tools/test/node-runtime.test.ts | 49 ++++++++++++++++++++ 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/node-tools/src/runtime.ts b/node-tools/src/runtime.ts index 2b6329f..017b2cf 100644 --- a/node-tools/src/runtime.ts +++ b/node-tools/src/runtime.ts @@ -9,8 +9,10 @@ // and the per-module shape is the list with one entry. A bundle that fails to import is named — // in the log and in what `tools` answers for it — and the others serve. -import { pathToFileURL } from "node:url"; -import { resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { dirname, join, resolve } from "node:path"; +import { existsSync, readFileSync } from "node:fs"; +import { registerHooks } from "node:module"; import { useBroker } from "@novox/mesh-sdk/messaging"; import { collectTools, toolKey, type ToolDefinition } from "@novox/mesh-sdk/tools"; import type { Broker } from "@novox/mesh-sdk/messaging"; @@ -109,6 +111,9 @@ export async function runTools(opts: RuntimeOptions): Promise<() => void> { // to the module whose bundle made it. // A bundle that is not plain JavaScript — or is marked executable — is launched as a process // and spoken to over MCP on stdio instead (ADR 0188); what it lists is registered the same way. + // Every bundle's import of the SDK resolves to this runtime's copy (04-ISSUES/209): one registry + // of tools, one broker. Installed before the first bundle is imported. + oneSdk(); const failed = new Map(); const owner: string[] = []; // registration index → the module whose bundle registered it const launched: { module: string; owner: string; tools: ToolDefinition[] }[] = []; @@ -313,3 +318,63 @@ async function serveClaimedSeats( if (typeof broker.onMembership === "function") broker.onMembership(() => void serve()); return () => stops.forEach((s) => s()); } + +let sdkHooked = false; +const SDK = "@novox/mesh-sdk"; +/** + * The one SDK in a node's runtime (novox/hq ADR 0175, 04-ISSUES/209). + * + * A bundle carries its own dependencies — the toolchain copies them in so a bundle starts anywhere + * (to-be 38 WP3) — and among them is a copy of the SDK. Imported in this process, that copy would be + * a second SDK: its own registry of tools, its own broker handle. A bundle calling registerModuleTools + * through it registers into a list this runtime never reads, and its tools are silently not served. + * So every import of the SDK, from whichever bundle, is resolved as if this runtime had written it: + * one registry, one broker — the runtime's. Everything else a bundle carries resolves from the + * bundle's own tree, as before. A launched bundle (ADR 0188) is another process and is untouched. + * + * Installed once, in-thread, before the first bundle is imported; the hook sees every import after, + * `require` included. A bundle whose own copy is another version than the runtime's is said once, + * so a tool failing against the runtime's SDK points at the bundle rather than at the runtime. + */ +function oneSdk(): void { + if (sdkHooked) return; + registerHooks({ + resolve(specifier, context, next) { + if (specifier === SDK || specifier.startsWith(SDK + "/")) { + if (context.parentURL) sayOtherSdk(context.parentURL); + return next(specifier, { ...context, parentURL: import.meta.url }); + } + return next(specifier, context); + }, + }); + sdkHooked = true; +} + +const sdkSaid = new Set(); +/** The version of the SDK copy nearest a file, by its package.json, or nothing when the file has none above it. */ +function sdkVersionNear(fileURL: string): { dir: string; version: string } | undefined { + let dir = dirname(fileURLToPath(fileURL)); + for (;;) { + const pkg = join(dir, "node_modules", SDK, "package.json"); + if (existsSync(pkg)) { + try { + return { dir, version: String((JSON.parse(readFileSync(pkg, "utf8")) as { version?: string }).version ?? "?") }; + } catch { + return { dir, version: "?" }; + } + } + const up = dirname(dir); + if (up === dir) return undefined; + dir = up; + } +} +function sayOtherSdk(parentURL: string): void { + if (!parentURL.startsWith("file:")) return; + const own = sdkVersionNear(import.meta.url); + const theirs = sdkVersionNear(parentURL); + if (!theirs || !own || theirs.dir === own.dir || sdkSaid.has(theirs.dir)) return; + sdkSaid.add(theirs.dir); + if (theirs.version !== own.version) { + console.log(`[mesh-tools] ${theirs.dir} carries ${SDK} ${theirs.version}; this runtime's is ${own.version}, and the bundle speaks to the runtime's`); + } +} diff --git a/node-tools/test/node-runtime.test.ts b/node-tools/test/node-runtime.test.ts index fd9a61e..e09ae86 100644 --- a/node-tools/test/node-runtime.test.ts +++ b/node-tools/test/node-runtime.test.ts @@ -11,6 +11,9 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; +import { cpSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; import { connect, StringCodec } from "nats"; import { resetTools } from "@novox/mesh-sdk/tools"; @@ -192,3 +195,49 @@ test("the node's runtime serves five modules' bundles on one credential — two resetTools(); } }); + +test("a bundle carrying its own copy of the SDK registers into the runtime's registry, and its tools are served (issue 209)", async (t) => { + if (!url) return t.skip("MESH_TEST_NATS unset"); + resetTools(); + let dir = ""; + let stop = () => {}; + const closing: Array<() => Promise> = []; + const said: string[] = []; + const log = console.log; + try { + // A bundle as the toolchain packs one: its compiled entrypoint, a package.json saying ES modules, + // and its dependencies copied in — the SDK among them, a second copy beside the runtime's own, + // and a dependency of the bundle's own that the runtime does not carry. + dir = mkdtempSync(join(tmpdir(), "mesh-bundle-")); + const sdk = realpathSync(fileURLToPath(new URL("../node_modules/@novox/mesh-sdk/", import.meta.url))); + cpSync(sdk, join(dir, "node_modules", "@novox", "mesh-sdk"), { recursive: true }); + mkdirSync(join(dir, "node_modules", "zeta-flavour"), { recursive: true }); + writeFileSync(join(dir, "node_modules", "zeta-flavour", "package.json"), '{"name":"zeta-flavour","type":"module","main":"index.js"}\n'); + writeFileSync(join(dir, "node_modules", "zeta-flavour", "index.js"), 'export const flavour = "the bundle\'s own";\n'); + writeFileSync(join(dir, "package.json"), '{"type":"module","private":true}\n'); + writeFileSync(join(dir, "index.js"), + 'import { registerModuleTools } from "@novox/mesh-sdk/tools";\n' + + 'import { flavour } from "zeta-flavour";\n' + + 'registerModuleTools("zeta", () => [{ name: "probe", description: "answers", input: {}, run: async () => ({ zeta: true, flavour }) }]);\n'); + const mesh = await aMesh(); + closing.push(() => mesh.close()); + await mesh.issue(membershipOf("zeta", "anchor")); + const credential = { url, node: "anchor", module: "node-tools" }; + const nodeTools = await connectNats(credential); + closing.push(() => nodeTools.close()); + const asker = await connectNats({ url, module: "console", node: "workstation" }); + closing.push(() => asker.close()); + console.log = (...a: unknown[]) => said.push(a.join(" ")); + stop = await runTools({ broker: nodeTools, credential, serves: [{ module: "zeta", entrypoints: [join(dir, "index.js")] }] }); + console.log = log; + assert.ok(said.some((s) => /serving 1 tool\(s\) for 1 module\(s\): zeta\.probe/.test(s)), said.join("\n")); + // The SDK is the runtime's (the registration arrived); the bundle's other dependency is its own. + assert.deepEqual((await callTool(asker, "zeta.probe@anchor", {})).result, { zeta: true, flavour: "the bundle's own" }); + } finally { + console.log = log; + stop(); + for (const close of closing.reverse()) await close(); + if (dir) rmSync(dir, { recursive: true, force: true }); + resetTools(); + } +});