mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 18:55:22 +00:00
feat(pi-hermes-memory): 导入上游 v0.9.10 源码快照
从官方 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。
This commit is contained in:
@@ -0,0 +1,905 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user