| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364 |
- 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<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
- const testCtx = {
- ...ctx,
- ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
- 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<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
- const testCtx = {
- ...ctx,
- ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
- 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<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
- const testCtx = {
- ...ctx,
- ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
- 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<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
- const testCtx = {
- ...ctx,
- ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
- 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<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
- const testCtx = {
- ...ctx,
- ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
- 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<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
- const testCtx = {
- ...ctx,
- ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
- 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<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
- const testCtx = {
- ...ctx,
- ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
- 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<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
- const testCtx = {
- ...ctx,
- ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
- 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<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
- const testCtx = {
- ...ctx,
- ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
- 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))
- },
- })
- })
- })
|