mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
- 组合包拥有、可随时重建的配置、日志与缓存统一放在 <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>
221 lines
8.7 KiB
TypeScript
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);
|
|
});
|