Files
my-pi/tests/bundle-config-rebuild.test.ts
T
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 2682bd7fa7 feat: 组合包配置集中到 my-pi/ 目录并改为整体重建
- 组合包拥有、可随时重建的配置、日志与缓存统一放在
  <agentDir>/my-pi/:permission-system/(policy.jsonc、hard-guards.jsonc、
  logs/)、rtk-optimizer/config.json、tool-search/(config.json、
  catalog-v1.json)与 system-prompt-cover.md。hashline.json、lsp.json
  由外部扩展读取固定路径,是唯一例外。
- 新增 config/bundle-manifest.json 与 scripts/rebuild-bundle-config.mjs:
  先校验全部来源与目标,在临时目录构建完整树,再整体删除旧 my-pi/
  (符号链接只删链接)并原子替换,不备份。install.sh / update.sh 每次
  运行都调用它;扩展自身不再播种或迁移配置。
- update.sh 删除 pi update 失败后的 git clean 恢复逻辑:Pi 1.3.0 会在
  下一次 pi update 时依据中断标记自行恢复。同时删除 Tool Search
  settings.json 快照迁移与封面部署脚本。
- Tool Search 改为读取 my-pi/tool-search/config.json(源为
  config/tool-search.json,须与 BUNDLE_TOOL_SEARCH_DEFAULTS 一致),
  不再读写 settings.json 的 toolSearch 段,移除 bundleDefaultsVersion。
- 默认权限策略开启 debugLog。
- 旧位置的残留文件不迁移、不清理;AGENTS.md / README 同步新约束。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 02:26:57 -07:00

221 lines
8.7 KiB
TypeScript

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<number> {
return stat(path).then((info) => info.mode & 0o777);
}
async function readSnapshot(path: string): Promise<string> {
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:<path> 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);
});