external-directory.test.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. import { describe, expect, test } from "bun:test"
  2. import path from "path"
  3. import { Effect } from "effect"
  4. import type { Tool } from "@/tool/tool"
  5. import { Instance } from "../../src/project/instance"
  6. import { WithInstance } from "../../src/project/with-instance"
  7. import { assertExternalDirectory } from "../../src/tool/external-directory"
  8. import { Filesystem } from "@/util/filesystem"
  9. import { tmpdir } from "../fixture/fixture"
  10. import type { Permission } from "../../src/permission"
  11. import { SessionID, MessageID } from "../../src/session/schema"
  12. const baseCtx: Omit<Tool.Context, "ask"> = {
  13. sessionID: SessionID.make("ses_test"),
  14. messageID: MessageID.make(""),
  15. callID: "",
  16. agent: "build",
  17. abort: AbortSignal.any([]),
  18. messages: [],
  19. metadata: () => Effect.void,
  20. }
  21. const glob = (p: string) =>
  22. process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/")
  23. function makeCtx() {
  24. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  25. const ctx: Tool.Context = {
  26. ...baseCtx,
  27. ask: (req) =>
  28. Effect.sync(() => {
  29. requests.push(req)
  30. }),
  31. }
  32. return { requests, ctx }
  33. }
  34. describe("tool.assertExternalDirectory", () => {
  35. test("no-ops for empty target", async () => {
  36. const { requests, ctx } = makeCtx()
  37. await WithInstance.provide({
  38. directory: "/tmp",
  39. fn: async () => {
  40. await assertExternalDirectory(ctx)
  41. },
  42. })
  43. expect(requests.length).toBe(0)
  44. })
  45. test("no-ops for paths inside Instance.directory", async () => {
  46. const { requests, ctx } = makeCtx()
  47. await WithInstance.provide({
  48. directory: "/tmp/project",
  49. fn: async () => {
  50. await assertExternalDirectory(ctx, path.join("/tmp/project", "file.txt"))
  51. },
  52. })
  53. expect(requests.length).toBe(0)
  54. })
  55. test("asks with a single canonical glob", async () => {
  56. const { requests, ctx } = makeCtx()
  57. const directory = "/tmp/project"
  58. const target = "/tmp/outside/file.txt"
  59. const expected = glob(path.join(path.dirname(target), "*"))
  60. await WithInstance.provide({
  61. directory,
  62. fn: async () => {
  63. await assertExternalDirectory(ctx, target)
  64. },
  65. })
  66. const req = requests.find((r) => r.permission === "external_directory")
  67. expect(req).toBeDefined()
  68. expect(req!.patterns).toEqual([expected])
  69. expect(req!.always).toEqual([expected])
  70. })
  71. test("uses target directory when kind=directory", async () => {
  72. const { requests, ctx } = makeCtx()
  73. const directory = "/tmp/project"
  74. const target = "/tmp/outside"
  75. const expected = glob(path.join(target, "*"))
  76. await WithInstance.provide({
  77. directory,
  78. fn: async () => {
  79. await assertExternalDirectory(ctx, target, { kind: "directory" })
  80. },
  81. })
  82. const req = requests.find((r) => r.permission === "external_directory")
  83. expect(req).toBeDefined()
  84. expect(req!.patterns).toEqual([expected])
  85. expect(req!.always).toEqual([expected])
  86. })
  87. test("skips prompting when bypass=true", async () => {
  88. const { requests, ctx } = makeCtx()
  89. await WithInstance.provide({
  90. directory: "/tmp/project",
  91. fn: async () => {
  92. await assertExternalDirectory(ctx, "/tmp/outside/file.txt", { bypass: true })
  93. },
  94. })
  95. expect(requests.length).toBe(0)
  96. })
  97. if (process.platform === "win32") {
  98. test("normalizes Windows path variants to one glob", async () => {
  99. const { requests, ctx } = makeCtx()
  100. await using outerTmp = await tmpdir({
  101. init: async (dir) => {
  102. await Bun.write(path.join(dir, "outside.txt"), "x")
  103. },
  104. })
  105. await using tmp = await tmpdir({ git: true })
  106. const target = path.join(outerTmp.path, "outside.txt")
  107. const alt = target
  108. .replace(/^[A-Za-z]:/, "")
  109. .replaceAll("\\", "/")
  110. .toLowerCase()
  111. await WithInstance.provide({
  112. directory: tmp.path,
  113. fn: async () => {
  114. await assertExternalDirectory(ctx, alt)
  115. },
  116. })
  117. const req = requests.find((r) => r.permission === "external_directory")
  118. const expected = glob(path.join(outerTmp.path, "*"))
  119. expect(req).toBeDefined()
  120. expect(req!.patterns).toEqual([expected])
  121. expect(req!.always).toEqual([expected])
  122. })
  123. test("uses drive root glob for root files", async () => {
  124. const { requests, ctx } = makeCtx()
  125. await using tmp = await tmpdir({ git: true })
  126. const root = path.parse(tmp.path).root
  127. const target = path.join(root, "boot.ini")
  128. await WithInstance.provide({
  129. directory: tmp.path,
  130. fn: async () => {
  131. await assertExternalDirectory(ctx, target)
  132. },
  133. })
  134. const req = requests.find((r) => r.permission === "external_directory")
  135. const expected = path.join(root, "*")
  136. expect(req).toBeDefined()
  137. expect(req!.patterns).toEqual([expected])
  138. expect(req!.always).toEqual([expected])
  139. })
  140. }
  141. })