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) { 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", "pi_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); });