external-directory.test.ts 4.8 KB

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