feat: 接入 Pi 内置 codemode,并修复全面审查发现的问题

- codemode:Tool Search 常驻(alwaysEnabled)、权限 allow(嵌套调用仍逐个过权限门)、
  tools.jsonc 登记使用规则;RTK 跳过 parentToolCallId 嵌套调用,避免丢失 bash
  structuredContent;Tool Search 目录过滤 hidden / codemode(script-only)exposure;
  扩展加载测试加载 builtin:codemode 并断言其常驻激活
- 搜索 key 不再经命令行参数传递:search-auth.mjs 改为 --set-stdin(先读校验再加锁),
  configure.sh 交互输入经管道、非交互用 --<provider> -
- CodeGraph 客户端监听子进程 stdin error,避免 EPIPE 未处理导致崩溃
- 移除 PI_TOOL_SEARCH/HERMES_MEMORY/RTK_CONFIG_PATH 环境变量入口,测试改用工厂 configPath 注入
- pi-subagents 固定 vitest / coverage-istanbul 4.1.10,修复 npm ls 版本冲突
- 补 pi-web-search / pi-worktree UPSTREAM.md 与 pi-minimal-footer AGENTS.md,
  files 白名单补齐并加测试;.codegraph/.gitignore 移出跟踪
- detect_kitty 优先识别官方安装目录

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
云服务部-叶林立
2026-10-21 23:18:34 -07:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 56cbc83296
commit f9a7f743dd
46 changed files with 585 additions and 198 deletions
+15 -30
View File
@@ -13,10 +13,7 @@ import {
getToolSearchCatalogPath,
getToolSearchConfigPath,
} from "../pi-tool-search/extensions/config.ts";
import {
CONFIG_PATH as RTK_CONFIG_PATH,
resolveConfigPath as resolveRtkConfigPath,
} from "../pi-rtk-optimizer/src/constants.ts";
import { CONFIG_PATH as RTK_CONFIG_PATH } from "../pi-rtk-optimizer/src/constants.ts";
import { CONFIG_PATH as COMPACTION_CONFIG_PATH } from "../pi-compaction/src/config.ts";
import { WORKTREE_CONFIG_PATH } from "../pi-worktree/src/settings.ts";
import { resolvePreamblePath } from "../extensions/system-prompt-config.ts";
@@ -75,33 +72,21 @@ test("every bundle JSON/JSONC config file parses", async () => {
});
test("each extension resolves its config path inside the bundle config/ dir", () => {
const savedToolSearch = process.env.PI_TOOL_SEARCH_CONFIG_PATH;
const savedRtk = process.env.PI_RTK_CONFIG_PATH;
delete process.env.PI_TOOL_SEARCH_CONFIG_PATH;
delete process.env.PI_RTK_CONFIG_PATH;
try {
const inBundleConfig = (resolved: string, file: string) =>
assert.equal(
resolved,
join(bundleConfigDir, file),
`expected the bundle config path for ${file}`,
);
const inBundleConfig = (resolved: string, file: string) =>
assert.equal(
resolved,
join(bundleConfigDir, file),
`expected the bundle config path for ${file}`,
);
inBundleConfig(getGlobalConfigPath(), "pi-permission-policy.jsonc");
inBundleConfig(getHardGuardConfigPath(), "pi-permission-hard-guards.jsonc");
inBundleConfig(getToolSearchConfigPath(), "tool-search.json");
inBundleConfig(RTK_CONFIG_PATH, "rtk-optimizer.json");
inBundleConfig(resolveRtkConfigPath(), "rtk-optimizer.json");
inBundleConfig(COMPACTION_CONFIG_PATH, "pi-compaction.json");
inBundleConfig(WORKTREE_CONFIG_PATH, "pi-worktree.json");
inBundleConfig(HERMES_CONFIG_PATH, "pi-hermes-memory.json");
inBundleConfig(resolvePreamblePath(), "prompt/preamble.md");
} finally {
if (savedToolSearch === undefined) delete process.env.PI_TOOL_SEARCH_CONFIG_PATH;
else process.env.PI_TOOL_SEARCH_CONFIG_PATH = savedToolSearch;
if (savedRtk === undefined) delete process.env.PI_RTK_CONFIG_PATH;
else process.env.PI_RTK_CONFIG_PATH = savedRtk;
}
inBundleConfig(getGlobalConfigPath(), "pi-permission-policy.jsonc");
inBundleConfig(getHardGuardConfigPath(), "pi-permission-hard-guards.jsonc");
inBundleConfig(getToolSearchConfigPath(), "tool-search.json");
inBundleConfig(RTK_CONFIG_PATH, "rtk-optimizer.json");
inBundleConfig(COMPACTION_CONFIG_PATH, "pi-compaction.json");
inBundleConfig(WORKTREE_CONFIG_PATH, "pi-worktree.json");
inBundleConfig(HERMES_CONFIG_PATH, "pi-hermes-memory.json");
inBundleConfig(resolvePreamblePath(), "prompt/preamble.md");
});
test("disposable runtime state stays under the agent-dir my-pi tree", () => {
+34
View File
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import {
execFileSync,
spawn,
@@ -13,6 +14,7 @@ import {
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PassThrough } from "node:stream";
import { fileURLToPath } from "node:url";
import test from "node:test";
@@ -278,6 +280,38 @@ test("client close() terminates the child process", async () => {
}
});
test("a stdin EPIPE after child death rejects pending requests without throwing", async () => {
const dir = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
const child = new EventEmitter() as ChildProcess;
child.stdin = new PassThrough();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.exitCode = null;
child.signalCode = null;
const client = new CodegraphRpcClient({
root: dir,
spawn: () => child,
requestTimeoutMs: 5_000,
});
try {
const start = client.start();
await sleep(20);
// Without the stdin "error" listener this emit throws uncaught.
child.stdin.emit(
"error",
Object.assign(new Error("write EPIPE"), { code: "EPIPE" }),
);
child.exitCode = 1;
child.emit("exit", 1, null);
child.emit("close", 1, null);
await assert.rejects(start, /子进程退出/);
assert.equal(client.dead, true);
} finally {
await client.close();
await rm(dir, { recursive: true, force: true });
}
});
// ── handler ─────────────────────────────────────────────────────────────────
test("indexed project explores through the serve child", async () => {
+33 -3
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, readFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
@@ -292,9 +292,31 @@ test("all package extensions load together without global registration conflicts
const home = await mkdtemp(join(tmpdir(), "my-pi-extension-load-"));
const mockBin = join(home, "bin");
await mkdir(mockBin, { recursive: true });
const args = ["--no-extensions"];
// Probe loaded LAST: records every registered tool (name + exposure) and the
// final active set so the test can assert the pinned built-in codemode.
const probeOutput = join(home, "probe-tools.json");
const probePath = join(home, "probe.ts");
await writeFile(
probePath,
[
'import { writeFileSync } from "node:fs";',
"export default function (pi: any): void {",
'\tpi.on("session_start", () => {',
"\t\twriteFileSync(",
`\t\t\t${JSON.stringify(probeOutput)},`,
"\t\t\tJSON.stringify({",
"\t\t\t\tall: pi.getAllTools().map((tool: any) => ({ name: tool.name, exposure: tool.exposure })),",
"\t\t\t\tactive: pi.getActiveTools(),",
"\t\t\t}),",
"\t\t);",
"\t});",
"}",
"",
].join("\n"),
);
const args = ["--no-extensions", "-e", "builtin:codemode"];
for (const entry of packageJson.pi.extensions) args.push("-e", resolve(repositoryRoot, entry));
args.push("--mode", "rpc");
args.push("-e", probePath, "--mode", "rpc");
const loadEnvironment = {
...process.env,
@@ -313,4 +335,12 @@ test("all package extensions load together without global registration conflicts
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /Failed to load extension|conflicts with/u);
const probe = JSON.parse(await readFile(probeOutput, "utf8")) as {
all: { name: string; exposure?: string }[];
active: string[];
};
const codemode = probe.all.find((tool) => tool.name === "codemode");
assert.equal(codemode?.exposure, "model-only", "Pi's built-in codemode must load with model-only exposure");
assert.ok(probe.active.includes("codemode"), "Tool Search must pin codemode into the active set");
});
+70
View File
@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { accessSync, chmodSync, constants } from "node:fs";
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
const LIB_SH = fileURLToPath(new URL("../scripts/lib.sh", import.meta.url));
const SYSTEM_KITTY = "/Applications/kitty.app/Contents/MacOS/kitty";
function systemKittyPath(): string | undefined {
try {
accessSync(SYSTEM_KITTY, constants.X_OK);
return SYSTEM_KITTY;
} catch {
return undefined;
}
}
function runDetectKitty(home: string, pathDir: string) {
return spawnSync(
"sh",
["-c", `. "$1"; detect_kitty; status=$?; printf '%s' "$kitty_bin"; exit "$status"`, "sh", LIB_SH],
{
encoding: "utf8",
env: { ...process.env, HOME: home, PATH: `${pathDir}:${process.env.PATH}` },
},
);
}
test("detect_kitty prefers an official install over a PATH kitty", async () => {
const home = await mkdtemp(join(tmpdir(), "my-pi-libsh-"));
try {
const official = join(home, ".local", "kitty.app", "bin", "kitty");
const pathDir = join(home, "pathbin");
await mkdir(dirname(official), { recursive: true });
await mkdir(pathDir, { recursive: true });
await writeFile(official, "#!/bin/sh\n");
chmodSync(official, 0o755);
await symlink(official, join(pathDir, "kitty"));
const result = runDetectKitty(home, pathDir);
assert.equal(result.status, 0, result.stderr);
// A real /Applications kitty also wins over $HOME/.local and PATH.
assert.equal(result.stdout, systemKittyPath() ?? official);
} finally {
await rm(home, { recursive: true, force: true });
}
});
test("detect_kitty falls back to a PATH kitty when no official install exists", async () => {
const home = await mkdtemp(join(tmpdir(), "my-pi-libsh-"));
try {
const pathDir = join(home, "pathbin");
const pathKitty = join(pathDir, "kitty");
await mkdir(pathDir, { recursive: true });
await writeFile(pathKitty, "#!/bin/sh\n");
chmodSync(pathKitty, 0o755);
const result = runDetectKitty(home, pathDir);
assert.equal(result.status, 0, result.stderr);
// The isolated HOME has no ~/.local install; only a machine-level
// /Applications kitty (when present) still takes precedence.
assert.equal(result.stdout, systemKittyPath() ?? pathKitty);
} finally {
await rm(home, { recursive: true, force: true });
}
});
+13
View File
@@ -43,6 +43,19 @@ test("every declared extension entry and file: dependency manifest is inside the
}
});
test("every file: dependency ships an UPSTREAM.md source record", () => {
for (const [name, specifier] of Object.entries(packageJson.dependencies)) {
const match = /^file:\.\/(.+)$/u.exec(specifier);
if (!match) continue;
const upstream = `${match[1]}/UPSTREAM.md`;
assert.ok(
readFileSync(join(repositoryRoot, upstream), "utf8").trim().length > 0,
`${name} is a file: dependency — ${upstream} must exist and be non-empty`,
);
assert.ok(packedFiles.has(upstream), `${name} is a file: dependency — ${upstream} must be packed`);
}
});
test("relative import specifiers inside packed sources resolve to packed files", () => {
const specifierPatterns = [
/\bfrom\s+["'](\.{1,2}\/[^"']+)["']/gu,
+66 -12
View File
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -142,19 +143,25 @@ test("template parser handles env names, braces and escapes", () => {
const AUTH_MJS = new URL("../scripts/search-auth.mjs", import.meta.url).pathname;
const CONFIGURE_SH = new URL("../configure.sh", import.meta.url).pathname;
function runMjs(args: string[], env: Record<string, string> = {}) {
return spawnSync("node", [AUTH_MJS, ...args], { encoding: "utf8", env: { ...process.env, ...env } });
function runMjs(args: string[], env: Record<string, string> = {}, input?: string) {
return spawnSync("node", [AUTH_MJS, ...args], {
encoding: "utf8",
env: { ...process.env, ...env },
...(input === undefined ? {} : { input }),
});
}
test("search-auth.mjs --set writes api_key entries and preserves other providers", async () => {
test("search-auth.mjs --set-stdin writes api_key entries and preserves other providers", async () => {
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
const authPath = join(dir, "auth.json");
await writeFile(
authPath,
JSON.stringify({ anthropic: { type: "oauth", access: "tok", refresh: "ref", expires: 42, accountId: "acc" } }),
);
const result = runMjs(["--auth", authPath, "--set", "tavily=tvly-new", "--set", "exa=!op read x"]);
assert.equal(result.status, 0, result.stderr);
const first = runMjs(["--auth", authPath, "--set-stdin", "tavily"], {}, "tvly-new\n");
assert.equal(first.status, 0, first.stderr);
const second = runMjs(["--auth", authPath, "--set-stdin", "exa"], {}, " !op read x \n");
assert.equal(second.status, 0, second.stderr);
const written = JSON.parse(await readFile(authPath, "utf8")) as Record<string, any>;
assert.deepEqual(written.anthropic, { type: "oauth", access: "tok", refresh: "ref", expires: 42, accountId: "acc" });
assert.deepEqual(written.tavily, { type: "api_key", key: "tvly-new" });
@@ -163,14 +170,33 @@ test("search-auth.mjs --set writes api_key entries and preserves other providers
assert.equal(getSearchApiKey("tavily", { authPath }), "tvly-new");
});
test("search-auth.mjs preserves an existing credential env map on --set", async () => {
test("search-auth.mjs --set is rejected: keys never travel in argv", async () => {
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
const authPath = join(dir, "auth.json");
const result = runMjs(["--auth", authPath, "--set", "tavily=tvly-leak"]);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /命令行参数|--set-stdin/);
assert.equal(existsSync(authPath), false);
});
test("search-auth.mjs allows only one --set-stdin and rejects empty stdin", async () => {
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
const authPath = join(dir, "auth.json");
const dup = runMjs(["--auth", authPath, "--set-stdin", "tavily", "--set-stdin", "exa"], {}, "key");
assert.notEqual(dup.status, 0);
const empty = runMjs(["--auth", authPath, "--set-stdin", "tavily"], {}, " \n");
assert.notEqual(empty.status, 0);
assert.equal(existsSync(authPath), false);
});
test("search-auth.mjs preserves an existing credential env map on --set-stdin", async () => {
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
const authPath = join(dir, "auth.json");
await writeFile(
authPath,
JSON.stringify({ keenable: { type: "api_key", key: "old", env: { KEENABLE_API_URL: "https://k.example" } } }),
);
const result = runMjs(["--auth", authPath, "--set", "keenable=new-key"]);
const result = runMjs(["--auth", authPath, "--set-stdin", "keenable"], {}, "new-key");
assert.equal(result.status, 0, result.stderr);
const written = JSON.parse(await readFile(authPath, "utf8")) as Record<string, any>;
assert.deepEqual(written.keenable, {
@@ -183,7 +209,7 @@ test("search-auth.mjs preserves an existing credential env map on --set", async
test("search-auth.mjs creates a missing auth.json and honors PI_CODING_AGENT_DIR", async () => {
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
const agentDir = join(dir, "agent");
const result = runMjs(["--set", "keenable=keen-1"], { PI_CODING_AGENT_DIR: agentDir });
const result = runMjs(["--set-stdin", "keenable"], { PI_CODING_AGENT_DIR: agentDir }, "keen-1");
assert.equal(result.status, 0, result.stderr);
const written = JSON.parse(await readFile(join(agentDir, "auth.json"), "utf8")) as Record<string, any>;
assert.deepEqual(written.keenable, { type: "api_key", key: "keen-1" });
@@ -204,7 +230,7 @@ test("search-auth.mjs refuses to clobber a malformed auth.json", async () => {
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
const authPath = join(dir, "auth.json");
await writeFile(authPath, "{oops");
const result = runMjs(["--auth", authPath, "--set", "tavily=x"]);
const result = runMjs(["--auth", authPath, "--set-stdin", "tavily"], {}, "x");
assert.notEqual(result.status, 0);
assert.match(result.stderr, /不是合法 JSON|abort|clobber/i);
assert.equal(await readFile(authPath, "utf8"), "{oops");
@@ -238,15 +264,43 @@ test("search-auth.mjs --has and --list never print key material", async () => {
assert.doesNotMatch(list.stdout, /supersecret/);
});
test("configure.sh search writes keys through search-auth.mjs noninteractively", async () => {
test("configure.sh search writes keys through search-auth.mjs via stdin", async () => {
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
const authPath = join(dir, "auth.json");
const result = spawnSync("sh", [CONFIGURE_SH, "search", "--auth", authPath, "--tavily", "tvly-sh", "--exa", "exa-sh"], {
const first = spawnSync("sh", [CONFIGURE_SH, "search", "--auth", authPath, "--tavily", "-"], {
encoding: "utf8",
input: "tvly-sh\n",
});
assert.equal(result.status, 0, result.stderr);
assert.equal(first.status, 0, first.stderr);
const second = spawnSync("sh", [CONFIGURE_SH, "search", "--auth", authPath, "--exa", "-"], {
encoding: "utf8",
input: "exa-sh",
});
assert.equal(second.status, 0, second.stderr);
const written = JSON.parse(await readFile(authPath, "utf8")) as Record<string, any>;
assert.deepEqual(written.tavily, { type: "api_key", key: "tvly-sh" });
assert.deepEqual(written.exa, { type: "api_key", key: "exa-sh" });
assert.equal((await stat(authPath)).mode & 0o777, 0o600);
});
test("configure.sh search rejects a plaintext key argument", async () => {
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
const authPath = join(dir, "auth.json");
const result = spawnSync("sh", [CONFIGURE_SH, "search", "--auth", authPath, "--tavily", "tvly-leak"], {
encoding: "utf8",
});
assert.notEqual(result.status, 0);
assert.match(result.stderr, /命令行参数/);
assert.equal(existsSync(authPath), false);
});
test("configure.sh search allows at most one stdin provider per call", async () => {
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
const authPath = join(dir, "auth.json");
const result = spawnSync("sh", [CONFIGURE_SH, "search", "--auth", authPath, "--tavily", "-", "--exa", "-"], {
encoding: "utf8",
input: "one-key",
});
assert.notEqual(result.status, 0);
assert.match(result.stderr, /最多一个/);
});