skill-discovery.test.ts 6.1 KB

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