diff --git a/node-tools/src/runtime.ts b/node-tools/src/runtime.ts index 2b6329f..0cea96a 100644 --- a/node-tools/src/runtime.ts +++ b/node-tools/src/runtime.ts @@ -11,6 +11,7 @@ import { pathToFileURL } from "node:url"; import { resolve } from "node:path"; +import { register } 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 +110,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 +317,11 @@ async function serveClaimedSeats( if (typeof broker.onMembership === "function") broker.onMembership(() => void serve()); return () => stops.forEach((s) => s()); } + +let sdkHooked = false; +/** Resolve `@novox/mesh-sdk` for every bundle to the copy this runtime imported (sdk-hooks.ts). */ +function oneSdk(): void { + if (sdkHooked) return; + sdkHooked = true; + register("./sdk-hooks.js", { parentURL: import.meta.url, data: { runtimeURL: import.meta.url } }); +} diff --git a/node-tools/src/sdk-hooks.ts b/node-tools/src/sdk-hooks.ts new file mode 100644 index 0000000..38788f3 --- /dev/null +++ b/node-tools/src/sdk-hooks.ts @@ -0,0 +1,38 @@ +// 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 @novox/mesh-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. +// +// Installed through module.register(), whose resolve hook sees every import that follows. + +interface ResolveContext { + parentURL?: string; + conditions: string[]; + importAttributes: Record; +} +interface Resolved { + url: string; + format?: string | null; + shortCircuit?: boolean; +} +type NextResolve = (specifier: string, context?: Partial) => Promise; + +const SDK = "@novox/mesh-sdk"; +let runtimeURL = ""; + +/** Told, once, which file's tree holds the runtime's SDK. */ +export function initialize(data: { runtimeURL: string }): void { + runtimeURL = data.runtimeURL; +} + +export async function resolve(specifier: string, context: ResolveContext, next: NextResolve): Promise { + if (runtimeURL && (specifier === SDK || specifier.startsWith(SDK + "/"))) { + return next(specifier, { ...context, parentURL: runtimeURL }); + } + return next(specifier, context); +} diff --git a/node-tools/test/node-runtime.test.ts b/node-tools/test/node-runtime.test.ts index fd9a61e..7e2e1b8 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, 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,40 @@ 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(); + // 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. + const 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 }); + writeFileSync(join(dir, "package.json"), '{"type":"module","private":true}\n'); + writeFileSync(join(dir, "index.js"), + 'import { registerModuleTools } from "@novox/mesh-sdk/tools";\n' + + 'registerModuleTools("zeta", () => [{ name: "probe", description: "answers", input: {}, run: async () => ({ zeta: true }) }]);\n'); + const mesh = await aMesh(); + await mesh.issue(membershipOf("zeta", "anchor")); + const credential = { url, node: "anchor", module: "node-tools" }; + const nodeTools = await connectNats(credential); + const asker = await connectNats({ url, module: "console", node: "workstation" }); + const said: string[] = []; + const log = console.log; + console.log = (...a: unknown[]) => said.push(a.join(" ")); + let stop = () => {}; + try { + 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")); + assert.deepEqual((await callTool(asker, "zeta.probe@anchor", {})).result, { zeta: true }); + } finally { + console.log = log; + stop(); + await asker.close(); + await nodeTools.close(); + await mesh.close(); + rmSync(dir, { recursive: true, force: true }); + resetTools(); + } +});