SDK: per-key tool serving (ADR 0052) and the provider contract (ADR 0053) #2

Merged
jschoubben merged 4 commits from events/tool-per-key into main 2026-09-05 01:02:41 +00:00
Showing only changes of commit 285c1f1653 - Show all commits
+40 -38
View File
@@ -4,19 +4,12 @@ import { mkdtemp, writeFile, readFile, readdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { seal, unseal, compareVersions } from "../dist/primitives/index.js";
import { registerModuleTools, collectTools, resetTools, serveTools, listTools } from "../dist/tools/index.js";
import { runProvisioner, type Grant } from "../dist/provisioner/index.js";
import { compareVersions } from "../dist/primitives/index.js";
import { registerModuleTools, collectTools, resetTools, serveTools, listTools, invokeTool } from "../dist/tools/index.js";
import { runProvisioner } from "../dist/provisioner/index.js";
import { emit, on, type Event } from "../dist/events/index.js";
import { useBroker } from "../dist/messaging/index.js";
test("seal round-trips and rejects the wrong key", () => {
const sealed = seal("hunter2", "node-key");
assert.equal(unseal(sealed, "node-key"), "hunter2");
assert.notEqual(sealed, "hunter2");
assert.throws(() => unseal(sealed, "wrong-key"));
});
test("semver orders releases", () => {
assert.equal(compareVersions("1.2.3", "1.2.10"), -1);
assert.equal(compareVersions("2.0.0", "1.9.9"), 1);
@@ -38,39 +31,46 @@ test("module tools register and collect, a thrower is skipped not fatal", () =>
assert.equal(broken?.tools.length, 0);
});
test("provisioner creates a sealed credential for a grant, then removes on withdrawal", async () => {
test("provisioner creates each consumer with the mesh's login and password, removes on withdrawal", async () => {
const dir = await mkdtemp(join(tmpdir(), "prov-"));
const created: string[] = [];
const created: { as: string; password: string; values: unknown }[] = [];
const removed: string[] = [];
const grant: Grant = { resource: "analytics", consumer: "webapp", node: "anchor", values: { name: "webapp" } };
await writeFile(join(dir, "webapp.grant.json"), JSON.stringify(grant));
// What the mesh delivers: the password it minted (as the host leaves it after unsealing) and a
// contributions file naming the consumer's login and where that password is.
await writeFile(join(dir, "webapp.secret"), "minted-pw\n");
const receives = join(dir, "analytics.json");
const doc = (given: unknown[]): string =>
JSON.stringify({ contributions: 1, requirement: "analytics", given });
await writeFile(
receives,
doc([{ from: "webapp", node: "anchor", as: "webapp-anchor", secret: join(dir, "webapp.secret"), values: { name: "webapp" } }]),
);
const stop = runProvisioner(
"analytics",
{
async create(g) {
created.push(g.consumer);
return { fields: { siteId: "abc", snippet: "<script>", dashboard: "https://x/webapp" } };
async create(p) {
created.push({ as: p.as, password: p.password, values: p.values });
},
async remove(g) {
removed.push(g.consumer);
async remove(p) {
removed.push(p.as);
},
},
{ grants: dir, sealKey: "k", everyMs: 20 },
{ receives, everyMs: 20 },
);
await waitFor(() => created.length === 1, 2000);
const files = await readdir(dir);
const credFile = files.find((f) => f.endsWith(".credential"));
assert.ok(credFile, "a credential file was written");
const sealed = await readFile(join(dir, credFile!), "utf8");
const body = JSON.parse(unseal(sealed, "k")) as { fields: Record<string, string> };
assert.equal(body.fields.siteId, "abc");
// The adapter is handed the mesh's login and the mesh's password — not one it generated, and the
// trailing newline of the unsealed file is stripped.
assert.equal(created[0].as, "webapp-anchor");
assert.equal(created[0].password, "minted-pw");
assert.deepEqual(created[0].values, { name: "webapp" });
// Withdraw the grant → the harness removes via the adapter and deletes the credential.
await (await import("node:fs/promises")).rm(join(dir, "webapp.grant.json"));
// Withdraw: the mesh drops the consumer from the file → the harness removes it via the adapter.
await writeFile(receives, doc([]));
await waitFor(() => removed.length === 1, 2000);
assert.equal(removed[0], "webapp-anchor");
stop();
});
@@ -106,26 +106,28 @@ test("modules can SERVE: a real async tool, loaded and invoked over the broker",
const broker = memBroker();
const stop = await serveTools(broker, {});
// Invoke it the way a caller (mesh-control's command API) would — over the broker, by name.
const result = await broker.request<{ tool: string; args: Record<string, unknown> }, { snippet: string }>(
"tools.invoke",
{ tool: "create_site", args: { domain: "my-app" } },
);
// Invoke it the way a caller (mesh-control's command API) would — over the broker, by module and
// tool, each served on its own key `demo.create_site` (novox/hq ADR 0052).
const result = (await invokeTool(broker, "demo", "create_site", { domain: "my-app" })) as { snippet: string };
assert.match(result.snippet, /data-website-id="site-123"/);
// Discovery works, and an unknown tool is refused rather than silently dropped.
assert.deepEqual(listTools({}).map((t) => t.name), ["create_site"]);
await assert.rejects(broker.request("tools.invoke", { tool: "nope", args: {} }));
await assert.rejects(invokeTool(broker, "demo", "nope", {}));
stop();
server.close();
});
test("serving refuses two modules exposing one tool name", async () => {
test("serving refuses one module exposing two tools of the same name", async () => {
// Two *modules* may share a tool name — each is served on its own `module.tool` key (ADR 0052).
// What is refused is one module exposing the same name twice, where the key would collide.
resetTools();
registerModuleTools("a", () => [{ name: "dup", description: "", input: {}, run: async () => 1 }]);
registerModuleTools("b", () => [{ name: "dup", description: "", input: {}, run: async () => 2 }]);
await assert.rejects(serveTools(memBroker(), {}), /exposed by two modules/);
registerModuleTools("a", () => [
{ name: "dup", description: "", input: {}, run: async () => 1 },
{ name: "dup", description: "", input: {}, run: async () => 2 },
]);
await assert.rejects(serveTools(memBroker(), {}), /exposes two tools named dup/);
});
// A minimal in-memory broker: request routes to a registered handle, and publish routes to every