import { test } from "node:test"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { BundleError, buildBundle, sessionModel, SOURCES } from "../src/bundle.mjs"; import { resolvedFor, scratch } from "./helpers.mjs"; const sha = (p) => createHash("sha256").update(readFileSync(p)).digest("hex"); const mode = (p) => statSync(p).mode & 0o777; function build(t, resolved, extra = {}) { const dir = scratch(t); const workspace = join(dir, "ws"); mkdirSync(workspace); const bundle = buildBundle({ dir: join(dir, "bundle"), resolved, contract: "# Contract\n\nDo the work.\n", tracker: true, credentials: [{ service: "gitea", date: "2099-01-01", state: "valid" }], session: { run: "run-1", launchedBy: "pm", workspace, toolSocket: join(dir, "tools.sock"), turnMarker: join(dir, "turn.done") }, piVersion: "0.85.1", claudeVersion: "2.1.0 (Claude Code)", ...extra, }); return { dir, workspace, ...bundle }; } test("sessionModel: agent vars win, then the system's execution settings", (t) => { assert.deepEqual(sessionModel(resolvedFor(t, "pm")), { harness: "pi", provider: "zai", model: "claude-opus-5-5", thinking: null }); assert.deepEqual(sessionModel(resolvedFor(t, "coder")), { harness: "pi", provider: "zai", model: "glm-5.3-flash", thinking: null }); // The test system's adapter is "mock", which isn't a session harness. assert.throws(() => sessionModel(resolvedFor(t, "reviewer")), (e) => e instanceof BundleError && /harness "mock"/.test(e.message)); }); test("a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory", (t) => { const b = build(t, resolvedFor(t, "pm")); assert.equal(mode(join(b.dir, "bundle")), 0o700); for (const f of Object.values(b.files)) assert.equal(mode(f), 0o600, f); assert.deepEqual(Object.keys(b.files).sort(), ["manifest", "policy", "prompt", "tools"]); const prompt = readFileSync(b.files.prompt, "utf8"); assert.match(prompt, /^# Contract\n\nDo the work\.\n\n## This session\n/); assert.match(prompt, /Role instance: pm \(definition pm\)\. Run: run-1\./); assert.match(prompt, /Bus tools: send_message, launch, /); assert.deepEqual(JSON.parse(readFileSync(b.files.policy, "utf8")), { harness: "pi", workspace: b.workspace, tools: [...b.manifest.builtins], typed: b.tools.map((x) => x.name), network: "api-only", needs: ["gitea", "vikunja"], credentials: [{ service: "gitea", date: "2099-01-01", state: "valid" }], }); const m = b.manifest; assert.equal(m.harness, "pi"); assert.equal(m.model, "claude-opus-5-5"); assert.equal(m.piVersion, "0.85.1"); assert.equal(m.claudeVersion, null); assert.equal(m.gateTimeout, null); assert.deepEqual(m.skills, []); assert.equal(m.reliesOn.length, 5); assert.ok(m.typed.includes("launch")); assert.deepEqual(Object.keys(m.sources).sort(), ["gate", "piExtension", "tools"]); assert.equal(m.sources.piExtension.sha256, sha(SOURCES.piExtension)); for (const [name, digest] of Object.entries(m.files)) assert.equal(digest, sha(join(b.dir, "bundle", name)), name); assert.deepEqual(JSON.parse(readFileSync(b.files.manifest, "utf8")), JSON.parse(JSON.stringify(m))); }); test("a claude-code bundle adds the wrapped gate hook and the MCP config", (t) => { const r = resolvedFor(t, "coder", (d) => (d.roles.coder.vars = { harness: "claude-code", model: "claude-sonnet-5-5" })); const b = build(t, r); assert.deepEqual(Object.keys(b.files).sort(), ["manifest", "mcp", "policy", "prompt", "settings", "tools"]); const settings = JSON.parse(readFileSync(b.files.settings, "utf8")); const hook = settings.hooks.PreToolUse[0]; assert.equal(hook.matcher, "*"); assert.equal(hook.hooks[0].timeout, 20); assert.equal(hook.hooks[0].command, `timeout -k 2 10 '${process.execPath}' '${SOURCES.claudeGate}' '${b.files.policy}' || exit 2`); const mcp = JSON.parse(readFileSync(b.files.mcp, "utf8")); assert.deepEqual(mcp, { mcpServers: { mosaic: { type: "stdio", command: process.execPath, args: [SOURCES.mcpServer, b.files.tools, join(b.dir, "tools.sock")] } } }); const m = b.manifest; assert.equal(m.piVersion, null); assert.equal(m.claudeVersion, "2.1.0 (Claude Code)"); assert.deepEqual(m.gateTimeout, { n: 10, k: 2, hook: 20 }); assert.ok(m.builtins.includes("Bash")); assert.ok(m.typed.every((n) => n.startsWith("mcp__mosaic__"))); assert.deepEqual(Object.keys(m.sources).sort(), ["claudeGate", "gate", "mcpServer", "tools"]); }); test("a bundle is written once: an existing file refuses", (t) => { const dir = scratch(t); mkdirSync(join(dir, "bundle")); writeFileSync(join(dir, "bundle", "prompt.md"), "old"); assert.throws(() => buildBundle({ dir: join(dir, "bundle"), resolved: resolvedFor(t, "pm"), contract: "c", session: { run: "r", launchedBy: "pm", workspace: dir, toolSocket: join(dir, "s"), turnMarker: join(dir, "m") }, }), /EEXIST/); assert.equal(readFileSync(join(dir, "bundle", "prompt.md"), "utf8"), "old"); }); test("a path with a single quote can't go into the hook command", (t) => { const r = resolvedFor(t, "coder", (d) => (d.roles.coder.vars = { harness: "claude-code", model: "claude-sonnet-5-5" })); const dir = scratch(t); assert.throws(() => buildBundle({ dir: join(dir, "it's"), resolved: r, contract: "c", session: { run: "r", launchedBy: "pm", workspace: dir, toolSocket: join(dir, "s"), turnMarker: join(dir, "m") }, }), (e) => e instanceof BundleError && /single quote/.test(e.message)); });