+21

![opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)



![opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)



James Long
Brendan Allan
Kit Langton
opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Affan Ali
affanali2k3
Frank
opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴
Aiden Cline
Jay V
Dax Raad
Aarav Sareen
OpeOginni
Luke Parker
Ben Guthrie
Dax
Filip
Max Anderson
Brendan Allan
Jack
Shoubhit Dash
Dustin Deus
starptech
Aiden Cline
usrnk1
Jay
runvip
opencode
Julian Coy
Vladimir Glafirov
8c94e9005f
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Affan Ali <93028901+affanali2k3@users.noreply.github.com> Co-authored-by: affanali2k3 <affanalikhanxx@gmail.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Jay V <air@live.ca> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: Ben Guthrie <benjee.012@gmail.com> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Filip <34747899+neriousy@users.noreply.github.com> Co-authored-by: Max Anderson <max.a.anderson95@gmail.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: runvip <164729189+runvip@users.noreply.github.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Julian Coy <julian@ex-machina.co> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
166 lines
6.1 KiB
TypeScript
166 lines
6.1 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 { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
|
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|
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> = {}, cache?: Awaited<ReturnType<typeof tmpdir>>) {
|
|
const tmp = cache ?? (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 skillDiscoveryLayer = AppNodeBuilder.build(SkillDiscovery.node, [
|
|
[LayerNodePlatform.httpClient, http],
|
|
[Global.node, Global.layerWith({ cache: tmp.path })],
|
|
])
|
|
const directories = await Effect.runPromise(
|
|
Effect.gen(function* () {
|
|
return yield* (yield* SkillDiscovery.Service).pull(base)
|
|
}).pipe(Effect.provide(skillDiscoveryLayer)),
|
|
)
|
|
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]()
|
|
}
|
|
})
|
|
|
|
test("refreshes cached files when the version changes", async () => {
|
|
const tmp = await tmpdir()
|
|
try {
|
|
const first = await pull(
|
|
[{ name: "deploy", version: "1", files: ["SKILL.md"] }],
|
|
{
|
|
[`${base}deploy/SKILL.md`]: "# Old",
|
|
},
|
|
tmp,
|
|
)
|
|
const second = await pull(
|
|
[{ name: "deploy", version: "2", files: ["SKILL.md"] }],
|
|
{
|
|
[`${base}deploy/SKILL.md`]: "# New",
|
|
},
|
|
tmp,
|
|
)
|
|
|
|
expect(await fs.readFile(path.join(first.directories[0], "SKILL.md"), "utf8")).toBe("# New")
|
|
expect(second.requests).toContain(`${base}deploy/SKILL.md`)
|
|
const third = await pull(
|
|
[{ name: "deploy", version: "2", files: ["SKILL.md"] }],
|
|
{ [`${base}deploy/SKILL.md`]: "# Ignored" },
|
|
tmp,
|
|
)
|
|
expect(third.requests).toEqual([`${base}index.json`])
|
|
} finally {
|
|
await tmp[Symbol.asyncDispose]()
|
|
}
|
|
})
|
|
|
|
test("publishes complete updates and removes stale files", async () => {
|
|
const tmp = await tmpdir()
|
|
try {
|
|
const first = await pull(
|
|
[{ name: "deploy", version: "1", files: ["SKILL.md", "old.md"] }],
|
|
{
|
|
[`${base}deploy/SKILL.md`]: "# Old",
|
|
[`${base}deploy/old.md`]: "old reference",
|
|
},
|
|
tmp,
|
|
)
|
|
const root = first.directories[0]
|
|
|
|
await pull(
|
|
[{ name: "deploy", version: "2", files: ["SKILL.md", "missing.md"] }],
|
|
{ [`${base}deploy/SKILL.md`]: "# Partial" },
|
|
tmp,
|
|
)
|
|
expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# Old")
|
|
expect(await fs.readFile(path.join(root, "old.md"), "utf8")).toBe("old reference")
|
|
|
|
await pull([{ name: "deploy", version: "3", files: ["SKILL.md"] }], { [`${base}deploy/SKILL.md`]: "# New" }, tmp)
|
|
expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# New")
|
|
expect(await Bun.file(path.join(root, "old.md")).exists()).toBe(false)
|
|
} finally {
|
|
await tmp[Symbol.asyncDispose]()
|
|
}
|
|
})
|
|
})
|