Files
harness-tools/extensions/shared/child-session.test.ts
T

278 lines
8.1 KiB
TypeScript

import assert from "node:assert/strict";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import * as path from "node:path";
import test from "node:test";
import {
createAgentSession,
DefaultResourceLoader,
defineTool,
ProjectTrustStore,
SessionManager,
SettingsManager,
type SessionShutdownEvent,
} from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import {
bindChildSessionExtensions,
CHILD_EXCLUDED_TOOL_NAMES,
childToolPolicy,
createChildResources,
resolveStandaloneChildProjectTrust,
shutdownAndDisposeChildSession,
waitForChildOperation,
type DisposableChildSession,
} from "./child-session.ts";
async function withTempDir(run: (directory: string) => Promise<void>) {
const directory = await mkdtemp(path.join(tmpdir(), "pi-child-policy-"));
try {
await run(directory);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
test("child denylist keeps extension and workflow structured tools available", async () => {
await withTempDir(async (directory) => {
let starts = 0;
let shutdowns = 0;
const settingsManager = SettingsManager.inMemory(undefined, {
projectTrusted: false,
});
const inlineLoader = new DefaultResourceLoader({
cwd: directory,
agentDir: path.join(directory, "inline-agent"),
settingsManager,
extensionFactories: [
(pi) => {
pi.on("session_start", () => {
starts++;
});
pi.on("session_shutdown", () => {
shutdowns++;
});
for (const name of [
"fixture_extension_tool",
...CHILD_EXCLUDED_TOOL_NAMES,
]) {
pi.registerTool({
name,
label: name,
description: name,
parameters: Type.Object({}),
async execute() {
return {
content: [{ type: "text", text: "ok" }],
details: {},
};
},
});
}
},
],
});
await inlineLoader.reload();
const structuredOutput = defineTool({
name: "structured_output",
label: "Structured Output",
description: "fixture structured result",
parameters: Type.Object({ value: Type.String() }),
async execute(_id, params) {
return {
content: [{ type: "text", text: params.value }],
details: {},
};
},
});
const { session } = await createAgentSession({
cwd: directory,
agentDir: path.join(directory, "inline-agent"),
resourceLoader: inlineLoader,
settingsManager,
sessionManager: SessionManager.inMemory(directory),
customTools: [structuredOutput],
...childToolPolicy(),
});
await bindChildSessionExtensions(session);
assert.deepEqual(
[...CHILD_EXCLUDED_TOOL_NAMES],
[
"subagent_spawn",
"subagent_wait",
"subagent_cancel",
"subagent_check",
"subagent_list",
"workflow",
"ask_user",
],
);
const allTools = new Set(session.getAllTools().map((tool) => tool.name));
const activeTools = new Set(session.getActiveToolNames());
assert.equal(starts, 1);
assert.equal(allTools.has("fixture_extension_tool"), true);
assert.equal(activeTools.has("fixture_extension_tool"), true);
assert.equal(allTools.has("structured_output"), true);
assert.equal(activeTools.has("structured_output"), true);
for (const denied of CHILD_EXCLUDED_TOOL_NAMES) {
assert.equal(allTools.has(denied), false, `${denied} should be denied`);
assert.equal(
activeTools.has(denied),
false,
`${denied} should be inactive`,
);
}
for (const builtin of ["read", "bash", "edit", "write"]) {
assert.equal(
activeTools.has(builtin),
true,
`${builtin} should stay active`,
);
}
await Promise.all([
shutdownAndDisposeChildSession(session),
shutdownAndDisposeChildSession(session),
]);
assert.equal(shutdowns, 1);
});
});
test("resource loading gates project extensions but retains global extensions", async () => {
await withTempDir(async (directory) => {
const cwd = path.join(directory, "project");
const agentDir = path.join(directory, "agent");
await mkdir(path.join(cwd, ".pi", "extensions"), { recursive: true });
await mkdir(path.join(agentDir, "extensions"), { recursive: true });
const extensionSource = (name: string) => `
export default function (pi) {
pi.registerTool({
name: ${JSON.stringify(name)}, label: ${JSON.stringify(name)},
description: "fixture", parameters: { type: "object", properties: {} },
async execute() { return { content: [{ type: "text", text: "ok" }] }; }
});
}
`;
await writeFile(
path.join(agentDir, "extensions", "global.ts"),
extensionSource("global_fixture"),
);
await writeFile(
path.join(cwd, ".pi", "extensions", "project.ts"),
extensionSource("project_fixture"),
);
const untrusted = await createChildResources({
cwd,
agentDir,
projectTrusted: false,
});
const untrustedTools = untrusted.loader
.getExtensions()
.extensions.flatMap((extension) => [...extension.tools.keys()]);
assert.equal(untrustedTools.includes("global_fixture"), true);
assert.equal(untrustedTools.includes("project_fixture"), false);
const trusted = await createChildResources({
cwd,
agentDir,
projectTrusted: true,
});
const trustedTools = trusted.loader
.getExtensions()
.extensions.flatMap((extension) => [...extension.tools.keys()]);
assert.equal(trustedTools.includes("global_fixture"), true);
assert.equal(trustedTools.includes("project_fixture"), true);
});
});
test("alternate standalone cwd only uses explicit saved trust", async () => {
await withTempDir(async (directory) => {
const parentCwd = path.join(directory, "parent");
const childCwd = path.join(directory, "alternate");
const agentDir = path.join(directory, "agent");
await mkdir(parentCwd, { recursive: true });
await mkdir(childCwd, { recursive: true });
assert.equal(
resolveStandaloneChildProjectTrust({
parentCwd,
childCwd: parentCwd,
parentTrusted: true,
agentDir,
}),
true,
);
assert.equal(
resolveStandaloneChildProjectTrust({
parentCwd,
childCwd,
parentTrusted: true,
agentDir,
}),
false,
);
new ProjectTrustStore(agentDir).set(childCwd, true);
assert.equal(
resolveStandaloneChildProjectTrust({
parentCwd,
childCwd,
parentTrusted: false,
agentDir,
}),
true,
);
});
});
test("shutdown helper balances hooks and disposal despite errors", async () => {
let emits = 0;
let disposals = 0;
const session: DisposableChildSession = {
extensionRunner: {
hasHandlers: () => true,
async emit(event: SessionShutdownEvent) {
emits++;
assert.deepEqual(event, { type: "session_shutdown", reason: "quit" });
throw new Error("fixture shutdown failure");
},
},
dispose() {
disposals++;
},
};
await Promise.all([
shutdownAndDisposeChildSession(session),
shutdownAndDisposeChildSession(session),
shutdownAndDisposeChildSession(session),
]);
assert.equal(emits, 1);
assert.equal(disposals, 1);
});
test("child operations are bounded independently of teardown", async () => {
const startedAt = Date.now();
await waitForChildOperation(new Promise(() => {}), 5);
assert.ok(Date.now() - startedAt < 100);
});
test("shutdown helper bounds a stuck hook before disposal", async () => {
let disposals = 0;
const session: DisposableChildSession = {
extensionRunner: {
hasHandlers: () => true,
emit: () => new Promise(() => {}),
},
dispose() {
disposals++;
},
};
await shutdownAndDisposeChildSession(session, { timeoutMs: 10 });
assert.equal(disposals, 1);
});