diff --git a/node-tools/src/main.ts b/node-tools/src/main.ts index 9c01630..d8fa243 100644 --- a/node-tools/src/main.ts +++ b/node-tools/src/main.ts @@ -29,7 +29,7 @@ import { readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; import { connectNats, fatalBrokerReason as fatalNatsReason, type Credential } from "./broker-nats.js"; -import { runTools, type ServedModule } from "./runtime.js"; +import { takeToolEnvs, runTools, type ServedModule } from "./runtime.js"; import { serveMcpHttp, type Listening } from "./http.js"; /** The credential this process connected with, for what it says beyond the connection (ADR 0159). */ @@ -163,7 +163,9 @@ async function serve(): Promise { const broker = await connectBrokerPatiently(); // Parsed after connecting: a bare entrypoint belongs to the module the credential names. const { serves, moduleEntrypoints } = servedModulesFrom(process.env.MESH_TOOL_MODULES ?? "", lastCredential?.module); - const stop = await runTools({ broker, serves, moduleEntrypoints, credential: lastCredential }); + // Each module's environment, composed by the mesh (ADR 0192): taken before any bundle is imported. + const envs = takeToolEnvs(); + const stop = await runTools({ broker, serves, moduleEntrypoints, credential: lastCredential, envs }); // The console is this runtime's serving mode (ADR 0175 §6): as node-tools, or wherever the // listen address is given, the same process answers MCP on loopback for whoever is on the diff --git a/node-tools/src/runtime.ts b/node-tools/src/runtime.ts index 017b2cf..9cfe86f 100644 --- a/node-tools/src/runtime.ts +++ b/node-tools/src/runtime.ts @@ -14,6 +14,7 @@ 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 * as sdkTools from "@novox/mesh-sdk/tools"; import { collectTools, toolKey, type ToolDefinition } from "@novox/mesh-sdk/tools"; import type { Broker } from "@novox/mesh-sdk/messaging"; import { atWork, seatToolSubject, type Credential, type RuntimeBroker } from "./broker-nats.js"; @@ -61,6 +62,39 @@ export interface RuntimeOptions { * (novox/hq ADR 0159). Absent for a runtime started by hand, which then serves no seat its * memberships do not name. */ credential?: Credential; + /** What each served module's bundles are given (novox/hq ADR 0192): module → words, composed by + * the mesh per machine. A module absent here is given the runtime's own words and nothing more. */ + envs?: ReadonlyMap>>; +} + +/** The variable the mesh composes every served module's environment into, as JSON (ADR 0192). Read + * once at start and removed from the process's environment, so no bundle finds another's there. */ +export const TOOL_ENV = "MESH_TOOL_ENV"; + +/** Read and remove the composed environments from an environment (the process's, by default). */ +export function takeToolEnvs(env: NodeJS.ProcessEnv = process.env): Map> { + const raw = env[TOOL_ENV]; + delete env[TOOL_ENV]; + const out = new Map>(); + if (!raw) return out; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error(`${TOOL_ENV} is not JSON; the mesh composes it as {"": {"": ""}}`); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`${TOOL_ENV} is not an object of modules`); + } + for (const [module, words] of Object.entries(parsed as Record)) { + if (!words || typeof words !== "object" || Array.isArray(words)) { + throw new Error(`${TOOL_ENV}: ${module}'s environment is not an object of words`); + } + const own: Record = {}; + for (const [k, v] of Object.entries(words as Record)) own[k] = String(v); + out.set(module, own); + } + return out; } /** Two environment words the mesh sets for the node's runtime and every tool reads from its @@ -105,6 +139,10 @@ export async function runTools(opts: RuntimeOptions): Promise<() => void> { for (const module of served.keys()) await runtime.follow(module); } + // What each module's bundles are given: the runtime's own words, and over them the module's own. + const envs = opts.envs ?? new Map>(); + const envFor = (module: string): NodeJS.ProcessEnv => ({ ...process.env, ...(envs.get(module) ?? {}) }); + // Import each bundle, guarded (ADR 0175: one faulty bundle must not take the node's tools down). // Importing the entrypoint runs its registerModuleTools(...) — that is the whole handshake — and // the registrations it adds are the ones that appear after it, which is how each is attributed @@ -123,7 +161,7 @@ export async function runTools(opts: RuntimeOptions): Promise<() => void> { const path = resolve(entry); try { if (launches(path)) { - const child = await launch(module, path); + const child = await launch(module, path, envFor(module)); children.push(child.stop); for (const r of child.registrations) launched.push({ ...r, owner: module }); continue; @@ -148,8 +186,16 @@ export async function runTools(opts: RuntimeOptions): Promise<() => void> { // out rather than fatal — on 2026-10-01 the credential of a module that had just learned to // implement a seat did not yet name the claim, and the whole runtime restarted for it. const claimed = seatsClaimed(served.keys(), self, opts.credential, runtime); + // Each registration's contributor is given its own module's environment and no other's (ADR 0192): + // the runtime's own words, and over them what the mesh composed for the module whose bundle made + // the registration. An SDK too old to ask per registration cannot do that; said, not hidden. + const each = (sdkTools as { collectToolsEach?: (f: (module: string, i: number) => NodeJS.ProcessEnv) => { module: string; tools: ToolDefinition[] }[] }).collectToolsEach; + if (!each && envs.size > 0) { + console.log(`[mesh-tools] this runtime's SDK cannot give each bundle its own environment; ${[...envs.keys()].join(", ")} serve with the runtime's words only (novox/hq ADR 0192)`); + } + const collected = each ? each((module, i) => envFor(owner[i] ?? self ?? module)) : collectTools(); const registrations = [ - ...collectTools().map((r, i) => ({ ...r, owner: owner[i] ?? self ?? r.module })), + ...collected.map((r, i) => ({ ...r, owner: owner[i] ?? self ?? r.module })), ...launched, ]; const ownRegistrations = registrations.filter(({ module, owner: by }) => { diff --git a/node-tools/test/fixtures/env-delta.mjs b/node-tools/test/fixtures/env-delta.mjs new file mode 100644 index 0000000..e5fa7ec --- /dev/null +++ b/node-tools/test/fixtures/env-delta.mjs @@ -0,0 +1,4 @@ +import { registerModuleTools } from "@novox/mesh-sdk/tools"; +registerModuleTools("delta", (env) => [ + { name: "given", description: "what delta was given", input: {}, run: async () => ({ mine: env.DELTA_TOKEN_FILE ?? null, theirs: env.GAMMA_CONFIG_FILE ?? null }) }, +]); diff --git a/node-tools/test/fixtures/env-gamma.mjs b/node-tools/test/fixtures/env-gamma.mjs new file mode 100644 index 0000000..8f6f135 --- /dev/null +++ b/node-tools/test/fixtures/env-gamma.mjs @@ -0,0 +1,5 @@ +// A bundle that reads what it was given (novox/hq ADR 0192): its contributor's environment. +import { registerModuleTools } from "@novox/mesh-sdk/tools"; +registerModuleTools("gamma", (env) => [ + { name: "given", description: "what gamma was given", input: {}, run: async () => ({ mine: env.GAMMA_CONFIG_FILE ?? null, theirs: env.DELTA_TOKEN_FILE ?? null, runtime: env.MESH_OPERATOR_ACCOUNT ?? null, composed: env.MESH_TOOL_ENV ?? null }) }, +]); diff --git a/node-tools/test/fixtures/env-zeta.mjs b/node-tools/test/fixtures/env-zeta.mjs new file mode 100755 index 0000000..806518e --- /dev/null +++ b/node-tools/test/fixtures/env-zeta.mjs @@ -0,0 +1,6 @@ +#!/usr/bin/env node +// Launched (ADR 0188): its environment is the child's own. +import { serveStdio } from "@novox/mesh-sdk/stdio"; +await serveStdio("zeta", [ + { name: "given", description: "what zeta was given", input: {}, run: async () => ({ mine: process.env.ZETA_URL ?? null, theirs: process.env.GAMMA_CONFIG_FILE ?? null, composed: process.env.MESH_TOOL_ENV ?? null }) }, +]); diff --git a/node-tools/test/node-runtime.test.ts b/node-tools/test/node-runtime.test.ts index e09ae86..24fd813 100644 --- a/node-tools/test/node-runtime.test.ts +++ b/node-tools/test/node-runtime.test.ts @@ -21,7 +21,7 @@ import { resetTools } from "@novox/mesh-sdk/tools"; import { connectNats, membershipSubject } from "../dist/broker-nats.js"; import { callTool, toolsOn } from "../dist/client.js"; import { servedModulesFrom } from "../dist/main.js"; -import { runTools } from "../dist/runtime.js"; +import { runTools, takeToolEnvs } from "../dist/runtime.js"; const url = process.env.MESH_TEST_NATS; const fixture = (name: string) => fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)); @@ -241,3 +241,51 @@ test("a bundle carrying its own copy of the SDK registers into the runtime's reg resetTools(); } }); + +test("each bundle is given its own environment and none of another's, imported or launched (ADR 0192)", async (t) => { + if (!url) return t.skip("MESH_TEST_NATS unset"); + resetTools(); + const mesh = await aMesh(); + for (const m of ["gamma", "delta", "zeta"]) await mesh.issue(membershipOf(m, "anchor")); + const credential = { url, node: "anchor", module: "node-tools" }; + const nodeTools = await connectNats(credential); + const asker = await connectNats({ url, module: "console", node: "workstation" }); + const log = console.log; + let stop = () => {}; + const before = process.env.MESH_TOOL_ENV; + try { + process.env.MESH_OPERATOR_ACCOUNT = "somebody"; + process.env.MESH_TOOL_ENV = JSON.stringify({ + gamma: { GAMMA_CONFIG_FILE: "/var/lib/mesh/gamma/config.json" }, + delta: { DELTA_TOKEN_FILE: "/var/lib/mesh/delta/token" }, + zeta: { ZETA_URL: "http://127.0.0.1:3000" }, + }); + const envs = takeToolEnvs(); + assert.equal(process.env.MESH_TOOL_ENV, undefined, "the composed environments were left in the process's"); + console.log = () => {}; + stop = await runTools({ + broker: nodeTools, credential, envs, + serves: [ + { module: "gamma", entrypoints: [fixture("env-gamma.mjs")] }, + { module: "delta", entrypoints: [fixture("env-delta.mjs")] }, + { module: "zeta", entrypoints: [fixture("env-zeta.mjs")] }, + ], + }); + console.log = log; + assert.deepEqual((await callTool(asker, "gamma.given@anchor", {})).result, + { mine: "/var/lib/mesh/gamma/config.json", theirs: null, runtime: "somebody", composed: null }); + assert.deepEqual((await callTool(asker, "delta.given@anchor", {})).result, + { mine: "/var/lib/mesh/delta/token", theirs: null }); + assert.deepEqual((await callTool(asker, "zeta.given@anchor", {})).result, + { mine: "http://127.0.0.1:3000", theirs: null, composed: null }); + } finally { + console.log = log; + if (before === undefined) delete process.env.MESH_TOOL_ENV; else process.env.MESH_TOOL_ENV = before; + delete process.env.MESH_OPERATOR_ACCOUNT; + stop(); + await asker.close(); + await nodeTools.close(); + await mesh.close(); + resetTools(); + } +});