mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 18:55:22 +00:00
从官方 Git 仓库导入 chandra447/pi-hermes-memory 在 tag v0.9.10 (df624056a2e8c68bc89ba0a81e1c120856ec9665,2026-10-02)的源码快照。 本次仅导入源码,尚未接入组合包:package.json、加载顺序、依赖与权限策略 均未改动。 - 原样导入 src/、tests/、docs/、scripts/、README、CHANGELOG、PLAN、 AGENTS 与 LICENSE;该快照共 156 个上游文件。 - 剔除嵌套 .git、上游 .github 工作流、.ralph/、.claude/、node_modules、 覆盖率与构建产物。 - 新增中文 UPSTREAM.md,记录来源,以及启用前必须完成的待决事项: 在 pi-compaction 之前注册加载顺序、better-sqlite3 版本与根 allowScripts 对齐、strip-ansi 解析确认。 - 说明选择 tag 而非 main 的原因:导入时上游 main 已领先至 24fcd8ad26fc27badb8d7dbaf8034645de07b893(2026-10-11),选 tag 是为 对应一个已发布、可复现的版本。 尚未接线。启用前还需反转 tests/extension-load.test.ts 中"不得加载 Hermes"的断言,并同步 pi-tool-search 分组、权限策略与文档。 验证:快照 156 个上游文件全部导入,目录内仅多出 UPSTREAM.md 一份; 已确认与上游 main 内容存在差异,证明取的是 tag 而非 main。
906 lines
35 KiB
TypeScript
906 lines
35 KiB
TypeScript
import { fileURLToPath } from "node:url";
|
|
import { spawn } from "node:child_process";
|
|
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { describe, it } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import {
|
|
buildChildPiPromptArgs,
|
|
detectAuthAdapterExtensionPaths,
|
|
execChildPrompt,
|
|
inheritedExtensionArgs,
|
|
resolveChildPiInvocation,
|
|
resolveWatchedChildPiInvocation,
|
|
} from "../../src/handlers/pi-child-process.js";
|
|
|
|
function logicalChildArgs(call: { cmd: string; args: string[] }): string[] {
|
|
const underlying = { command: call.args[3], args: call.args.slice(4) };
|
|
const expected = resolveWatchedChildPiInvocation(underlying, 30000, call.args[2]);
|
|
assert.deepStrictEqual(call, { cmd: expected.command, args: expected.args });
|
|
return underlying.command === "pi" ? underlying.args : underlying.args.slice(1);
|
|
}
|
|
|
|
// Compute the expected OWN_EXTENSION_PATH same logic as pi-child-process.ts
|
|
const OWN_EXTENSION_PATH = path.resolve(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
"../../src/index.ts",
|
|
);
|
|
|
|
// Adapter detection scans the real sibling/agent node_modules trees, so a
|
|
// contributor with a provider auth adapter installed gets extra -e args in
|
|
// every child invocation. Derive them instead of assuming a bare environment.
|
|
const DETECTED_ADAPTER_ARGS = detectAuthAdapterExtensionPaths().flatMap((p) => ["-e", p]);
|
|
|
|
const EXT_ARGS = ["--no-extensions", "-e", OWN_EXTENSION_PATH, ...DETECTED_ADAPTER_ARGS];
|
|
|
|
describe("inheritedExtensionArgs", () => {
|
|
it("captures explicit -e and --extension parent args", () => {
|
|
assert.deepStrictEqual(
|
|
inheritedExtensionArgs(["-e", "src/index.ts", "--extension", "/tmp/other.ts"]),
|
|
["-e", "src/index.ts", "--extension", "/tmp/other.ts"],
|
|
);
|
|
});
|
|
|
|
it("captures --extension=... parent args", () => {
|
|
assert.deepStrictEqual(
|
|
inheritedExtensionArgs(["--extension=src/index.ts"]),
|
|
["--extension=src/index.ts"],
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("detectAuthAdapterExtensionPaths", () => {
|
|
it("returns empty when no sibling packages exist under the root", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-empty-"));
|
|
try {
|
|
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), []);
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("detects a convention-named adapter via its package.json manifest (xai-oauth-adapter)", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-xai-"));
|
|
const adapterDir = path.join(root, "xai-oauth-adapter");
|
|
const adapterPath = path.join(adapterDir, "extensions", "index.ts");
|
|
await fs.mkdir(path.dirname(adapterPath), { recursive: true });
|
|
await fs.writeFile(adapterPath, "export default () => {};");
|
|
await fs.writeFile(
|
|
path.join(adapterDir, "package.json"),
|
|
JSON.stringify({ name: "xai-oauth-adapter", pi: { extensions: ["extensions/index.ts"] } }),
|
|
);
|
|
try {
|
|
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), [adapterPath]);
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("detects a scoped convention-named adapter one level under @scope", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-scoped-"));
|
|
const adapterDir = path.join(root, "@xai", "pi-oauth-adapter");
|
|
const adapterPath = path.join(adapterDir, "entry.ts");
|
|
await fs.mkdir(path.dirname(adapterPath), { recursive: true });
|
|
await fs.writeFile(adapterPath, "export default () => {};");
|
|
await fs.writeFile(
|
|
path.join(adapterDir, "package.json"),
|
|
JSON.stringify({ name: "@xai/pi-oauth-adapter", pi: { extensions: ["entry.ts"] } }),
|
|
);
|
|
try {
|
|
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), [adapterPath]);
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("ignores packages that do not match the auth-adapter naming convention", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-nonmatch-"));
|
|
const pkgDir = path.join(root, "some-other-package");
|
|
const extPath = path.join(pkgDir, "extensions", "index.ts");
|
|
await fs.mkdir(path.dirname(extPath), { recursive: true });
|
|
await fs.writeFile(extPath, "export default () => {};");
|
|
await fs.writeFile(
|
|
path.join(pkgDir, "package.json"),
|
|
JSON.stringify({ name: "some-other-package", pi: { extensions: ["extensions/index.ts"] } }),
|
|
);
|
|
try {
|
|
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), []);
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("returns empty for a matching package name without package.json", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-nopkg-"));
|
|
await fs.mkdir(path.join(root, "my-oauth-adapter"), { recursive: true });
|
|
try {
|
|
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), []);
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("returns empty when package.json lacks pi.extensions", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-nopi-"));
|
|
const adapterDir = path.join(root, "my-auth-adapter");
|
|
await fs.mkdir(adapterDir, { recursive: true });
|
|
await fs.writeFile(path.join(adapterDir, "package.json"), JSON.stringify({ name: "my-auth-adapter" }));
|
|
try {
|
|
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), []);
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("filters out manifest entries whose files do not exist on disk", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-missing-"));
|
|
const adapterDir = path.join(root, "pi-oauth-adapter");
|
|
const presentPath = path.join(adapterDir, "extensions", "present.ts");
|
|
await fs.mkdir(path.dirname(presentPath), { recursive: true });
|
|
await fs.writeFile(presentPath, "export default () => {};");
|
|
await fs.writeFile(
|
|
path.join(adapterDir, "package.json"),
|
|
JSON.stringify({
|
|
name: "pi-oauth-adapter",
|
|
pi: { extensions: ["extensions/missing.ts", "extensions/present.ts"] },
|
|
}),
|
|
);
|
|
try {
|
|
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), [presentPath]);
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("collects extension entries from every matching package under one root", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-multi-"));
|
|
const firstDir = path.join(root, "alpha-oauth-adapter");
|
|
const firstPath = path.join(firstDir, "a.ts");
|
|
const secondDir = path.join(root, "beta-auth-adapter");
|
|
const secondPath = path.join(secondDir, "b.ts");
|
|
await fs.mkdir(path.dirname(firstPath), { recursive: true });
|
|
await fs.writeFile(firstPath, "export default () => {};");
|
|
await fs.writeFile(
|
|
path.join(firstDir, "package.json"),
|
|
JSON.stringify({ name: "alpha-oauth-adapter", pi: { extensions: ["a.ts"] } }),
|
|
);
|
|
await fs.mkdir(path.dirname(secondPath), { recursive: true });
|
|
await fs.writeFile(secondPath, "export default () => {};");
|
|
await fs.writeFile(
|
|
path.join(secondDir, "package.json"),
|
|
JSON.stringify({ name: "beta-auth-adapter", pi: { extensions: ["b.ts"] } }),
|
|
);
|
|
try {
|
|
const detected = detectAuthAdapterExtensionPaths([root]);
|
|
assert.equal(detected.length, 2);
|
|
assert.ok(detected.includes(firstPath));
|
|
assert.ok(detected.includes(secondPath));
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("merges matches from multiple roots passed in the roots array", async () => {
|
|
const rootA = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-roota-"));
|
|
const rootB = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-rootb-"));
|
|
const pathA = path.join(rootA, "one-oauth-adapter", "one.ts");
|
|
const pathB = path.join(rootB, "two-oauth-adapter", "two.ts");
|
|
await fs.mkdir(path.dirname(pathA), { recursive: true });
|
|
await fs.writeFile(pathA, "export default () => {};");
|
|
await fs.writeFile(
|
|
path.join(rootA, "one-oauth-adapter", "package.json"),
|
|
JSON.stringify({ name: "one-oauth-adapter", pi: { extensions: ["one.ts"] } }),
|
|
);
|
|
await fs.mkdir(path.dirname(pathB), { recursive: true });
|
|
await fs.writeFile(pathB, "export default () => {};");
|
|
await fs.writeFile(
|
|
path.join(rootB, "two-oauth-adapter", "package.json"),
|
|
JSON.stringify({ name: "two-oauth-adapter", pi: { extensions: ["two.ts"] } }),
|
|
);
|
|
try {
|
|
const detected = detectAuthAdapterExtensionPaths([rootA, rootB]);
|
|
assert.equal(detected.length, 2);
|
|
assert.ok(detected.includes(pathA));
|
|
assert.ok(detected.includes(pathB));
|
|
} finally {
|
|
await fs.rm(rootA, { recursive: true, force: true });
|
|
await fs.rm(rootB, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("detects the allowlisted pi-claude-auth package", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-claude-"));
|
|
const adapterDir = path.join(root, "pi-claude-auth");
|
|
const adapterPath = path.join(adapterDir, "src", "index.ts");
|
|
await fs.mkdir(path.dirname(adapterPath), { recursive: true });
|
|
await fs.writeFile(adapterPath, "export default () => {};");
|
|
await fs.writeFile(
|
|
path.join(adapterDir, "package.json"),
|
|
JSON.stringify({ name: "pi-claude-auth", pi: { extensions: ["src/index.ts"] } }),
|
|
);
|
|
try {
|
|
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), [adapterPath]);
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("detects the allowlisted scoped @gotgenes/pi-anthropic-auth package", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-anthropic-"));
|
|
const adapterDir = path.join(root, "@gotgenes", "pi-anthropic-auth");
|
|
const adapterPath = path.join(adapterDir, "src", "index.ts");
|
|
await fs.mkdir(path.dirname(adapterPath), { recursive: true });
|
|
await fs.writeFile(adapterPath, "export default () => {};");
|
|
await fs.writeFile(
|
|
path.join(adapterDir, "package.json"),
|
|
JSON.stringify({
|
|
name: "@gotgenes/pi-anthropic-auth",
|
|
pi: { extensions: ["src/index.ts"] },
|
|
}),
|
|
);
|
|
try {
|
|
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), [adapterPath]);
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("ignores a suffixless auth package that is not allowlisted", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-untrusted-"));
|
|
const pkgDir = path.join(root, "pi-vault-auth");
|
|
const extPath = path.join(pkgDir, "index.ts");
|
|
await fs.mkdir(pkgDir, { recursive: true });
|
|
await fs.writeFile(extPath, "export default () => {};");
|
|
await fs.writeFile(
|
|
path.join(pkgDir, "package.json"),
|
|
JSON.stringify({ name: "pi-vault-auth", pi: { extensions: ["index.ts"] } }),
|
|
);
|
|
try {
|
|
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), []);
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
|
|
describe("buildChildPiPromptArgs", () => {
|
|
it("uses --no-extensions and only passes hermes-memory extension", () => {
|
|
assert.deepStrictEqual(
|
|
buildChildPiPromptArgs("hello", {}, []),
|
|
["-p", "--no-session", ...EXT_ARGS, "hello"],
|
|
);
|
|
});
|
|
|
|
it("adds a model override and defaults thinking to off", () => {
|
|
assert.deepStrictEqual(
|
|
buildChildPiPromptArgs("hello", { llmModelOverride: "openrouter/deepseek/deepseek-v4-flash" }, []),
|
|
["-p", "--no-session", "--model", "openrouter/deepseek/deepseek-v4-flash", "--thinking", "off", ...EXT_ARGS, "hello"],
|
|
);
|
|
});
|
|
it("inherits the active provider/model when no override is configured", () => {
|
|
assert.deepStrictEqual(
|
|
buildChildPiPromptArgs("hello", {}, [], { provider: "local-llama", id: "local-9b" }),
|
|
["-p", "--no-session", "--model", "local-llama/local-9b", ...EXT_ARGS, "hello"],
|
|
);
|
|
});
|
|
|
|
it("prefers the configured model override over the active session model", () => {
|
|
assert.deepStrictEqual(
|
|
buildChildPiPromptArgs(
|
|
"hello",
|
|
{ llmModelOverride: "openrouter/deepseek/deepseek-v4-flash" },
|
|
[],
|
|
{ provider: "local-llama", id: "local-9b" },
|
|
),
|
|
["-p", "--no-session", "--model", "openrouter/deepseek/deepseek-v4-flash", "--thinking", "off", ...EXT_ARGS, "hello"],
|
|
);
|
|
});
|
|
|
|
|
|
it("allows thinking overrides without a model override", () => {
|
|
assert.deepStrictEqual(
|
|
buildChildPiPromptArgs("hello", { llmThinkingOverride: "low" }, []),
|
|
["-p", "--no-session", "--thinking", "low", ...EXT_ARGS, "hello"],
|
|
);
|
|
});
|
|
|
|
it("ignores missing inherited extension paths", () => {
|
|
assert.deepStrictEqual(
|
|
buildChildPiPromptArgs("hello", {}, ["-e", "src/index.ts"]),
|
|
["-p", "--no-session", ...EXT_ARGS, "hello"],
|
|
);
|
|
});
|
|
|
|
it("passes configured extension sources through Pi's -e resolver and excludes inherited extensions", async () => {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-child-extensions-"));
|
|
const configured = path.join(dir, "configured.ts");
|
|
const inherited = path.join(dir, "inherited.ts");
|
|
await fs.writeFile(configured, "export default () => {};");
|
|
await fs.writeFile(inherited, "export default () => {};");
|
|
try {
|
|
assert.deepStrictEqual(
|
|
buildChildPiPromptArgs("hello", {
|
|
childExtensionPaths: [
|
|
configured,
|
|
configured,
|
|
"~/.pi/agent/extensions/provider.ts",
|
|
"./relative-provider.ts",
|
|
"git:github.com/example/provider-extension@v1",
|
|
"npm:@example/provider-extension@1.0.0",
|
|
OWN_EXTENSION_PATH,
|
|
],
|
|
}, ["-e", inherited, `--extension=${configured}`]),
|
|
[
|
|
"-p", "--no-session", "--no-extensions",
|
|
"-e", OWN_EXTENSION_PATH,
|
|
"-e", configured,
|
|
"-e", "~/.pi/agent/extensions/provider.ts",
|
|
"-e", "./relative-provider.ts",
|
|
"-e", "git:github.com/example/provider-extension@v1",
|
|
"-e", "npm:@example/provider-extension@1.0.0",
|
|
...DETECTED_ADAPTER_ARGS,
|
|
"hello",
|
|
],
|
|
);
|
|
} finally {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("detects a sibling pi-claude-oauth-adapter extension via its package.json manifest", async () => {
|
|
const nodeModules = await fs.mkdtemp(path.join(os.tmpdir(), "pi-node-modules-"));
|
|
const adapterDir = path.join(nodeModules, "pi-claude-oauth-adapter");
|
|
const adapterPath = path.join(adapterDir, "extensions", "index.ts");
|
|
await fs.mkdir(path.dirname(adapterPath), { recursive: true });
|
|
await fs.writeFile(adapterPath, "export default () => {};");
|
|
await fs.writeFile(
|
|
path.join(adapterDir, "package.json"),
|
|
JSON.stringify({ name: "pi-claude-oauth-adapter", pi: { extensions: ["extensions/index.ts"] } }),
|
|
);
|
|
try {
|
|
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([nodeModules]), [adapterPath]);
|
|
} finally {
|
|
await fs.rm(nodeModules, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("resolveChildPiInvocation", () => {
|
|
it("keeps non-Windows child pi invocations unchanged", () => {
|
|
const args = ["-p", "--no-session", "hello"];
|
|
|
|
assert.deepStrictEqual(
|
|
resolveChildPiInvocation(args, { platform: "linux" }),
|
|
{ command: "pi", args },
|
|
);
|
|
});
|
|
|
|
it("runs node.exe with cli.js first on Windows when a Pi CLI path is available", () => {
|
|
assert.deepStrictEqual(
|
|
resolveChildPiInvocation(["-p", "--no-session", "hello"], {
|
|
platform: "win32",
|
|
execPath: "C:\\Program Files\\nodejs\\node.exe",
|
|
piCliPath: "C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\cli.js",
|
|
}),
|
|
{
|
|
command: "C:\\Program Files\\nodejs\\node.exe",
|
|
args: [
|
|
"C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\cli.js",
|
|
"-p",
|
|
"--no-session",
|
|
"hello",
|
|
],
|
|
},
|
|
);
|
|
});
|
|
|
|
it("resolves a standard Windows command shim to its adjacent cli.js", async () => {
|
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "pi-windows-shim-"));
|
|
const binDirectory = path.join(directory, "bin");
|
|
const cliPath = path.join(
|
|
binDirectory,
|
|
"node_modules",
|
|
"@earendil-works",
|
|
"pi-coding-agent",
|
|
"dist",
|
|
"cli.js",
|
|
);
|
|
const args = ["-p", "--no-session", "hello"];
|
|
const originalPath = process.env.PATH;
|
|
|
|
await fs.mkdir(path.dirname(cliPath), { recursive: true });
|
|
await fs.writeFile(path.join(binDirectory, "pi.cmd"), "@echo off\r\n");
|
|
await fs.writeFile(cliPath, "");
|
|
process.env.PATH = `${binDirectory};C:\\Windows\\System32`;
|
|
try {
|
|
assert.deepStrictEqual(
|
|
resolveChildPiInvocation(args, {
|
|
platform: "win32",
|
|
execPath: "C:\\Program Files\\nodejs\\node.exe",
|
|
piCliPath: null,
|
|
}),
|
|
{
|
|
command: "C:\\Program Files\\nodejs\\node.exe",
|
|
args: [cliPath, ...args],
|
|
},
|
|
);
|
|
} finally {
|
|
if (originalPath === undefined) delete process.env.PATH;
|
|
else process.env.PATH = originalPath;
|
|
await fs.rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("execChildPrompt", () => {
|
|
it("runs child Pi behind a hard process-tree watchdog", async () => {
|
|
const calls: Array<{ cmd: string; args: string[]; timeout?: number }> = [];
|
|
await execChildPrompt({
|
|
exec: async (cmd: string, args: string[], options: { timeout?: number }) => {
|
|
calls.push({ cmd, args, timeout: options.timeout });
|
|
return { code: 0, stdout: "ok", stderr: "", killed: false };
|
|
},
|
|
} as any, "bounded child", {}, { timeoutMs: 30000 });
|
|
|
|
assert.equal(calls.length, 1);
|
|
assert.equal(calls[0].cmd, process.execPath);
|
|
assert.match(calls[0].args[0].replaceAll("\\", "/"), /child-process-watchdog\.mjs$/);
|
|
assert.equal(calls[0].args[1], "30000");
|
|
assert.match(calls[0].args[2], /cancel$/);
|
|
assert.equal(calls[0].args[3], "pi");
|
|
assert.equal(calls[0].timeout, 35000);
|
|
});
|
|
|
|
it("uses the watchdog marker for cancellation without aborting its process", async () => {
|
|
const abortController = new AbortController();
|
|
let cancelPath = "";
|
|
const result = await execChildPrompt({
|
|
exec: async (_cmd: string, args: string[], options: { signal?: AbortSignal }) => {
|
|
cancelPath = args[2];
|
|
assert.equal(options.signal, undefined);
|
|
abortController.abort();
|
|
const deadline = Date.now() + 1000;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
await fs.access(cancelPath);
|
|
return { code: 143, stderr: "child cancelled" };
|
|
} catch {
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
}
|
|
assert.fail("abort did not notify the watchdog");
|
|
},
|
|
} as any, "cancel child", {}, {
|
|
signal: abortController.signal,
|
|
timeoutMs: 30000,
|
|
});
|
|
|
|
assert.equal(result.code, 143);
|
|
await assert.rejects(fs.access(cancelPath), { code: "ENOENT" });
|
|
});
|
|
|
|
it("writes cancellation before launching an already-aborted child", async () => {
|
|
const abortController = new AbortController();
|
|
abortController.abort();
|
|
let cancelPath = "";
|
|
await execChildPrompt({
|
|
exec: async (_cmd: string, args: string[], options: { signal?: AbortSignal }) => {
|
|
cancelPath = args[2];
|
|
assert.equal(options.signal, undefined);
|
|
await fs.access(cancelPath);
|
|
return { code: 143, stderr: "child cancelled" };
|
|
},
|
|
} as any, "cancel before launch", {}, {
|
|
signal: abortController.signal,
|
|
timeoutMs: 30000,
|
|
});
|
|
|
|
await assert.rejects(fs.access(cancelPath), { code: "ENOENT" });
|
|
});
|
|
|
|
it("forwards explicit cwd and timeout while cancellation stays with the watchdog", async () => {
|
|
const signal = new AbortController().signal;
|
|
let received: { cwd?: string; signal?: AbortSignal; timeout?: number } | undefined;
|
|
await execChildPrompt({
|
|
exec: async (_cmd: string, _args: string[], options: typeof received) => {
|
|
received = options;
|
|
return { code: 0, stdout: "ok", stderr: "" };
|
|
},
|
|
} as any, "bounded child", {}, {
|
|
cwd: "/tmp/session",
|
|
signal,
|
|
timeoutMs: 30000,
|
|
});
|
|
|
|
assert.deepStrictEqual(received, { cwd: "/tmp/session", timeout: 35000 });
|
|
});
|
|
|
|
|
|
it("hard-kills a child that ignores graceful timeout termination", async () => {
|
|
const watchdogPath = fileURLToPath(
|
|
new URL("../../src/handlers/child-process-watchdog.mjs", import.meta.url),
|
|
);
|
|
const startedAt = Date.now();
|
|
const result = await new Promise<{ code: number | null; stderr: string }>((resolve) => {
|
|
const child = spawn(process.execPath, [
|
|
watchdogPath,
|
|
"100",
|
|
"-",
|
|
process.execPath,
|
|
"-e",
|
|
"process.on('SIGTERM',()=>{});setInterval(()=>{},1000)",
|
|
], { stdio: ["ignore", "ignore", "pipe"] });
|
|
let stderr = "";
|
|
child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
|
|
child.on("close", (code) => resolve({ code, stderr }));
|
|
});
|
|
|
|
assert.equal(result.code, 124);
|
|
assert.match(result.stderr, /timed out after 100ms/i);
|
|
assert.ok(Date.now() - startedAt < 3000, "watchdog must enforce a bounded hard stop");
|
|
});
|
|
|
|
it("terminates the child tree when its cancellation marker appears", async () => {
|
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "pi-watchdog-cancel-"));
|
|
const cancelPath = path.join(directory, "cancel");
|
|
const watchdogPath = fileURLToPath(
|
|
new URL("../../src/handlers/child-process-watchdog.mjs", import.meta.url),
|
|
);
|
|
const startedAt = Date.now();
|
|
try {
|
|
const result = await new Promise<{ code: number | null; stderr: string }>((resolve) => {
|
|
const child = spawn(process.execPath, [
|
|
watchdogPath,
|
|
"10000",
|
|
cancelPath,
|
|
process.execPath,
|
|
"-e",
|
|
"process.on('SIGTERM',()=>{});setInterval(()=>{},1000)",
|
|
], { stdio: ["ignore", "ignore", "pipe"] });
|
|
let stderr = "";
|
|
child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
|
|
child.on("close", (code) => resolve({ code, stderr }));
|
|
setTimeout(() => void fs.writeFile(cancelPath, ""), 100);
|
|
});
|
|
|
|
assert.equal(result.code, 143);
|
|
assert.match(result.stderr, /child cancellation requested/i);
|
|
assert.ok(Date.now() - startedAt < 3000, "watchdog cancellation must be bounded");
|
|
} finally {
|
|
await fs.rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("hard-kills descendants in the timed-out child process group", {
|
|
skip: process.platform === "win32" ? "uses taskkill /T on Windows" : false,
|
|
}, async () => {
|
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "pi-watchdog-tree-"));
|
|
const pidPath = path.join(directory, "descendant.pid");
|
|
const watchdogPath = fileURLToPath(
|
|
new URL("../../src/handlers/child-process-watchdog.mjs", import.meta.url),
|
|
);
|
|
const descendant = "process.on('SIGTERM',()=>{});setInterval(()=>{},1000)";
|
|
const parent = [
|
|
"const {spawn}=require('node:child_process');",
|
|
"const fs=require('node:fs');",
|
|
`const child=spawn(process.execPath,['-e',${JSON.stringify(descendant)}],{stdio:'ignore'});`,
|
|
`fs.writeFileSync(${JSON.stringify(pidPath)},String(child.pid));`,
|
|
"process.on('SIGTERM',()=>{});setInterval(()=>{},1000);",
|
|
].join("");
|
|
|
|
try {
|
|
const code = await new Promise<number | null>((resolve) => {
|
|
const child = spawn(process.execPath, [
|
|
watchdogPath,
|
|
"100",
|
|
"-",
|
|
process.execPath,
|
|
"-e",
|
|
parent,
|
|
], { stdio: "ignore" });
|
|
child.on("close", resolve);
|
|
});
|
|
assert.equal(code, 124);
|
|
const descendantPid = Number(await fs.readFile(pidPath, "utf-8"));
|
|
const deadline = Date.now() + 1000;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
process.kill(descendantPid, 0);
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ESRCH") return;
|
|
throw error;
|
|
}
|
|
}
|
|
assert.fail("watchdog left a descendant process alive");
|
|
} finally {
|
|
await fs.rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("keeps a sensitive prompt out of argv and removes its mode-0600 temporary file", async () => {
|
|
const secret = "PRIVATE-MEMORY-CONTENT";
|
|
let promptPath = "";
|
|
const pi = {
|
|
exec: async (_cmd: string, args: string[]) => {
|
|
assert.ok(args.every((arg) => !arg.includes(secret)), "child argv must not contain prompt content");
|
|
const promptArg = args.at(-1)!;
|
|
assert.match(promptArg, /^@/);
|
|
promptPath = promptArg.slice(1);
|
|
assert.equal(await fs.readFile(promptPath, "utf-8"), secret);
|
|
assert.equal((await fs.stat(promptPath)).mode & 0o777, 0o600);
|
|
return { code: 0, stdout: "ok", stderr: "" };
|
|
},
|
|
};
|
|
|
|
const result = await execChildPrompt(pi as any, secret, {}, { timeoutMs: 30000 });
|
|
|
|
assert.equal(result.code, 0);
|
|
await assert.rejects(fs.access(promptPath), { code: "ENOENT" });
|
|
await assert.rejects(fs.access(path.dirname(promptPath)), { code: "ENOENT" });
|
|
});
|
|
|
|
it("removes the temporary prompt file when child execution throws", async () => {
|
|
let promptPath = "";
|
|
const pi = {
|
|
exec: async (_cmd: string, args: string[]) => {
|
|
promptPath = args.at(-1)!.slice(1);
|
|
assert.equal(await fs.readFile(promptPath, "utf-8"), "secret failure prompt");
|
|
throw new Error("child failed");
|
|
},
|
|
};
|
|
|
|
await assert.rejects(
|
|
execChildPrompt(pi as any, "secret failure prompt", {}, { timeoutMs: 30000 }),
|
|
/child failed/,
|
|
);
|
|
|
|
assert.ok(promptPath.startsWith(os.tmpdir()));
|
|
await assert.rejects(fs.access(promptPath), { code: "ENOENT" });
|
|
});
|
|
|
|
it("returns a successful child result when temporary cleanup fails", async () => {
|
|
let cleanupCalls = 0;
|
|
let promptDirectory = "";
|
|
const pi = {
|
|
exec: async (_cmd: string, args: string[]) => {
|
|
promptDirectory = path.dirname(args.at(-1)!.slice(1));
|
|
return { code: 0, stdout: "completed", stderr: "" };
|
|
},
|
|
};
|
|
|
|
try {
|
|
const result = await execChildPrompt(
|
|
pi as any,
|
|
"cleanup failure prompt",
|
|
{},
|
|
{ timeoutMs: 30000 },
|
|
{
|
|
removeTemporaryDirectory: async () => {
|
|
cleanupCalls++;
|
|
throw new Error("cleanup denied");
|
|
},
|
|
},
|
|
);
|
|
|
|
assert.equal(result.code, 0);
|
|
assert.equal(result.stdout, "completed");
|
|
assert.equal(cleanupCalls, 1);
|
|
} finally {
|
|
if (promptDirectory) await fs.rm(promptDirectory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("passes configured auth adapters to both override attempts", async () => {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-child-auth-"));
|
|
const adapterPath = path.join(dir, "adapter.ts");
|
|
await fs.writeFile(adapterPath, "export default () => {};");
|
|
const calls: string[][] = [];
|
|
const pi = {
|
|
exec: async (_cmd: string, args: string[]) => {
|
|
calls.push(args);
|
|
return calls.length === 1
|
|
? { code: 1, stdout: "", stderr: "model not found" }
|
|
: { code: 0, stdout: "ok", stderr: "" };
|
|
},
|
|
};
|
|
try {
|
|
await execChildPrompt(pi as any, "hello", {
|
|
llmModelOverride: "missing/model",
|
|
childExtensionPaths: [adapterPath],
|
|
}, { timeoutMs: 30000, retryWithoutOverrides: true });
|
|
|
|
assert.equal(calls.length, 2);
|
|
for (const args of calls) {
|
|
const index = args.indexOf(adapterPath);
|
|
assert.ok(index > 0);
|
|
assert.equal(args[index - 1], "-e");
|
|
}
|
|
} finally {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("excludes unrelated inherited extensions from primary and retry attempts", async () => {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-child-untrusted-"));
|
|
const inherited = path.join(dir, "unrelated-extension.ts");
|
|
await fs.writeFile(inherited, "export default () => {};");
|
|
const originalArgv = process.argv;
|
|
const calls: string[][] = [];
|
|
process.argv = [originalArgv[0], originalArgv[1], "-e", inherited];
|
|
try {
|
|
await execChildPrompt({
|
|
exec: async (_cmd: string, args: string[]) => {
|
|
calls.push(args);
|
|
return calls.length === 1
|
|
? { code: 1, stderr: "model not found" }
|
|
: { code: 0, stdout: "ok" };
|
|
},
|
|
} as any, "private review", {
|
|
llmModelOverride: "missing/model",
|
|
}, { timeoutMs: 30000, retryWithoutOverrides: true });
|
|
|
|
assert.equal(calls.length, 2);
|
|
for (const args of calls) {
|
|
assert.equal(args.includes(inherited), false);
|
|
assert.ok(args.includes("--no-extensions"));
|
|
}
|
|
} finally {
|
|
process.argv = originalArgv;
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("retries once without overrides when requested and the override subprocess fails for model resolution reasons", async () => {
|
|
const calls: Array<{ cmd: string; args: string[] }> = [];
|
|
const pi = {
|
|
exec: async (cmd: string, args: string[]) => {
|
|
calls.push({ cmd, args });
|
|
if (calls.length === 1) {
|
|
return { code: 1, stdout: "", stderr: "model not found" };
|
|
}
|
|
return { code: 0, stdout: "ok", stderr: "" };
|
|
},
|
|
};
|
|
|
|
const result = await execChildPrompt(pi as any, "hello", {
|
|
llmModelOverride: "openrouter/deepseek/deepseek-v4-flash",
|
|
}, {
|
|
timeoutMs: 30000,
|
|
retryWithoutOverrides: true,
|
|
model: { provider: "local-llama", id: "local-9b" },
|
|
});
|
|
|
|
assert.strictEqual(result.code, 0);
|
|
const logicalCalls = calls.map(logicalChildArgs);
|
|
const promptReference = logicalCalls[0].at(-1)!;
|
|
assert.match(promptReference, /^@/);
|
|
assert.deepStrictEqual(logicalCalls, [
|
|
["-p", "--no-session", "--model", "openrouter/deepseek/deepseek-v4-flash", "--thinking", "off", ...EXT_ARGS, promptReference],
|
|
// Retry drops configured overrides but preserves the active session model.
|
|
["-p", "--no-session", "--model", "local-llama/local-9b", ...EXT_ARGS, promptReference],
|
|
]);
|
|
});
|
|
|
|
it("uses the resolved Windows node.exe invocation for both override retry attempts", async () => {
|
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "pi-windows-retry-"));
|
|
const binDirectory = path.join(directory, "bin");
|
|
const cliPath = path.join(
|
|
binDirectory,
|
|
"node_modules",
|
|
"@earendil-works",
|
|
"pi-coding-agent",
|
|
"dist",
|
|
"cli.js",
|
|
);
|
|
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
|
const originalPath = process.env.PATH;
|
|
await fs.mkdir(path.dirname(cliPath), { recursive: true });
|
|
await fs.writeFile(path.join(binDirectory, "pi.cmd"), "@echo off\r\n");
|
|
await fs.writeFile(cliPath, "");
|
|
process.env.PATH = `${binDirectory};C:\\Windows\\System32`;
|
|
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
|
|
try {
|
|
const calls: Array<{ cmd: string; args: string[] }> = [];
|
|
const pi = {
|
|
exec: async (cmd: string, args: string[]) => {
|
|
calls.push({ cmd, args });
|
|
if (calls.length === 1) {
|
|
return { code: 1, stdout: "", stderr: "model not found" };
|
|
}
|
|
return { code: 0, stdout: "ok", stderr: "" };
|
|
},
|
|
};
|
|
|
|
const result = await execChildPrompt(pi as any, "hello", {
|
|
llmModelOverride: "openrouter/deepseek/deepseek-v4-flash",
|
|
}, {
|
|
timeoutMs: 30000,
|
|
retryWithoutOverrides: true,
|
|
});
|
|
|
|
assert.strictEqual(result.code, 0);
|
|
assert.strictEqual(calls.length, 2);
|
|
assert.strictEqual(calls[0].cmd, process.execPath);
|
|
assert.strictEqual(calls[1].cmd, process.execPath);
|
|
assert.match(calls[0].args[4].replace(/\\/g, "/"), /\/cli\.js$/);
|
|
assert.match(calls[1].args[4].replace(/\\/g, "/"), /\/cli\.js$/);
|
|
const promptReference = calls[0].args.at(-1)!;
|
|
assert.match(promptReference, /^@/);
|
|
assert.deepStrictEqual(calls.map(logicalChildArgs), [
|
|
["-p", "--no-session", "--model", "openrouter/deepseek/deepseek-v4-flash", "--thinking", "off", ...EXT_ARGS, promptReference],
|
|
["-p", "--no-session", ...EXT_ARGS, promptReference],
|
|
]);
|
|
} finally {
|
|
if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform);
|
|
if (originalPath === undefined) delete process.env.PATH;
|
|
else process.env.PATH = originalPath;
|
|
await fs.rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not retry generic non-zero child failures that are unrelated to override resolution", async () => {
|
|
const calls: Array<{ cmd: string; args: string[] }> = [];
|
|
const pi = {
|
|
exec: async (cmd: string, args: string[]) => {
|
|
calls.push({ cmd, args });
|
|
return { code: 1, stdout: "", stderr: "memory tool returned no changes" };
|
|
},
|
|
};
|
|
|
|
const result = await execChildPrompt(pi as any, "hello", {
|
|
llmModelOverride: "openrouter/deepseek/deepseek-v4-flash",
|
|
}, {
|
|
timeoutMs: 30000,
|
|
retryWithoutOverrides: true,
|
|
});
|
|
|
|
assert.strictEqual(result.code, 1);
|
|
assert.strictEqual(calls.length, 1);
|
|
});
|
|
|
|
it("does not retry generic thrown errors that are unrelated to override resolution", async () => {
|
|
const calls: Array<{ cmd: string; args: string[] }> = [];
|
|
const pi = {
|
|
exec: async (cmd: string, args: string[]) => {
|
|
calls.push({ cmd, args });
|
|
throw new Error("timed out waiting for child process");
|
|
},
|
|
};
|
|
|
|
await assert.rejects(
|
|
() => execChildPrompt(pi as any, "hello", {
|
|
llmModelOverride: "openrouter/deepseek/deepseek-v4-flash",
|
|
}, {
|
|
timeoutMs: 30000,
|
|
retryWithoutOverrides: true,
|
|
}),
|
|
/timed out waiting for child process/,
|
|
);
|
|
|
|
assert.strictEqual(calls.length, 1);
|
|
});
|
|
|
|
it("does not retry when retryWithoutOverrides is false", async () => {
|
|
const calls: Array<{ cmd: string; args: string[] }> = [];
|
|
const pi = {
|
|
exec: async (cmd: string, args: string[]) => {
|
|
calls.push({ cmd, args });
|
|
return { code: 1, stdout: "", stderr: "model not found" };
|
|
},
|
|
};
|
|
|
|
const result = await execChildPrompt(pi as any, "hello", {
|
|
llmModelOverride: "openrouter/deepseek/deepseek-v4-flash",
|
|
}, {
|
|
timeoutMs: 30000,
|
|
retryWithoutOverrides: false,
|
|
});
|
|
|
|
assert.strictEqual(result.code, 1);
|
|
assert.strictEqual(calls.length, 1);
|
|
});
|
|
});
|