import assert from "node:assert/strict"; import { lstat, mkdir, mkdtemp, readdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; import { getGlobalConfigPath, getGlobalLogsDir, getHardGuardConfigPath, } from "../pi-permission-system/src/config-paths.ts"; import { BUNDLE_TOOL_SEARCH_DEFAULTS, getToolSearchCatalogPath, getToolSearchConfigPath, } from "../pi-tool-search/extensions/config.ts"; import { resolveCoverPath } from "../extensions/system-prompt-cover-config.ts"; import { rebuildBundleConfig } from "../scripts/rebuild-bundle-config.mjs"; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const rebuildScript = join(repositoryRoot, "scripts", "rebuild-bundle-config.mjs"); const manifestPath = join(repositoryRoot, "config", "bundle-manifest.json"); interface ManifestFile { source: string; target: string; } const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as { version: number; directory: string; files: ManifestFile[]; }; async function makeTempDir() { return mkdtemp(join(tmpdir(), "my-pi-rebuild-")); } function modeOf(path: string): Promise { return stat(path).then((info) => info.mode & 0o777); } async function readSnapshot(path: string): Promise { return readFile(path, "utf8"); } test("rebuild replaces the my-pi tree with byte-identical manifest targets and leaves outside files alone", async () => { const dir = await makeTempDir(); try { // Pre-existing junk inside the managed directory must disappear entirely. await mkdir(join(dir, "my-pi", "permission-system"), { recursive: true }); await writeFile(join(dir, "my-pi", "permission-system", "policy.jsonc"), "STALE POLICY", "utf8"); await writeFile(join(dir, "my-pi", "leftover.txt"), "JUNK", "utf8"); await mkdir(join(dir, "my-pi", "stray", "deep"), { recursive: true }); // Files outside my-pi/ must survive byte-identical — the script owns only my-pi/. const outside = [ join(dir, "settings.json"), join(dir, "sessions", "abc", "session.jsonl"), join(dir, "extensions", "pi-permission-system", "policy.jsonc"), ]; for (const path of outside) { await mkdir(dirname(path), { recursive: true }); await writeFile(path, `outside ${path}\n`, "utf8"); } const before = new Map(await Promise.all(outside.map(async (p) => [p, await readSnapshot(p)]))); await rebuildBundleConfig(dir); const myPi = join(dir, "my-pi"); const expectedFiles = new Set(manifest.files.map((file) => file.target)); const actualFiles: string[] = []; const walk = async (root: string, rel: string) => { for (const entry of await readdir(root, { withFileTypes: true })) { const next = rel ? `${rel}/${entry.name}` : entry.name; if (entry.isDirectory()) await walk(join(root, entry.name), next); else actualFiles.push(next); } }; await walk(myPi, ""); assert.deepEqual(actualFiles.sort(), [...expectedFiles].sort(), "my-pi must contain exactly the manifest targets"); for (const file of manifest.files) { const target = join(myPi, file.target); assert.equal(await readFile(target, "utf8"), await readFile(join(repositoryRoot, file.source), "utf8")); assert.equal(await modeOf(target), 0o600, `${file.target} must be owner-only`); assert.equal(await modeOf(dirname(target)), 0o700, `${dirname(file.target)} must be owner-only`); } for (const [path, content] of before) { assert.equal(await readSnapshot(path), content, `outside file ${path} must be untouched`); } } finally { await rm(dir, { recursive: true, force: true }); } }); test("rejects an empty or relative agentDir without deleting anything", async () => { const dir = await makeTempDir(); try { const sentinel = join(dir, "keep.txt"); await writeFile(sentinel, "keep", "utf8"); await assert.rejects(rebuildBundleConfig(""), /absolute/); await assert.rejects(rebuildBundleConfig("relative/dir"), /absolute/); assert.equal(await readFile(sentinel, "utf8"), "keep"); } finally { await rm(dir, { recursive: true, force: true }); } }); test("CLI rejects a relative agentDir and reports the failure", async () => { const dir = await makeTempDir(); try { await mkdir(join(dir, "my-pi"), { recursive: true }); await writeFile(join(dir, "my-pi", "sentinel.txt"), "still here", "utf8"); const failure = spawnSync(process.execPath, [rebuildScript, "relative/dir"], { encoding: "utf8", }); assert.equal(failure.status, 2); assert.match(failure.stderr ?? "", /absolute/); assert.equal(await readFile(join(dir, "my-pi", "sentinel.txt"), "utf8"), "still here"); } finally { await rm(dir, { recursive: true, force: true }); } }); test("CLI prints rebuilt: on success", async () => { const dir = await makeTempDir(); try { const result = spawnSync(process.execPath, [rebuildScript, dir], { encoding: "utf8" }); assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); assert.equal(result.stdout.trim(), `rebuilt:${join(dir, "my-pi")}`); } finally { await rm(dir, { recursive: true, force: true }); } }); test("a manifest target escaping the directory is rejected without deleting my-pi", async () => { const dir = await makeTempDir(); try { await mkdir(join(dir, "my-pi"), { recursive: true }); await writeFile(join(dir, "my-pi", "sentinel.txt"), "still here", "utf8"); const badManifestPath = join(dir, "bad-manifest.json"); await writeFile( badManifestPath, JSON.stringify({ version: 1, directory: "my-pi", files: [{ source: "config/tool-search.json", target: "../escape.txt" }], }), "utf8", ); await assert.rejects( rebuildBundleConfig(dir, { manifestPath: badManifestPath }), /escapes/, ); assert.equal(await readFile(join(dir, "my-pi", "sentinel.txt"), "utf8"), "still here"); await assert.rejects(readFile(join(dir, "escape.txt"), "utf8"), /ENOENT/); } finally { await rm(dir, { recursive: true, force: true }); } }); test("a symlinked my-pi is replaced, never followed", async () => { const dir = await makeTempDir(); const elsewhere = await makeTempDir(); try { const targetContent = join(elsewhere, "precious.txt"); await writeFile(targetContent, "do not touch", "utf8"); await symlink(elsewhere, join(dir, "my-pi"), "dir"); await rebuildBundleConfig(dir); // The link itself must be replaced by a real directory… const rebuilt = await stat(join(dir, "my-pi")); assert.ok(rebuilt.isDirectory()); assert.ok(!(await lstat(join(dir, "my-pi"))).isSymbolicLink(), "my-pi must be a real directory now"); // …and the link target's contents must survive untouched. assert.equal(await readFile(targetContent, "utf8"), "do not touch"); } finally { await rm(dir, { recursive: true, force: true }); await rm(elsewhere, { recursive: true, force: true }); } }); test("manifest targets match the paths the extensions actually read", () => { const targets = new Map(manifest.files.map((file) => [file.target, file.source])); const agentDir = "/agent"; const base = `${agentDir}/${manifest.directory}`; const expectTarget = (source: string, resolved: string, suffix: string) => { assert.ok(resolved.startsWith(`${base}/`), `${resolved} must live under ${base}`); assert.equal( targets.get(resolved.slice(base.length + 1)), source, `${resolved} must be a manifest target backed by ${source}`, ); assert.ok(resolved.endsWith(suffix), `${resolved} must end with ${suffix}`); }; expectTarget("config/pi-permission-policy.jsonc", getGlobalConfigPath(agentDir), "my-pi/permission-system/policy.jsonc"); expectTarget("config/pi-permission-hard-guards.jsonc", getHardGuardConfigPath(agentDir), "my-pi/permission-system/hard-guards.jsonc"); expectTarget("config/tool-search.json", getToolSearchConfigPath(agentDir), "my-pi/tool-search/config.json"); expectTarget("config/system-prompt-cover.md", resolveCoverPath(agentDir), "my-pi/system-prompt-cover.md"); // Runtime state that is created rather than deployed must still live under my-pi/. assert.ok(getGlobalLogsDir(agentDir).endsWith("my-pi/permission-system/logs")); assert.ok(getToolSearchCatalogPath(agentDir).endsWith("my-pi/tool-search/catalog-v1.json")); }); test("deployed tool-search config is exactly BUNDLE_TOOL_SEARCH_DEFAULTS", async () => { const deployed = JSON.parse(await readFile(join(repositoryRoot, "config", "tool-search.json"), "utf8")); assert.deepEqual(deployed, BUNDLE_TOOL_SEARCH_DEFAULTS); });