import { describe, expect, test } from "bun:test" import path from "path" import { BashTool } from "../../src/tool/bash" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" import type { PermissionNext } from "../../src/permission/next" import { Truncate } from "../../src/tool/truncation" const ctx = { sessionID: "test", messageID: "", callID: "", agent: "build", abort: AbortSignal.any([]), messages: [], metadata: () => {}, ask: async () => {}, } const projectRoot = path.join(__dirname, "../..") describe("tool.bash", () => { test("basic", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { const bash = await BashTool.init() const result = await bash.execute( { command: "echo 'test'", description: "Echo test message", }, ctx, ) expect(result.metadata.exit).toBe(0) expect(result.metadata.output).toContain("test") }, }) }) }) describe("tool.bash permissions", () => { test("asks for bash permission with correct pattern", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { const bash = await BashTool.init() const requests: Array> = [] const testCtx = { ...ctx, ask: async (req: Omit) => { requests.push(req) }, } await bash.execute( { command: "echo hello", description: "Echo hello", }, testCtx, ) expect(requests.length).toBe(1) expect(requests[0].permission).toBe("bash") expect(requests[0].patterns).toContain("echo hello") }, }) }) test("asks for bash permission with multiple commands", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { const bash = await BashTool.init() const requests: Array> = [] const testCtx = { ...ctx, ask: async (req: Omit) => { requests.push(req) }, } await bash.execute( { command: "echo foo && echo bar", description: "Echo twice", }, testCtx, ) expect(requests.length).toBe(1) expect(requests[0].permission).toBe("bash") expect(requests[0].patterns).toContain("echo foo") expect(requests[0].patterns).toContain("echo bar") }, }) }) test("asks for external_directory permission when cd to parent", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { const bash = await BashTool.init() const requests: Array> = [] const testCtx = { ...ctx, ask: async (req: Omit) => { requests.push(req) }, } await bash.execute( { command: "cd ../", description: "Change to parent directory", }, testCtx, ) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() }, }) }) test("asks for external_directory permission when workdir is outside project", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { const bash = await BashTool.init() const requests: Array> = [] const testCtx = { ...ctx, ask: async (req: Omit) => { requests.push(req) }, } await bash.execute( { command: "ls", workdir: "/tmp", description: "List /tmp", }, testCtx, ) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() expect(extDirReq!.patterns).toContain("/tmp") }, }) }) test("does not ask for external_directory permission when rm inside project", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { const bash = await BashTool.init() const requests: Array> = [] const testCtx = { ...ctx, ask: async (req: Omit) => { requests.push(req) }, } await Bun.write(path.join(tmp.path, "tmpfile"), "x") await bash.execute( { command: "rm tmpfile", description: "Remove tmpfile", }, testCtx, ) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeUndefined() }, }) }) test("includes always patterns for auto-approval", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { const bash = await BashTool.init() const requests: Array> = [] const testCtx = { ...ctx, ask: async (req: Omit) => { requests.push(req) }, } await bash.execute( { command: "git log --oneline -5", description: "Git log", }, testCtx, ) expect(requests.length).toBe(1) expect(requests[0].always.length).toBeGreaterThan(0) expect(requests[0].always.some((p) => p.endsWith("*"))).toBe(true) }, }) }) test("does not ask for bash permission when command is cd only", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { const bash = await BashTool.init() const requests: Array> = [] const testCtx = { ...ctx, ask: async (req: Omit) => { requests.push(req) }, } await bash.execute( { command: "cd .", description: "Stay in current directory", }, testCtx, ) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeUndefined() }, }) }) test("matches redirects in permission pattern", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { const bash = await BashTool.init() const requests: Array> = [] const testCtx = { ...ctx, ask: async (req: Omit) => { requests.push(req) }, } await bash.execute({ command: "cat > /tmp/output.txt", description: "Redirect ls output" }, testCtx) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeDefined() expect(bashReq!.patterns).toContain("cat > /tmp/output.txt") }, }) }) test("always pattern has space before wildcard to not include different commands", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { const bash = await BashTool.init() const requests: Array> = [] const testCtx = { ...ctx, ask: async (req: Omit) => { requests.push(req) }, } await bash.execute({ command: "ls -la", description: "List" }, testCtx) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeDefined() const pattern = bashReq!.always[0] expect(pattern).toBe("ls *") }, }) }) }) describe("tool.bash truncation", () => { test("truncates output exceeding line limit", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { const bash = await BashTool.init() const lineCount = Truncate.MAX_LINES + 500 const result = await bash.execute( { command: `seq 1 ${lineCount}`, description: "Generate lines exceeding limit", }, ctx, ) expect((result.metadata as any).truncated).toBe(true) expect(result.output).toContain("truncated") expect(result.output).toContain("The tool call succeeded but the output was truncated") }, }) }) test("truncates output exceeding byte limit", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { const bash = await BashTool.init() const byteCount = Truncate.MAX_BYTES + 10000 const result = await bash.execute( { command: `head -c ${byteCount} /dev/zero | tr '\\0' 'a'`, description: "Generate bytes exceeding limit", }, ctx, ) expect((result.metadata as any).truncated).toBe(true) expect(result.output).toContain("truncated") expect(result.output).toContain("The tool call succeeded but the output was truncated") }, }) }) test("does not truncate small output", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { const bash = await BashTool.init() const result = await bash.execute( { command: "echo hello", description: "Echo hello", }, ctx, ) expect((result.metadata as any).truncated).toBe(false) expect(result.output).toBe("hello\n") }, }) }) test("full output is saved to file when truncated", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { const bash = await BashTool.init() const lineCount = Truncate.MAX_LINES + 100 const result = await bash.execute( { command: `seq 1 ${lineCount}`, description: "Generate lines for file check", }, ctx, ) expect((result.metadata as any).truncated).toBe(true) const filepath = (result.metadata as any).outputPath expect(filepath).toBeTruthy() const saved = await Bun.file(filepath).text() const lines = saved.trim().split("\n") expect(lines.length).toBe(lineCount) expect(lines[0]).toBe("1") expect(lines[lineCount - 1]).toBe(String(lineCount)) }, }) }) })