import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import test from "node:test"; import type { RequestPermissionRequest } from "@agentclientprotocol/sdk"; import { AcpClient, decidePermission } from "../src/acp/client.js"; const request = (rawInput: unknown, overrides: Partial = {}): RequestPermissionRequest => ({ sessionId: "session", toolCall: { toolCallId: "call", title: "bash git status", kind: "execute", name: "bash", rawInput, ...overrides }, options: [ { optionId: "allow", name: "Allow", kind: "allow_once" }, { optionId: "reject", name: "Reject", kind: "reject_once" } ] }); test("permission deny and allowlist fail closed", () => { assert.equal(decidePermission(request("git status"), { mode: "deny", allowedTools: [], allowedCommandPatterns: [] }).outcome.outcome, "selected"); const allowed = decidePermission(request("git status"), { mode: "allowlist", allowedTools: ["bash"], allowedCommandPatterns: ["^git status$"] }); assert.deepEqual(allowed.outcome, { outcome: "selected", optionId: "allow" }); const objectInput = decidePermission(request({ command: "git status", timeout: 60 }), { mode: "allowlist", allowedTools: ["bash"], allowedCommandPatterns: ["^git status$"] }); assert.deepEqual(objectInput.outcome, { outcome: "selected", optionId: "allow" }); const missingDetail = decidePermission(request(undefined), { mode: "allowlist", allowedTools: ["bash"], allowedCommandPatterns: ["^git status$"] }); assert.deepEqual(missingDetail.outcome, { outcome: "selected", optionId: "reject" }); const destructive = decidePermission(request("rm -rf /"), { mode: "allowlist", allowedTools: ["bash"], allowedCommandPatterns: ["^git status$"] }); assert.deepEqual(destructive.outcome, { outcome: "selected", optionId: "reject" }); const unanchored = { mode: "allowlist" as const, allowedTools: ["bash"], allowedCommandPatterns: ["git status"] }; assert.deepEqual(decidePermission(request("git status"), unanchored).outcome, { outcome: "selected", optionId: "allow" }); assert.deepEqual(decidePermission(request("git status; rm -rf /"), unanchored).outcome, { outcome: "selected", optionId: "reject" }); assert.deepEqual(decidePermission(request("git status", { name: "python", title: "bash git status" }), unanchored).outcome, { outcome: "selected", optionId: "reject" }); assert.deepEqual(decidePermission(request("git status", { name: undefined, title: "bash git status" }), unanchored).outcome, { outcome: "selected", optionId: "reject" }); assert.deepEqual(decidePermission(request({ command: "git status", env: { PATH: "/tmp" } }), unanchored).outcome, { outcome: "selected", optionId: "reject" }); }); test("reports activity for every update from the active session", async () => { const child = spawn(process.execPath, [path.resolve("test/fixtures/fake-acp-agent.mjs")], { stdio: ["pipe", "pipe", "pipe"] }); let activities = 0; const client = new AcpClient(child, { initializeTimeoutMs: 1_000, policy: { mode: "deny", allowedTools: [], allowedCommandPatterns: [] }, onSessionActivity: () => { activities++; } }); try { await client.initialize(); await client.newSession(path.resolve(".")); assert.equal(await client.prompt("activity"), "firstsecond"); assert.equal(activities, 2); } finally { client.close(); child.kill("SIGTERM"); } }); test("prompt sends text and image content blocks and records the image capability", async () => { const logFile = path.join(os.tmpdir(), `gori-acp-client-${crypto.randomUUID()}.log`); const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs"); const child = spawn(process.execPath, [fixture], { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, FAKE_ACP_LOG: logFile } }); const client = new AcpClient(child, { initializeTimeoutMs: 1_000, policy: { mode: "deny", allowedTools: [], allowedCommandPatterns: [] } }); try { await client.initialize(); assert.equal(client.supportsImageInput(), true); await client.newSession(path.resolve(".")); const image = Buffer.from("fake-png").toString("base64"); const reply = await client.prompt([ { type: "image", data: image, mimeType: "image/png" }, { type: "text", text: "hello blocks" } ]); assert.match(reply, /^reply:.+:hello blocks$/); const prompts = fs.readFileSync(logFile, "utf8").trim().split("\n").filter(Boolean) .map((line) => JSON.parse(line) as { method: string; text?: string; images?: number }) .filter((entry) => entry.method === "session/prompt"); assert.equal(prompts.length, 1); assert.equal(prompts[0]!.text, "hello blocks"); assert.equal(prompts[0]!.images, 1); } finally { client.close(); child.kill("SIGTERM"); await fs.promises.rm(logFile, { force: true }); } });