skill-discovery.test.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. import fs from "fs/promises"
  2. import path from "path"
  3. import { describe, expect, test } from "bun:test"
  4. import { Effect } from "effect"
  5. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  6. import { Global } from "@opencode-ai/util/global"
  7. import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
  8. import { tmpdir } from "./fixture/tmpdir"
  9. type Fixture = {
  10. tmp: Awaited<ReturnType<typeof tmpdir>>
  11. server: Bun.Server<undefined>
  12. state: {
  13. skills: unknown[]
  14. files: Record<string, string>
  15. requests: string[]
  16. }
  17. base: string
  18. }
  19. async function pull(skills: unknown[], files: Record<string, string> = {}, fixture?: Fixture) {
  20. const state = fixture?.state ?? { skills, files, requests: [] }
  21. state.skills = skills
  22. state.files = files
  23. state.requests = []
  24. const server =
  25. fixture?.server ??
  26. Bun.serve({
  27. port: 0,
  28. fetch(request) {
  29. state.requests.push(request.url)
  30. const pathname = new URL(request.url).pathname
  31. const body =
  32. pathname === "/catalog/index.json" ? JSON.stringify({ skills: state.skills }) : state.files[pathname]
  33. return new Response(body ?? "Not Found", { status: body === undefined ? 404 : 200 })
  34. },
  35. })
  36. const tmp = fixture?.tmp ?? (await tmpdir())
  37. const base = fixture?.base ?? new URL("/catalog/", server.url).href
  38. const skillDiscoveryLayer = AppNodeBuilder.build(SkillDiscovery.node, [
  39. [Global.node, Global.layerWith({ cache: tmp.path })],
  40. ])
  41. const directories = await Effect.runPromise(
  42. Effect.gen(function* () {
  43. return yield* (yield* SkillDiscovery.Service).pull(base)
  44. }).pipe(Effect.provide(skillDiscoveryLayer)),
  45. )
  46. return { tmp, server, state, base, requests: state.requests, directories }
  47. }
  48. async function dispose(fixture: Fixture) {
  49. await fixture.server.stop(true)
  50. await fixture.tmp[Symbol.asyncDispose]()
  51. }
  52. describe("SkillDiscovery.pull", () => {
  53. test("rejects skill name traversal without fetching files", async () => {
  54. const result = await pull([{ name: "../outside", files: ["SKILL.md"] }])
  55. try {
  56. expect(result.directories).toEqual([])
  57. expect(result.requests).toEqual([`${result.base}index.json`])
  58. expect(await fs.readdir(result.tmp.path)).toEqual([])
  59. } finally {
  60. await dispose(result)
  61. }
  62. })
  63. test("rejects file traversal without fetching files", async () => {
  64. const result = await pull([{ name: "deploy", files: ["SKILL.md", "../outside.md"] }])
  65. try {
  66. expect(result.directories).toEqual([])
  67. expect(result.requests).toEqual([`${result.base}index.json`])
  68. expect(await fs.readdir(result.tmp.path)).toEqual([])
  69. } finally {
  70. await dispose(result)
  71. }
  72. })
  73. test("rejects absolute file paths without fetching files", async () => {
  74. const result = await pull([{ name: "deploy", files: ["SKILL.md", "/tmp/outside.md"] }])
  75. try {
  76. expect(result.directories).toEqual([])
  77. expect(result.requests).toEqual([`${result.base}index.json`])
  78. expect(await fs.readdir(result.tmp.path)).toEqual([])
  79. } finally {
  80. await dispose(result)
  81. }
  82. })
  83. test("rejects cross-origin file URLs without fetching files", async () => {
  84. const result = await pull([{ name: "deploy", files: ["SKILL.md", "https://evil.example.test/outside.md"] }])
  85. try {
  86. expect(result.directories).toEqual([])
  87. expect(result.requests).toEqual([`${result.base}index.json`])
  88. expect(await fs.readdir(result.tmp.path)).toEqual([])
  89. } finally {
  90. await dispose(result)
  91. }
  92. })
  93. test("downloads safe nested files under the skill root", async () => {
  94. const result = await pull([{ name: "deploy", files: ["SKILL.md", "references/guide.md"] }], {
  95. "/catalog/deploy/SKILL.md": "# Deploy",
  96. "/catalog/deploy/references/guide.md": "# Guide",
  97. })
  98. try {
  99. expect(result.directories).toHaveLength(1)
  100. expect(result.requests.toSorted()).toEqual(
  101. [
  102. `${result.base}index.json`,
  103. `${result.base}deploy/SKILL.md`,
  104. `${result.base}deploy/references/guide.md`,
  105. ].toSorted(),
  106. )
  107. expect(await fs.readFile(path.join(result.directories[0], "SKILL.md"), "utf8")).toBe("# Deploy")
  108. expect(await fs.readFile(path.join(result.directories[0], "references", "guide.md"), "utf8")).toBe("# Guide")
  109. } finally {
  110. await dispose(result)
  111. }
  112. })
  113. test("refreshes cached files when the version changes", async () => {
  114. const first = await pull([{ name: "deploy", version: "1", files: ["SKILL.md"] }], {
  115. "/catalog/deploy/SKILL.md": "# Old",
  116. })
  117. try {
  118. const second = await pull(
  119. [{ name: "deploy", version: "2", files: ["SKILL.md"] }],
  120. { "/catalog/deploy/SKILL.md": "# New" },
  121. first,
  122. )
  123. expect(await fs.readFile(path.join(first.directories[0], "SKILL.md"), "utf8")).toBe("# New")
  124. expect(second.requests).toContain(`${first.base}deploy/SKILL.md`)
  125. const third = await pull(
  126. [{ name: "deploy", version: "2", files: ["SKILL.md"] }],
  127. { "/catalog/deploy/SKILL.md": "# Ignored" },
  128. first,
  129. )
  130. expect(third.requests).toEqual([`${first.base}index.json`])
  131. } finally {
  132. await dispose(first)
  133. }
  134. })
  135. test("publishes complete updates and removes stale files", async () => {
  136. const first = await pull([{ name: "deploy", version: "1", files: ["SKILL.md", "old.md"] }], {
  137. "/catalog/deploy/SKILL.md": "# Old",
  138. "/catalog/deploy/old.md": "old reference",
  139. })
  140. try {
  141. const root = first.directories[0]
  142. await pull(
  143. [{ name: "deploy", version: "2", files: ["SKILL.md", "missing.md"] }],
  144. { "/catalog/deploy/SKILL.md": "# Partial" },
  145. first,
  146. )
  147. expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# Old")
  148. expect(await fs.readFile(path.join(root, "old.md"), "utf8")).toBe("old reference")
  149. await pull(
  150. [{ name: "deploy", version: "3", files: ["SKILL.md"] }],
  151. { "/catalog/deploy/SKILL.md": "# New" },
  152. first,
  153. )
  154. expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# New")
  155. expect(await Bun.file(path.join(root, "old.md")).exists()).toBe(false)
  156. } finally {
  157. await dispose(first)
  158. }
  159. })
  160. })