mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: enable permission-aware subagents
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* agent-runner-e2e.test.ts — End-to-end test against the REAL pi-mono runtime.
|
||||
*
|
||||
* Every other agent-runner test mocks `@earendil-works/pi-coding-agent`: it
|
||||
* asserts that `runAgent` hands the right `tools:` allowlist to a *simulated*
|
||||
* `createAgentSession`. That proves our allowlist math, but not the assumption
|
||||
* the math rests on — that real pi-mono actually gates a session to that
|
||||
* allowlist, admitting extension-registered tools (the #47 fix) and dropping
|
||||
* the rest.
|
||||
*
|
||||
* This test closes that loop with NO pi-mono mock:
|
||||
* - a real extension fixture (`fixtures/e2e-probe-ext.mjs`) registers a tool,
|
||||
* - the real `DefaultResourceLoader` loads it via `additionalExtensionPaths`,
|
||||
* - the real `createAgentSession` builds the session,
|
||||
* - we read the real `session.getActiveToolNames()` at `onSessionCreated`
|
||||
* (fires after construction, before any prompt) and assert what the LLM
|
||||
* would actually be allowed to call.
|
||||
*
|
||||
* No network/LLM: a faux Model object satisfies `createAgentSession`'s `model`
|
||||
* param, and we never depend on a turn completing — the assertion is on the
|
||||
* gated tool set, which is fixed at construction. (Driving a live faux model
|
||||
* through `session.prompt()` is intentionally avoided: under Vite the faux
|
||||
* provider registers in a different `pi-ai` module instance than the one
|
||||
* pi-coding-agent streams through, which is brittle and orthogonal to gating.)
|
||||
*/
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { extensionCanonicalName, runAgent } from "../src/agent-runner.js";
|
||||
import { registerAgents } from "../src/agent-types.js";
|
||||
import type { AgentConfig } from "../src/types.js";
|
||||
import { registerFauxProvider } from "./helpers/pi-ai.js";
|
||||
|
||||
// These tests spin up the REAL pi-mono runtime (loader + dynamic extension
|
||||
// import + session construction), so a cold first run under full-suite CPU
|
||||
// contention can exceed vitest's 5s default. Give the file generous headroom —
|
||||
// a genuine hang still fails, just later.
|
||||
vi.setConfig({ testTimeout: 30_000 });
|
||||
|
||||
const FIXTURE = resolve(fileURLToPath(new URL("./fixtures/e2e-probe-ext.mjs", import.meta.url)));
|
||||
/** The fixture registers exactly this tool. */
|
||||
const EXT_TOOL = "e2e_probe";
|
||||
const BUILTINS = ["read", "bash", "edit", "write", "grep", "find", "ls"];
|
||||
|
||||
/** Minimal `pi` stub — `detectEnv` only needs `exec` (returns non-git). */
|
||||
function makePi() {
|
||||
return { exec: async () => ({ code: 1, stdout: "", stderr: "" }) } as any;
|
||||
}
|
||||
|
||||
describe("agent-runner end-to-end (real pi-mono session + real extension)", () => {
|
||||
let cwd: string;
|
||||
let faux: ReturnType<typeof registerFauxProvider>;
|
||||
|
||||
beforeEach(() => {
|
||||
cwd = mkdtempSync(join(tmpdir(), "subagents-e2e-"));
|
||||
// Only used as a valid Model object for createAgentSession; we never rely
|
||||
// on it actually streaming (we assert on the pre-prompt gated tool set).
|
||||
faux = registerFauxProvider({ provider: "faux", models: [{ id: "faux-1", contextWindow: 200_000 }] });
|
||||
});
|
||||
afterEach(() => {
|
||||
faux.unregister();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Register `cfg` as agent type "e2e", run it through the REAL runAgent, and
|
||||
* return the real session's active tool names captured at construction time.
|
||||
*/
|
||||
async function activeToolsFor(
|
||||
cfg: Partial<AgentConfig>,
|
||||
mandatoryExtensionPaths?: readonly string[],
|
||||
): Promise<string[]> {
|
||||
registerAgents(
|
||||
new Map([
|
||||
[
|
||||
"e2e",
|
||||
{
|
||||
name: "e2e",
|
||||
description: "e2e",
|
||||
builtinToolNames: BUILTINS,
|
||||
skills: false,
|
||||
systemPrompt: "You are e2e.",
|
||||
promptMode: "replace",
|
||||
inheritContext: false,
|
||||
runInBackground: false,
|
||||
isolated: false,
|
||||
...cfg,
|
||||
} as AgentConfig,
|
||||
],
|
||||
]),
|
||||
);
|
||||
const model = faux.getModel();
|
||||
const modelRegistry: any = {
|
||||
find: () => model,
|
||||
getAll: () => [model],
|
||||
getAvailable: () => [model],
|
||||
hasConfiguredAuth: () => true,
|
||||
isUsingOAuth: () => false,
|
||||
getApiKeyAndHeaders: async () => ({ apiKey: "faux", headers: {} }),
|
||||
registerProvider: () => {},
|
||||
unregisterProvider: () => {},
|
||||
};
|
||||
const ctx: any = { cwd, getSystemPrompt: () => "PARENT", model, modelRegistry };
|
||||
|
||||
let active: string[] = [];
|
||||
try {
|
||||
await runAgent(ctx, "e2e", "go", {
|
||||
pi: makePi(),
|
||||
model,
|
||||
mandatoryExtensionPaths,
|
||||
onSessionCreated: (s) => {
|
||||
active = s.getActiveToolNames();
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// A no-op/erroring prompt turn is fine — the gated tool set is fixed at
|
||||
// construction, which `onSessionCreated` already captured.
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
it("real pi-mono admits an extension-registered tool when it's in the allowlist (#47)", async () => {
|
||||
const active = await activeToolsFor({ extensions: [FIXTURE] });
|
||||
// The extension actually loaded and its tool reached the live session.
|
||||
expect(active).toContain(EXT_TOOL);
|
||||
for (const b of BUILTINS) expect(active).toContain(b);
|
||||
});
|
||||
|
||||
it("an extension tool is absent when extensions are disabled (not loaded)", async () => {
|
||||
const active = await activeToolsFor({ extensions: false });
|
||||
expect(active).not.toContain(EXT_TOOL);
|
||||
for (const b of BUILTINS) expect(active).toContain(b);
|
||||
});
|
||||
|
||||
it("loads an exact mandatory extension under extensions:false but keeps its tool hidden", async () => {
|
||||
const active = await activeToolsFor({ extensions: false }, [FIXTURE]);
|
||||
// Reaching onSessionCreated proves exact-path post-reload verification passed
|
||||
// against the real loader. Mandatory lifecycle must not imply tool visibility.
|
||||
expect(active).not.toContain(EXT_TOOL);
|
||||
for (const b of BUILTINS) expect(active).toContain(b);
|
||||
});
|
||||
|
||||
it("disallowedTools removes a real extension tool from the live session", async () => {
|
||||
const active = await activeToolsFor({ extensions: [FIXTURE], disallowedTools: [EXT_TOOL] });
|
||||
expect(active).not.toContain(EXT_TOOL); // loaded, then denied at construction
|
||||
expect(active).toContain("read");
|
||||
});
|
||||
|
||||
it("the ext: allowlist flip mutes a loaded-but-unselected extension in real pi-mono", async () => {
|
||||
// Extension loads (extensions: [FIXTURE]), but a single ext: selector for a
|
||||
// *different* name flips extension tools to an allowlist — the unselected
|
||||
// fixture contributes nothing, even though it loaded and ran its handlers.
|
||||
const active = await activeToolsFor({ extensions: [FIXTURE], extSelectors: ["ext:not-the-fixture"] });
|
||||
expect(active).not.toContain(EXT_TOOL);
|
||||
for (const b of BUILTINS) expect(active).toContain(b);
|
||||
});
|
||||
|
||||
it("an ext: selector surfaces the loaded extension's tool through the flip", async () => {
|
||||
// Derive the canonical name the loader/selector matcher uses, so the test
|
||||
// tracks `extensionCanonicalName` rather than hard-coding a filename form.
|
||||
const canon = extensionCanonicalName(FIXTURE);
|
||||
const active = await activeToolsFor({
|
||||
extensions: [FIXTURE],
|
||||
builtinToolNames: ["read"],
|
||||
extSelectors: [`ext:${canon}`],
|
||||
});
|
||||
expect(active).toContain(EXT_TOOL); // selected → surfaces despite the flip
|
||||
expect(active).toContain("read");
|
||||
expect(active).not.toContain("bash"); // builtinToolNames: ["read"] only
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user