/** * 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 the mesh answers every role's tools, from its records: the mesh-controller seat's own * `tools` verb (novox/hq ADR 0154, design 33 §5). */ const SEAT_TOOLS = "seat:mesh-controller.tools"; /** 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 mesh's own records are * listed here as `mesh-controller (seat)` when the control plane did not answer. */ 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): Promise { const [answered, roles] = await Promise.all([ bus.request, { modules?: { module: string }[] }>(CATALOGUE_MODULES, {}), // The roles' tools, from the mesh's records (design 33 §5). Asked beside the modules rather // than first: a control plane that is restarting must not hide every module's tools with it. bus .request, { seats?: { seat: string; scope?: string; tools?: ToolsAnswer["tools"] }[] }>( SEAT_TOOLS, {}, ) .catch(() => 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 0169). 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("mesh-controller (seat)"); } 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}`; }