diff --git a/README.md b/README.md index f5bb790..0c40988 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,7 @@ marked executable — is **launched** rather than imported (novox/hq ADR 0188): as a child with its own environment and speaks MCP over stdio to it, `tools/list` once and `tools/call` per call. A tool it lists as `.` is the seat's implementation. A child that exits is named in the log and started again on its next call. So a tools bundle may be written in -any language; the mesh's SDK for each is the stdio loop and nothing more (`node-tools/src/launch.ts` -is the runtime's side of it). +any language; the mesh's SDK for each is the stdio loop and nothing more (`node-tools/internal/launch` is the runtime's side of it). Everything hard — dispatch, collection, duplicate-name safety — is the sdk's. This is the thin wrapper that binds the bus and loads the modules. Keeping the bus client here, out of the sdk, is diff --git a/node-tools/go.mod b/node-tools/go.mod index c355438..751f6a9 100644 --- a/node-tools/go.mod +++ b/node-tools/go.mod @@ -8,7 +8,7 @@ require ( ) require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103142843-f8bd12e4b7b1 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/klauspost/compress v1.20.0 // indirect diff --git a/node-tools/go.sum b/node-tools/go.sum index 0111c9e..abad296 100644 --- a/node-tools/go.sum +++ b/node-tools/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103142843-f8bd12e4b7b1 h1:T66tg9T/1hHg6KN/bDScHPT41IHkxLAGGO8VJiAOlb8= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103142843-f8bd12e4b7b1/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/node-tools/internal/bus/bus.go b/node-tools/internal/bus/bus.go index d5e5e3d..23f0cf2 100644 --- a/node-tools/internal/bus/bus.go +++ b/node-tools/internal/bus/bus.go @@ -1046,7 +1046,7 @@ func (c *Conn) AskAs(module, key string, body any) (Answered, error) { // EventsStream is where every module's events land, and ConsumerOf the durable consumer the // mesh makes for a module on a machine: the same names the module's own runtime bound -// (node-tools/src/broker-nats.ts), so moving the module into the node's runtime neither loses an +// (the retired TypeScript runtime's broker-nats.ts), so moving the module into the node's runtime neither loses an // event nor sees one twice. const EventsStream = "EVENTS" diff --git a/node-tools/internal/meshtest/meshtest_test.go b/node-tools/internal/meshtest/meshtest_test.go index 8da2521..8a8a000 100644 --- a/node-tools/internal/meshtest/meshtest_test.go +++ b/node-tools/internal/meshtest/meshtest_test.go @@ -110,6 +110,13 @@ func TestTheRepositoryHoldsNoCheckScriptAndNodeToolsIsItsOnlyModule(t *testing.T if !d.IsDir() && d.Name() == "module.json" { found = append(found, filepath.ToSlash(p)) } + // ADR 0604 section 4: the TypeScript that called the old catalog module's tools is gone, and nothing + // under node-tools names one (the needle is built so this file does not name it either). + if !d.IsDir() && strings.Contains(filepath.ToSlash(p), "/node-tools/") { + if body, rerr := os.ReadFile(p); rerr == nil && strings.Contains(string(body), "catalog"+"_modules") { + t.Errorf("%s names a catalog tool of the retired module", p) + } + } return nil }) if err != nil { diff --git a/node-tools/internal/provide/provide_test.go b/node-tools/internal/provide/provide_test.go index a6aa784..47957c9 100644 --- a/node-tools/internal/provide/provide_test.go +++ b/node-tools/internal/provide/provide_test.go @@ -206,7 +206,12 @@ func TestTheManifestNamesNothingOfTheController(t *testing.T) { if err != nil { t.Fatal(err) } - if refused := catalogue.ManifestRefusals(map[string]catalogue.Manifest{Module: m}); len(refused) != 0 { - t.Errorf("the controller-dependence check refuses node-tools' manifest: %v", refused) + // Read as text, as mesh-catalog's controllerdeps check reads its own: mesh-sdk names no module (issue 774), and the + // manifest says it is the tool runner rather than being known by its name. + if strings.Contains(string(raw), "mesh-controller") { + t.Error("node-tools' manifest names the ended controller") + } + if !catalogue.IsToolRunner(m) { + t.Error("node-tools' manifest does not say it is the tool runner (\"tool-runner\": true)") } } diff --git a/node-tools/module.json b/node-tools/module.json index e6cb6db..535300f 100644 --- a/node-tools/module.json +++ b/node-tools/module.json @@ -2,6 +2,7 @@ "module": "node-tools", "version": "1", "slug": "node-tools", + "tool-runner": true, "invokes": [ "*" ], diff --git a/node-tools/package-lock.json b/node-tools/package-lock.json index ab5293f..5576fea 100644 --- a/node-tools/package-lock.json +++ b/node-tools/package-lock.json @@ -11,10 +11,6 @@ "@novox/mesh-sdk": "^0.1.6", "nats": "^2.29.0" }, - "bin": { - "mesh": "dist/mesh.js", - "mesh-tools": "dist/main.js" - }, "devDependencies": { "@types/node": "^22.20.1", "esbuild": "^0.25.0", diff --git a/node-tools/package.json b/node-tools/package.json index 9bc65fa..68dbaef 100644 --- a/node-tools/package.json +++ b/node-tools/package.json @@ -1,17 +1,8 @@ { "name": "@novox/mesh-tools", "version": "0.1.0", - "description": "The Novox Mesh tool runtime \u2014 binds the mesh broker and serves the assigned modules' tools.", + "description": "The Novox Mesh tool runtime — binds the mesh broker and serves the assigned modules' tools.", "type": "module", - "bin": { - "mesh-tools": "./dist/main.js", - "mesh": "./dist/mesh.js" - }, - "scripts": { - "build": "tsc", - "pretest": "tsc", - "test": "node --test --test-concurrency=1 --experimental-strip-types 'test/*.test.ts'" - }, "dependencies": { "@novox/mesh-sdk": "^0.1.6", "nats": "^2.29.0" diff --git a/node-tools/src/announce.ts b/node-tools/src/announce.ts deleted file mode 100644 index 6641d11..0000000 --- a/node-tools/src/announce.ts +++ /dev/null @@ -1,139 +0,0 @@ -// What a runtime serves, it announces (novox/hq ADR 0197): the NATS services protocol's discovery — -// `$SRV.PING`, `$SRV.INFO`, `$SRV.STATS`, and the same followed by the service's name and its id — -// answered in the io.nats.micro.v1 format with what is served at the moment of the request. Serving is -// unchanged; this only says what is served. One service per runtime process, because the bus admits -// one reply per request from each responder: one endpoint per tool per subject, its metadata saying -// which module, seat, scope and machine it is. The same shape the Go runtime answers. - -import { StringCodec } from "nats"; -import { asSchema } from "@novox/mesh-sdk/stdio"; -import type { ToolDefinition } from "@novox/mesh-sdk/tools"; -import type { RuntimeBroker } from "./broker-nats.js"; - -const sc = StringCodec(); - -export const VERSION = "0.1.0"; -export const INFO_RESPONSE = "io.nats.micro.v1.info_response"; -export const PING_RESPONSE = "io.nats.micro.v1.ping_response"; -export const STATS_RESPONSE = "io.nats.micro.v1.stats_response"; - -/** One tool served on one subject, as announced. */ -export interface Endpoint { - kind: "tool" | "seat"; - module: string; - tool: string; - seat?: string; - scope?: "mesh" | "node"; - node: string; - description: string; - schema: unknown; - interchangeable: boolean; - subject: string; - queue?: string; -} - -/** A seat's verb as the runtime serves it, with the definition that answers it. */ -export interface ServedSeatVerb { - seat: string; - verb: string; - subject: string; - holder: string; - tool: ToolDefinition; -} - -export interface Service { - name: string; - id: string; - description: string; - metadata: Record; -} - -/** The endpoint's name as the protocol allows it; the metadata, not the name, identifies it. */ -function nameOf(e: Endpoint): string { - const clean = (s: string) => s.replace(/[^A-Za-z0-9_-]/g, "_"); - return `${clean(e.kind === "seat" ? e.seat ?? "" : e.module)}__${clean(e.tool)}`; -} - -/** The info_response for these endpoints. */ -export function info(s: Service, endpoints: Endpoint[]): Record { - return { - name: s.name, id: s.id, version: VERSION, metadata: s.metadata, type: INFO_RESPONSE, description: s.description, - endpoints: endpoints.map((e) => { - const metadata: Record = { - kind: e.kind, module: e.module, tool: e.tool, node: e.node, description: e.description, - schema: JSON.stringify(e.schema ?? {}), interchangeable: e.interchangeable ? "true" : "false", - }; - if (e.kind === "seat") { - metadata.seat = e.seat ?? ""; - metadata.scope = e.scope ?? "mesh"; - } - return { name: nameOf(e), subject: e.subject, queue_group: e.queue ?? "", metadata }; - }), - }; -} - -/** Everything served now: each module's tools on every subject issued for them, and each held - * seat's verbs on the seat's subject. */ -export function endpointsOf( - broker: RuntimeBroker, - own: { module: string; tools: ToolDefinition[] }[], - seats: ServedSeatVerb[], -): Endpoint[] { - const node = broker.node ?? ""; - const out: Endpoint[] = []; - for (const { module, tools } of own) { - for (const t of tools) { - const served = broker.servedOn ? broker.servedOn(module, t.name) : []; - const interchangeable = served.some((s) => s.subject === `mesh.mod.${module}.tool.${t.name}`); - for (const s of served) { - out.push({ kind: "tool", module, tool: t.name, node, description: t.description, schema: asSchema(t.input), - interchangeable, subject: s.subject, queue: s.queue }); - } - } - } - for (const v of seats) { - out.push({ kind: "seat", module: v.holder, tool: v.verb, seat: v.seat, - scope: node && v.subject.endsWith(`.${node}`) ? "node" : "mesh", node, description: v.tool.description, - schema: asSchema(v.tool.input), interchangeable: false, subject: v.subject }); - } - return out; -} - -/** Answer discovery for one service until stopped. */ -export function announce(broker: RuntimeBroker, s: Service, current: () => Endpoint[]): () => void { - const started = new Date().toISOString(); - const identity = { name: s.name, id: s.id, version: VERSION, metadata: s.metadata }; - const answer = (subject: string): Uint8Array | undefined => { - const parts = subject.split("."); - if (parts[0] !== "$SRV" || parts.length < 2) return undefined; - if (parts.length >= 3 && parts[2] !== s.name) return undefined; // another service's - if (parts.length >= 4 && parts[3] !== s.id) return undefined; // another instance's - let v: unknown; - switch (parts[1]) { - case "PING": - v = { ...identity, type: PING_RESPONSE }; - break; - case "INFO": - v = info(s, current()); - break; - case "STATS": - v = { ...identity, type: STATS_RESPONSE, started, endpoints: current().map((e) => ({ - name: nameOf(e), subject: e.subject, queue_group: e.queue ?? "", num_requests: 0, num_errors: 0, - last_error: "", processing_time: 0, average_processing_time: 0 })) }; - break; - default: - return undefined; - } - return sc.encode(JSON.stringify(v)); - }; - const stops: (() => void)[] = []; - // Exactly the questions asked of every service and of this one by name and instance — what the - // grants allow (novox/hq ADR 0197). A wildcard is refused by the bus, and a refused subscription - // ends a runtime: on 2026-10-03 it crash-looped every container that announced itself. - for (const verb of ["PING", "INFO", "STATS"]) { - for (const subject of [`$SRV.${verb}`, `$SRV.${verb}.${s.name}`, `$SRV.${verb}.${s.name}.${s.id}`]) { - stops.push(broker.raw!(subject, (subj) => answer(subj))); - } - } - return () => stops.forEach((stop) => stop()); -} diff --git a/node-tools/src/broker-nats.ts b/node-tools/src/broker-nats.ts deleted file mode 100644 index a413a3b..0000000 --- a/node-tools/src/broker-nats.ts +++ /dev/null @@ -1,680 +0,0 @@ -// The tool runtime's broker client, on NATS. -// -// **The sdk's contract does not change** (novox/hq ADR 0106, ADR 0039): a module is written -// against `request`, `handle`, `publish`, `subscribe`, `close`, and the runtime implements them. -// That is why a module built before any of this runs on the new runtime without a rebuild, and -// why the sdk's own diff for the whole bus change is three comments. -// -// Underneath, everything is a subject and durability is JetStream (novox/hq design 25, -// design 29). -// -// mesh.mod..event. an event this module emits -// mesh.mod..tool. a tool this module serves -// mesh.seat..accept. work submitted to a role -// -// The module never writes one of those: it names its events and tools locally and the mesh -// derives the subject (design 29 §1), so reorganising the subject space leaves every module -// correct. - -import { AsyncLocalStorage } from "node:async_hooks"; -import { createHash } from "node:crypto"; -import net from "node:net"; -import tls from "node:tls"; -import { connect as natsConnect, headers as natsHeaders, StringCodec, type JsMsg, type Subscription, type TlsOptions } from "nats"; -import type { Broker, Envelope, EventHeaders } from "@novox/mesh-sdk/messaging"; - -const sc = StringCodec(); - -/** Requests wait this long for an answer before failing. Unchanged from what modules already - * expect, so a module's timeout handling is not something the bus quietly redefines. */ -const REQUEST_TIMEOUT_MS = 30_000; - -export class PinMismatchError extends Error {} - -/** A broker credential as the mesh delivers it (novox/hq ADR 0120): the bus's address, the - * fingerprint of the certificate it must present, and the node and module the account is scoped - * to — the runtime derives its subjects from those rather than being told them. */ -export interface Credential { - url: string; - fingerprint?: string; - node?: string; - module?: string; - user?: string; - password?: string; - /** The seats this module claims, with the verbs each promises (novox/hq ADR 0159). The runtime - * serves each claimed seat's verbs with its tools of the same name; the bus admits only the - * holder's subscription, so claiming and not holding costs a refused subscription and nothing else. */ - claims?: { seat: string; scope?: string; serves?: string[] }[]; -} - -/** - * What the mesh issued this assignment (novox/hq ADR 0160): where its tools are served, in which - * queue, the verbs of the seats it holds, where its events land, what it may reach. Read from the - * ASSIGNMENTS stream at `mesh.assignment..` — the one subject a runtime derives for - * itself — and followed live. Absent for a mesh older than the membership, and then the runtime - * serves the shape it always derived, and says so. - */ -export interface Membership { - node: string; - module: string; - /** Addresses a tool is answered on; `{tool}` stands for the tool's name. */ - serves: { subject: string; queue?: string }[]; - seats?: { seat: string; verb: string; subject: string }[]; - emits: string; - reaches?: Record; - tools: string; -} - -/** What a tool call answers: the module's own result, and which machine answered it - * (novox/hq ADR 0159) — a module on several machines is otherwise an answer from nowhere. */ -export interface Answered { - result: Res; - node?: string; -} - -/** The bus as the runtime sees it: the sdk's contract, and the few things only the runtime needs — - * an answer that says which machine gave it, serving a subject that is not a module's own tool - * (a seat's verb), and following the memberships of the modules it serves beside its own. */ -export interface RuntimeBroker extends Broker { - /** Call a tool by key, or — when `on` names a subject the mesh listed for it (ADR 0160) — there. */ - ask(key: string, body: Req, on?: string): Promise>; - handleSubject(subject: string, handler: (body: Req) => Promise): Promise<() => void>; - /** - * Serve another module's tools from this connection: read its membership on this machine and - * follow it, so `handle(".")` is served where the mesh issued that module (novox/hq - * ADR 0175: one runtime per node, every assigned module's tools). The account must be allowed - * to read that membership and to subscribe its subjects — the node's is; a module's own is not, - * and is refused by the bus, not here. - */ - follow(module: string): Promise; - /** What the mesh issued this assignment — this module's when none is named — or undefined when - * nothing has been issued yet. */ - membership(module?: string): Membership | undefined; - /** Called when the mesh issues a new membership to any module this connection follows; the - * runtime re-serves on it. The membership says which module it is for. */ - onMembership(handler: (m: Membership) => void): void; - /** The modules whose memberships this connection follows: its own and every one `follow` added. */ - serving(): string[]; - /** The module this connection is: what its credential named, and what a bare key serves as. */ - readonly module: string; - /** Where a served module's tool is answered right now (ADR 0197: what it serves, it announces). */ - servedOn?(module: string, tool: string): { subject: string; queue?: string }[]; - /** Answer a subject in a format of its own, not a tool's reply envelope — the NATS services - * protocol's discovery (novox/hq ADR 0197). An undefined answer is no reply. */ - raw?(subject: string, answer: (subject: string, data: Uint8Array) => Uint8Array | undefined): () => void; - /** The machine this connection serves on, when its credential names one. */ - readonly node?: string; -} - -/** - * Which module's tool is at work on this connection, when one is (novox/hq ADR 0175). One runtime - * carries many modules' tools, and an event a tool emits must land on the emitting *module's* - * subject, not the runtime's — so the runtime runs each tool inside this store, and `publish` - * reads the module from it. Outside a tool the connection's own module stands. - */ -export const atWork = new AsyncLocalStorage<{ module: string }>(); - -const ASSIGNMENTS_STREAM = "ASSIGNMENTS"; - -/** The one address a runtime derives for itself (ADR 0160). */ -export function membershipSubject(node: string, module: string): string { - return `mesh.assignment.${node}.${module}`; -} - -/** Whether a connection failure is worth retrying, or is a fact about this configuration that - * retrying cannot change. The runtime's supervisor asks this and does not need to know what it - * is connected to. */ -export function fatalBrokerReason(err: unknown): string | null { - if (err instanceof PinMismatchError) return "the bus's certificate does not match the pin"; - const e = err as { code?: string; message?: string }; - const message = typeof e?.message === "string" ? e.message : String(err); - if (e?.code === "ERR_INVALID_URL" || /invalid url/i.test(message)) { - return "the bus address is not a usable URL"; - } - if (/authorization violation|user authentication expired|permissions violation/i.test(message)) { - return "the bus refused this account"; - } - return null; -} - -/** - * Connect to the mesh bus and return a Broker. - * - * **A module's subjects come from its credential, not from its calls.** `node` and `module` name - * the account the mesh issued, and every subject this client publishes or subscribes is derived - * from them — so a module cannot name another's namespace even by mistake, and what it emits - * matches what the mesh authorised (ADR 0074's identity rule). - */ -export async function connectNats( - target: string | Credential, - opts: { module?: string } = {}, -): Promise { - const cred: Credential = typeof target === "string" ? { url: target } : target; - const self = cred.module ?? opts.module; - if (!self) { - throw new Error( - "a broker credential with no module: the runtime derives its subjects from the account " + - "the mesh issued, and cannot guess which module it is", - ); - } - - const conn = await natsConnect({ - servers: cred.url, - user: cred.user, - pass: cred.password, - name: `${cred.node ?? "?"}.${self}`, - tls: cred.fingerprint ? await pinnedTls(cred.url, cred.fingerprint) : undefined, - // Its own inbox, not a random one: every user's inbox is private to it (design 25 §4), and the - // grant names `_INBOX..>` — a reply space the client invented would be refused, and with - // it every pull for the next message and every answer to a tool call. - inboxPrefix: cred.user ? `_INBOX.${cred.user}` : undefined, - // Reconnect forever: the bus being restarted is an upgrade, not a reason for every module on - // the mesh to exit. `close()` stays the only thing that ends the connection. - maxReconnectAttempts: -1, - }); - const js = conn.jetstream(); - - const subs: Subscription[] = []; - // Every registration, and the one reader that dispatches to them. A module has one durable - // consumer; the loop belongs to the connection rather than to a subscription. - const listeners: { pattern: string; handler: (env: Envelope) => Promise }[] = []; - let reading: Awaited>["consume"]>> | undefined; - let closed = false; - - const node = cred.node; - - // The memberships, one per module this connection serves, each read once and followed. A - // module's own runtime follows one, its own; the node's runtime follows one per assigned module - // (novox/hq ADR 0175) — the same subject shape, the same stream, read as many times as there are - // modules, and nothing on the bus learns a new shape for it. A direct get is one request on the - // stream's API, which is the whole of what an account may ask JetStream for a subject it is - // granted; a 404 is a mesh that has not issued one, which is a fact to say and not an error to - // retry. - const issued = new Map(); - const issuedHandlers: ((m: Membership) => void)[] = []; - const follow = async (module: string): Promise => { - if (!node || issued.has(module)) return; - issued.set(module, undefined); - const subjectOfTheirs = membershipSubject(node, module); - try { - // The subject-addressed form of a direct get — the stream, then the subject, nothing in - // the body — because that is the one address the mesh grants this account on the - // stream's API; the body form asks the stream's root, which it may not. - const got = await conn.request(`$JS.API.DIRECT.GET.${ASSIGNMENTS_STREAM}.${subjectOfTheirs}`, - new Uint8Array(0), { timeout: 5_000 }); - const status = got.headers?.code ?? 0; - if (status === 0 && got.data.length > 0) { - issued.set(module, JSON.parse(sc.decode(got.data)) as Membership); - } - } catch { - // Not readable here: an older mesh, a stream not yet asserted, or no grant. Said below. - } - if (!issued.get(module)) { - console.log(`[mesh-tools] no membership issued for ${module} on ${node} yet; serving the derived shape until one arrives`); - } - try { - const live = conn.subscribe(subjectOfTheirs); - subs.push(live); - void (async () => { - for await (const msg of live) { - try { - const m = JSON.parse(sc.decode(msg.data)) as Membership; - issued.set(module, m); - console.log(`[mesh-tools] ${module} on ${node} was issued a new membership; re-serving on it`); - for (const h of issuedHandlers) h(m); - } catch (err) { - console.log(`[mesh-tools] a membership arrived that is not one: ${err}`); - } - } - })(); - } catch { - // A subscription this account may not make is a mesh older than the membership. - } - }; - await follow(self); - - /** The subjects a tool of a served module is served on: from that module's membership when - * issued, derived otherwise (the shape the mesh issues on day one, so the two agree). */ - const servedOn = (module: string, tool: string): { subject: string; queue?: string }[] => { - const m = issued.get(module); - if (m) { - const out = m.serves.map((s) => ({ subject: s.subject.replace("{tool}", tool), queue: s.queue })); - // The verb that lists what this module serves is answered on the mesh's plain address for it - // whatever the placement — one answer suffices, so a queue — and on this machine's beside it. - if (tool === "tools" && m.tools && !out.some((s) => s.subject === m.tools)) { - out.unshift({ subject: m.tools, queue: `serve.${module}` }); - } - return out; - } - const base = `mesh.mod.${module}.tool.${tool}`; - const out: { subject: string; queue?: string }[] = [{ subject: base, queue: `serve.${module}` }]; - if (node) out.push({ subject: `${base}.${node}` }); - return out; - }; - - /** Where a call by key goes: a subject this connection's own membership says it reaches, when it - * says one — the machine's when named — else the derived shape. */ - const reachedAt = (key: string): string => { - const [name, wanted] = key.split("@", 2); - const reach = issued.get(self)?.reaches?.[name]; - if (reach && reach.length > 0) { - if (wanted) { - const at = reach.find((s) => s.endsWith(`.${wanted}`)); - if (at) return at; - } else { - return reach[0]; - } - } - return toolSubject(key, self); - }; - - /** Answer one subject with one handler, and say which machine answered (novox/hq ADR 0159). */ - const answerOn = ( - subject: string, - queue: string | undefined, - handler: (body: Req) => Promise, - ): (() => void) => { - const sub = conn.subscribe(subject, queue ? { queue } : {}); - subs.push(sub); - void (async () => { - // **A refused subscription is said, never fatal** (novox/hq 04-ISSUES/218, as 217 for the - // announcements). The grants are the mesh's word on what this account may answer; a subject - // they leave out — a seat claimed here and held elsewhere — costs that subject, never the - // module's other tools, its handlers and its provisioning. Unhandled, the refusal ended the - // process and a module's runtime crash-looped on 2026-10-04. - try { - for await (const msg of sub) { - let reply: { result?: Res; error?: string; node?: string }; - try { - reply = { result: await handler(JSON.parse(sc.decode(msg.data)) as Req) }; - } catch (err) { - // The caller is told, rather than left to time out: a handler that threw is a - // different failure from a tool nobody serves, and only one of them is worth retrying. - reply = { error: err instanceof Error ? err.message : String(err) }; - } - if (node) reply.node = node; - msg.respond(sc.encode(JSON.stringify(reply))); - } - } catch (err) { - console.log(`[mesh-tools] the bus refused ${subject}: ${err instanceof Error ? err.message : String(err)}; ` + - "not served here, and the rest serves on"); - } - })(); - return () => sub.unsubscribe(); - }; - - /** - * Ask one question and await one answer, with the machine that gave it. - * - * Core NATS request/reply, not JetStream: a tool call must never be persisted (design 25 §3), - * and a lost one is a timeout the caller already handles. The reply travels on the inbox the - * request carries, which the responder may answer because its account has `allow_responses` - * — one reply to a message it actually received, and nothing wider. - */ - const ask = async (key: string, body: Req, on?: string): Promise> => { - const msg = await conn.request(on ?? reachedAt(key), sc.encode(JSON.stringify(body)), { - timeout: REQUEST_TIMEOUT_MS, - }); - const reply = JSON.parse(sc.decode(msg.data)) as { result?: Res; error?: string; node?: string }; - if (reply.error) throw new Error(reply.error); - return { result: reply.result as Res, node: reply.node }; - }; - - return { - async request(key: string, body: Req): Promise { - return (await ask(key, body)).result; - }, - - ask, - - /** - * Answer a question, two ways (novox/hq ADR 0159): on the module's subject in a queue group, - * so several machines may serve one tool and exactly one of them answers each call; and on the - * same subject with this machine as its last token, so a caller that names the machine reaches - * this instance and no other. A runtime that does not know its machine serves only the first, - * which is how it always behaved. - */ - async handle(key: string, handler: (body: Req) => Promise): Promise<() => void> { - // A seat's verb named outright is served on the seat's subject as given, for a holder that - // knows its role without a membership; everything else is a served module's own tool, served - // where the mesh issued that module (ADR 0160). A key naming a module this connection does - // not follow is not served here at all: a module serves its own tools, and the node's - // runtime those of the modules it was given (ADR 0175) — never a stranger's. - if (key.startsWith("seat:")) { - const stop = answerOn(toolSubject(key, self), undefined, handler); - return () => stop(); - } - const dot = key.indexOf("."); - const module = dot < 0 ? self : key.slice(0, dot); - if (!issued.has(module) && module !== self) { - throw new Error( - `${self} cannot serve ${key}: a module serves its own tools, and a runtime those of the ` + - "modules it follows", - ); - } - const tool = dot < 0 ? key : key.slice(dot + 1); - let stops = servedOn(module, tool).map((s) => answerOn(s.subject, s.queue, handler)); - // When that module's new membership arrives, serve where it now says and stop serving where - // it no longer does. - issuedHandlers.push((m) => { - if (m.module !== module) return; - stops.forEach((stop) => stop()); - stops = servedOn(module, tool).map((s) => answerOn(s.subject, s.queue, handler)); - }); - return () => stops.forEach((stop) => stop()); - }, - - module: self, - node, - servedOn, - raw(subject: string, answer: (subject: string, data: Uint8Array) => Uint8Array | undefined): () => void { - const sub = conn.subscribe(subject); - subs.push(sub); - void (async () => { - // **A refusal here is said, never fatal** (novox/hq 04-ISSUES/217). This serves discovery — - // what the runtime says about itself — not the work; a bus that refuses it costs the mesh - // seeing this runtime, not the runtime's tools and handlers. Unhandled, the refusal ended the - // process and every per-module container crash-looped on 2026-10-03. - try { - for await (const msg of sub) { - const body = answer(msg.subject, msg.data); - if (body) msg.respond(body); - } - } catch (err) { - console.log(`[mesh-tools] the bus refused ${subject}: ${err instanceof Error ? err.message : String(err)}; ` + - "discovery will not see this runtime there, and it serves on"); - } - })(); - return () => sub.unsubscribe(); - }, - follow, - serving: () => [...issued.keys()], - membership: (module?: string) => issued.get(module ?? self), - onMembership: (handler: (m: Membership) => void) => { - issuedHandlers.push(handler); - }, - - async handleSubject(subject: string, handler: (body: Req) => Promise): Promise<() => void> { - return answerOn(subject, undefined, handler); - }, - - /** - * Emit an event. - * - * Published into JetStream and awaited, so a publish the bus never accepted fails the emit - * rather than vanishing — at-least-once starts at the emitter, not only the consumer - * (ADR 0042). - * - * `msgID` is the event's own id, so a redelivery after a crash between publishing and - * acknowledging is de-duplicated by the server inside its window rather than seen twice. - */ - async publish(env: Envelope): Promise { - // **The body is the payload and the metadata rides as headers** (ADR 0042). That shape - // is what the conformance suite pins: an implementation that nested the whole envelope in - // the body would pass every one of its own tests and agree with nobody. - const meta = (env.headers ?? {}) as Record; - const h = natsHeaders(); - for (const [k, v] of Object.entries(meta)) { - if (v != null) h.set(k, String(v)); - } - if (!meta["content-type"]) h.set("content-type", "application/json"); - if (env.node) h.set("x-node", env.node); - - // The emitting module's subject: the tool at work's when a served module's tool emits from - // the node's runtime (ADR 0175), this connection's own otherwise. - await js.publish(eventSubject(env.key, atWork.getStore()?.module ?? self), sc.encode(JSON.stringify(env.body)), { - headers: h, - // De-duplicated by the server inside its window, so a redelivery after a crash between - // publishing and acknowledging is not seen twice. Only the emitter can make this id. - msgID: meta["x-event-id"], - }); - }, - - /** - * React to events. - * - * The durable consumer is the **mesh's** to create, from what this module declared it - * consumes (design 29 §3) — this binds to it and never creates one. A runtime that created - * its own would be a module deciding its own delivery semantics, and its account cannot - * reach the JetStream API to do it anyway. - * - * **One consumer, one loop, however many patterns a module registers.** A module has exactly one - * durable consumer, so two loops reading it would each take half the messages — and a loop that - * received one its own pattern does not match acknowledges it, which is the right answer for a - * filter wider than anything registered and silent loss when it is another handler's. Every - * registration is therefore dispatched from one reader, and a message is acknowledged once every - * handler it is for has taken it. - */ - async subscribe( - pattern: string, - handler: (env: Envelope) => Promise, - ): Promise<() => void> { - const listener = { pattern, handler: handler as (env: Envelope) => Promise }; - listeners.push(listener); - if (!reading) { - const durable = `${cred.node ?? "?"}_${self}`; - const consumer = await js.consumers.get("EVENTS", durable); - const messages = await consumer.consume(); - reading = messages; - void (async () => { - for await (const msg of messages) { - await deliver(msg, listeners); - } - })(); - } - return () => { - const at = listeners.indexOf(listener); - if (at >= 0) listeners.splice(at, 1); - if (listeners.length === 0 && reading) { - void reading.close(); - reading = undefined; - } - }; - }, - - async close(): Promise { - if (closed) return; - closed = true; - for (const sub of subs) sub.unsubscribe(); - // Drain rather than close: an in-flight reply is finished instead of dropped, which for a - // tool call is the difference between an answer and an unexplained timeout at the caller. - await conn.drain(); - }, - }; -} - -/** - * Deliver one event to every handler it is for, acknowledging only once each has taken it. - * - * Several registrations share one durable consumer, so matching happens here rather than by having - * each registration read the stream: two readers of one consumer would split it between them, and a - * message that reached the wrong one would be acknowledged as not-for-me and lost. - */ -async function deliver( - msg: JsMsg, - listeners: { pattern: string; handler: (env: Envelope) => Promise }[], -): Promise { - let env: Envelope; - try { - env = toEnvelope(msg); - } catch { - // Unparseable: terminate it. Redelivering a message no version of this code can read is - // an infinite loop, and what the mesh keeps of a message given up on (DEAD_LETTERS, - // novox/hq issue 330) is for handlers that fail, not for bytes that were never an envelope. - msg.term(); - return; - } - const forThis = listeners.filter((l) => topicMatches(l.pattern, env.key)); - if (forThis.length === 0) { - // The consumer's filters are the mesh's, derived from what the module declared it - // consumes, and may be wider than anything it registered a handler for. Acknowledge it, or it - // would be redelivered until it expired. - msg.ack(); - return; - } - try { - for (const l of forThis) await l.handler(env); - msg.ack(); - } catch { - // Negative-acknowledge with a delay, so a handler failing on a transient cause gets another - // attempt, and one failing permanently exhausts max-deliver rather than spinning: the consumer - // gives the event up, and the mesh keeps it in DEAD_LETTERS until a person delivers it - // again or drops it (novox/hq issue 330). Delivered again, it arrives under - // `mesh.again..…`, and keyFromSubject reads the same key from it. The consumer's - // limits are the mesh's; this only says "not done". - msg.nak(5_000); - } -} - -/** Rebuild the envelope a module sees, from the subject, the headers and the payload — the - * mirror of publish, and the reason both live beside each other. */ -function toEnvelope(msg: JsMsg): Envelope { - const headers: Record = {}; - if (msg.headers) { - for (const k of msg.headers.keys()) headers[k] = msg.headers.get(k); - } - return { - // The event's own key, recovered from the subject: `mesh.mod..event.`. The - // module never sees the subject, only the key it declared. - key: keyFromSubject(msg.subject), - node: headers["x-node"] ?? "", - body: JSON.parse(sc.decode(msg.data)) as T, - headers: headers as EventHeaders, - }; -} - -/** The event key a module sees: the emitter and the event, which is exactly how its manifest names - * what it consumes (novox/hq design 29 §1, 04-ISSUES/127). - * - * **One vocabulary for the declaration and the handler.** This returned the event name alone, so a - * manifest declaring `consumes: builder.built` produced a handler pattern that could never match - * the key it was compared against — and a module consuming the same event from two emitters could - * not tell them apart except by reading a header. The subject already carries the emitter; naming it - * here makes a mismatch between manifest and code a typo rather than a category error. */ -function keyFromSubject(subject: string): string { - const marker = ".event."; - const at = subject.indexOf(marker); - if (at < 0) return subject; - const event = subject.slice(at + marker.length); - // `mesh.mod..event.…` — the emitter is the token before the marker. - const before = subject.slice(0, at).split("."); - const emitter = before[before.length - 1]; - return emitter ? `${emitter}.${event}` : event; -} - -/** A module's own event subject. Derived, never taken from the caller: the module names its - * event and the mesh decides where it lands (design 29 §1). */ -function eventSubject(type: string, self: string): string { - return `mesh.mod.${self}.event.${type}`; -} - -/** A tool's subject. A bare name is this module's own tool; `.` addresses - * another's, which is how a request reaches a module that is not this one; `seat:.` - * addresses a role's tool, answered by whoever holds the seat (novox/hq ADR 0132) — with - * `seat:.@` for a node-scoped seat, whose tool carries the machine (design 33 §4). */ -function toolSubject(key: string, self: string): string { - if (key.startsWith("seat:")) { - const rest = key.slice("seat:".length); - const dot = rest.indexOf("."); - if (dot < 0) throw new Error(`"${key}" names a seat and no verb: seat:.`); - const seat = rest.slice(0, dot); - const [verb, node] = rest.slice(dot + 1).split("@", 2); - return node ? `mesh.seat.${seat}.tool.${verb}.${node}` : `mesh.seat.${seat}.tool.${verb}`; - } - // `.@` names the machine (novox/hq ADR 0159): the same subject with the - // machine as its last token, which is what that instance serves beside the queue. - const [name, node] = key.split("@", 2); - const dot = name.indexOf("."); - const base = dot < 0 - ? `mesh.mod.${self}.tool.${name}` - : `mesh.mod.${name.slice(0, dot)}.tool.${name.slice(dot + 1)}`; - return node ? `${base}.${node}` : base; -} - -/** A seat's verb, as its holder serves it: flat for a mesh seat, carrying the machine for a - * node-scoped one (design 33 §4) — the same shape the mesh grants. */ -export function seatToolSubject(seat: string, verb: string, scope: string | undefined, node: string | undefined): string { - const base = `mesh.seat.${seat}.tool.${verb}`; - return scope === "node" && node ? `${base}.${node}` : base; -} - -function normalizeFingerprint(fingerprint: string): string { - return fingerprint.replace(/^sha256:/i, "").replace(/:/g, "").toLowerCase(); -} - -/** - * Dial once to see the certificate, and refuse unless it is exactly the one the mesh pinned. - * A certificate authority is not consulted: the mesh issued this and knows its fingerprint, - * which is stronger than trusting whoever a machine's trust store happens to contain. - * - * **The pin is the only check.** What comes back is handed to the client as its TLS options, and - * the client's transport spreads them into Node's own `tls.connect` — so the pinned certificate - * is the one authority the handshake accepts, and the hostname check beside it is replaced with - * one that always passes. Pinning the exact certificate makes verifying its name redundant, and - * the bus's certificate names the seat (`mesh-broker`), not the address a machine happens to - * dial it by: every module on the mesh met "does not match certificate's altnames" the first time - * it reached the handshake (2026-09-28). - */ -async function pinnedTls(rawUrl: string, fingerprint: string): Promise { - const url = new URL(rawUrl.includes("://") ? rawUrl : `nats://${rawUrl}`); - const port = url.port ? Number(url.port) : 4222; - // **The bus speaks first, in the clear.** A NATS server sends its INFO line before TLS begins, - // and only then expects the client to start the handshake; a raw TLS connect to that port reads - // the INFO line as a TLS record and fails with "wrong version number" — which is what every - // module met the first time it dialled the bus being built (2026-09-28). So: connect, wait for - // INFO, then start TLS on the same socket, and read the certificate the server presents. - const certificate = await new Promise((resolve, reject) => { - const plain = net.connect({ host: url.hostname, port }, () => {}); - let seenInfo = false; - let buffered = ""; - plain.on("error", reject); - plain.on("data", (chunk: Buffer) => { - if (seenInfo) return; - buffered += chunk.toString("utf8"); - if (!buffered.includes("\r\n")) return; - seenInfo = true; - plain.removeAllListeners("data"); - const secure = tls.connect( - { socket: plain, rejectUnauthorized: false, servername: url.hostname }, - () => { - const peer = secure.getPeerCertificate(true); - secure.end(); - resolve(peer); - }, - ); - secure.on("error", reject); - }); - }); - const seen = createHash("sha256").update(certificate.raw).digest("hex"); - if (seen !== normalizeFingerprint(fingerprint)) { - throw new PinMismatchError( - `the bus at ${url.hostname}:${port} presented ${seen}, not the pinned ${normalizeFingerprint(fingerprint)}`, - ); - } - const pem = `-----BEGIN CERTIFICATE-----\n${certificate.raw.toString("base64").replace(/(.{64})/g, "$1\n")}\n-----END CERTIFICATE-----\n`; - // Node's option, not the client's: the transport passes the whole object on. `undefined` from - // checkServerIdentity is "the name is fine"; the pin above already decided the rest. - return { ca: pem, checkServerIdentity: () => undefined } as TlsOptions; -} - -/** The mesh's topic matching: `*` is one token, `#` the rest. This is the module's vocabulary — - * a module's `consumes` pattern is matched here, and the subject it becomes is the mesh's - * business, not the module's. */ -export function topicMatches(pattern: string, key: string): boolean { - return matchFrom(pattern.split("."), 0, key.split("."), 0); -} - -function matchFrom(p: string[], pi: number, k: string[], ki: number): boolean { - if (pi === p.length) return ki === k.length; - // `**` is the mesh's wildcard for the rest of a name; `#` is the old bus's, accepted so a pattern - // written either way behaves the same while both buses ship (novox/hq design 29 §1). - if (p[pi] === "#" || p[pi] === "**") { - for (let skip = ki; skip <= k.length; skip++) { - if (matchFrom(p, pi + 1, k, skip)) return true; - } - return false; - } - if (ki === k.length) return false; - if (p[pi] !== "*" && p[pi] !== k[ki]) return false; - return matchFrom(p, pi + 1, k, ki + 1); -} diff --git a/node-tools/src/client.ts b/node-tools/src/client.ts deleted file mode 100644 index 53826f6..0000000 --- a/node-tools/src/client.ts +++ /dev/null @@ -1,298 +0,0 @@ -/** - * The mesh's tools, for whoever is on a machine (novox/hq design 25 §7, design 34). - * - * Two surfaces over one thing. A command line, for somebody at a terminal; an MCP server, for an - * agent. Both are adapters over the same three calls — what tools are there, what does this one take, - * call it — because a second way of reaching a tool is a second thing to keep correct. - * - * **It uses the same client a module's runtime uses.** Not a second protocol and not a bridge: the - * caller connects as its own bus user — a person's, or the console's — publishes on the tool subjects - * that account permits, and the server refuses anything else. So "what may this ask" is answered by the - * same permission list that answers it for a module, and there is nothing here for an audit to read - * separately. - * - * What the caller may NOT do is the more interesting half, and none of it is enforced here — it is the - * account (design 25 §4): it cannot publish an event, so it cannot claim a module said something; it - * has no consumer, so there is no delivery to acknowledge; and it cannot answer a request, so it cannot - * impersonate a module on a bus where anyone may serve a tool. - */ -import { readFile } from "node:fs/promises"; - -import type { Broker } from "@novox/mesh-sdk/messaging"; - -import { connectNats, type Answered, type Credential } from "./broker-nats.js"; -import { TOOLS_VERB, type ToolsAnswer } from "./runtime.js"; - -/** Where the catalogue answers which modules the mesh holds. */ -const CATALOGUE_MODULES = "mesh-catalog.catalog_modules"; - -/** Where every role's tools are answered: the console on this machine, the Go runtime, which holds mesh-sdk's seat - * definitions and reads who holds each seat from placement (novox/hq ADR 0571 clauses 4 and 5). Asked over the loopback - * channel `mesh --console` already uses, by the method `mesh/seats` beside MCP's; never another record. */ -export const SEATS_METHOD = "mesh/seats"; -const DEFAULT_CONSOLE = "http://127.0.0.1:4270"; - -type SeatsAnswer = { seats?: { seat: string; scope?: string; tools?: ToolsAnswer["tools"] }[]; holders_why?: string }; - -/** Every role's tools from the console on this machine. */ -async function seatsFromConsole(consoleUrl: string): Promise { - const endpoint = consoleUrl.endsWith("/mcp") ? consoleUrl : `${consoleUrl.replace(/\/$/, "")}/mcp`; - const res = await fetch(endpoint, { - method: "POST", - headers: { "content-type": "application/json", accept: "application/json" }, - body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: SEATS_METHOD }), - signal: AbortSignal.timeout(5_000), - }); - if (!res.ok) throw new Error(`the console at ${endpoint} answered ${res.status}`); - const reply = (await res.json()) as { result?: SeatsAnswer; error?: { message: string } }; - if (reply.error) throw new Error(reply.error.message); - return reply.result ?? {}; -} - -/** A tool as its module describes it. */ -export interface Tool { - /** The module that serves it — or, for a role's tool, the seat. */ - module: string; - name: string; - description?: string; - /** The JSON schema of what it takes, as the module declared it. */ - input?: unknown; - /** True for a role's tool: addressed to the seat, answered by whoever holds it (ADR 0132). */ - seat?: boolean; - /** A role's scope: `node` for a seat held once per machine, whose verb is asked of one machine - * (design 33 §4) and takes `node` for it; `mesh` or absent otherwise. */ - scope?: string; - /** Where the tool is answered, as the mesh issued it (ADR 0160): the plain subject first when the - * module answers for itself anywhere, then one per machine. Absent for a runtime older than this. */ - subjects?: string[]; -} - -/** - * What the mesh could say about its tools when asked (design 34 §3). - * - * **Silence is named, never dropped.** A module the catalogue holds and nothing answered for is in - * `notAnswering`, because a tool that is not offered looks exactly like a tool that does not exist, - * and those need different people to fix them. - */ -export interface Listing { - tools: Tool[]; - /** Modules the catalogue holds whose runtime did not answer `tools`: not assigned, not up, or built - * before the runtime answered it. Each may still be called by name. The roles' tools are listed here, with the - * reason, when the console on this machine did not answer them. */ - notAnswering: string[]; -} - -/** The seats and the verbs each declares, from the last listing, so a call can tell a role's tool - * from a module's when the two share a prefix (a module and a seat may share a name). */ -export type Seats = Map>; - -/** The roles' tools, keyed the way `toolKey` names them. */ -export function seatsIn(have: Listing): Seats { - const seats: Seats = new Map(); - for (const t of have.tools) { - if (!t.seat) continue; - if (!seats.has(t.module)) seats.set(t.module, new Set()); - seats.get(t.module)!.add(t.name); - } - return seats; -} - -/** The key a call uses for `.`: a role's when the prefix is a seat declaring that - * verb, a module's otherwise. Both names for one capability are deliberate and bounded (ADR 0132); - * the seat wins only for a verb it actually declares, so a module's own tool is never shadowed. */ -export function toolKey(name: string, seats?: Seats): string { - if (name.startsWith("seat:")) return name; - const dot = name.indexOf("."); - if (dot < 0) return name; - const prefix = name.slice(0, dot); - const verb = name.slice(dot + 1); - if (seats?.get(prefix)?.has(verb)) return `seat:${prefix}.${verb}`; - return name; -} - -/** - * A person's credential, as `operator issue` prints it. - * - * The same shape a module is handed, minus the parts a module needs and a person does not: no node, - * because a person is not on a machine, and no module, because they are not one. - */ -export interface PersonCredential extends Credential { - person?: string; - invokes?: string[]; -} - -/** Read the credential from the file `operator issue` produced. */ -export async function credentialFrom(path: string): Promise { - const raw = await readFile(path, "utf8"); - let held: PersonCredential; - try { - held = JSON.parse(raw) as PersonCredential; - } catch (e) { - throw new Error( - `${path} is not a credential this mesh issued: ${(e as Error).message}. ` + - "It is the JSON `operator issue` printed, saved verbatim.", - ); - } - if (!held.url || !held.user || !held.password) { - throw new Error( - `${path} names no bus, user or password. It is the JSON \`operator issue\` printed, saved ` + - "verbatim — not an edited copy of it.", - ); - } - return held; -} - -/** Connect as this person. The module name the runtime wants is their own user, because every subject - * it derives is for a tool somebody else serves. */ -export async function connectAs(held: PersonCredential): Promise { - return connectNats({ ...held, module: held.user }); -} - -/** - * Connect as the console: the module credential the mesh delivered (novox/hq ADR 0152), read from - * the same variable every runtime reads. It names the node and the module, so the account's inbox - * and subjects derive from what the mesh authorised and from nothing in this process's environment. - */ -export async function connectAsTheConsole(path: string): Promise<{ bus: Broker; who: string }> { - const raw = await readFile(path, "utf8"); - let held: Credential; - try { - held = JSON.parse(raw) as Credential; - } catch (e) { - throw new Error(`${path} is not a broker credential: ${(e as Error).message}`); - } - if (!held.url || !held.module || !held.user) { - throw new Error( - `${path} names no bus, module or user: the console runs on the credential the mesh sealed to ` + - "this machine for it, and nothing else", - ); - } - return { bus: await connectNats(held), who: `${held.node ?? "?"}.${held.module}` }; -} - -/** - * What tools the mesh has, asked of the modules (design 34 §3). - * - * The catalogue says which modules the mesh holds; each module says what it serves, through the one - * verb its runtime answers for it. **Asked, not configured**: a client carrying its own list would be a - * list that goes stale the first time a module is assigned. Every module is asked at once, and the bus - * refuses at once a request nothing serves, so the cost is bounded by the modules that are up. - */ -export async function toolsOn(bus: Broker, consoleUrl: string = process.env.MESH_CONSOLE || DEFAULT_CONSOLE): Promise { - let rolesWhy = ""; - const [answered, roles] = await Promise.all([ - bus.request, { modules?: { module: string }[] }>(CATALOGUE_MODULES, {}), - // The roles' tools, from the console on this machine (ADR 0571). Asked beside the modules rather than first: a - // console that is restarting must not hide every module's tools with it. - seatsFromConsole(consoleUrl).catch((e: unknown) => { - rolesWhy = e instanceof Error ? e.message : String(e); - return undefined; - }), - ]); - const names = (answered.modules ?? []).map((m) => m.module).filter((m) => typeof m === "string"); - - const asked = await Promise.allSettled( - names.map((module) => bus.request, ToolsAnswer>(`${module}.${TOOLS_VERB}`, {})), - ); - const tools: Tool[] = []; - const notAnswering: string[] = []; - if (roles) { - for (const s of roles.seats ?? []) { - // A node-scoped seat's tool is asked of one machine (design 33 §4): listed with its scope, so - // a caller names the machine and the call carries it — `seat:.@`. Left out - // of the listing, the verb never resolved as a seat's and nothing served it (ADR 0170). - for (const t of s.tools ?? []) { - tools.push({ module: s.seat, name: t.name, description: t.description, input: t.input, seat: true, scope: s.scope }); - } - } - } else { - notAnswering.push(`the roles' tools (the console at ${consoleUrl} did not answer ${SEATS_METHOD}: ${rolesWhy})`); - } - asked.forEach((outcome, i) => { - const module = names[i]!; - if (outcome.status === "fulfilled" && typeof outcome.value?.failed === "string") { - // The runtime answered for it and serves nothing: the bundle failed to load (ADR 0175). Said - // with the reason, because "not answering" would send somebody to check an assignment that - // is fine. - notAnswering.push(`${module} (its tools bundle failed to load: ${outcome.value.failed})`); - } else if (outcome.status === "fulfilled" && Array.isArray(outcome.value?.tools)) { - for (const t of outcome.value.tools) { - tools.push({ module, name: t.name, description: t.description, input: t.input, subjects: t.subjects }); - } - } else { - notAnswering.push(module); - } - }); - tools.sort((a, b) => `${a.module}.${a.name}`.localeCompare(`${b.module}.${b.name}`)); - notAnswering.sort(); - return { tools, notAnswering }; -} - -/** Call one tool. The key is `.`, which is what a person types and what the account - * permits — one vocabulary, so a refusal names the thing they asked for. */ -/** Call a tool and learn which machine answered (novox/hq ADR 0159). `.@` asks - * the instance on one machine; without it, whichever instance answers first does, and the answer - * says which. */ -export async function callTool( - bus: Broker, - key: string, - args: unknown, - seats?: Seats, - listing?: Listing, -): Promise> { - const at = key.indexOf("@"); - const name = at < 0 ? key : key.slice(0, at); - const node = at < 0 ? "" : key.slice(at + 1); - if (!name.includes(".")) { - throw new Error( - `"${key}" does not name a tool: write ., as \`mesh tools\` lists them, ` + - "or .@ for the instance on one machine", - ); - } - const resolved = toolKey(name, seats) + (node ? `@${node}` : ""); - // Where the tool is answered is the module's to say and the mesh's to issue (ADR 0160): when the - // listing carried subjects for it, the call goes to one of those and composes nothing. - const on = subjectListed(name, node, listing); - const asking = bus as Broker & { ask?: (k: string, b: Req, on?: string) => Promise> }; - if (typeof asking.ask === "function") return asking.ask(resolved, args ?? {}, on); - return { result: await bus.request(resolved, args ?? {}) }; -} - -/** The subject the listing says answers `.` — the machine's when one is named, else - * the plain one — or undefined when the listing said none, and the key is composed as before. */ -export function subjectListed(name: string, node: string, listing?: Listing): string | undefined { - if (!listing) return undefined; - const dot = name.indexOf("."); - const module = name.slice(0, dot); - const tool = name.slice(dot + 1); - const found = listing.tools.find((t) => t.module === module && t.name === tool && !t.seat); - const subjects = found?.subjects ?? []; - if (subjects.length === 0) return undefined; - if (node) return subjects.find((s) => s.endsWith(`.${node}`)); - return subjects[0]; -} - -/** - * Why a call failed, said so that the remedy is in the words. - * - * Three answers a person actually gets, and they need different things done: nobody serves that tool, - * the mesh refused this account, or the tool itself failed. Without this they are one timeout and a - * stack trace. - */ -export function whyItFailed(key: string, err: unknown): string { - const message = err instanceof Error ? err.message : String(err); - if (/no responders|503/i.test(message)) { - return `nothing serves ${key}. The module may not be assigned to any machine, or it is down` + - (key.startsWith("seat:") ? ", or nothing holds that seat" : "") + - " — `mesh tools` lists what answered."; - } - if (/permissions violation|authorization/i.test(message)) { - return `this account may not call ${key}. What it may call was fixed when it was issued — a ` + - "person's by `operator issue`, the console's by its manifest."; - } - if (/timeout/i.test(message)) { - return `${key} did not answer in time. Something is serving it, so this is the tool being slow ` + - "rather than absent."; - } - return `${key} failed: ${message}`; -} diff --git a/node-tools/src/http.ts b/node-tools/src/http.ts deleted file mode 100644 index 94e9bb5..0000000 --- a/node-tools/src/http.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * The console's endpoint: MCP over HTTP, on a machine's loopback (novox/hq ADR 0152, design 34 §2). - * - * **Loopback is the authority boundary.** Whoever can connect is on the machine, and whoever is on the - * machine is the account that owns the mesh there (ADR 0034, ADR 0144). So there is no token and no - * login here, and the one thing this file enforces is that it binds nothing else: a console reachable - * from another machine would be authority over the mesh handed to whoever finds the port. - * - * The transport is the streamable-HTTP shape an agent host speaks: `POST /mcp` with one JSON-RPC - * message, answered with one JSON body. No session, because the surface holds nothing per caller; no - * event stream, because nothing here has anything to say unasked. - */ -import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; - -import type { Broker } from "@novox/mesh-sdk/messaging"; - -import { mcpSurface, type Reply, type Request } from "./mcp.js"; - -/** The most a request body may be. A tool's arguments are small; a megabyte is somebody else's file. */ -const BODY_LIMIT = 1 << 20; - -export interface Listening { - /** Where it listens, as `host:port`, with the port the machine actually gave. */ - address: string; - close(): Promise; -} - -/** Hosts that are this machine and no other. */ -const loopback = new Set(["127.0.0.1", "::1", "localhost", "[::1]"]); - -/** - * Listen on `host:port`. Refused unless the host is loopback — said before binding, so a manifest or - * a flag that would open the console to a network is a startup failure rather than something - * discovered by whoever finds it. - */ -export async function serveMcpHttp(bus: Broker, who: string, listen: string): Promise { - const at = listen.lastIndexOf(":"); - if (at < 0) { - throw new Error(`"${listen}" is not host:port`); - } - const host = listen.slice(0, at); - const port = Number(listen.slice(at + 1)); - if (!loopback.has(host)) { - throw new Error( - `the console listens on loopback and nowhere else (novox/hq ADR 0152): "${host}" is not this ` + - "machine's own address — whoever is on the machine owns the mesh there, and nobody else may reach this", - ); - } - if (!Number.isInteger(port) || port < 0 || port > 65535) { - throw new Error(`"${listen.slice(at + 1)}" is not a port`); - } - - const surface = mcpSurface(bus, who); - const server = createServer((req, res) => { - void route(req, res, surface.handle).catch((e) => { - json(res, 500, { jsonrpc: "2.0", id: null, error: { code: -32603, message: String(e) } }); - }); - }); - - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(port, host.replace(/^\[|\]$/g, ""), () => resolve()); - }); - const bound = server.address(); - const address = typeof bound === "object" && bound ? `${host}:${bound.port}` : listen; - return { - address, - close: () => - new Promise((resolve) => { - server.close(() => resolve()); - }), - }; -} - -async function route( - req: IncomingMessage, - res: ServerResponse, - handle: (r: Request) => Promise, -): Promise { - const path = (req.url ?? "/").split("?")[0]; - if (path === "/") { - res.writeHead(200, { "content-type": "text/plain; charset=utf-8" }); - res.end("the mesh's console: MCP over HTTP at POST /mcp (novox/hq design 34)\n"); - return; - } - if (path !== "/mcp") { - json(res, 404, { error: "the console serves /mcp and nothing else" }); - return; - } - switch (req.method) { - case "POST": - break; - case "DELETE": - // A host ending a session. There is no session to end; saying so is the truthful answer. - res.writeHead(204).end(); - return; - case "GET": - // A host opening an event stream. The console has nothing to say unasked. - res.writeHead(405, { allow: "POST, DELETE" }).end(); - return; - default: - res.writeHead(405, { allow: "POST, DELETE" }).end(); - return; - } - - let body: string; - try { - body = await read(req); - } catch (e) { - json(res, 413, { jsonrpc: "2.0", id: null, error: { code: -32600, message: String(e) } }); - return; - } - let parsed: unknown; - try { - parsed = JSON.parse(body); - } catch { - json(res, 400, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "the body is not JSON" } }); - return; - } - - // One message, or a batch of them; a batch is answered as a batch. A notification gets no reply - // and, alone, no body: 202 is how the transport says "heard". - if (Array.isArray(parsed)) { - const replies = (await Promise.all(parsed.map((r) => handle(r as Request)))).filter(Boolean); - if (replies.length === 0) { - res.writeHead(202).end(); - } else { - json(res, 200, replies); - } - return; - } - const reply = await handle(parsed as Request); - if (!reply) { - res.writeHead(202).end(); - return; - } - json(res, 200, reply); -} - -function read(req: IncomingMessage): Promise { - return new Promise((resolve, reject) => { - let size = 0; - const chunks: Buffer[] = []; - req.on("data", (chunk: Buffer) => { - size += chunk.length; - if (size > BODY_LIMIT) { - reject(new Error(`the request is larger than ${BODY_LIMIT} bytes`)); - req.destroy(); - return; - } - chunks.push(chunk); - }); - req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); - req.on("error", reject); - }); -} - -function json(res: ServerResponse, status: number, body: unknown): void { - const text = JSON.stringify(body); - res.writeHead(status, { - "content-type": "application/json; charset=utf-8", - "content-length": Buffer.byteLength(text), - }); - res.end(text); -} diff --git a/node-tools/src/launch.ts b/node-tools/src/launch.ts deleted file mode 100644 index 23a3625..0000000 --- a/node-tools/src/launch.ts +++ /dev/null @@ -1,192 +0,0 @@ -// A tools bundle as a process the runtime launches (novox/hq ADR 0188). -// -// The runtime does not run a tool's code itself when the bundle is not JavaScript: it starts the -// bundle's executable as a child with the runtime's environment and speaks MCP over stdio to it — -// `initialize`, `tools/list` once, `tools/call` per call. A Rust binary, a Go binary, a Python -// script and a Node script are the same thing from here: a process that answers those. Everything -// the mesh adds — the subjects from the membership, the held seats, the `tools` answer, a bundle -// that failed named and the others serving — is the runtime's, outside this file. -// -// A tool the child lists as `.` is the module's implementation of that seat's verb; -// any other name is the module's own tool. The same rule the in-process registration follows. - -import { spawn, type ChildProcess } from "node:child_process"; -import { accessSync, constants } from "node:fs"; -import type { ToolDefinition } from "@novox/mesh-sdk/tools"; -import { broker, type Envelope } from "@novox/mesh-sdk/messaging"; -import { atWork } from "./broker-nats.js"; - -/** The protocol version this speaks; a bundle says the same. */ -export const PROTOCOL = "2025-03-26"; - -/** How long a child has to answer `initialize` and `tools/list` before it is a failed bundle, and - * how long a call may take before the caller is told the tool is slow rather than absent. */ -const HANDSHAKE_MS = 10_000; -const CALL_MS = 30_000; - -/** Whether an entrypoint is launched as a process rather than imported: anything that is not a - * plain JavaScript file, and a JavaScript file marked executable — a bundle written against the - * protocol in TypeScript, served the same way as any other language. */ -export function launches(entry: string): boolean { - const javascript = /\.(m|c)?js$/.test(entry); - let executable = false; - try { - accessSync(entry, constants.X_OK); - executable = true; - } catch { - // not executable, or not there — importing will say which - } - return !javascript || executable; -} - -/** What a launched bundle registers: the groups the in-process path would have, by name. */ -export interface Launched { - registrations: { module: string; tools: ToolDefinition[] }[]; - stop(): void; -} - -interface Pending { - resolve(v: any): void; - reject(e: Error): void; - timer: NodeJS.Timeout; -} - -/** - * Launch a bundle and learn its tools. Rejects when the child cannot be started or does not complete - * the handshake, which the runtime records as the bundle having failed. A child that exits later is - * started again on the next call, once; a call in flight when it died is told so. - */ -export async function launch(module: string, entry: string, env: NodeJS.ProcessEnv = process.env): Promise { - let child: ChildProcess | undefined; - let nextId = 1; - const pending = new Map(); - let stopped = false; - - const start = async (): Promise => { - const proc = spawn(entry, [], { stdio: ["pipe", "pipe", "pipe"], env }); - child = proc; - let buffered = ""; - proc.stdout!.on("data", (chunk: Buffer) => { - buffered += chunk.toString("utf8"); - let at: number; - while ((at = buffered.indexOf("\n")) >= 0) { - const line = buffered.slice(0, at).trim(); - buffered = buffered.slice(at + 1); - if (!line) continue; - let reply: { id?: number | string; method?: string; params?: unknown; result?: unknown; error?: { message?: string } }; - try { - reply = JSON.parse(line); - } catch { - console.log(`[mesh-tools] ${module}'s bundle said something that is not a reply: ${line.slice(0, 120)}`); - continue; - } - // **The bundle asks the runtime to emit** (novox/hq ADR 0193): published on the bus as this - // module, and answered once the bus has accepted it, so the tool's emit means what it means - // in-process. Nothing else a bundle may ask. - if (typeof reply.method === "string") { - const id = reply.id; - const answer = (m: Record) => proc.stdin!.write(JSON.stringify({ jsonrpc: "2.0", id, ...m }) + "\n"); - if (reply.method !== "mesh/publish") { - if (id !== undefined) answer({ error: { code: -32601, message: `the runtime answers no ${reply.method} from a bundle` } }); - continue; - } - atWork.run({ module }, () => broker().publish(reply.params as Envelope)) - .then(() => { if (id !== undefined) answer({ result: {} }); }) - .catch((err: unknown) => { if (id !== undefined) answer({ error: { code: -32000, message: err instanceof Error ? err.message : String(err) } }); }); - continue; - } - const waiting = typeof reply.id === "number" ? pending.get(reply.id) : undefined; - if (!waiting) continue; - pending.delete(reply.id as number); - clearTimeout(waiting.timer); - if (reply.error) waiting.reject(new Error(reply.error.message ?? "the bundle refused the request")); - else waiting.resolve(reply.result); - } - }); - // stderr is the bundle's log; kept under the module's name so a fault reads where it belongs. - // The last thing it said is kept, so a bundle that dies says why in its own words, not by code. - let lastSaid = ""; - proc.stderr!.on("data", (chunk: Buffer) => { - for (const line of chunk.toString("utf8").split("\n")) { - if (!line.trim()) continue; - console.log(`[${module}] ${line}`); - if (/\S/.test(line) && !/^\s+at\s/.test(line) && !/^Node\.js v/.test(line)) lastSaid = line.trim(); - } - }); - const exited = new Promise((_, reject) => { - proc.once("error", (err) => reject(err)); - proc.once("exit", (code, signal) => { - const why = `${module}'s bundle exited (${signal ?? code})` + (lastSaid ? `: ${lastSaid}` : ""); - for (const [id, p] of pending) { - pending.delete(id); - clearTimeout(p.timer); - p.reject(new Error(why)); - } - if (child === proc) child = undefined; - if (!stopped) console.log(`[mesh-tools] ${why}; started again on its next call`); - reject(new Error(why)); - }); - }); - const ask = (method: string, params: unknown, ms: number): Promise => - Promise.race([ - new Promise((resolve, reject) => { - const id = nextId++; - const timer = setTimeout(() => { - pending.delete(id); - reject(new Error(`${module}'s bundle did not answer ${method} in ${ms / 1000}s`)); - }, ms); - pending.set(id, { resolve, reject, timer }); - proc.stdin!.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); - }), - exited, - ]); - exited.catch(() => {}); // observed through the race; never unhandled - (proc as ChildProcess & { ask?: typeof ask }).ask = ask; - await ask("initialize", { protocolVersion: PROTOCOL, capabilities: {}, clientInfo: { name: "node-tools", version: "1" } }, HANDSHAKE_MS); - proc.stdin!.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n"); - }; - - const asking = async (method: string, params: unknown, ms: number): Promise => { - if (!child) await start(); - return (child as ChildProcess & { ask: (m: string, p: unknown, ms: number) => Promise }).ask(method, params, ms); - }; - - await start(); - const listed = (await asking("tools/list", {}, HANDSHAKE_MS)) as { tools?: { name: string; description?: string; inputSchema?: unknown }[] }; - const groups = new Map(); - for (const t of listed.tools ?? []) { - const dot = t.name.indexOf("."); - const under = dot < 0 ? module : t.name.slice(0, dot); - const name = dot < 0 ? t.name : t.name.slice(dot + 1); - const tools = groups.get(under) ?? []; - tools.push({ - name, - description: t.description ?? "", - input: (t.inputSchema as Record | undefined) ?? {}, - run: async (args) => { - const result = (await asking("tools/call", { name: t.name, arguments: args ?? {} }, CALL_MS)) as { - content?: { type: string; text?: string }[]; - isError?: boolean; - }; - const text = result?.content?.find((c) => c.type === "text")?.text ?? ""; - if (result?.isError) throw new Error(text || `${module}.${t.name} failed`); - // The bundle's answer is JSON as text (that is what every MCP host renders); handed back as - // the value it encodes so a caller on the bus sees what an in-process tool would return. - try { - return JSON.parse(text); - } catch { - return text; - } - }, - }); - groups.set(under, tools); - } - return { - registrations: [...groups].map(([under, tools]) => ({ module: under, tools })), - stop: () => { - stopped = true; - child?.kill("SIGTERM"); - child = undefined; - }, - }; -} diff --git a/node-tools/src/main.ts b/node-tools/src/main.ts deleted file mode 100644 index d8fa243..0000000 --- a/node-tools/src/main.ts +++ /dev/null @@ -1,309 +0,0 @@ -// The runnable entrypoint. Three modes: -// -// mesh-tools serve — bind the broker and serve the assigned modules until -// stopped; as the node-tools module, also the console on loopback. A module entrypoint that subscribes to events (on("#")) -// starts consuming as it is imported, so this also runs consumers. -// mesh-tools emit TYPE [JSON] emit one event onto the mesh and exit — an operable primitive, -// and what an events test uses to put a message on the wire. -// mesh-tools prepare bring this module's state to the shape this version needs and exit -// — the runtime's answer to the word the mesh asks every module -// (novox/hq ADR 0135). The entrypoints come from MESH_PREPARE, which -// the module's own image names beside MESH_TOOL_MODULES. -// mesh-tools run ENTRYPOINT run one compiled module entrypoint to completion and exit — the -// runtime side of a run-once step (novox/hq ADR 0052). It imports -// the given entrypoint, whose top-level code does its work — seed a -// store, migrate, health-gate — and awaits it. It does NOT connect -// to the broker: a first-boot step runs offline, before the module -// has anything to talk to, and the host gates the container that -// depends on it on this process exiting 0. -// -// The broker, in order of preference: -// MESH_BROKER_FILE a sealed {url, fingerprint} the mesh delivered (novox/hq ADR 0043) — an -// amqps account scoped to this module. Preferred: a module holds its own. -// MESH_BROKER_URL a plain URL, for the bootstrap/admin case before a module has an account. -// MESH_TOOL_MODULES =/path/a,… the modules to serve and their compiled entrypoints (serve -// mode; ADR 0175); a bare path is an entrypoint of the credential's own module -// MESH_PREPARE /path/a,/path/b,… compiled entrypoints that prepare this module's state -// MESH_MODULE / MESH_NODE the identity stamped onto emitted events (ADR 0042) - -import { readFileSync } from "node:fs"; -import { pathToFileURL } from "node:url"; -import { connectNats, fatalBrokerReason as fatalNatsReason, type Credential } from "./broker-nats.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). */ -let lastCredential: Credential | undefined; -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"; - -/** - * Connect the way this process is meant to: with its sealed credential if the mesh gave it one, and - * over the plain bootstrap URL otherwise. A scoped module assumes the foundation's exchanges exist — - * its account may not declare them (ADR 0043). - */ -// fatalBrokerReasonFor is the reason a connection failure is final rather than "not yet", for -// whichever bus this runtime is on — each transport knows its own refusals. -function fatalBrokerReasonFor(err: unknown): string | null { - return fatalNatsReason(err); -} - -async function connectBroker(): Promise { - const file = process.env.MESH_BROKER_FILE; - if (file) { - let credential: Credential; - try { - credential = JSON.parse(readFileSync(file, "utf8")) as Credential; - lastCredential = credential; - } catch (err) { - console.error(`mesh-tools: cannot read the broker credential at ${file}: ${err}`); - process.exit(1); - } - if (!credential.url) { - console.error(`mesh-tools: ${file} carries no url — it is not a broker credential`); - process.exit(1); - } - // The mesh scoped this account to a node and module; take the runtime's identity from the - // credential so its queue and the events it emits match what the mesh authorised, no matter - // what the environment says. - if (credential.node) process.env.MESH_NODE = credential.node; - if (credential.module) process.env.MESH_MODULE = credential.module; - // **The credential names the bus.** A module moved to the bus being built was handed a - // credential for it — `nats://…` with user, password and fingerprint beside the address — and - // nothing else in its environment changed (design 25; novox/hq design 28 task 5.2). The scheme - // is enough to know which bus to speak; a runtime that always dialled the old one would keep - // serving and answer nobody. - // One bus (novox/hq ADR 0131, design 28 task 5.5): the credential names it, and it is this. - return connectNats(credential); - } - const url = process.env.MESH_BROKER_URL; - if (!url) { - console.error( - "mesh-tools: set MESH_BROKER_FILE (a sealed credential) or MESH_BROKER_URL — there is no broker to reach", - ); - process.exit(1); - } - return connectNats({ url }); -} - -/** - * Connect for serve mode, retrying while the broker is merely not reachable yet. At startup that - * is the NORMAL case, not a failure: a container comes up in seconds and the overlay tunnel a - * moment later (novox/hq issue 058). Exiting instead delegated the retry to the container - * runtime, which read as a crash-loop to every restart-counting health check and every person - * watching. Retried indefinitely, aloud: the dependency appears or somebody reads why not. - * - * A failure that waiting cannot fix (see fatalBrokerReason) is thrown at once rather than retried — - * a permanent fault masquerading as "not reachable yet" is the silent non-progress this whole - * change exists to remove. Missing-file and empty-URL configuration errors exit inside - * connectBroker before they reach here; a malformed URL and a refused login are caught here. - */ -async function connectBrokerPatiently(): Promise { - for (let delay = 2_000; ; delay = Math.min(delay * 2, 30_000)) { - try { - return await connectBroker(); - } catch (err) { - const fatal = fatalBrokerReasonFor(err); - if (fatal !== null) { - console.error(`mesh-tools: ${fatal} — waiting will not fix this; giving up`); - throw err; - } - const why = err instanceof Error ? err.message : String(err); - // A little jitter so every module that was up when the broker bounced does not retry in - // lockstep and stampede it as it recovers. - const wait = delay + Math.floor(Math.random() * 1_000); - console.error(`mesh-tools: the broker is not reachable yet (${why}); retrying in ${Math.round(wait / 1000)}s`); - await new Promise((r) => setTimeout(r, wait)); - } - } -} - -/** - * What MESH_TOOL_MODULES names (novox/hq ADR 0175, to-be 38 WP1): `=` entries, - * comma-separated, several per module allowed — the node's runtime serving every assigned module's - * bundle. A bare path is the one-module form the per-module containers still set: an entrypoint of - * the credential's own module. Both may appear; the result is one list of modules. - */ -export function servedModulesFrom(spec: string, own: string | undefined): { serves: ServedModule[]; moduleEntrypoints: string[] } { - const serves = new Map(); - const moduleEntrypoints: string[] = []; - for (const raw of spec.split(",")) { - const entry = raw.trim(); - if (!entry) continue; - const eq = entry.indexOf("="); - if (eq < 0) { - moduleEntrypoints.push(entry); - continue; - } - const module = entry.slice(0, eq).trim(); - const path = entry.slice(eq + 1).trim(); - if (!module || !path) { - throw new Error(`MESH_TOOL_MODULES: "${entry}" is neither = nor an entrypoint of this module`); - } - if (module === own) { - moduleEntrypoints.push(path); - continue; - } - serves.set(module, [...(serves.get(module) ?? []), path]); - } - return { serves: [...serves].map(([module, entrypoints]) => ({ module, entrypoints })), moduleEntrypoints }; -} - -/** The module that is the node's tool runtime (novox/hq ADR 0175, to-be 38 WP3): on its credential, - * `serve` is also the console — MCP on the machine's loopback (design 34). */ -export const RUNTIME_MODULE = "node-tools"; - -/** Where the console listens when the runtime is node-tools and nothing says otherwise: the port - * the module's manifest declares `from: machine`. MESH_CONSOLE_LISTEN overrides it either way. */ -const CONSOLE_LISTEN = "127.0.0.1:4270"; - -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); - // 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 - // machine. A module's own runtime in a container on the machine's network does not — two of - // them on one port would be the fault, and the console is one per machine. - const listen = process.env.MESH_CONSOLE_LISTEN ?? (lastCredential?.module === RUNTIME_MODULE ? CONSOLE_LISTEN : ""); - let consoleUp: Listening | undefined; - if (listen) { - const who = `${lastCredential?.node ?? "?"}.${lastCredential?.module ?? RUNTIME_MODULE}`; - consoleUp = await serveMcpHttp(broker, who, listen); - console.log(`mesh console listening on http://${consoleUp.address}/mcp as ${who}`); - } - - const shutdown = async (): Promise => { - stop(); - await consoleUp?.close(); - await broker.close(); - process.exit(0); - }; - process.on("SIGTERM", () => void shutdown()); - process.on("SIGINT", () => void shutdown()); -} - -async function emitOnce(type: string, bodyJson: string): Promise { - let body: unknown = {}; - if (bodyJson) { - try { - body = JSON.parse(bodyJson); - } catch { - console.error(`mesh-tools emit: body is not JSON: ${bodyJson}`); - process.exit(1); - } - } - const broker = await connectBroker(); - useBroker(() => broker); - // emit awaits the broker's publish confirm (ADR 0042), so the event is accepted before we close. - await emit(type, body); - await broker.close(); -} - -async function invokeOnce(module: string, tool: string, argsJson: string): Promise { - let args: Record = {}; - if (argsJson) { - try { - args = JSON.parse(argsJson) as Record; - } 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(); -} - -/** - * Run one compiled module entrypoint to completion — the runtime side of a run-once step - * (novox/hq ADR 0052). Importing it runs its top-level code and awaits any top-level await, so this - * returns only once the step's own code has finished; a step that throws rejects here and the - * process exits non-zero, which is how the host knows the step did not complete and must not start - * the container it gates. No broker is connected — a first-boot seed or migration runs offline. - */ -async function runEntry(entrypoint: string): Promise { - if (!entrypoint) { - console.error("mesh-tools run — a compiled module entrypoint path is required"); - process.exit(1); - } - // A file URL, not a bare path: dynamic import of an absolute path is not portable, and the - // entrypoint the manifest names is an absolute path inside the image. - await import(pathToFileURL(entrypoint).href); -} - -/** - * Bring this module's state to the shape this version needs, and exit — the runtime's answer to the - * one word the mesh asks every module (novox/hq ADR 0135). - * - * The entrypoints come from `MESH_PREPARE`, which a module's own image sets beside the entrypoints it - * already lists there: the module knows which of its files prepares its state, and nothing else could. - * Each is imported in the order given, to completion, with no broker — preparation runs before the - * version that would use it, so there is nothing yet to talk to. - * - * **An empty list is a failure, not a no-op.** The mesh only asks this of a module whose manifest says - * it prepares something; a module that says so and names nothing has been built wrong, and exiting 0 - * would let that version serve against a state nobody shaped. - */ -async function prepareState(): Promise { - const named = (process.env.MESH_PREPARE ?? "") - .split(",") - .map((entry) => entry.trim()) - .filter((entry) => entry !== ""); - if (named.length === 0) { - console.error( - "mesh-tools prepare: this module was asked to prepare its state and its image names nothing " + - "to do it with — set MESH_PREPARE to the compiled entrypoint(s) that prepare it, the way " + - "MESH_TOOL_MODULES names the ones it serves", - ); - process.exit(1); - } - for (const entrypoint of named) { - console.log(`[mesh-tools] preparing with ${entrypoint}`); - await runEntry(entrypoint); - } - console.log(`[mesh-tools] prepared: ${named.length} entrypoint(s) ran to completion`); -} - -async function main(): Promise { - const [command, ...rest] = process.argv.slice(2); - if (command === "run") { - await runEntry(rest[0] ?? ""); - return; - } - if (command === "prepare") { - await prepareState(); - return; - } - if (command === "invoke") { - const [module, tool] = rest; - if (!module || !tool) { - console.error("mesh-tools invoke [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) { - console.error("mesh-tools emit [json-body] — a routing key is required"); - process.exit(1); - } - await emitOnce(type, rest[1] ?? ""); - return; - } - await serve(); -} - -// Only when run, so a test can import the pieces. -if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) { - void main(); -} diff --git a/node-tools/src/mcp.ts b/node-tools/src/mcp.ts deleted file mode 100644 index 7ee754d..0000000 --- a/node-tools/src/mcp.ts +++ /dev/null @@ -1,258 +0,0 @@ -/** - * The mesh's tools as an MCP server (novox/hq design 25 §7, design 34). - * - * **A thin adapter and nothing more.** Every tool an agent sees is one a module answered for and one - * this account may call; the schema is the module's own; the answer is the module's own. Nothing here - * decides anything, which is why it is short — an MCP surface that reshaped arguments or summarised - * answers would be a second definition of what a tool is, and the module's code is the first. - * - * Implemented against the protocol directly rather than through a library: the surface is three - * methods and one framing, and a dependency here would be a dependency on every machine. - * - * One handler, two transports. Over stdio for a program a person starts (`mesh mcp`), over HTTP on a - * machine's loopback for the console the mesh assigns there (`mesh serve`, http.ts). The handler does - * not know which asked. - */ -import type { Broker } from "@novox/mesh-sdk/messaging"; - -import { callTool, seatsIn, toolKey, toolsOn, whyItFailed, type Listing, type Seats } from "./client.js"; - -/** The protocol version this speaks. Stated, because a host that wants another should be told so - * rather than discovering it through a shape it did not expect. */ -export const PROTOCOL = "2025-03-26"; - -export interface Request { - jsonrpc: string; - id?: number | string | null; - method: string; - params?: Record; -} - -export interface Reply { - jsonrpc: "2.0"; - id: Request["id"]; - result?: unknown; - error?: { code: number; message: string }; -} - -/** How long a fetched tool list is kept before the modules are asked again. An agent asks on every - * turn; the mesh changes on the order of minutes. */ -export const LISTING_KEPT_MS = 30_000; - -export interface Surface { - /** Answer one request; undefined for a notification, which expects none. */ - handle(request: Request): Promise; -} - -/** - * The surface over one bus connection, as one account. - * - * The tool list is fetched when first asked and kept for a short while (design 34 §3): asking every - * module on every `tools/list` would fan out on every agent turn for something nobody changed, and - * never refreshing would hide a module assigned a moment ago. - */ -export function mcpSurface(bus: Broker, who: string): Surface { - let known: { listing: Listing; at: number } | undefined; - - const answer = (id: Request["id"], result: unknown): Reply => ({ jsonrpc: "2.0", id, result }); - const refuse = (id: Request["id"], code: number, message: string): Reply => ({ - jsonrpc: "2.0", - id, - error: { code, message }, - }); - - const listing = async (): Promise => { - if (!known || Date.now() - known.at > LISTING_KEPT_MS) { - known = { listing: await toolsOn(bus), at: Date.now() }; - } - return known.listing; - }; - // The roles the last listing knew, so `.` resolves to the seat. Fetched once if a - // call arrives before any list did; a listing that failed leaves no roles, and the name is then - // a module's, which is the right fallback for a mesh whose control plane is away. - const roles = async (): Promise => { - try { - return seatsIn(await listing()); - } catch { - return undefined; - } - }; - - return { - async handle(request) { - // A notification has no id and expects no answer; `initialized` is the one every host sends. - const notification = request.id === undefined || request.id === null; - - switch (request.method) { - case "initialize": - return answer(request.id, { - protocolVersion: PROTOCOL, - capabilities: { tools: {} }, - serverInfo: { name: "mesh", version: "1" }, - // Said in the handshake, because an agent that knows whose authority it is acting under - // can say so when a call is refused — and a refusal is the one thing here that is not - // the mesh's fault or the tool's. - instructions: - `These are the tools of a Novox mesh, reached as ${who}. Every call goes to the module ` + - `that serves it; what may be called was fixed when this account was issued, so a ` + - `refusal means the account, not the tool. The list is what the running modules ` + - `answered, plus every role's tools from the mesh's records — the mesh's own verbs ` + - `(mesh-delivery.deliveries, mesh-placement.propose …) among them; a module that did not answer ` + - `is named in the list's _meta and can still be called by ..`, - }); - - case "notifications/initialized": - return undefined; - - case "ping": - return notification ? undefined : answer(request.id, {}); - - case "tools/list": { - let have: Listing; - try { - have = await listing(); - } catch (e) { - return refuse(request.id, -32603, whyItFailed("mesh-catalog.catalog_modules", e)); - } - return answer(request.id, { - tools: have.tools.map((t) => ({ - name: `${t.module}.${t.name}`, - description: t.description ?? `${t.name}, served by ${t.module}`, - // The module's own schema, passed through — with `node`, the machine to ask when - // the module runs on several (novox/hq ADR 0159); a seat's verb takes none, the - // seat's scope decides. An empty object is a tool that takes nothing, which is a - // real answer and not a missing one. - inputSchema: t.seat && t.scope !== "node" ? asSchema(t.input) - : t.seat ? withNode(asSchema(t.input), "the machine whose seat answers; required, the seat is held once per machine", true) - : withNode(asSchema(t.input)), - })), - // Silence, named (design 34 §3): the modules the catalogue holds and nothing answered - // for. Not a tool, so not in `tools`; not dropped either. - _meta: { notAnswering: have.notAnswering }, - }); - } - - case "tools/call": { - const given = String(request.params?.name ?? ""); - const args = { ...((request.params?.arguments as Record | undefined) ?? {}) }; - // The machine, when the caller names one, travels in the subject and never reaches the - // module's arguments (novox/hq ADR 0159) — for a module's tool. A seat's verb takes no - // machine from the console (the seat's scope decides), so a `node` among its arguments - // is the verb's own, as `push` and `assign` take one, and is handed through untouched. - const have = await listing().catch(() => undefined); - const roles = have && seatsIn(have); - const bare = given.split("@", 1)[0]; - const isSeatVerb = roles ? toolKey(bare, roles).startsWith("seat:") : false; - // A node-scoped seat's verb is asked of one machine (design 33 §4, ADR 0170): `node` - // names it and travels in the subject, as for a module's tool. - const nodeScoped = isSeatVerb && (have?.tools.some((t) => t.seat && t.scope === "node" && - `${t.module}.${t.name}` === bare) ?? false); - const takesNode = !isSeatVerb || nodeScoped; - const node = takesNode && typeof args.node === "string" && args.node !== "" ? args.node : ""; - if (takesNode) delete args.node; - if (nodeScoped && !node && !given.includes("@")) { - return refuse(request.id, -32602, `${given} is a machine's seat's verb: name the machine with \`node\``); - } - const name = node && !given.includes("@") ? `${given}@${node}` : given; - try { - const { result, node: answeredBy } = await callTool(bus, name, args, roles, have); - // Text, because that is what every host renders. The content is the module's answer - // as JSON, unshaped: an adapter that flattened it would be deciding what matters in - // somebody else's answer. Which machine answered follows it as its own line. - const content: { type: string; text: string }[] = [ - { type: "text", text: JSON.stringify(result, null, 2) }, - ]; - if (answeredBy) content.push({ type: "text", text: `answered by ${answeredBy}` }); - return answer(request.id, { content }); - } catch (e) { - // **An error the agent can act on, not a stack.** isError rather than a protocol - // failure, because the call was well-formed and the mesh answered it — with a refusal, - // an absence or a fault, and the words say which. - return answer(request.id, { - content: [{ type: "text", text: whyItFailed(name, e) }], - isError: true, - }); - } - } - - default: - return notification ? undefined : refuse(request.id, -32601, `mesh's MCP surface has no ${request.method}`); - } - }, - }; -} - -/** - * A module's declared input as a JSON schema an agent can read. - * - * The sdk keeps a tool's input opaque, and the catalogue's modules write it as a bare map of - * property to description — `{ module: { type, description } }` — which is the `properties` of a - * schema rather than a schema. Wrapped here when that is what arrived; passed through when a module - * already wrote a schema; an empty object when it declared nothing. The module's words are kept - * either way. - */ -export function asSchema(input: unknown): Record { - if (!input || typeof input !== "object" || Array.isArray(input)) { - return { type: "object", properties: {} }; - } - const given = input as Record; - if (given.type === "object" || "properties" in given) return given; - if (Object.keys(given).length === 0) return { type: "object", properties: {} }; - return { type: "object", properties: given }; -} - -/** - * Serve over stdio until stdin closes, which is how a host ends a session. - */ -export async function serveMcp(bus: Broker, who: string): Promise { - const surface = mcpSurface(bus, who); - const say = (message: unknown) => { - process.stdout.write(`${JSON.stringify(message)}\n`); - }; - for await (const line of lines()) { - let request: Request; - try { - request = JSON.parse(line) as Request; - } catch { - // Unparseable, and with no id there is nobody to tell. Skipped rather than answered, because a - // reply to a request that was never framed is noise on the same channel. - continue; - } - const reply = await surface.handle(request); - if (reply) say(reply); - } -} - -/** stdin as newline-framed messages, which is what MCP over stdio is. */ -async function* lines(): AsyncGenerator { - let buffered = ""; - for await (const chunk of process.stdin) { - buffered += (chunk as Buffer).toString("utf8"); - let at: number; - while ((at = buffered.indexOf("\n")) >= 0) { - const line = buffered.slice(0, at).trim(); - buffered = buffered.slice(at + 1); - if (line !== "") yield line; - } - } - if (buffered.trim() !== "") yield buffered.trim(); -} - -/** Every module tool takes an optional `node`: the machine to ask when the module runs on several - * (novox/hq ADR 0159). Added to the listing, stripped before the call, never seen by the module. */ -function withNode(schema: Record, description?: string, required = false): Record { - const properties = { ...((schema.properties as Record | undefined) ?? {}) }; - if (!("node" in properties)) { - properties.node = { - type: "string", - description: description ?? - "the machine to ask, when this module runs on several; else whichever answers, and the answer says which", - }; - } - const out: Record = { ...schema, type: "object", properties }; - if (required) { - const have = Array.isArray(schema.required) ? (schema.required as string[]) : []; - out.required = have.includes("node") ? have : [...have, "node"]; - } - return out; -} diff --git a/node-tools/src/mesh.ts b/node-tools/src/mesh.ts deleted file mode 100644 index 90449e0..0000000 --- a/node-tools/src/mesh.ts +++ /dev/null @@ -1,278 +0,0 @@ -#!/usr/bin/env node -/** - * `mesh` — the mesh's tools, for whoever is on a machine (novox/hq design 25 §7, design 34). - * - * Four verbs and nothing else. What tools are there, call one, serve the same two to an agent over - * stdio, and serve them on a machine's loopback as the console the mesh assigns. Deliberately thin: - * everything that could be a decision is one the mesh already made, and a client that grew opinions - * would be a second place the mesh's behaviour is defined. - * - * mesh tools what the running modules answer - * mesh call . [json] call one, arguments as JSON on the command line or on stdin - * mesh mcp the same two, as an MCP server over stdio - * mesh serve [--listen host:port] the console: MCP over HTTP on this machine's loopback - * - * Who it speaks as, in order of preference: - * MESH_BROKER_FILE the module credential the mesh delivered — the console's (ADR 0152) - * MESH_CREDENTIAL / --credential a person's, as `operator issue` printed it (design 25 §7) - * MESH_CONSOLE / --console no credential: `tools` and `call` go through a console - * already running on this machine, over loopback HTTP - */ -import { readFile } from "node:fs/promises"; - -import type { Broker } from "@novox/mesh-sdk/messaging"; - -import { - callTool, - connectAs, - connectAsTheConsole, - credentialFrom, - seatsIn, - toolsOn, - whyItFailed, - type Listing, -} from "./client.js"; -import { serveMcpHttp } from "./http.js"; -import { serveMcp } from "./mcp.js"; - -const usage = `mesh tools -mesh call . [json] -mesh mcp -mesh serve [--listen host:port] - - --credential a person's credential, the JSON \`operator issue\` printed; default $MESH_CREDENTIAL - --console a console on this machine to ask through instead; default $MESH_CONSOLE - --listen where \`serve\` listens; loopback only; default $MESH_CONSOLE_LISTEN or 127.0.0.1:4270 - MESH_BROKER_FILE the module credential the mesh delivered, which \`serve\` runs on`; - -/** What the console listens on when nothing says otherwise. */ -const DEFAULT_LISTEN = "127.0.0.1:4270"; - -async function main(argv: string[]): Promise { - const args = [...argv]; - let credentialPath = process.env.MESH_CREDENTIAL ?? ""; - let consoleUrl = process.env.MESH_CONSOLE ?? ""; - let listen = process.env.MESH_CONSOLE_LISTEN ?? DEFAULT_LISTEN; - for (let i = 0; i < args.length; i++) { - const take = () => { - const v = args[i + 1] ?? ""; - args.splice(i, 2); - i--; - return v; - }; - if (args[i] === "--credential") credentialPath = take(); - else if (args[i] === "--console") consoleUrl = take(); - else if (args[i] === "--listen") listen = take(); - } - const verb = args.shift(); - if (!verb || verb === "help" || verb === "--help") { - console.log(usage); - return verb ? 0 : 1; - } - - // Through a console already on this machine: no credential to hold, which is the point of one. - if (consoleUrl && (verb === "tools" || verb === "call")) { - return verb === "tools" ? listingVia(consoleUrl) : callingVia(consoleUrl, args); - } - - const { bus, who } = await connecting(credentialPath, verb); - try { - switch (verb) { - case "tools": - return await listing(bus, who); - case "call": - return await calling(bus, args); - case "mcp": - // Serves until stdin closes, which is how an MCP host ends a session. - await serveMcp(bus, who); - return 0; - case "serve": { - const up = await serveMcpHttp(bus, who, listen); - console.log(`mesh console listening on http://${up.address}/mcp as ${who}`); - await new Promise((resolve) => { - process.once("SIGTERM", () => resolve()); - process.once("SIGINT", () => resolve()); - }); - await up.close(); - return 0; - } - default: - console.error(`mesh has no "${verb}".\n\n${usage}`); - return 1; - } - } finally { - await bus.close(); - } -} - -/** - * Who this process is on the bus. The module credential first: a console is started by the mesh with - * MESH_BROKER_FILE and nothing else, and must not fall back to a person's file lying around. - */ -async function connecting(credentialPath: string, verb: string): Promise<{ bus: Broker; who: string }> { - const delivered = process.env.MESH_BROKER_FILE; - if (delivered) { - return connectAsTheConsole(delivered); - } - if (!credentialPath) { - throw new Error( - verb === "serve" - ? "no credential: the console runs on MESH_BROKER_FILE, the module credential the mesh " + - "delivered; to run it by hand, pass --credential with a person's credential" - : "no credential: set MESH_CREDENTIAL or pass --credential (the JSON `operator issue` " + - "printed, saved verbatim), or --console to ask through a console on this machine", - ); - } - const held = await credentialFrom(credentialPath); - return { bus: await connectAs(held), who: held.person ?? held.user ?? "somebody" }; -} - -function printListing(have: Listing, who?: string): void { - if (have.tools.length === 0) { - console.log("no running module answered with any tool"); - } - // **What the modules answered, not what this account may call.** The two differ and the - // difference is the point: an account seeing only its own tools cannot tell "not installed" from - // "not yours", and those need different people to fix them. - for (const t of have.tools) { - const name = `${t.module}.${t.name}`; - const line = t.description ? `${name.padEnd(36)} ${t.description}` : name; - console.log(t.seat ? `${line} (a role's tool: answered by whoever holds the ${t.module} seat)` : line); - } - if (have.notAnswering.length > 0) { - console.log( - `\nheld by the mesh and not answering: ${have.notAnswering.join(", ")} — not assigned, not up, ` + - "or built before the runtime answered `tools`; each can still be called by name", - ); - } - if (who) { - console.log(`\nasked as ${who}; what ${who} may call was fixed when the account was issued.`); - } -} - -async function listing(bus: Broker, who?: string): Promise { - let have: Listing; - try { - have = await toolsOn(bus); - } catch (e) { - console.error(whyItFailed("mesh-catalog.catalog_modules", e)); - return 1; - } - printListing(have, who); - return 0; -} - -async function calling(bus: Broker, args: string[]): Promise { - const key = args.shift(); - if (!key) { - console.error("mesh call . [json]"); - return 1; - } - const parsed = await argumentsFrom(args); - if (parsed === undefined) return 1; - try { - // `.` reaches the role when the mesh lists that verb for the seat; `seat:` says so - // outright and asks nothing first. - const have = key.startsWith("seat:") ? undefined : await toolsOn(bus).catch(() => undefined); - const { result, node } = await callTool(bus, key, parsed, have && seatsIn(have), have); - console.log(JSON.stringify(result, null, 2)); - if (node) console.error(`answered by ${node}`); - return 0; - } catch (e) { - console.error(whyItFailed(key, e)); - return 1; - } -} - -/** The console's answer to one MCP request, over loopback HTTP. */ -async function viaConsole(consoleUrl: string, method: string, params?: unknown): Promise { - const endpoint = consoleUrl.endsWith("/mcp") ? consoleUrl : `${consoleUrl.replace(/\/$/, "")}/mcp`; - const res = await fetch(endpoint, { - method: "POST", - headers: { "content-type": "application/json", accept: "application/json" }, - body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), - }); - if (!res.ok) { - throw new Error(`the console at ${endpoint} answered ${res.status}`); - } - const reply = (await res.json()) as { result?: any; error?: { message: string } }; - if (reply.error) throw new Error(reply.error.message); - return reply.result; -} - -async function listingVia(consoleUrl: string): Promise { - try { - const result = await viaConsole(consoleUrl, "tools/list"); - const have: Listing = { - tools: (result.tools ?? []).map((t: { name: string; description?: string; inputSchema?: unknown }) => { - const at = t.name.indexOf("."); - return { module: t.name.slice(0, at), name: t.name.slice(at + 1), description: t.description, input: t.inputSchema }; - }), - notAnswering: result._meta?.notAnswering ?? [], - }; - printListing(have); - return 0; - } catch (e) { - console.error(e instanceof Error ? e.message : String(e)); - return 1; - } -} - -async function callingVia(consoleUrl: string, args: string[]): Promise { - const key = args.shift(); - if (!key) { - console.error("mesh call . [json]"); - return 1; - } - const parsed = await argumentsFrom(args); - if (parsed === undefined) return 1; - try { - const result = await viaConsole(consoleUrl, "tools/call", { name: key, arguments: parsed }); - const text = result?.content?.[0]?.text ?? JSON.stringify(result); - if (result?.isError) { - console.error(text); - return 1; - } - console.log(text); - return 0; - } catch (e) { - console.error(e instanceof Error ? e.message : String(e)); - return 1; - } -} - -/** A call's arguments: JSON on the command line, else on stdin, else nothing. Undefined when what - * was given is not JSON, after saying so. */ -async function argumentsFrom(args: string[]): Promise { - const raw = args.length > 0 ? args.join(" ") : await maybeStdin(); - if (raw.trim() === "") return {}; - try { - return JSON.parse(raw); - } catch (e) { - console.error(`the arguments are not JSON: ${(e as Error).message}`); - return undefined; - } -} - -/** Arguments on stdin, for a call whose JSON is too long or too quoted to type. Empty when stdin is a - * terminal, so `mesh call x.y` with no arguments does not hang waiting for something nobody is - * typing. */ -async function maybeStdin(): Promise { - if (process.stdin.isTTY) return ""; - const chunks: Buffer[] = []; - for await (const chunk of process.stdin) chunks.push(chunk as Buffer); - return Buffer.concat(chunks).toString("utf8"); -} - -// Only when run, so a test can import the pieces. -if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) { - main(process.argv.slice(2)) - .then((code) => process.exit(code)) - .catch((e) => { - console.error(e instanceof Error ? e.message : String(e)); - process.exit(1); - }); -} - -export { main, usage }; -export const _readFile = readFile; diff --git a/node-tools/src/runtime.ts b/node-tools/src/runtime.ts deleted file mode 100644 index 99e7ae5..0000000 --- a/node-tools/src/runtime.ts +++ /dev/null @@ -1,384 +0,0 @@ -// The tool runtime — the per-node process that makes the mesh's tools actually serve (novox/hq -// ADR 0175). It binds the mesh broker, loads the served modules' tool bundles (each of which calls -// registerModuleTools as it imports), and serves every module's tools on that module's subjects and -// every held seat's verbs on the seat's. Everything hard — dispatch, collection, duplicate-name -// safety — is the sdk's; this is the wrapper. -// -// One runtime, many modules. It was written for one module per process and ran that way in a -// container per module; it now serves a list, as the one process per node the host supervises, -// 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 { useBroker } from "@novox/mesh-sdk/messaging"; -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"; -import { launch, launches } from "./launch.js"; -import { announce, endpointsOf, type ServedSeatVerb } from "./announce.js"; - -/** - * The one verb every module's runtime answers for it (novox/hq ADR 0152, design 34 §3): the - * module's tool names, descriptions and argument schemas, from the code that answers them and - * from nowhere else. Discovery asks the module, because a copy kept anywhere else drifts. - */ -export const TOOLS_VERB = "tools"; - -/** What `tools` answers for one module. */ -export interface ToolsAnswer { - module: string; - tools: { - name: string; - description: string; - input: Readonly>; - /** Where this tool is answered, as the mesh issued it (ADR 0160): the module's plain subject - * first when there is one, then this machine's. A caller composes nothing. */ - subjects?: string[]; - }[]; - /** Why this module serves nothing here, when its bundle failed to load (ADR 0175): said where - * discovery looks, so a module that is silent and one that is broken are told apart. */ - failed?: string; -} - -/** One module this runtime serves: its name and its compiled tool entrypoints. */ -export interface ServedModule { - module: string; - /** Absolute paths to the module's compiled tool entrypoints (e.g. .../umami/tools/index.js). */ - entrypoints: string[]; -} - -export interface RuntimeOptions { - /** The mesh broker to serve over. */ - broker: Broker; - /** The modules to serve, each with its entrypoints. */ - serves?: ServedModule[]; - /** The credential's own module's entrypoints — the one-module form, which the per-module - * containers still use; the same as naming the credential's module in `serves`. */ - moduleEntrypoints?: string[]; - /** The credential the mesh delivered, for what it says about the seats this module claims - * (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 - * environment: whose machine this is (novox/hq to-be 37 §3, ADR 0175). */ -export const OPERATOR_ACCOUNT = "MESH_OPERATOR_ACCOUNT"; -export const OPERATOR_HOME = "MESH_OPERATOR_HOME"; - -/** Load the modules, bind the broker, and serve. Returns a stop function that unhooks serving. */ -export async function runTools(opts: RuntimeOptions): Promise<() => void> { - useBroker(() => opts.broker); - const runtime = opts.broker as RuntimeBroker; - // Whose runtime this is: the credential's module, or the connection's own when a runtime is - // started by hand without one — the broker was told its module when it connected. - const self = opts.credential?.module ?? (typeof runtime.module === "string" ? runtime.module : undefined); - - // What to serve: the list, with the one-module form folded in as the credential's own entry. - const served = new Map(); - for (const s of opts.serves ?? []) { - served.set(s.module, [...(served.get(s.module) ?? []), ...s.entrypoints]); - } - const ownEntrypoints = opts.moduleEntrypoints ?? []; - if (opts.moduleEntrypoints?.length) { - if (!self) { - throw new Error( - "entrypoints were given with no module to serve them as: name the module (MESH_TOOL_MODULES " + - "as =) or connect on a credential that names one", - ); - } - served.set(self, [...(served.get(self) ?? []), ...opts.moduleEntrypoints]); - } - - // The operator's machine, said once so a tool's behaviour under it can be read back from the - // log. Tools read the two words from their own environment, which is this process's. - const account = process.env[OPERATOR_ACCOUNT]; - if (account) { - console.log(`[mesh-tools] the operator's account here is ${account}` + - (process.env[OPERATOR_HOME] ? ` (home ${process.env[OPERATOR_HOME]})` : "")); - } - - // Follow every served module's membership before loading anything, so what each is issued is - // known when its tools are bound. A module's own runtime already follows its own. - if (typeof runtime.follow === "function") { - 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) ?? {}) }); - - // Every bundle this runtime serves is launched as a process and spoken to over MCP on stdio (ADR - // 0188, ADR 0193): given the runtime's words and its module's own, and told the module it serves - // it as. The runtime knows no language; an entrypoint that is not executable was not built to be - // served, and is refused by name. A bundle that fails to start is named, and the others serve - // (ADR 0175: one faulty bundle must not take the node's tools down). - // - // The one-module form — the credential's own module's entrypoints, which the per-module containers - // still use for their event handlers and provisioners until they move (to-be 38 WP4c) — is imported - // into this process as before: one module, one SDK, its container's own environment. - // The machine this runtime serves, which an event a launched tool emits is stamped with. - const node = opts.credential?.node ?? (typeof (runtime as unknown as { node?: unknown }).node === "string" ? (runtime as unknown as { node: string }).node : undefined); - const failed = new Map(); - const launched: { module: string; owner: string; tools: ToolDefinition[] }[] = []; - const children: Array<() => void> = []; - for (const [module, entrypoints] of served) { - const imported = module === self && ownEntrypoints.length > 0; - for (const entry of entrypoints) { - const path = resolve(entry); - try { - if (imported) { - await import(pathToFileURL(path).href); - continue; - } - if (!launches(path)) { - throw new Error(`${path} is not executable; a bundle the runtime serves is started, never imported, and its build makes it executable (novox/hq ADR 0193)`); - } - const child = await launch(module, path, { - ...envFor(module), MESH_SERVED_MODULE: module, MESH_MODULE: module, - ...(node ? { MESH_NODE: node } : {}), - }); - children.push(child.stop); - for (const r of child.registrations) launched.push({ ...r, owner: module }); - } catch (err) { - const why = err instanceof Error ? err.message : String(err); - failed.set(module, why); - console.log(`[mesh-tools] ${module}'s bundle ${entry} failed to load: ${why}; its tools are not served here`); - } - } - } - - // A registration under a served module's name is that module's tools, served on its subjects. - // One under a seat's name is the module's implementation of that seat's verbs (ADR 0159, 0160): - // served on the seat's subjects by serveClaimedSeats where some served module claims the seat, - // never as a module's tools and never listed among them. A module named like its seat (the - // catalogue is the mesh-catalog seat) registers once and is both. Anything else is said and left - // 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); - const registrations = [ - ...collectTools().map((r) => ({ ...r, owner: self ?? r.module })), - ...launched, - ]; - const ownRegistrations = registrations.filter(({ module, owner: by }) => { - if (served.has(module)) return true; - if (claimed.has(module)) return false; - console.log(`[mesh-tools] ${by} registers tools under "${module}", which is neither a module served here nor a seat one of them claims; not served until the mesh issues the claim`); - return false; - }); - const stops: Array<() => void> = [...children]; - const stop = (): void => stops.splice(0).forEach((s) => s()); - - // Refused before anything is bound if a module named a tool of its own `tools`: one name - // answering two things is the fault nobody can diagnose afterwards, and the runtime is the only - // place that sees both. Likewise two tools of one module under one name. - for (const { module, tools: own } of ownRegistrations) { - if (own.some((t) => t.name === TOOLS_VERB)) { - throw new Error( - `${module} names a tool "${TOOLS_VERB}", which is the verb the runtime answers for every ` + - "module with what it serves (novox/hq ADR 0152) — refused, rename it", - ); - } - const seen = new Set(); - for (const t of own) { - if (seen.has(t.name)) throw new Error(`${module} exposes two tools named ${t.name} — refused`); - seen.add(t.name); - } - } - - // Each tool on its own key, namespaced by its module (ADR 0047); where that key is answered is - // the broker's to know from the module's membership (ADR 0160). A tool runs attributed to its - // module, so what it emits lands on the module's subject and not the runtime's. - const names: string[] = []; - for (const { module, tools: own } of ownRegistrations) { - for (const t of own) { - names.push(toolKey(module, t.name)); - stops.push(await opts.broker.handle(toolKey(module, t.name), (args: Record | undefined) => - atWork.run({ module }, () => t.run(args ?? {})))); - } - } - - // And, for every served module, the verb that says what it serves — nothing, and why, for a - // module whose bundle failed. A module that registered nothing and did not fail is a pure-events - // module (the audit logger), whose scoped account may not declare the serve queue; it is left - // silent as it always was. - const byModule = new Map(); - for (const { module, tools: own } of ownRegistrations) { - byModule.set(module, [...(byModule.get(module) ?? []), ...own]); - } - for (const module of served.keys()) { - const own = byModule.get(module) ?? []; - const why = failed.get(module); - if (own.length === 0 && !why) continue; - const subjectsOf = (tool: string): string[] | undefined => { - const m = typeof runtime.membership === "function" ? runtime.membership(module) : undefined; - if (!m) return undefined; - const plain = m.serves.filter((s) => s.queue).map((s) => s.subject.replace("{tool}", tool)); - const mine = m.serves.filter((s) => !s.queue).map((s) => s.subject.replace("{tool}", tool)); - return [...plain, ...mine]; - }; - stops.push(await opts.broker.handle(toolKey(module, TOOLS_VERB), async (): Promise => ({ - module, - tools: own.map((t) => ({ name: t.name, description: t.description, input: t.input, subjects: subjectsOf(t.name) })), - ...(why ? { failed: why } : {}), - }))); - } - - console.log(`[mesh-tools] serving ${names.length} tool(s) for ${served.size} module(s): ${names.join(", ") || "(none)"}` + - (failed.size ? `; not serving ${[...failed.keys()].join(", ")}, whose bundle(s) failed to load` : "")); - const seats = await serveClaimedSeats(runtime, [...served.keys()], self, opts.credential, registrations); - stops.push(seats.stop); - // **What it serves, it announces** (novox/hq ADR 0197), asked at the moment of the request. - if (typeof runtime.raw === "function") { - stops.push(announce(runtime, { - name: self ?? "runtime", id: runtime.node ?? self ?? "runtime", - description: `the tool runtime of ${self ?? "a module"}${runtime.node ? ` on ${runtime.node}` : ""}`, - metadata: runtime.node ? { node: runtime.node } : {}, - }, () => endpointsOf(runtime, ownRegistrations, seats.serving()))); - } - return () => stop(); -} - -/** The seats some served module claims: from the credential for its own module, and from every - * served module's membership (ADR 0160) — the node's runtime holds no claims of its own. */ -function seatsClaimed( - modules: Iterable, - self: string | undefined, - credential: Credential | undefined, - runtime: RuntimeBroker, -): Set { - const out = new Set(); - for (const c of credential?.claims ?? []) if (credential?.module === self) out.add(c.seat); - for (const module of modules) { - const m = typeof runtime.membership === "function" ? runtime.membership(module) : undefined; - for (const s of m?.seats ?? []) out.add(s.seat); - } - return out; -} - -/** One seat's verb, where its callers ask, and which served module holds the seat. */ -interface SeatVerb { - seat: string; - verb: string; - subject: string; - holder: string; -} - -/** - * Holding a seat means serving its tools (design 33 §3, novox/hq ADR 0159). What a served module - * claims and promises comes from its membership (ADR 0160) — and, for a module's own runtime, from - * its credential, which named the claims before memberships did. Each verb is served on the seat's - * own subject by the tool of the same name registered under the seat's name. Whether this instance - * *holds* the seat is the bus's to decide: only the holder's account may subscribe the seat's - * subjects, so a claimant that does not hold it here is refused the subscription and serves nothing - * — never a failure of its own tools. - */ -async function serveClaimedSeats( - broker: RuntimeBroker, - served: string[], - self: string | undefined, - credential: Credential | undefined, - registrations: { module: string; owner: string; tools: ToolDefinition[] }[], -): Promise<{ stop: () => void; serving: () => ServedSeatVerb[] }> { - if (typeof broker.handleSubject !== "function") return { stop: () => {}, serving: () => [] }; - // A seat's verbs are the role's, not the software's (ADR 0159): implemented under the seat's - // name — `registerModuleTools("mesh-store", …)` — and never confused with the module's own tools. - const implementations = new Map) => Promise>>(); - const definitions = new Map>(); - for (const { module, tools } of registrations) { - const defs = definitions.get(module) ?? new Map(); - for (const t of tools) defs.set(t.name, t); - definitions.set(module, defs); - } - for (const { module, owner, tools } of registrations) { - const verbs = implementations.get(module) ?? new Map) => Promise>(); - for (const t of tools) verbs.set(t.name, (args) => atWork.run({ module: owner }, () => t.run(args))); - implementations.set(module, verbs); - } - - /** Every verb of every seat a served module claims, where the mesh issued it. */ - const wanted = (): SeatVerb[] => { - const out: SeatVerb[] = []; - const have = new Set(); - const add = (v: SeatVerb): void => { - if (have.has(v.subject)) return; - have.add(v.subject); - out.push(v); - }; - for (const module of served) { - const m = typeof broker.membership === "function" ? broker.membership(module) : undefined; - for (const s of m?.seats ?? []) add({ seat: s.seat, verb: s.verb, subject: s.subject, holder: module }); - // The credential's claims, for the module's own runtime, ONLY until the mesh issues a - // membership (novox/hq issue 218). The credential names what the module claims; the membership - // names what it holds on this machine. A seat held once for the mesh is claimed by every - // machine running the module and held by one, so once a membership exists it decides: a claim - // it leaves out is not held here, and serving it anyway announced the seat from a machine the - // bus then refused it on. - if (module !== self || m) continue; - for (const claim of credential?.claims ?? []) { - for (const verb of claim.serves ?? []) { - const subject = seatToolSubject(claim.seat, verb, claim.scope, credential?.node); - add({ seat: claim.seat, verb, subject, holder: module }); - } - } - } - return out; - }; - - let stops: (() => void)[] = []; - let servingNow: ServedSeatVerb[] = []; - const serve = async (): Promise => { - stops.forEach((s) => s()); - stops = []; - servingNow = []; - for (const v of wanted()) { - const run = implementations.get(v.seat)?.get(v.verb); - const tool = definitions.get(v.seat)?.get(v.verb); - if (!run) { - console.log(`[mesh-tools] ${v.holder} claims ${v.seat} and implements no ${v.verb}, which that seat promises; not served`); - continue; - } - stops.push(await broker.handleSubject(v.subject, run)); - if (tool) servingNow.push({ ...v, tool }); - console.log(`[mesh-tools] serving ${v.seat}'s ${v.verb} on ${v.subject}, admitted where ${v.holder} holds the seat`); - } - }; - await serve(); - // A membership issued to any served module may add, move or withdraw a seat's verbs. - if (typeof broker.onMembership === "function") broker.onMembership(() => void serve()); - return { stop: () => stops.forEach((s) => s()), serving: () => servingNow }; -} diff --git a/node-tools/test/announce.test.ts b/node-tools/test/announce.test.ts deleted file mode 100644 index 7df625c..0000000 --- a/node-tools/test/announce.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * What a runtime serves, it announces (novox/hq ADR 0197): the TypeScript runtime the per-module - * containers still run answers the NATS services protocol's discovery in the same shape as the Go - * tool runtime — its module's tools on every subject issued, and the seat verbs it serves. - * - * MESH_TEST_NATS=nats://127.0.0.1:14232 node --test --experimental-strip-types test/announce.test.ts - */ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { fileURLToPath } from "node:url"; -import { connect, StringCodec } from "nats"; - -import { resetTools } from "@novox/mesh-sdk/tools"; - -import { connectNats, membershipSubject } from "../dist/broker-nats.js"; -import { runTools } from "../dist/runtime.js"; - -const url = process.env.MESH_TEST_NATS; -const fixture = (name: string) => fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)); -const sc = StringCodec(); - -test("the runtime answers $SRV.INFO with what it serves, in the services protocol's format", async (t) => { - if (!url) return t.skip("MESH_TEST_NATS unset"); - resetTools(); - const nc = await connect({ servers: url }); - const jsm = await nc.jetstreamManager(); - try { - await jsm.streams.delete("ASSIGNMENTS"); - } catch { - // none yet - } - await jsm.streams.add({ name: "ASSIGNMENTS", subjects: ["mesh.assignment.>"], max_msgs_per_subject: 1, allow_direct: true } as never); - await nc.jetstream().publish(membershipSubject("anchor", "shop"), sc.encode(JSON.stringify({ - node: "anchor", module: "shop", - serves: [{ subject: "mesh.mod.shop.tool.{tool}.anchor" }, { subject: "mesh.mod.shop.tool.{tool}", queue: "serve.shop" }], - emits: "mesh.mod.shop.event.{event}", tools: "mesh.mod.shop.tool.tools", - }))); - const shop = await connectNats({ url, node: "anchor", module: "shop" }); - let stop = () => {}; - try { - stop = await runTools({ broker: shop, moduleEntrypoints: [fixture("shop-tools.mjs")] }); - const msg = await nc.request("$SRV.INFO.shop.anchor", sc.encode(""), { timeout: 2000 }); - const info = JSON.parse(sc.decode(msg.data)) as { - type: string; name: string; id: string; version: string; - endpoints: { name: string; subject: string; queue_group: string; metadata: Record }[]; - }; - assert.equal(info.type, "io.nats.micro.v1.info_response"); - assert.equal(info.name, "shop"); - assert.equal(info.id, "anchor"); - assert.ok(info.version); - const price = info.endpoints.filter((e) => e.metadata.tool === "price").map((e) => `${e.subject}|${e.queue_group}`).sort(); - assert.deepEqual(price, ["mesh.mod.shop.tool.price.anchor|", "mesh.mod.shop.tool.price|serve.shop"]); - const one = info.endpoints.find((e) => e.metadata.tool === "price")!; - assert.equal(one.metadata.kind, "tool"); - assert.equal(one.metadata.module, "shop"); - assert.equal(one.metadata.node, "anchor"); - assert.equal(one.metadata.interchangeable, "true"); - assert.ok(JSON.parse(one.metadata.schema).type === "object"); - // Ping answers with the same identity; another service's request is not answered. - const ping = JSON.parse(sc.decode((await nc.request("$SRV.PING", sc.encode(""), { timeout: 2000 })).data)); - assert.equal(ping.type, "io.nats.micro.v1.ping_response"); - await assert.rejects(nc.request("$SRV.INFO.somebody-else", sc.encode(""), { timeout: 300 })); - } finally { - stop(); - await shop.close(); - await nc.close(); - resetTools(); - } -}); diff --git a/node-tools/test/client.test.ts b/node-tools/test/client.test.ts deleted file mode 100644 index 6e0af27..0000000 --- a/node-tools/test/client.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * A person's client, against a real bus. - * - * What is worth checking is not that a request/reply works — the runtime's own tests cover that — but - * that the two surfaces are the same thing. An agent and a person must see the same tools and get the - * same answers, or the MCP surface becomes a second definition of what a tool is. - * - * docker run -d --rm --name t -p 14232:4222 nats:2.10-alpine -js - * MESH_TEST_NATS=nats://127.0.0.1:14232 node --test --experimental-strip-types test/client.test.ts - */ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -// The built output, not the source: the client imports its siblings as `.js`, which is what ships and -// what every other file here does, and cannot be loaded as TypeScript directly. `pretest` builds. -import { connectNats } from "../dist/broker-nats.js"; -import { callTool, seatsIn, toolKey, toolsOn, whyItFailed } from "../dist/client.js"; - -import { aConsoleWithSeats } from "./console-seats.ts"; - -const url = process.env.MESH_TEST_NATS; - -/** A mesh as discovery sees it (design 34 §3): the catalogue holding three modules, two of them up - * and answering `tools`, one held and not running. Two connections, because a person and a module - * are different users even in a test. */ -async function aMeshWithTools() { - const catalogue = await connectNats({ url: url!, module: "mesh-catalog" }); - const shop = await connectNats({ url: url!, module: "shop" }); - await catalogue.handle("catalog_modules", async () => ({ - modules: [{ module: "shop" }, { module: "mesh-catalog" }, { module: "ghost" }], - })); - await catalogue.handle("tools", async () => ({ - module: "mesh-catalog", - tools: [{ name: "catalog_modules", description: "what modules the mesh has", input: {} }], - })); - await shop.handle("tools", async () => ({ - module: "shop", - tools: [{ name: "price", description: "what something costs", input: { type: "object" } }], - })); - await shop.handle("price", async (body: { of?: string }) => ({ of: body.of ?? "nothing", cost: 12 })); - // Every role's tools, from the console on this machine (ADR 0571), the method beside MCP's; the controller's - // verb answers a call of its seat and is never asked for the roles. - const controller = await connectNats({ url: url!, module: "mesh-placement" }); - let controllerToolsAsked = 0; - await controller.handle("seat:mesh-placement.tools", async () => { - controllerToolsAsked++; - return { seats: [] }; - }); - await controller.handle("seat:mesh-placement.status", async () => ({ output: "all quiet", ok: true })); - const local = await aConsoleWithSeats([ - { seat: "mesh-placement", scope: "mesh", tools: [{ name: "status", description: "what is wrong", input: {} }] }, - { seat: "node-dns-resolver", scope: "node", tools: [{ name: "lookup", description: "one machine's", input: {} }] }, - ]); - return { - async close() { - await catalogue.close(); - await shop.close(); - await controller.close(); - await local.close(); - assert.equal(controllerToolsAsked, 0, "the controller was asked for the roles' tools"); - }, - }; -} - -test("a person sees what the running modules answer, sorted, and who did not answer", async (t) => { - if (!url) return t.skip("MESH_TEST_NATS unset"); - const mesh = await aMeshWithTools(); - const person = await connectNats({ url, module: "person.ada" }); - try { - const began = Date.now(); - const have = await toolsOn(person); - assert.deepEqual( - have.tools.map((x) => `${x.module}.${x.name}`), - ["mesh-catalog.catalog_modules", "mesh-placement.status", "node-dns-resolver.lookup", "shop.price"], - "the list is what the modules answered plus every role's tools, in a stable order", - ); - // A role's tool is marked as one; a node-scoped seat's carries its scope, so a caller names - // the machine and the verb resolves as the seat's (design 33 §4, ADR 0170). - assert.ok(have.tools.find((x) => x.module === "mesh-placement")!.seat); - const lookup = have.tools.find((x) => x.module === "node-dns-resolver")!; - assert.ok(lookup.seat && lookup.scope === "node"); - assert.equal(toolKey("node-dns-resolver.lookup", seatsIn(have)), "seat:node-dns-resolver.lookup"); - // Silence is named, never dropped: a module the catalogue holds and nothing answered for. - assert.deepEqual(have.notAnswering, ["ghost"]); - // And at once: a module that is not running costs nothing, or the list is unusable. - assert.ok(Date.now() - began < 5_000, "an absent module waited out the timeout"); - } finally { - await person.close(); - await mesh.close(); - } -}); - -test("a person calls a tool and gets the module's own answer, unshaped", async (t) => { - if (!url) return t.skip("MESH_TEST_NATS unset"); - const mesh = await aMeshWithTools(); - const person = await connectNats({ url, module: "person.ada" }); - try { - const answer = await callTool(person, "shop.price", { of: "a hat" }); - assert.deepEqual(answer.result, { of: "a hat", cost: 12 }); - } finally { - await person.close(); - await mesh.close(); - } -}); - -test("a tool nobody serves says so at once, and says what to do about it", async (t) => { - if (!url) return t.skip("MESH_TEST_NATS unset"); - const person = await connectNats({ url: url!, module: "person.ada" }); - try { - const began = Date.now(); - await assert.rejects(() => callTool(person, "ghost.missing", {})); - // At once, not after the whole wait: "that module is down" and "that tool is slow" need - // different things done, and a timeout cannot tell them apart. - assert.ok(Date.now() - began < 5_000, "a tool nobody serves waited out the timeout"); - } finally { - await person.close(); - } -}); - -test("a name that is not . is refused before anything is sent", async (t) => { - if (!url) return t.skip("MESH_TEST_NATS unset"); - const person = await connectNats({ url: url!, module: "person.ada" }); - try { - await assert.rejects(() => callTool(person, "price", {}), /does not name a tool/); - } finally { - await person.close(); - } -}); - -test("each way a call fails says what to do about it", () => { - // The three answers a person actually gets. Without this they are one timeout and a stack trace, - // and the remedies are in three different places. - assert.match(whyItFailed("shop.price", new Error("no responders")), /nothing serves shop\.price/); - assert.match( - whyItFailed("shop.price", new Error("Permissions Violation for Publish")), - /may not call shop\.price/, - ); - assert.match(whyItFailed("shop.price", new Error("timeout")), /did not answer in time/); - assert.match(whyItFailed("shop.price", new Error("something else")), /something else/); -}); - -test("a role's tool is reached through the seat, and a module's own name is never shadowed", async (t) => { - if (!url) return t.skip("MESH_TEST_NATS unset"); - const { seatsIn, toolKey } = await import("../dist/client.js"); - const mesh = await aMeshWithTools(); - const person = await connectNats({ url, module: "person.ada" }); - try { - const roles = seatsIn(await toolsOn(person)); - assert.equal(toolKey("mesh-placement.status", roles), "seat:mesh-placement.status"); - assert.equal(toolKey("mesh-placement.other", roles), "mesh-placement.other", "a verb the seat does not declare is a module's"); - assert.equal(toolKey("shop.price", roles), "shop.price"); - const answer = await callTool(person, "mesh-placement.status", {}, roles); - assert.deepEqual(answer.result, { output: "all quiet", ok: true }); - const direct = await callTool(person, "seat:mesh-placement.status", {}); - assert.deepEqual(direct.result, { output: "all quiet", ok: true }); - } finally { - await person.close(); - await mesh.close(); - } -}); - -// A module on two machines (novox/hq ADR 0159): a call names the machine and reaches that instance -// and no other; a call that names none reaches one of them and says which; a seat's verb is served -// by the claimant's tool of the same name on the seat's own subject. -test("a tool call names the machine it is for, and every answer says which machine answered", async (t) => { - if (!url) return t.skip("MESH_TEST_NATS unset"); - const onAnchor = await connectNats({ url: url!, module: "store", node: "anchor" }); - const onHome = await connectNats({ url: url!, module: "store", node: "home-server" }); - const asker = await connectNats({ url: url!, module: "console", node: "workstation" }); - try { - await onAnchor.handle("databases", async () => ({ at: "anchor" })); - await onHome.handle("databases", async () => ({ at: "home-server" })); - - const home = await callTool(asker, "store.databases@home-server", {}); - assert.deepEqual(home.result, { at: "home-server" }); - assert.equal(home.node, "home-server"); - const anchor = await callTool(asker, "store.databases@anchor", {}); - assert.deepEqual(anchor.result, { at: "anchor" }); - assert.equal(anchor.node, "anchor"); - - const whichever = await callTool(asker, "store.databases", {}); - assert.ok(["anchor", "home-server"].includes(whichever.node ?? ""), `an unnamed call still says who answered: ${whichever.node}`); - assert.deepEqual(whichever.result, { at: whichever.node }); - - // A seat's verb, served by the claimant on the seat's subject; a mesh seat's is flat. - await onAnchor.handleSubject("mesh.seat.mesh-store.tool.databases", async () => ({ seat: "mesh-store", at: "anchor" })); - const viaSeat = await callTool(asker, "seat:mesh-store.databases", {}); - assert.deepEqual(viaSeat.result, { seat: "mesh-store", at: "anchor" }); - assert.equal(viaSeat.node, "anchor"); - } finally { - await onAnchor.close(); - await onHome.close(); - await asker.close(); - } -}); diff --git a/node-tools/test/conformance.mjs b/node-tools/test/conformance.mjs deleted file mode 100644 index 0d8517c..0000000 --- a/node-tools/test/conformance.mjs +++ /dev/null @@ -1,54 +0,0 @@ -// The TypeScript implementation, held to the shared fixtures (novox/hq ADR 0074, design 19). -// -// Run against a NATS server, because the question is what actually reaches the wire: -// -// docker run -d --rm --name c -p 14222:4222 nats:2.10-alpine -js -// node test/conformance.mjs -// -// **The runner lives with the implementation it exercises; the fixture does not.** It is read -// from the sdk's conformance directory by sibling path — the same file the Go suite reads. A -// fixture copied into each implementation is two fixtures, and two fixtures drift, which is the -// failure the suite exists to prevent. -import { readFileSync } from "node:fs"; -import { connect } from "nats"; - -const clientPath = process.argv[2] ?? "../dist/broker-nats.js"; -const { connectNats } = await import(clientPath); -const f = JSON.parse(readFileSync(new URL("../../mesh-sdk/conformance/events/module-event.json", import.meta.url))); -const URL_ = process.env.MESH_TEST_NATS ?? "nats://127.0.0.1:14222"; - -let failed = 0; -const check = (ok, what) => { console.log(` ${ok ? "ok " : "FAIL"} ${what}`); if (!ok) failed++; }; - -const admin = await connect({ servers: URL_ }); -const jsm = await admin.jetstreamManager(); -await jsm.streams.add({ name: "EVENTS", subjects: ["mesh.mod.*.event.>", "mesh.seat.*.event.>"] }); - -const shop = await connectNats({ url: URL_, node: f.given.node, module: f.given.module }); -await shop.publish({ key: f.given.key, node: f.given.node, body: f.given.body, headers: f.given.headers }); - -// What actually landed, read back from the stream rather than from the client that wrote it. -const msg = await jsm.streams.getMessage("EVENTS", { last_by_subj: f.wire.subject }); -check(!!msg, `it lands on ${f.wire.subject}`); - -if (msg) { - const got = {}; - if (msg.header) for (const k of msg.header.keys()) got[k] = msg.header.get(k); - for (const h of f.wire.requiredHeaders) { - check(got[h] !== undefined && got[h] !== "", `${h} is set`); - } - check(got["content-type"] === f.wire.headerFormats["content-type"], "content-type is as pinned"); - check(new RegExp(f.wire.headerFormats["x-event-id"]).test(got["x-event-id"]), "x-event-id is as pinned"); - check(!Number.isNaN(Date.parse(got["x-time"])), "x-time parses as a date"); - check(got["x-source"] === f.given.module, "x-source agrees with the subject's module"); - - const payload = JSON.parse(new TextDecoder().decode(msg.data)); - check(payload.envelope === undefined && payload.key === undefined, - "the payload is the body alone, not the envelope (the fixture refuses nesting)"); - check(JSON.stringify(payload) === JSON.stringify(f.given.body), - "the body round-trips — semantic, not byte-exact, per the README"); -} - -await shop.close(); await admin.close(); -console.log(failed ? `\n${failed} failed` : "\nall passed"); -process.exit(failed ? 1 : 0); diff --git a/node-tools/test/console-seats.ts b/node-tools/test/console-seats.ts deleted file mode 100644 index 6053591..0000000 --- a/node-tools/test/console-seats.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * The console on this machine as a client asks it for every role's tools (novox/hq ADR 0571): the method `mesh/seats` - * beside MCP's, answered in the shape the Go console's `seatsAnswer` gives (internal/console/mcp.go). Sets - * MESH_CONSOLE to it, so a client, and a `mesh` it spawns, asks it. - */ -import { createServer } from "node:http"; -import type { AddressInfo } from "node:net"; - -import { SEATS_METHOD } from "../dist/client.js"; - -export type SeatOfConsole = { seat: string; scope: string; tools: { name: string; description?: string; input?: unknown }[] }; - -export async function aConsoleWithSeats(seats: SeatOfConsole[]): Promise<{ url: string; close: () => Promise }> { - const local = createServer((req, res) => { - let body = ""; - req.on("data", (c) => (body += c)); - req.on("end", () => { - const asked = JSON.parse(body) as { id: number; method: string }; - res.setHeader("content-type", "application/json"); - if (req.url !== "/mcp" || asked.method !== SEATS_METHOD) { - res.end(JSON.stringify({ jsonrpc: "2.0", id: asked.id, error: { code: -32601, message: "no such method" } })); - return; - } - res.end(JSON.stringify({ jsonrpc: "2.0", id: asked.id, result: { seats: seats.map((s) => ({ ...s, holders: [] })) } })); - }); - }); - await new Promise((done) => local.listen(0, "127.0.0.1", done)); - const url = `http://127.0.0.1:${(local.address() as AddressInfo).port}`; - process.env.MESH_CONSOLE = url; - return { url, close: () => new Promise((done) => local.close(() => done())) }; -} diff --git a/node-tools/test/http.test.ts b/node-tools/test/http.test.ts deleted file mode 100644 index 4ffc644..0000000 --- a/node-tools/test/http.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * The console: the same surface over HTTP on loopback, started the way the mesh starts it — on the - * module credential in MESH_BROKER_FILE (novox/hq ADR 0152, design 34 §2). - * - * docker run -d --rm --name t -p 14232:4222 nats:2.10-alpine -js - * MESH_TEST_NATS=nats://127.0.0.1:14232 node --test --experimental-strip-types test/http.test.ts - */ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { spawn, type ChildProcess } from "node:child_process"; -import { mkdtemp, writeFile } from "node:fs/promises"; -import { join } from "node:path"; - -import { connectNats } from "../dist/broker-nats.js"; -import { serveMcpHttp } from "../dist/http.js"; - -const url = process.env.MESH_TEST_NATS; - -async function aMesh(t: { after: (fn: () => Promise | void) => void }) { - const catalogue = await connectNats({ url: url!, module: "mesh-catalog" }); - const shop = await connectNats({ url: url!, module: "shop" }); - await catalogue.handle("catalog_modules", async () => ({ modules: [{ module: "shop" }] })); - await shop.handle("tools", async () => ({ - module: "shop", - tools: [{ name: "price", description: "what something costs", input: {} }], - })); - await shop.handle("price", async (body: { of?: string }) => ({ of: body.of ?? "nothing", cost: 12 })); - t.after(async () => { - await catalogue.close(); - await shop.close(); - }); -} - -/** `mesh serve` as the mesh runs it: MESH_BROKER_FILE, a listen address, nothing else. */ -async function aConsole(t: { after: (fn: () => Promise | void) => void }): Promise { - const dir = await mkdtemp("/tmp/mesh-console-"); - const credential = join(dir, "broker"); - await writeFile(credential, JSON.stringify({ url, node: "desk", module: "mesh-console", user: "desk.mesh-console", password: "x" })); - const child: ChildProcess = spawn(process.execPath, ["dist/mesh.js", "serve", "--listen", "127.0.0.1:0"], { - env: { ...process.env, MESH_BROKER_FILE: credential, MESH_CREDENTIAL: "" }, - stdio: ["ignore", "pipe", "pipe"], - }); - t.after(() => { - child.kill("SIGTERM"); - }); - return new Promise((resolve, reject) => { - let out = ""; - let err = ""; - child.stdout!.on("data", (d) => { - out += d.toString(); - const m = /listening on (http:\/\/[^/]+\/mcp)/.exec(out); - if (m) resolve(m[1]!); - }); - child.stderr!.on("data", (d) => (err += d.toString())); - child.on("exit", (code) => reject(new Error(`serve exited ${code}: ${err}`))); - }); -} - -async function post(endpoint: string, body: unknown): Promise<{ status: number; json?: any }> { - const res = await fetch(endpoint, { - method: "POST", - headers: { "content-type": "application/json", accept: "application/json" }, - body: JSON.stringify(body), - }); - const text = await res.text(); - return { status: res.status, json: text ? JSON.parse(text) : undefined }; -} - -test("the console answers a host on loopback, as the account the mesh gave it", async (t) => { - if (!url) return t.skip("MESH_TEST_NATS unset"); - await aMesh(t); - const endpoint = await aConsole(t); - - const hello = await post(endpoint, { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); - assert.equal(hello.status, 200); - assert.match(hello.json.result.instructions, /desk\.mesh-console/, "the handshake names the console's account"); - - const heard = await post(endpoint, { jsonrpc: "2.0", method: "notifications/initialized" }); - assert.equal(heard.status, 202, "a notification is heard and not answered"); - - const listed = await post(endpoint, { jsonrpc: "2.0", id: 2, method: "tools/list" }); - assert.deepEqual(listed.json.result.tools.map((x: { name: string }) => x.name), ["shop.price"]); - - const called = await post(endpoint, { - jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "shop.price", arguments: { of: "a hat" } }, - }); - assert.deepEqual(JSON.parse(called.json.result.content[0].text), { of: "a hat", cost: 12 }); - - // A person's client through the same endpoint, with no credential of its own. - const { main } = await import("../dist/mesh.js"); - const logged: string[] = []; - const was = console.log; - console.log = (line: string) => logged.push(String(line)); - try { - assert.equal(await main(["tools", "--console", endpoint]), 0); - } finally { - console.log = was; - } - assert.ok(logged.some((l) => l.startsWith("shop.price")), `the client did not list through the console: ${logged}`); -}); - -test("the console binds loopback and nowhere else", async (t) => { - if (!url) return t.skip("MESH_TEST_NATS unset"); - const bus = await connectNats({ url, module: "mesh-console", node: "desk" }); - try { - await assert.rejects(() => serveMcpHttp(bus, "desk.mesh-console", "0.0.0.0:0"), /loopback and nowhere else/); - const up = await serveMcpHttp(bus, "desk.mesh-console", "127.0.0.1:0"); - assert.match(up.address, /^127\.0\.0\.1:\d+$/); - await up.close(); - } finally { - await bus.close(); - } -}); diff --git a/node-tools/test/mcp.test.ts b/node-tools/test/mcp.test.ts deleted file mode 100644 index 289a089..0000000 --- a/node-tools/test/mcp.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * The MCP surface, driven the way a host drives it. - * - * **The claim worth checking is that it is the same thing the command line is.** An agent and a - * person must see the same tools and get the same answers, or this becomes a second definition of what - * a tool is — which is exactly what a thin adapter is supposed to avoid. - * - * docker run -d --rm --name t -p 14232:4222 nats:2.10-alpine -js - * MESH_TEST_NATS=nats://127.0.0.1:14232 node --test --experimental-strip-types test/mcp.test.ts - */ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { spawn } from "node:child_process"; - - -import { aConsoleWithSeats } from "./console-seats.ts"; -import { connectNats } from "../dist/broker-nats.js"; - -const url = process.env.MESH_TEST_NATS; - -/** A module answering the catalogue's list and one tool, plus a credential file the client reads. */ -async function aMeshAndACredential(t: { after: (fn: () => Promise | void) => void }) { - const catalogue = await connectNats({ url: url!, module: "mesh-catalog" }); - const shop = await connectNats({ url: url!, module: "shop" }); - await catalogue.handle("catalog_modules", async () => ({ - modules: [{ module: "shop" }, { module: "ghost" }], - })); - await shop.handle("tools", async () => ({ - module: "shop", - tools: [{ name: "price", description: "what something costs", input: { of: { type: "string" } } }], - })); - await shop.handle("price", async (body: { of?: string }) => ({ of: body.of ?? "nothing", cost: 12 })); - const controller = await connectNats({ url: url!, module: "mesh-placement" }); - // Every role's tools, from the console on this machine (ADR 0571), which the spawned `mesh` asks through MESH_CONSOLE. - const local = await aConsoleWithSeats([ - { seat: "mesh-placement", scope: "mesh", tools: [ - { name: "status", description: "what is wrong", input: {} }, - { name: "push", description: "tell a machine", input: { node: { type: "string" } } }, - ] }, - // A seat held once per machine (design 33 §4, ADR 0170): its verb is asked of one. - { seat: "node-dns-resolver", scope: "node", tools: [{ name: "lookup", description: "one machine's", input: {} }] }, - ]); - await controller.handle("seat:node-dns-resolver.lookup@anchor", async () => ({ machine: "anchor", answered: true })); - await controller.handle("seat:mesh-placement.status", async () => ({ output: "all quiet", ok: true })); - await controller.handle("seat:mesh-placement.push", async (body: { node?: string }) => ({ told: body.node ?? "nobody" })); - t.after(async () => { - await catalogue.close(); - await shop.close(); - await controller.close(); - await local.close(); - }); - - const { mkdtemp, writeFile } = await import("node:fs/promises"); - const { join } = await import("node:path"); - const dir = await mkdtemp("/tmp/mesh-client-"); - const path = join(dir, "credential.json"); - await writeFile( - path, - JSON.stringify({ url, user: "person.ada", password: "x", person: "ada", invokes: ["shop.price"] }), - ); - return path; -} - -/** Drive `mesh mcp` over stdio and collect the replies, as a host would. */ -function driving(credential: string, requests: unknown[]): Promise[]> { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, ["dist/mesh.js", "mcp", "--credential", credential], { - stdio: ["pipe", "pipe", "pipe"], - }); - let out = ""; - let err = ""; - child.stdout.on("data", (d) => (out += d.toString())); - child.stderr.on("data", (d) => (err += d.toString())); - child.on("error", reject); - child.on("close", () => { - const replies = out - .split("\n") - .filter((l) => l.trim() !== "") - .map((l) => JSON.parse(l) as Record); - if (replies.length === 0 && err !== "") reject(new Error(err)); - else resolve(replies); - }); - for (const r of requests) child.stdin.write(`${JSON.stringify(r)}\n`); - child.stdin.end(); - }); -} - -test("a host initialises, lists the mesh's tools and calls one", async (t) => { - if (!url) return t.skip("MESH_TEST_NATS unset"); - const credential = await aMeshAndACredential(t); - - const replies = await driving(credential, [ - { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, - { jsonrpc: "2.0", method: "notifications/initialized" }, - { jsonrpc: "2.0", id: 2, method: "tools/list" }, - { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "shop.price", arguments: { of: "a hat" } } }, - ]); - - const byId = new Map(replies.map((r) => [r.id, r])); - // A notification is answered with nothing, or a host waiting on ids sees a reply it cannot match. - assert.equal(replies.length, 3, `expected three replies, got ${JSON.stringify(replies)}`); - - const hello = byId.get(1)!.result; - assert.equal(hello.protocolVersion, "2025-03-26"); - assert.ok(hello.capabilities.tools, "a server offering no tools is not this one"); - assert.match(hello.instructions, /ada/, "the handshake says whose authority a call is made under"); - - const listed = byId.get(2)!.result.tools; - assert.deepEqual(listed.map((x: { name: string }) => x.name), - ["mesh-placement.push", "mesh-placement.status", "node-dns-resolver.lookup", "shop.price"], - "the modules' tools and the roles', named the way a person names them"); - // A node-scoped seat's verb takes the machine, and requires it (ADR 0170). - const lookup = listed[2]; - assert.equal(lookup.inputSchema.properties.node.type, "string"); - assert.deepEqual(lookup.inputSchema.required, ["node"]); - const price = listed[3]; - assert.ok(price.inputSchema, "a tool with no schema is one an agent cannot call"); - // A module's bare property map arrives as a schema an agent can read, its words kept — and - // `node`, the machine to ask when the module runs on several (novox/hq ADR 0159), beside them. - assert.deepEqual(price.inputSchema.properties.of, { type: "string" }); - assert.equal(price.inputSchema.properties.node.type, "string", "a module's tool takes the machine to ask"); - assert.ok(!listed[1].inputSchema.properties?.node, "a seat's verb takes no machine; the seat's scope decides"); - assert.equal(listed[0].inputSchema.properties?.node?.type, "string", "a seat's verb that takes a node of its own keeps it"); - // Silence is named: the module the catalogue holds and nothing answered for. - assert.deepEqual(byId.get(2)!.result._meta.notAnswering, ["ghost"]); - - const called = byId.get(3)!.result; - assert.ok(!called.isError, `the call failed: ${JSON.stringify(called)}`); - // The module's own answer, unshaped. An adapter that summarised it would be deciding what matters - // in somebody else's answer. - assert.deepEqual(JSON.parse(called.content[0].text), { of: "a hat", cost: 12 }); -}); - -test("a tool nobody serves comes back as an error the agent can act on", async (t) => { - if (!url) return t.skip("MESH_TEST_NATS unset"); - const credential = await aMeshAndACredential(t); - - const replies = await driving(credential, [ - { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "ghost.missing", arguments: {} } }, - ]); - const result = replies[0].result; - // isError, not a protocol failure: the call was well-formed and the mesh answered it — with an - // absence. A JSON-RPC error would tell the agent its request was malformed, which it was not. - assert.ok(result?.isError, `expected a tool error, got ${JSON.stringify(replies[0])}`); - assert.match(result.content[0].text, /nothing serves ghost\.missing/); -}); - -test("a method this surface does not have is refused, and a notification is not", async (t) => { - if (!url) return t.skip("MESH_TEST_NATS unset"); - const credential = await aMeshAndACredential(t); - - const replies = await driving(credential, [ - { jsonrpc: "2.0", id: 1, method: "resources/list" }, - { jsonrpc: "2.0", method: "notifications/cancelled" }, - ]); - assert.equal(replies.length, 1, "a notification was answered"); - assert.equal(replies[0].error.code, -32601); - assert.match(replies[0].error.message, /resources\/list/); -}); - -// A seat's verb that takes a machine as its own argument — `push ` — keeps it: the console -// moves `node` into the subject for a module's tool only (ADR 0159), never for a role's verb. -test("a seat's verb keeps a node of its own; only a module's tool gives it to the subject", async (t) => { - if (!url) return t.skip("MESH_TEST_NATS unset"); - const credential = await aMeshAndACredential(t); - const replies = await driving(credential, [ - { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "mesh-placement.push", arguments: { node: "anchor" } } }, - ]); - const result = replies[0].result; - assert.ok(!result.isError, JSON.stringify(replies[0])); - assert.deepEqual(JSON.parse(result.content[0].text), { told: "anchor" }); -}); - -test("a host calls the mesh's own verb through the seat", async (t) => { - if (!url) return t.skip("MESH_TEST_NATS unset"); - const credential = await aMeshAndACredential(t); - const replies = await driving(credential, [ - { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "mesh-placement.status", arguments: {} } }, - ]); - const result = replies[0].result; - assert.ok(!result.isError, JSON.stringify(replies[0])); - assert.deepEqual(JSON.parse(result.content[0].text), { output: "all quiet", ok: true }); -}); - -test("a node-scoped seat's verb is asked of the machine named, and refused without one", async (t) => { - if (!url) return t.skip("MESH_TEST_NATS unset"); - const credential = await aMeshAndACredential(t); - const replies = await driving(credential, [ - { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, - { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "node-dns-resolver.lookup", arguments: { node: "anchor" } } }, - { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "node-dns-resolver.lookup", arguments: {} } }, - ]); - const byId = new Map(replies.map((r) => [r.id, r])); - const answered = byId.get(2)!.result; - assert.ok(!answered.isError, JSON.stringify(answered)); - assert.match(answered.content[0].text, /"machine": "anchor"/, "the machine's holder answered"); - assert.match(byId.get(3)!.error?.message ?? JSON.stringify(byId.get(3)), /name the machine/); -}); diff --git a/node-tools/test/membership.test.ts b/node-tools/test/membership.test.ts deleted file mode 100644 index 6d9eda9..0000000 --- a/node-tools/test/membership.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -/** - * The mesh issues an assignment's subjects, and a runtime serves what it is issued (novox/hq ADR - * 0160). A runtime derives one address for itself — `mesh.assignment..` — reads the - * membership there, serves exactly what it says, and re-serves when a new one arrives. Against a - * real bus with JetStream, because the membership is a direct get on a stream. - * - * docker run -d --rm --name t -p 14232:4222 nats:2.10-alpine -js - * MESH_TEST_NATS=nats://127.0.0.1:14232 node --test --experimental-strip-types test/membership.test.ts - */ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { fileURLToPath } from "node:url"; -import { connect, StringCodec } from "nats"; - -import { resetTools } from "@novox/mesh-sdk/tools"; - -import { connectNats, membershipSubject } from "../dist/broker-nats.js"; -import { callTool, subjectListed } from "../dist/client.js"; -import { runTools } from "../dist/runtime.js"; - -const url = process.env.MESH_TEST_NATS; -const fixture = (name: string) => fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)); -const sc = StringCodec(); - -/** The ASSIGNMENTS stream as the controller asserts it: last-per-subject, readable by direct get. */ -async function anAssignmentsStream() { - const nc = await connect({ servers: url! }); - const jsm = await nc.jetstreamManager(); - try { - await jsm.streams.delete("ASSIGNMENTS"); - } catch { - // none yet - } - await jsm.streams.add({ - name: "ASSIGNMENTS", - subjects: ["mesh.assignment.>"], - max_msgs_per_subject: 1, - allow_direct: true, - } as never); - return { - async issue(m: object & { node: string; module: string }) { - await nc.jetstream().publish(membershipSubject(m.node, m.module), sc.encode(JSON.stringify(m))); - }, - async close() { - await nc.close(); - }, - }; -} - -test("a runtime serves exactly the subjects it is issued, and the listing carries them", async (t) => { - if (!url) return t.skip("MESH_TEST_NATS unset"); - resetTools(); - const stream = await anAssignmentsStream(); - // The mesh placed shop on two machines that are not interchangeable: each is served by name only. - await stream.issue({ - node: "anchor", - module: "shop", - serves: [{ subject: "mesh.mod.shop.tool.{tool}.anchor" }], - emits: "mesh.mod.shop.event.{event}", - tools: "mesh.mod.shop.tool.tools", - }); - const shop = await connectNats({ url, node: "anchor", module: "shop" }); - const asker = await connectNats({ url, module: "console", node: "workstation" }); - let stop = () => {}; - try { - stop = await runTools({ broker: shop, moduleEntrypoints: [fixture("shop-tools.mjs")] }); - assert.equal(shop.membership()?.module, "shop", "the runtime read what the mesh issued"); - // By name it answers; the plain subject was not issued, so nothing serves it. - const named = await callTool(asker, "shop.price@anchor", { of: "a hat" }); - assert.deepEqual(named.result, { of: "a hat", cost: 12 }); - assert.equal(named.node, "anchor"); - await assert.rejects(callTool(asker, "shop.price", {}), /no responders|503/i, "a subject not issued is not served"); - // The tools answer says where each is served, and the client composes nothing. - const tools = await asker.request, { tools: { name: string; subjects?: string[] }[] }>( - "shop.tools", - {}, - ); - assert.deepEqual(tools.tools.find((x) => x.name === "price")?.subjects, ["mesh.mod.shop.tool.price.anchor"]); - const listing = { tools: [{ module: "shop", name: "price", subjects: ["mesh.mod.shop.tool.price.anchor"] }], notAnswering: [] }; - assert.equal(subjectListed("shop.price", "anchor", listing as never), "mesh.mod.shop.tool.price.anchor"); - assert.equal(subjectListed("shop.price", "elsewhere", listing as never), undefined); - - // The mesh re-issues the membership with the plain subject too (the modules became - // interchangeable); the runtime re-serves on it without a restart. - await stream.issue({ - node: "anchor", - module: "shop", - serves: [{ subject: "mesh.mod.shop.tool.{tool}", queue: "serve.shop" }, { subject: "mesh.mod.shop.tool.{tool}.anchor" }], - emits: "mesh.mod.shop.event.{event}", - tools: "mesh.mod.shop.tool.tools", - }); - await new Promise((r) => setTimeout(r, 300)); - const plain = await callTool(asker, "shop.price", { of: "a coat" }); - assert.deepEqual(plain.result, { of: "a coat", cost: 12 }); - assert.equal(plain.node, "anchor"); - } finally { - stop(); - await asker.close(); - await shop.close(); - await stream.close(); - } -}); - -test("a seat's verbs are implemented under the seat's name, served where issued, never listed as the module's", async (t) => { - if (!url) return t.skip("MESH_TEST_NATS unset"); - resetTools(); - const stream = await anAssignmentsStream(); - await stream.issue({ - node: "anchor", - module: "postgres", - serves: [{ subject: "mesh.mod.postgres.tool.{tool}", queue: "serve.postgres" }, { subject: "mesh.mod.postgres.tool.{tool}.anchor" }], - seats: [{ seat: "mesh-store", verb: "databases", subject: "mesh.seat.mesh-store.tool.databases" }], - emits: "mesh.mod.postgres.event.{event}", - tools: "mesh.mod.postgres.tool.tools", - }); - const credential = { url, node: "anchor", module: "postgres", claims: [{ seat: "mesh-store", scope: "mesh", serves: ["databases", "query"] }] }; - const pg = await connectNats(credential); - const asker = await connectNats({ url, module: "console", node: "workstation" }); - let stop = () => {}; - try { - stop = await runTools({ broker: pg, credential, moduleEntrypoints: [fixture("store-seat.mjs")] }); - // The module's own `databases` and the seat's are two tools: postgres's on its subject, the - // store's on the seat's, each answering as itself. - const own = await callTool(asker, "postgres.databases", {}); - assert.deepEqual(own.result, { software: "postgres" }); - const seat = await callTool(asker, "seat:mesh-store.databases", {}); - assert.deepEqual(seat.result, { seat: "mesh-store" }); - assert.equal(seat.node, "anchor"); - // What the seat does not promise — creating a database — is postgres's tool and not the store's. - await assert.rejects(callTool(asker, "seat:mesh-store.postgres_create_database", {}), /no responders|503/i); - // And the module's `tools` lists only postgres's own, never the seat's implementation. - const tools = await asker.request, { module: string; tools: { name: string }[] }>("postgres.tools", {}); - assert.deepEqual(tools.tools.map((x) => x.name).sort(), ["databases", "postgres_create_database"]); - await assert.rejects(asker.request("mesh-store.tools", {}), /no responders|503/i, "a seat is not a module with a tools verb"); - } finally { - stop(); - await asker.close(); - await pg.close(); - await stream.close(); - } -}); - -// novox/hq issue 218: the store seat is claimed by every machine running postgres and held by one. -// Where the mesh issued a membership without the seat, the claim in the credential serves nothing: -// the module's own tools answer, the seat's verbs do not, and the runtime does not announce them. -test("a claimant the membership does not make the holder serves none of the seat's verbs", async (t) => { - if (!url) return t.skip("MESH_TEST_NATS unset"); - resetTools(); - const stream = await anAssignmentsStream(); - await stream.issue({ - node: "elsewhere", - module: "postgres", - serves: [{ subject: "mesh.mod.postgres.tool.{tool}.elsewhere" }], - emits: "mesh.mod.postgres.event.{event}", - tools: "mesh.mod.postgres.tool.tools", - }); - const credential = { url, node: "elsewhere", module: "postgres", claims: [{ seat: "mesh-store", scope: "mesh", serves: ["databases", "query"] }] }; - const pg = await connectNats(credential); - const asker = await connectNats({ url, module: "console", node: "workstation" }); - let stop = () => {}; - try { - stop = await runTools({ broker: pg, credential, moduleEntrypoints: [fixture("store-claimant.mjs")] }); - assert.deepEqual((await callTool(asker, "postgres.databases@elsewhere", {})).result, { software: "postgres" }); - await assert.rejects(callTool(asker, "seat:mesh-store.databases", {}), /no responders|503/i, "the store is not held here"); - } finally { - stop(); - await asker.close(); - await pg.close(); - await stream.close(); - } -}); - -test("a module named like its seat registers once, and answers as the module and as the seat", async (t) => { - if (!url) return t.skip("MESH_TEST_NATS unset"); - resetTools(); - const stream = await anAssignmentsStream(); - await stream.issue({ - node: "anchor", - module: "shop", - serves: [{ subject: "mesh.mod.shop.tool.{tool}", queue: "serve.shop" }, { subject: "mesh.mod.shop.tool.{tool}.anchor" }], - seats: [{ seat: "shop", verb: "price", subject: "mesh.seat.shop.tool.price" }], - emits: "mesh.mod.shop.event.{event}", - tools: "mesh.mod.shop.tool.tools", - }); - const credential = { url, node: "anchor", module: "shop", claims: [{ seat: "shop", scope: "mesh", serves: ["price"] }] }; - const shop = await connectNats(credential); - const asker = await connectNats({ url, module: "console", node: "workstation" }); - let stop = () => {}; - try { - stop = await runTools({ broker: shop, credential, moduleEntrypoints: [fixture("shop-seat.mjs")] }); - assert.deepEqual((await callTool(asker, "shop.price", { of: "a hat" })).result, { of: "a hat", cost: 12 }); - assert.deepEqual((await callTool(asker, "seat:shop.price", { of: "a hat" })).result, { of: "a hat", cost: 12 }); - const tools = await asker.request, { tools: { name: string }[] }>("shop.tools", {}); - assert.deepEqual(tools.tools.map((x) => x.name), ["price", "refund"]); - } finally { - stop(); - await asker.close(); - await shop.close(); - await stream.close(); - } -}); - -test("a mesh that has issued nothing yet gets the derived shape, and says so", async (t) => { - if (!url) return t.skip("MESH_TEST_NATS unset"); - const stream = await anAssignmentsStream(); - const said: string[] = []; - const log = console.log; - console.log = (...a: unknown[]) => said.push(a.join(" ")); - let lone; - try { - lone = await connectNats({ url, node: "home-server", module: "lone" }); - } finally { - console.log = log; - } - const asker = await connectNats({ url, module: "console", node: "workstation" }); - try { - assert.equal(lone.membership(), undefined); - assert.ok(said.some((s) => /no membership issued for lone on home-server/.test(s)), said.join("\n")); - await lone.handle("ping", async () => ({ pong: true })); - assert.deepEqual((await callTool(asker, "lone.ping", {})).result, { pong: true }); - assert.deepEqual((await callTool(asker, "lone.ping@home-server", {})).result, { pong: true }); - } finally { - await asker.close(); - await lone.close(); - await stream.close(); - } -}); - -// A module that implements a seat its credential does not (yet) claim is not served for it and does -// not fall over either: the runtime says so and serves the module's own tools. -test("a registration under a seat the credential does not claim is said and skipped, not fatal", async (t) => { - if (!url) return t.skip("MESH_TEST_NATS unset"); - resetTools(); - const stream = await anAssignmentsStream(); - const credential = { url, node: "anchor", module: "postgres" }; - const pg = 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: pg, credential, moduleEntrypoints: [fixture("store-seat-unclaimed.mjs")] }); - console.log = log; - assert.ok(said.some((s) => /registers tools under "mesh-store".*not served/.test(s)), said.join("\n")); - assert.deepEqual((await callTool(asker, "postgres.databases", {})).result, { software: "postgres" }); - await assert.rejects(callTool(asker, "seat:mesh-store.databases", {}), /no responders|503/i); - } finally { - console.log = log; - stop(); - await asker.close(); - await pg.close(); - await stream.close(); - } -}); diff --git a/node-tools/test/node-runtime.test.ts b/node-tools/test/node-runtime.test.ts deleted file mode 100644 index 406b648..0000000 --- a/node-tools/test/node-runtime.test.ts +++ /dev/null @@ -1,353 +0,0 @@ -/** - * One runtime per node serves every assigned module's tools (novox/hq ADR 0175, to-be 38 WP1). The - * node's runtime is handed a list of modules and their bundles on the node's credential; it reads - * one membership per module, serves each module's tools on that module's subjects and each held - * seat's verbs on the seat's, names a bundle that fails to load without dropping the others, and - * re-serves a module whose membership is re-issued mid-run. Against a real bus with JetStream. - * - * docker run -d --rm --name t -p 14232:4222 nats:2.10-alpine -js - * MESH_TEST_NATS=nats://127.0.0.1:14232 node --test --experimental-strip-types test/node-runtime.test.ts - */ -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"; - -import { connectNats, membershipSubject } from "../dist/broker-nats.js"; -import { callTool, toolsOn } from "../dist/client.js"; -import { servedModulesFrom } from "../dist/main.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)); -const sc = StringCodec(); - -/** The controller's job, done by hand: the ASSIGNMENTS stream (last-per-subject, direct get) and an - * EVENTS stream for what a tool emits. */ -async function aMesh() { - const nc = await connect({ servers: url! }); - const jsm = await nc.jetstreamManager(); - for (const name of ["ASSIGNMENTS", "EVENTS"]) { - try { - await jsm.streams.delete(name); - } catch { - // none yet - } - } - await jsm.streams.add({ name: "ASSIGNMENTS", subjects: ["mesh.assignment.>"], max_msgs_per_subject: 1, allow_direct: true } as never); - await jsm.streams.add({ name: "EVENTS", subjects: ["mesh.mod.*.event.>"] }); - return { - async issue(m: object & { node: string; module: string }) { - await nc.jetstream().publish(membershipSubject(m.node, m.module), sc.encode(JSON.stringify(m))); - }, - /** The next subject an event lands on, under a pattern. */ - nextEvent(pattern: string): Promise { - const sub = nc.subscribe(pattern, { max: 1 }); - return (async () => { - for await (const m of sub) return m.subject; - throw new Error("no event"); - })(); - }, - async close() { - await nc.close(); - }, - }; -} - -/** A membership as the controller issues one on a machine, with the module's own subject when it - * answers for the module anywhere, and the verbs of the node seats it holds. */ -function membershipOf(module: string, node: string, opts: { plain?: boolean; seats?: Record } = {}) { - const own = `mesh.mod.${module}`; - const serves: { subject: string; queue?: string }[] = [{ subject: `${own}.tool.{tool}.${node}` }]; - if (opts.plain) serves.push({ subject: `${own}.tool.{tool}`, queue: `serve.${module}` }); - const seats = Object.entries(opts.seats ?? {}).flatMap(([seat, verbs]) => - verbs.map((verb) => ({ seat, verb, subject: `mesh.seat.${seat}.tool.${verb}.${node}` }))); - return { node, module, serves, seats, emits: `${own}.event.{event}`, tools: `${own}.tool.tools` }; -} - -/** Wait for something to be served: the bus answers "no responders" at once until it is. */ -async function until(attempt: () => Promise, tries = 50): Promise { - for (let i = 0; ; i++) { - try { - return await attempt(); - } catch (e) { - if (i >= tries) throw e; - await new Promise((r) => setTimeout(r, 100)); - } - } -} - -test("MESH_TOOL_MODULES names modules and their entrypoints; a bare path is the credential's own module's", () => { - const have = servedModulesFrom(" alpha=/a/tools/index.js, beta=/b/one.js ,beta=/b/two.js, /mine/index.js ,node-tools=/own/x.js", "node-tools"); - assert.deepEqual(have.serves, [ - { module: "alpha", entrypoints: ["/a/tools/index.js"] }, - { module: "beta", entrypoints: ["/b/one.js", "/b/two.js"] }, - ]); - // The credential's own module, named or bare, is the one-module form either way. - assert.deepEqual(have.moduleEntrypoints, ["/mine/index.js", "/own/x.js"]); - assert.throws(() => servedModulesFrom("=/nothing.js", undefined), /neither =/); - assert.deepEqual(servedModulesFrom("", "x"), { serves: [], moduleEntrypoints: [] }); -}); - -test("the node's runtime serves five modules' bundles on one credential — two of them launched, one broken — and follows a re-issued membership", async (t) => { - if (!url) return t.skip("MESH_TEST_NATS unset"); - resetTools(); - const mesh = await aMesh(); - // Three modules assigned to the machine: alpha answers for itself anywhere, beta only here and - // holds the node-shelf seat, gamma's bundle is broken. - await mesh.issue(membershipOf("alpha", "anchor", { plain: true })); - await mesh.issue(membershipOf("beta", "anchor", { seats: { "node-shelf": ["list", "clear"] } })); - await mesh.issue(membershipOf("gamma", "anchor")); - // Two more, launched rather than loaded (ADR 0188): delta is Python and holds the node-lamp seat; - // epsilon is TypeScript written against the protocol and marked executable. - await mesh.issue(membershipOf("delta", "anchor", { seats: { "node-lamp": ["on"] } })); - await mesh.issue(membershipOf("epsilon", "anchor")); - // The node's credential: the runtime module's name, no claims (seats come from the memberships). - 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 { - process.env.MESH_OPERATOR_ACCOUNT = "somebody"; - process.env.MESH_OPERATOR_HOME = "/home/somebody"; - stop = await runTools({ - broker: nodeTools, - credential, - serves: [ - { module: "alpha", entrypoints: [fixture("many-alpha.serve.mjs")] }, - { module: "beta", entrypoints: [fixture("many-beta.serve.mjs")] }, - { module: "gamma", entrypoints: [fixture("many-broken.serve.mjs")] }, - { module: "delta", entrypoints: [fixture("many-delta.py")] }, - { module: "epsilon", entrypoints: [fixture("many-epsilon.mjs")] }, - ], - }); - console.log = log; - assert.deepEqual(nodeTools.serving().sort(), ["alpha", "beta", "delta", "epsilon", "gamma", "node-tools"]); - assert.ok(said.some((s) => /the operator's account here is somebody \(home \/home\/somebody\)/.test(s)), said.join("\n")); - assert.ok(said.some((s) => /gamma's bundle .*many-broken\.serve\.mjs failed to load: gamma's bundle exited \(1\): Error: gamma's bundle cannot find its client; its tools are not served here/.test(s)), said.join("\n")); - assert.ok(said.some((s) => /serving 8 tool\(s\) for 5 module\(s\): alpha\.one, alpha\.two, beta\.three, beta\.four, beta\.five, delta\.greet, delta\.die, epsilon\.seven; not serving gamma/.test(s)), said.join("\n")); - - // Five tools answer, each where its module's membership says: alpha anywhere and here, beta here only. - assert.deepEqual((await callTool(asker, "alpha.one", {})).result, { alpha: 1 }); - assert.deepEqual((await callTool(asker, "alpha.one@anchor", {})).result, { alpha: 1 }); - assert.deepEqual((await callTool(asker, "beta.three@anchor", {})).result, { beta: 3 }); - assert.deepEqual((await callTool(asker, "beta.four@anchor", {})).result, { beta: 4 }); - assert.deepEqual((await callTool(asker, "beta.five@anchor", {})).result, { beta: 5 }); - await assert.rejects(callTool(asker, "beta.three", {}), /no responders|503/i, "beta was not issued the module's plain subject"); - - // Two seat verbs answer on the seat's subjects, held by beta. - assert.deepEqual((await callTool(asker, "seat:node-shelf.list@anchor", {})).result, { shelf: ["a", "b"] }); - assert.deepEqual((await callTool(asker, "seat:node-shelf.clear@anchor", {})).result, { cleared: true }); - - // A bundle in another language answers the same way, its seat verb among them; so does a - // TypeScript bundle served through the protocol rather than imported. - assert.deepEqual((await callTool(asker, "delta.greet@anchor", { who: "mesh" })).result, { greeting: "hello mesh", language: "python" }); - assert.deepEqual((await callTool(asker, "seat:node-lamp.on@anchor", {})).result, { on: true, language: "python" }); - assert.deepEqual((await callTool(asker, "epsilon.seven@anchor", {})).result, { epsilon: 7, via: "stdio" }); - // A child that exits mid-call tells the caller so and is started again on the next call. - await assert.rejects(callTool(asker, "delta.die@anchor", {}), /delta's bundle exited \(3\)/); - assert.deepEqual((await callTool(asker, "delta.greet@anchor", {})).result, { greeting: "hello world", language: "python" }); - - // A tool that emits does so as its module, not as the runtime. - const landed = mesh.nextEvent("mesh.mod.*.event.>"); - assert.deepEqual((await callTool(asker, "alpha.two", {})).result, { alpha: 2 }); - assert.equal(await landed, "mesh.mod.alpha.event.happened"); - - // `tools` answers for each: what alpha and beta serve, and why gamma serves nothing. - const gamma = await asker.request, { module: string; tools: unknown[]; failed?: string }>("gamma.tools@anchor", {}); - assert.deepEqual(gamma, { module: "gamma", tools: [], failed: "gamma's bundle exited (1): Error: gamma's bundle cannot find its client" }); - const beta = await asker.request, { tools: { name: string; subjects?: string[] }[] }>("beta.tools@anchor", {}); - assert.deepEqual(beta.tools.map((x) => x.name), ["three", "four", "five"]); - assert.deepEqual(beta.tools[0]!.subjects, ["mesh.mod.beta.tool.three.anchor"]); - - // And discovery says so, with the reason, beside the modules that answered. - const catalogue = await connectNats({ url, module: "mesh-catalog" }); - await catalogue.handle("catalog_modules", async () => ({ modules: [{ module: "alpha" }, { module: "beta" }, { module: "gamma" }, { module: "delta" }, { module: "epsilon" }] })); - try { - // No console on this port: the roles' tools are said missing with the reason, never asked of the controller. - const have = await toolsOn(asker, "http://127.0.0.1:9"); - assert.deepEqual(have.tools.map((x) => `${x.module}.${x.name}`), ["alpha.one", "alpha.two", "beta.five", "beta.four", "beta.three", "delta.die", "delta.greet", "epsilon.seven"]); - assert.equal(have.notAnswering[0], "gamma (its tools bundle failed to load: gamma's bundle exited (1): Error: gamma's bundle cannot find its client)"); - assert.match(have.notAnswering[1] ?? "", /^the roles' tools \(the console at http:\/\/127\.0\.0\.1:9 did not answer mesh\/seats: /); - assert.equal(have.notAnswering.length, 2); - } finally { - await catalogue.close(); - } - - // The mesh re-issues beta's membership mid-run — now answering for the module anywhere — and - // the runtime serves the new subject without a restart. - await mesh.issue(membershipOf("beta", "anchor", { plain: true, seats: { "node-shelf": ["list", "clear"] } })); - assert.deepEqual((await until(() => callTool(asker, "beta.three", {}))).result, { beta: 3 }); - assert.deepEqual((await callTool(asker, "seat:node-shelf.list@anchor", {})).result, { shelf: ["a", "b"] }); - } finally { - console.log = log; - delete process.env.MESH_OPERATOR_ACCOUNT; - delete process.env.MESH_OPERATOR_HOME; - stop(); - await asker.close(); - await nodeTools.close(); - await mesh.close(); - 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.serve.mjs"), - '#!/usr/bin/env node\nimport { serveRegisteredOverStdio } from "@novox/mesh-sdk/stdio";\nawait import("./index.js");\nawait serveRegisteredOverStdio();\n', { mode: 0o755 }); - 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.serve.mjs")] }] }); - 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(); - } -}); - -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.serve.mjs")] }, - { module: "delta", entrypoints: [fixture("env-delta.serve.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(); - } -}); - -test("a launched bundle is told the module it serves, so its seat's verbs stay the seat's (ADR 0193)", async (t) => { - if (!url) return t.skip("MESH_TEST_NATS unset"); - resetTools(); - const mesh = await aMesh(); - await mesh.issue(membershipOf("theta", "anchor", { seats: { "node-shelf": ["list"] } })); - 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 = () => {}; - try { - console.log = () => {}; - stop = await runTools({ broker: nodeTools, credential, envs: new Map([["theta", { THETA_WORD: "given" }]]), - serves: [{ module: "theta", entrypoints: [fixture("served-seat-first.mjs")] }] }); - console.log = log; - assert.deepEqual((await callTool(asker, "theta.own@anchor", {})).result, { theta: "given" }); - assert.deepEqual((await callTool(asker, "seat:node-shelf.list@anchor", {})).result, { shelf: ["x"] }); - } finally { - console.log = log; - stop(); - await asker.close(); - await nodeTools.close(); - await mesh.close(); - resetTools(); - } -}); - -test("an entrypoint that is not executable is refused by name, and the others serve (ADR 0193)", async (t) => { - if (!url) return t.skip("MESH_TEST_NATS unset"); - resetTools(); - const mesh = await aMesh(); - for (const m of ["alpha", "plain"]) 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 said: string[] = []; - const log = console.log; - let stop = () => {}; - try { - console.log = (...a: unknown[]) => said.push(a.join(" ")); - stop = await runTools({ broker: nodeTools, credential, serves: [ - { module: "alpha", entrypoints: [fixture("many-alpha.serve.mjs")] }, - { module: "plain", entrypoints: [fixture("many-alpha.mjs")] }, - ] }); - console.log = log; - assert.ok(said.some((s) => /plain's bundle .*many-alpha\.mjs failed to load: .* is not executable; a bundle the runtime serves is started, never imported/.test(s)), said.join("\n")); - assert.deepEqual((await callTool(asker, "alpha.one@anchor", {})).result, { alpha: 1 }); - } finally { - console.log = log; - stop(); - await asker.close(); - await nodeTools.close(); - await mesh.close(); - resetTools(); - } -}); diff --git a/node-tools/test/node-tools-serve.test.ts b/node-tools/test/node-tools-serve.test.ts deleted file mode 100644 index 8f1c4e3..0000000 --- a/node-tools/test/node-tools-serve.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * The runtime as the node-tools module (novox/hq ADR 0175 §6, to-be 38 WP3): started the way the - * host starts it — `main.js` with the node's credential and MESH_TOOL_MODULES — it serves the bundles - * AND answers MCP on loopback as the console, through which a tool it serves can be called. - * - * docker run -d --rm --name t -p 14232:4222 nats:2.10-alpine -js - * MESH_TEST_NATS=nats://127.0.0.1:14232 node --test --experimental-strip-types test/node-tools-serve.test.ts - */ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { spawn, type ChildProcess } from "node:child_process"; -import { mkdtemp, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { connectNats } from "../dist/broker-nats.js"; - -const url = process.env.MESH_TEST_NATS; -const fixture = (name: string) => fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)); - -async function post(endpoint: string, body: unknown): Promise { - const res = await fetch(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); - return res.json(); -} - -test("as node-tools, serve loads the bundles and is the console on loopback", async (t) => { - if (!url) return t.skip("MESH_TEST_NATS unset"); - // The catalogue, for discovery; the node credential names the runtime module and no claims. - const catalogue = await connectNats({ url, module: "mesh-catalog" }); - await catalogue.handle("catalog_modules", async () => ({ modules: [{ module: "alpha" }] })); - t.after(() => catalogue.close()); - const dir = await mkdtemp("/tmp/node-tools-"); - const credential = join(dir, "broker"); - await writeFile(credential, JSON.stringify({ url, node: "desk", module: "node-tools", user: "desk.node-tools", password: "x" })); - const child: ChildProcess = spawn(process.execPath, ["dist/main.js"], { - env: { - ...process.env, - MESH_BROKER_FILE: credential, - MESH_TOOL_MODULES: `alpha=${fixture("many-alpha.serve.mjs")}`, - MESH_CONSOLE_LISTEN: "127.0.0.1:0", - }, - stdio: ["ignore", "pipe", "pipe"], - }); - t.after(() => { - child.kill("SIGTERM"); - }); - const endpoint = await new Promise((resolve, reject) => { - let out = ""; - let err = ""; - child.stdout!.on("data", (d) => { - out += d.toString(); - const m = /listening on (http:\/\/[^/]+\/mcp) as desk\.node-tools/.exec(out); - if (m) resolve(m[1]!); - }); - child.stderr!.on("data", (d) => (err += d.toString())); - child.on("exit", (code) => reject(new Error(`serve exited ${code}: ${err}`))); - }); - const listed = await post(endpoint, { jsonrpc: "2.0", id: 1, method: "tools/list" }); - assert.deepEqual(listed.result.tools.map((x: any) => x.name).filter((n: string) => n.startsWith("alpha.")), ["alpha.one", "alpha.two"]); - // A tool the same process serves on the bus, called through the console it also is. - const called = await post(endpoint, { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "alpha.one", arguments: { node: "desk" } } }); - assert.deepEqual(JSON.parse(called.result.content[0].text), { alpha: 1 }); -}); diff --git a/node-tools/test/patient-connect.test.ts b/node-tools/test/patient-connect.test.ts deleted file mode 100644 index 0f2b162..0000000 --- a/node-tools/test/patient-connect.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { spawn } from "node:child_process"; -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { fatalBrokerReason, PinMismatchError, topicMatches } from "../src/broker-nats.ts"; - -// novox/hq issue 058 (and its review): serve mode retries a broker that is not up yet, but must -// give up at once on a failure waiting cannot fix — otherwise a permanent fault loops for ever -// disguised as "not reachable". This is the classifier that draws the line; the bed cannot test it -// (it starts the consumer only after the broker is up), so it is proven here. - -test("a broker that is not up yet is retryable, not fatal", () => { - for (const err of [ - Object.assign(new Error("connect ECONNREFUSED 10.42.0.1:5671"), { code: "ECONNREFUSED" }), - Object.assign(new Error("connect ETIMEDOUT"), { code: "ETIMEDOUT" }), - Object.assign(new Error("getaddrinfo EAI_AGAIN anchor.internal"), { code: "EAI_AGAIN" }), - new Error("timed out fetching the broker's certificate"), - ]) { - assert.equal(fatalBrokerReason(err), null, `should retry: ${(err as Error).message}`); - } -}); - -test("a certificate that does not match the pin is fatal, by type not by message", () => { - // Typed, so rewording the message cannot turn an impostor back into an infinite retry. - assert.notEqual(fatalBrokerReason(new PinMismatchError("anything at all")), null); - // A plain Error with pin-ish words is NOT treated as the pin case — only the type is. - assert.equal(fatalBrokerReason(new Error("the pinned value was fine")), null); -}); - -test("a malformed broker URL is fatal — it never parses on the next try", () => { - assert.notEqual(fatalBrokerReason(Object.assign(new Error("Invalid URL"), { code: "ERR_INVALID_URL" })), null); - assert.notEqual(fatalBrokerReason(new Error("Invalid URL: not-a-url")), null); -}); - -test("a refused login is fatal — a wrong or revoked credential, not an absent broker", () => { - // The bus refuses a login in its own words; each is final, because the next try says the same. - for (const msg of ["Authorization Violation", "nats: user authentication expired", "Permissions Violation for Subscription to \"x\""]) { - assert.notEqual(fatalBrokerReason(new Error(msg)), null, `should be fatal: ${msg}`); - } -}); - -test("a non-Error value does not crash the classifier", () => { - assert.equal(fatalBrokerReason("just a string"), null); - assert.equal(fatalBrokerReason(undefined), null); -}); - -// **A module asked to prepare its state and naming nothing is a failure, not a no-op** (novox/hq -// ADR 0135). The mesh asks this only of a module whose manifest says it prepares something, so an -// image that names nothing was built wrong, and exiting 0 would let that version serve against a -// state nobody shaped. -test("preparing with nothing named fails rather than passing quietly", async () => { - const runtime = new URL("../dist/main.js", import.meta.url).pathname; - const ran = await new Promise<{ code: number | null; said: string }>((resolve) => { - const child = spawn(process.execPath, [runtime, "prepare"], { - env: { ...process.env, MESH_PREPARE: "" }, - }); - let said = ""; - child.stderr.on("data", (chunk) => (said += String(chunk))); - child.on("close", (code) => resolve({ code, said })); - }); - assert.notEqual(ran.code, 0, "a module that prepares nothing exited 0, so its version would serve"); - assert.match(ran.said, /MESH_PREPARE/); -}); - -// **One durable consumer feeds one reader, however many patterns a module registers.** -// -// A module has exactly one consumer, so two readers of it would each take half the messages — and a -// reader that received one its own pattern does not match acknowledges it, which is right for a -// filter wider than anything registered and silent loss when it is another handler's. The matching is -// therefore pure and tested as such: what a message is for is decided by the patterns registered, not -// by which reader happened to fetch it. -test("a message is for every pattern that matches it, and nothing else", () => { - const registered = ["mesh-build-machine.built", "mesh-placement.built-before"]; - const matched = (key: string) => registered.filter((p) => topicMatches(p, key)); - assert.deepEqual(matched("mesh-build-machine.built"), ["mesh-build-machine.built"]); - assert.deepEqual(matched("mesh-placement.built-before"), ["mesh-placement.built-before"]); - // Nothing registered for it: the consumer's filter is the controller's and may be wider. - assert.deepEqual(matched("mesh-catalog.upgraded"), []); - // And a handler that asked for everything gets both, which is what the audit logger does. - assert.deepEqual(["#"].filter((p) => topicMatches(p, "mesh-placement.built-before")), ["#"]); -}); diff --git a/node-tools/test/refused.test.ts b/node-tools/test/refused.test.ts deleted file mode 100644 index 87b5487..0000000 --- a/node-tools/test/refused.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * A refused announcement is said and never fatal (novox/hq 04-ISSUES/217): against a bus whose - * permissions refuse one discovery subject, the runtime's raw subscription is refused, logged, and - * the process keeps serving — its tools still answer. - * - * docker run -d --rm --name t -p 14233:4222 -v $PWD/test/fixtures/refusing-nats.conf:/c.conf nats:2.10-alpine -c /c.conf - * MESH_TEST_REFUSING_NATS=nats://127.0.0.1:14233 node --test --experimental-strip-types test/refused.test.ts - */ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { connectNats } from "../dist/broker-nats.js"; -import { callTool } from "../dist/client.js"; - -const url = process.env.MESH_TEST_REFUSING_NATS; - -test("a refused discovery subscription is logged and the runtime serves on", async (t) => { - if (!url) return t.skip("MESH_TEST_REFUSING_NATS unset"); - const bus = await connectNats({ url, user: "runtime", password: "runtime", module: "alpha", node: "anchor" }); - const said: string[] = []; - const log = console.log; - console.log = (...a: unknown[]) => said.push(a.join(" ")); - const crashed: unknown[] = []; - const onRejection = (e: unknown) => crashed.push(e); - process.on("unhandledRejection", onRejection); - try { - (bus as unknown as { raw: (s: string, f: () => Uint8Array | undefined) => () => void }).raw("$SRV.PING.>", () => undefined); - const stop = await bus.handle("alpha.ping", async () => ({ pong: true })); - for (let i = 0; i < 50 && !said.some((s) => s.includes("the bus refused $SRV.PING.>")); i++) await new Promise((r) => setTimeout(r, 50)); - console.log = log; - assert.ok(said.some((s) => /the bus refused \$SRV\.PING\.>.*serves on/.test(s)), said.join("\n")); - assert.equal(crashed.length, 0, `the refusal escaped: ${String(crashed[0])}`); - stop(); - } finally { - console.log = log; - process.off("unhandledRejection", onRejection); - await bus.close(); - } -}); - -// novox/hq issue 218: a tool subject the grants leave out — a seat claimed here and held elsewhere — -// is refused, said, and the module's other tools still answer. -test("a refused tool subscription is logged and the module's other tools answer", async (t) => { - if (!url) return t.skip("MESH_TEST_REFUSING_NATS unset"); - const bus = await connectNats({ url, user: "runtime", password: "runtime", module: "alpha", node: "anchor" }); - const asker = await connectNats({ url, user: "runtime", password: "runtime", module: "console", node: "workstation" }); - const said: string[] = []; - const log = console.log; - console.log = (...a: unknown[]) => said.push(a.join(" ")); - const crashed: unknown[] = []; - const onRejection = (e: unknown) => crashed.push(e); - process.on("unhandledRejection", onRejection); - try { - const refused = await bus.handleSubject!("mesh.seat.held-elsewhere.tool.databases", async () => ({ seat: true })); - const stop = await bus.handle("alpha.ping", async () => ({ pong: true })); - for (let i = 0; i < 50 && !said.some((s) => s.includes("the bus refused mesh.seat.held-elsewhere")); i++) await new Promise((r) => setTimeout(r, 50)); - console.log = log; - assert.ok(said.some((s) => /the bus refused mesh\.seat\.held-elsewhere\.tool\.databases.*serves on/.test(s)), said.join("\n")); - assert.equal(crashed.length, 0, `the refusal escaped: ${String(crashed[0])}`); - assert.deepEqual((await callTool(asker, "alpha.ping@anchor", {})).result, { pong: true }); - stop(); - refused(); - } finally { - console.log = log; - process.off("unhandledRejection", onRejection); - await asker.close(); - await bus.close(); - } -}); diff --git a/node-tools/test/roundtrip.mjs b/node-tools/test/roundtrip.mjs deleted file mode 100644 index 24ad971..0000000 --- a/node-tools/test/roundtrip.mjs +++ /dev/null @@ -1,57 +0,0 @@ -// Round-trip the runtime's NATS client against a real server: a tool call answered, and an -// event emitted and received with its envelope intact. -import { connect } from "nats"; -import { connectNats } from "../dist/broker-nats.js"; - -const URL = "nats://127.0.0.1:14222"; - -// The mesh's job, done by hand here: the stream and the module's durable consumer. -const admin = await connect({ servers: URL }); -const jsm = await admin.jetstreamManager(); -await jsm.streams.add({ name: "EVENTS", subjects: ["mesh.mod.*.event.>", "mesh.seat.*.event.>"] }); -await jsm.consumers.add("EVENTS", { - durable_name: "one_audit", ack_policy: "explicit", - filter_subjects: ["mesh.mod.shop.event.order.placed"], -}); - -const shop = await connectNats({ url: URL, node: "one", module: "shop" }); -const audit = await connectNats({ url: URL, node: "one", module: "audit" }); - -let failures = 0; -const check = (ok, what) => { console.log(` ${ok ? "ok " : "FAIL"} ${what}`); if (!ok) failures++; }; - -// A tool, served and called. -await shop.handle("price", async (body) => ({ total: body.qty * 3 })); -const answer = await audit.request("shop.price", { qty: 4 }); -check(answer.total === 12, "a tool call is answered across two connections"); - -// A handler that throws reaches the caller as an error, not a timeout. -await shop.handle("boom", async () => { throw new Error("no"); }); -let threw = null; -try { await audit.request("shop.boom", {}); } catch (e) { threw = e.message; } -check(threw === "no", "a handler that throws answers the caller instead of timing out"); - -// An event, emitted and received with its envelope intact. -const seen = []; -await audit.subscribe("order.placed", async (env) => { seen.push(env); }); -await shop.publish({ - key: "order.placed", node: "one", body: { id: "a1" }, - headers: { "x-event-id": "e1", "x-node": "one", "content-type": "application/json" }, -}); -await new Promise((r) => setTimeout(r, 800)); -check(seen.length === 1, `exactly one delivery (saw ${seen.length})`); -if (seen[0]) { - check(seen[0].key === "order.placed", "the key survives the subject round trip"); - check(seen[0].body?.id === "a1", "the body is the payload, not the whole envelope"); - check(seen[0].node === "one", "the node comes back from the headers"); - check(seen[0].headers?.["x-event-id"] === "e1", "the event id survives as a header"); -} - -// A module cannot reach into another's namespace by naming its own event oddly. -await shop.publish({ key: "other", node: "one", body: {}, headers: { "x-event-id": "e2" } }); -const msg = await jsm.streams.getMessage("EVENTS", { last_by_subj: "mesh.mod.shop.event.other" }); -check(!!msg, "an event lands under the emitting module's own namespace"); - -await shop.close(); await audit.close(); await admin.close(); -console.log(failures ? `\n${failures} failed` : "\nall passed"); -process.exit(failures ? 1 : 0); diff --git a/node-tools/test/runtime-tools.test.ts b/node-tools/test/runtime-tools.test.ts deleted file mode 100644 index b1891be..0000000 --- a/node-tools/test/runtime-tools.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Every module's runtime answers `tools` for it (novox/hq ADR 0152, design 34 §3): the names, - * descriptions and schemas from the code that answers them. Against a real bus, because the claim is - * what a second connection gets back. - * - * docker run -d --rm --name t -p 14232:4222 nats:2.10-alpine -js - * MESH_TEST_NATS=nats://127.0.0.1:14232 node --test --experimental-strip-types test/runtime-tools.test.ts - */ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { fileURLToPath } from "node:url"; - -import { resetTools } from "@novox/mesh-sdk/tools"; - -import { connectNats } from "../dist/broker-nats.js"; -import { runTools } from "../dist/runtime.js"; - -const url = process.env.MESH_TEST_NATS; -const fixture = (name: string) => fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)); - -test("a module registering two tools answers three names, the third being what it serves", async (t) => { - if (!url) return t.skip("MESH_TEST_NATS unset"); - resetTools(); - const shop = await connectNats({ url, node: "one", module: "shop" }); - const asker = await connectNats({ url, module: "person.ada" }); - const stop = await runTools({ broker: shop, moduleEntrypoints: [fixture("shop-tools.mjs")] }); - try { - const answer = await asker.request, { module: string; tools: { name: string; input: unknown }[] }>( - "shop.tools", - {}, - ); - assert.equal(answer.module, "shop"); - assert.deepEqual(answer.tools.map((x) => x.name), ["price", "refund"]); - // The schema travels with the name: a name alone is not callable by something that has never - // seen the mesh before. - assert.deepEqual(answer.tools[0].input, { of: { type: "string", description: "the thing" } }); - // And the tools themselves still answer beside it. - const priced = await asker.request<{ of: string }, { cost: number }>("shop.price", { of: "a hat" }); - assert.equal(priced.cost, 12); - } finally { - stop(); - await asker.close(); - await shop.close(); - } -}); - -test("a module naming a tool of its own `tools` is refused at load", async (t) => { - if (!url) return t.skip("MESH_TEST_NATS unset"); - resetTools(); - const clash = await connectNats({ url, node: "one", module: "clash" }); - try { - await assert.rejects( - () => runTools({ broker: clash, moduleEntrypoints: [fixture("clash-tools.mjs")] }), - /names a tool "tools"/, - ); - } finally { - resetTools(); - await clash.close(); - } -}); diff --git a/node-tools/tsconfig.json b/node-tools/tsconfig.json deleted file mode 100644 index 2606b43..0000000 --- a/node-tools/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "declaration": true, - "outDir": "dist", - "rootDir": "src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - }, - "include": ["src/**/*.ts"] -}