import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { initTheme } from "@earendil-works/pi-coding-agent"; import { afterEach, describe, expect, it } from "vitest"; import { createPiSession, type PiSession, } from "../../../test/fixture/create-pi-session.ts"; function gitSafePath(): string { return fileURLToPath(new URL("../index.ts", import.meta.url)); } function theme() { return { fg: (_style: string, text: string) => text, bold: (text: string) => text, } as any; } function render(component: { render(width: number): string[] }): string { return component.render(120).join("\n"); } // Full ToolRenderContext is not part of the package's public type exports, so // build the runtime shape the renderer receives. git_clone_safe's renderResult // ignores this context, but the signature requires it. function renderContext(expanded: boolean) { return { args: {}, toolCallId: "test", invalidate: () => {}, lastComponent: undefined, state: undefined, cwd: process.cwd(), executionStarted: true, argsComplete: true, isPartial: false, expanded, showImages: false, isError: false, durationMs: undefined, outputPad: 0, }; } describe("git-safe owned fixture", () => { let session: PiSession | undefined; let root: string | undefined; afterEach(async () => { await session?.dispose(); if (root) rmSync(root, { recursive: true, force: true }); session = undefined; root = undefined; }); it("loads in real Pi extension runtime", async () => { session = await createPiSession({ extensionPaths: [gitSafePath()] }); expect(session.extensions).toHaveLength(1); const tool = session.extensions[0]!.tools.get("git_clone_safe")!.definition; expect(tool.exposure).toBe("deferred"); expect(tool.annotations?.readOnlyHint).toBe(false); expect(tool.description).toContain( "Clone an HTTPS or SSH Git repository into a safe subdirectory", ); expect(tool.description).toContain( "canonical path, file/size/symlink counts, and warnings", ); expect(tool.promptSnippet).toBe("Safely clone Git repositories."); expect(tool.promptGuidelines).toEqual([ "Use git_clone_safe instead of raw git clone.", ]); expect(session.session.getActiveToolNames()).not.toContain( "git_clone_safe", ); expect(session.session.systemPrompt).not.toContain( "Use git_clone_safe instead of raw git clone.", ); }); it("reveals clone warnings only when expanded", async () => { initTheme("default", false); session = await createPiSession({ extensionPaths: [gitSafePath()] }); const tool = session.extensions[0]!.tools.get("git_clone_safe")!.definition; const result = { content: [{ type: "text" as const, text: "cloned" }], details: { url: "https://example.com/owner/repo.git", path: "/tmp/owner/repo", fileCount: 2, totalSizeBytes: 128, symlinkCount: 0, symlinks: [], warnings: ["Submodules were not initialized"], }, }; const collapsed = render( tool.renderResult!( result, { expanded: false, isPartial: false }, theme(), renderContext(false), ), ); const expanded = render( tool.renderResult!( result, { expanded: true, isPartial: false }, theme(), renderContext(true), ), ); expect(collapsed).toContain("to expand"); expect(collapsed).not.toContain("Submodules were not initialized"); expect(expanded).toContain("Submodules were not initialized"); expect(expanded).not.toContain("to expand"); const error = { content: [ { type: "text" as const, text: "Clone failed\ncause\nnext step\nalternative\nEND-OF-DIAGNOSTIC", }, ], isError: true, details: undefined, }; const collapsedError = render( tool.renderResult!( error, { expanded: false, isPartial: false }, theme(), renderContext(false), ), ); const expandedError = render( tool.renderResult!( error, { expanded: true, isPartial: false }, theme(), renderContext(true), ), ); expect(collapsedError).toContain("to expand"); expect(collapsedError).not.toContain("END-OF-DIAGNOSTIC"); expect(expandedError).toContain("END-OF-DIAGNOSTIC"); const longError = { content: [ { type: "text" as const, text: `Clone failed: ${"diagnostic ".repeat(30)}END-OF-LONG-DIAGNOSTIC`, }, ], isError: true, details: undefined, }; const collapsedLongError = render( tool.renderResult!( longError, { expanded: false, isPartial: false }, theme(), renderContext(false), ), ); const expandedLongError = render( tool.renderResult!( longError, { expanded: true, isPartial: false }, theme(), renderContext(true), ), ); expect(collapsedLongError).toContain("to expand"); expect(collapsedLongError).not.toContain("END-OF-LONG-DIAGNOSTIC"); expect(expandedLongError).toContain("END-OF-LONG-DIAGNOSTIC"); }); it("recovers a marked clone lazily when read", async () => { root = mkdtempSync(path.join(tmpdir(), "pi-git-safe-read-")); const clone = path.join(root, "owner", "repo"); const file = path.join(clone, "README.md"); mkdirSync(clone, { recursive: true }); writeFileSync(path.join(clone, ".pi-git-safe"), "{}", "utf8"); writeFileSync(file, "untrusted instructions", "utf8"); session = await createPiSession({ cwd: root, extensionPaths: [gitSafePath()], }); const result = await session.session.extensionRunner.emitToolResult({ type: "tool_result", toolName: "read", toolCallId: crypto.randomUUID(), input: { path: file }, content: [{ type: "text", text: "untrusted instructions" }], isError: false, } as never); expect(result?.content?.[0]).toMatchObject({ type: "text" }); expect( result?.content?.[0]?.type === "text" && result.content[0].text, ).toContain("UNTRUSTED DATA"); expect( result?.content?.[0]?.type === "text" && result.content[0].text, ).toContain("untrusted instructions"); }); it("leaves agent-issued shell commands untouched", async () => { session = await createPiSession({ extensionPaths: [gitSafePath()] }); for (const toolName of ["bash", "nu", "powershell"]) { const event = { type: "tool_call", toolName, toolCallId: crypto.randomUUID(), input: { command: "git status" }, }; const blocked = await session.session.extensionRunner.emitToolCall( event as never, ); expect(blocked, toolName).toBeUndefined(); expect(event.input.command, toolName).toBe("git status"); } }); });