105 lines
3.9 KiB
TypeScript
105 lines
3.9 KiB
TypeScript
import fs from "fs/promises"
|
|
import path from "path"
|
|
import { describe, expect, test } from "bun:test"
|
|
import { Effect, Layer } from "effect"
|
|
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
|
import { Global } from "@opencode-ai/core/global"
|
|
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
|
|
import { tmpdir } from "./fixture/tmpdir"
|
|
|
|
const base = "https://skills.example.test/catalog/"
|
|
|
|
async function pull(skills: unknown[], files: Record<string, string> = {}) {
|
|
const tmp = await tmpdir()
|
|
const requests: string[] = []
|
|
const http = Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
HttpClient.make((request) =>
|
|
Effect.sync(() => requests.push(request.url)).pipe(
|
|
Effect.map(() => {
|
|
const body = request.url === `${base}index.json` ? JSON.stringify({ skills }) : files[request.url]
|
|
return HttpClientResponse.fromWeb(
|
|
request,
|
|
new Response(body ?? "Not Found", { status: body === undefined ? 404 : 200 }),
|
|
)
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
const layer = SkillDiscovery.layer.pipe(
|
|
Layer.provide(http),
|
|
Layer.provide(FSUtil.defaultLayer),
|
|
Layer.provide(Global.layerWith({ cache: tmp.path })),
|
|
)
|
|
const directories = await Effect.runPromise(
|
|
Effect.gen(function* () {
|
|
return yield* (yield* SkillDiscovery.Service).pull(base)
|
|
}).pipe(Effect.provide(layer)),
|
|
)
|
|
return { tmp, requests, directories }
|
|
}
|
|
|
|
describe("SkillDiscovery.pull", () => {
|
|
test("rejects skill name traversal without fetching files", async () => {
|
|
const result = await pull([{ name: "../outside", files: ["SKILL.md"] }])
|
|
try {
|
|
expect(result.directories).toEqual([])
|
|
expect(result.requests).toEqual([`${base}index.json`])
|
|
expect(await fs.readdir(result.tmp.path)).toEqual([])
|
|
} finally {
|
|
await result.tmp[Symbol.asyncDispose]()
|
|
}
|
|
})
|
|
|
|
test("rejects file traversal without fetching files", async () => {
|
|
const result = await pull([{ name: "deploy", files: ["SKILL.md", "../outside.md"] }])
|
|
try {
|
|
expect(result.directories).toEqual([])
|
|
expect(result.requests).toEqual([`${base}index.json`])
|
|
expect(await fs.readdir(result.tmp.path)).toEqual([])
|
|
} finally {
|
|
await result.tmp[Symbol.asyncDispose]()
|
|
}
|
|
})
|
|
|
|
test("rejects absolute file paths without fetching files", async () => {
|
|
const result = await pull([{ name: "deploy", files: ["SKILL.md", "/tmp/outside.md"] }])
|
|
try {
|
|
expect(result.directories).toEqual([])
|
|
expect(result.requests).toEqual([`${base}index.json`])
|
|
expect(await fs.readdir(result.tmp.path)).toEqual([])
|
|
} finally {
|
|
await result.tmp[Symbol.asyncDispose]()
|
|
}
|
|
})
|
|
|
|
test("rejects cross-origin file URLs without fetching files", async () => {
|
|
const result = await pull([{ name: "deploy", files: ["SKILL.md", "https://evil.example.test/outside.md"] }])
|
|
try {
|
|
expect(result.directories).toEqual([])
|
|
expect(result.requests).toEqual([`${base}index.json`])
|
|
expect(await fs.readdir(result.tmp.path)).toEqual([])
|
|
} finally {
|
|
await result.tmp[Symbol.asyncDispose]()
|
|
}
|
|
})
|
|
|
|
test("downloads safe nested files under the skill root", async () => {
|
|
const result = await pull([{ name: "deploy", files: ["SKILL.md", "references/guide.md"] }], {
|
|
[`${base}deploy/SKILL.md`]: "# Deploy",
|
|
[`${base}deploy/references/guide.md`]: "# Guide",
|
|
})
|
|
try {
|
|
expect(result.directories).toHaveLength(1)
|
|
expect(result.requests.toSorted()).toEqual(
|
|
[`${base}index.json`, `${base}deploy/SKILL.md`, `${base}deploy/references/guide.md`].toSorted(),
|
|
)
|
|
expect(await fs.readFile(path.join(result.directories[0], "SKILL.md"), "utf8")).toBe("# Deploy")
|
|
expect(await fs.readFile(path.join(result.directories[0], "references", "guide.md"), "utf8")).toBe("# Guide")
|
|
} finally {
|
|
await result.tmp[Symbol.asyncDispose]()
|
|
}
|
|
})
|
|
})
|