skill-discovery.test.ts 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  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 { FSUtil } from "@opencode-ai/core/fs-util"
  7. import { Global } from "@opencode-ai/core/global"
  8. import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
  9. import { tmpdir } from "./fixture/tmpdir"
  10. const base = "https://skills.example.test/catalog/"
  11. async function pull(skills: unknown[], files: Record<string, string> = {}) {
  12. const tmp = await tmpdir()
  13. const requests: string[] = []
  14. const http = Layer.succeed(
  15. HttpClient.HttpClient,
  16. HttpClient.make((request) =>
  17. Effect.sync(() => requests.push(request.url)).pipe(
  18. Effect.map(() => {
  19. const body = request.url === `${base}index.json` ? JSON.stringify({ skills }) : files[request.url]
  20. return HttpClientResponse.fromWeb(
  21. request,
  22. new Response(body ?? "Not Found", { status: body === undefined ? 404 : 200 }),
  23. )
  24. }),
  25. ),
  26. ),
  27. )
  28. const layer = SkillDiscovery.layer.pipe(
  29. Layer.provide(http),
  30. Layer.provide(FSUtil.defaultLayer),
  31. Layer.provide(Global.layerWith({ cache: tmp.path })),
  32. )
  33. const directories = await Effect.runPromise(
  34. Effect.gen(function* () {
  35. return yield* (yield* SkillDiscovery.Service).pull(base)
  36. }).pipe(Effect.provide(layer)),
  37. )
  38. return { tmp, requests, directories }
  39. }
  40. describe("SkillDiscovery.pull", () => {
  41. test("rejects skill name traversal without fetching files", async () => {
  42. const result = await pull([{ name: "../outside", files: ["SKILL.md"] }])
  43. try {
  44. expect(result.directories).toEqual([])
  45. expect(result.requests).toEqual([`${base}index.json`])
  46. expect(await fs.readdir(result.tmp.path)).toEqual([])
  47. } finally {
  48. await result.tmp[Symbol.asyncDispose]()
  49. }
  50. })
  51. test("rejects file traversal without fetching files", async () => {
  52. const result = await pull([{ name: "deploy", files: ["SKILL.md", "../outside.md"] }])
  53. try {
  54. expect(result.directories).toEqual([])
  55. expect(result.requests).toEqual([`${base}index.json`])
  56. expect(await fs.readdir(result.tmp.path)).toEqual([])
  57. } finally {
  58. await result.tmp[Symbol.asyncDispose]()
  59. }
  60. })
  61. test("rejects absolute file paths without fetching files", async () => {
  62. const result = await pull([{ name: "deploy", files: ["SKILL.md", "/tmp/outside.md"] }])
  63. try {
  64. expect(result.directories).toEqual([])
  65. expect(result.requests).toEqual([`${base}index.json`])
  66. expect(await fs.readdir(result.tmp.path)).toEqual([])
  67. } finally {
  68. await result.tmp[Symbol.asyncDispose]()
  69. }
  70. })
  71. test("rejects cross-origin file URLs without fetching files", async () => {
  72. const result = await pull([{ name: "deploy", files: ["SKILL.md", "https://evil.example.test/outside.md"] }])
  73. try {
  74. expect(result.directories).toEqual([])
  75. expect(result.requests).toEqual([`${base}index.json`])
  76. expect(await fs.readdir(result.tmp.path)).toEqual([])
  77. } finally {
  78. await result.tmp[Symbol.asyncDispose]()
  79. }
  80. })
  81. test("downloads safe nested files under the skill root", async () => {
  82. const result = await pull([{ name: "deploy", files: ["SKILL.md", "references/guide.md"] }], {
  83. [`${base}deploy/SKILL.md`]: "# Deploy",
  84. [`${base}deploy/references/guide.md`]: "# Guide",
  85. })
  86. try {
  87. expect(result.directories).toHaveLength(1)
  88. expect(result.requests.toSorted()).toEqual(
  89. [`${base}index.json`, `${base}deploy/SKILL.md`, `${base}deploy/references/guide.md`].toSorted(),
  90. )
  91. expect(await fs.readFile(path.join(result.directories[0], "SKILL.md"), "utf8")).toBe("# Deploy")
  92. expect(await fs.readFile(path.join(result.directories[0], "references", "guide.md"), "utf8")).toBe("# Guide")
  93. } finally {
  94. await result.tmp[Symbol.asyncDispose]()
  95. }
  96. })
  97. })