@batonfx/skills 0.3.6 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +2 -2
- package/dist/index.js +2780 -609
- package/package.json +2 -2
- package/src/github-catalog.ts +80 -0
- package/src/hosted-catalog.ts +270 -0
- package/src/http-catalog.ts +29 -0
- package/src/index.ts +4 -0
- package/src/s3-catalog.ts +49 -0
- package/src/skill-document.ts +174 -0
- package/src/skill-loader.ts +30 -200
- package/test/hosted-catalog.test.ts +398 -0
- package/test/skill-loader.test.ts +67 -6
package/src/skill-loader.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Effect, FileSystem, Layer, Path, PlatformError, Stream } from "effect"
|
|
2
2
|
import { SkillSource } from "@batonfx/core"
|
|
3
|
+
import { parseDocument, parseFrontmatter, splitDocument } from "./skill-document"
|
|
3
4
|
|
|
4
5
|
/** @experimental Filesystem skill-loader options. */
|
|
5
6
|
export interface LoadOptions {
|
|
@@ -13,171 +14,12 @@ const DEFAULT_ROOTS = [".agents/skills", ".claude/skills", ".pi/skills"] as cons
|
|
|
13
14
|
|
|
14
15
|
const decoder = new TextDecoder()
|
|
15
16
|
|
|
16
|
-
interface ParsedDocument {
|
|
17
|
-
readonly frontmatter: SkillSource.Frontmatter
|
|
18
|
-
readonly body: string
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
interface ParsedHeader {
|
|
22
|
-
name?: string
|
|
23
|
-
description?: string
|
|
24
|
-
whenToUse?: string
|
|
25
|
-
allowedTools?: ReadonlyArray<string>
|
|
26
|
-
disableModelInvocation?: boolean
|
|
27
|
-
userInvocable?: boolean
|
|
28
|
-
contextFork?: boolean
|
|
29
|
-
agent?: string
|
|
30
|
-
model?: string
|
|
31
|
-
paths?: ReadonlyArray<string>
|
|
32
|
-
}
|
|
33
|
-
|
|
34
17
|
const sourceError = (source: string, message: string, cause?: unknown): SkillSource.SkillSourceError =>
|
|
35
18
|
new SkillSource.SkillSourceError({ source, message, ...(cause === undefined ? {} : { cause }) })
|
|
36
19
|
|
|
37
20
|
const mapPlatformError = (source: string, error: PlatformError.PlatformError): SkillSource.SkillSourceError =>
|
|
38
21
|
sourceError(source, error.message, error)
|
|
39
22
|
|
|
40
|
-
const normalizeKey = (key: string): string => key.replace(/[-_]/g, "").toLowerCase()
|
|
41
|
-
|
|
42
|
-
const stripQuotes = (value: string): string => {
|
|
43
|
-
const trimmed = value.trim()
|
|
44
|
-
return (trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
45
|
-
? trimmed.slice(1, -1)
|
|
46
|
-
: trimmed
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const parseInlineArray = (value: string): ReadonlyArray<string> => {
|
|
50
|
-
const trimmed = value.trim()
|
|
51
|
-
if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return []
|
|
52
|
-
const inner = trimmed.slice(1, -1).trim()
|
|
53
|
-
return inner.length === 0 ? [] : inner.split(",").map((item) => stripQuotes(item).trim())
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const parseBoolean = (value: string): boolean | undefined => {
|
|
57
|
-
const lowered = value.trim().toLowerCase()
|
|
58
|
-
if (lowered === "true") return true
|
|
59
|
-
if (lowered === "false") return false
|
|
60
|
-
return undefined
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const setValue = (target: Partial<ParsedHeader>, key: string, value: string | boolean | ReadonlyArray<string>) => {
|
|
64
|
-
switch (normalizeKey(key)) {
|
|
65
|
-
case "name":
|
|
66
|
-
if (typeof value === "string") target.name = value
|
|
67
|
-
break
|
|
68
|
-
case "description":
|
|
69
|
-
if (typeof value === "string") target.description = value
|
|
70
|
-
break
|
|
71
|
-
case "whentouse":
|
|
72
|
-
if (typeof value === "string") target.whenToUse = value
|
|
73
|
-
break
|
|
74
|
-
case "allowedtools":
|
|
75
|
-
if (Array.isArray(value)) target.allowedTools = value
|
|
76
|
-
break
|
|
77
|
-
case "disablemodelinvocation":
|
|
78
|
-
if (typeof value === "boolean") target.disableModelInvocation = value
|
|
79
|
-
break
|
|
80
|
-
case "userinvocable":
|
|
81
|
-
if (typeof value === "boolean") target.userInvocable = value
|
|
82
|
-
break
|
|
83
|
-
case "contextfork":
|
|
84
|
-
if (typeof value === "boolean") target.contextFork = value
|
|
85
|
-
break
|
|
86
|
-
case "agent":
|
|
87
|
-
if (typeof value === "string") target.agent = value
|
|
88
|
-
break
|
|
89
|
-
case "model":
|
|
90
|
-
if (typeof value === "string") target.model = value
|
|
91
|
-
break
|
|
92
|
-
case "paths":
|
|
93
|
-
if (Array.isArray(value)) target.paths = value
|
|
94
|
-
break
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const parseHeader = (source: string, block: string): Effect.Effect<ParsedHeader, SkillSource.SkillSourceError> =>
|
|
99
|
-
Effect.sync(() => {
|
|
100
|
-
const parsed: Partial<ParsedHeader> = {}
|
|
101
|
-
const lines = block.split("\n")
|
|
102
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
103
|
-
const line = lines[index]?.trimEnd() ?? ""
|
|
104
|
-
if (line.trim().length === 0) continue
|
|
105
|
-
const separator = line.indexOf(":")
|
|
106
|
-
if (separator === -1) continue
|
|
107
|
-
const key = line.slice(0, separator).trim()
|
|
108
|
-
const raw = line.slice(separator + 1).trim()
|
|
109
|
-
if (raw.length === 0) {
|
|
110
|
-
const values: Array<string> = []
|
|
111
|
-
while ((lines[index + 1]?.trimStart().startsWith("- ") ?? false) === true) {
|
|
112
|
-
index += 1
|
|
113
|
-
values.push(stripQuotes((lines[index] ?? "").trimStart().slice(2)))
|
|
114
|
-
}
|
|
115
|
-
setValue(parsed, key, values)
|
|
116
|
-
} else if (raw.startsWith("[") && raw.endsWith("]")) {
|
|
117
|
-
setValue(parsed, key, parseInlineArray(raw))
|
|
118
|
-
} else {
|
|
119
|
-
setValue(parsed, key, parseBoolean(raw) ?? stripQuotes(raw))
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
return parsed
|
|
123
|
-
}).pipe(Effect.catchCause((cause) => Effect.fail(sourceError(source, "Invalid SKILL.md frontmatter", cause))))
|
|
124
|
-
|
|
125
|
-
const splitDocument = (
|
|
126
|
-
source: string,
|
|
127
|
-
content: string,
|
|
128
|
-
): Effect.Effect<readonly [string, string], SkillSource.SkillSourceError> =>
|
|
129
|
-
Effect.gen(function* () {
|
|
130
|
-
const normalized = content.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n")
|
|
131
|
-
const lines = normalized.split("\n")
|
|
132
|
-
if (lines[0] !== "---") {
|
|
133
|
-
return yield* Effect.fail(sourceError(source, "Invalid SKILL.md document: missing opening frontmatter fence"))
|
|
134
|
-
}
|
|
135
|
-
const close = lines.findIndex((line, index) => index > 0 && line === "---")
|
|
136
|
-
if (close === -1) {
|
|
137
|
-
return yield* Effect.fail(sourceError(source, "Invalid SKILL.md document: missing closing frontmatter fence"))
|
|
138
|
-
}
|
|
139
|
-
return [lines.slice(1, close).join("\n"), lines.slice(close + 1).join("\n")] as const
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
const namespacedName = (path: Path.Path, relativeFile: string, explicitName: string | undefined): string => {
|
|
143
|
-
const directory = path.dirname(relativeFile)
|
|
144
|
-
const segments = directory === "." ? [] : directory.split("/").filter((part) => part.length > 0)
|
|
145
|
-
const directoryName = segments.at(-1) ?? path.basename(path.dirname(relativeFile))
|
|
146
|
-
const baseName = explicitName ?? directoryName
|
|
147
|
-
const namespace = segments.slice(0, -1).join(":")
|
|
148
|
-
return baseName.includes(":") || namespace.length === 0 ? baseName : `${namespace}:${baseName}`
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const parseDocument = (
|
|
152
|
-
source: string,
|
|
153
|
-
content: string,
|
|
154
|
-
name: string,
|
|
155
|
-
): Effect.Effect<ParsedDocument, SkillSource.SkillSourceError> =>
|
|
156
|
-
Effect.gen(function* () {
|
|
157
|
-
const [header, body] = yield* splitDocument(source, content)
|
|
158
|
-
const parsed = yield* parseHeader(source, header)
|
|
159
|
-
if (parsed.description === undefined || parsed.description.length === 0) {
|
|
160
|
-
return yield* Effect.fail(sourceError(source, "SKILL.md frontmatter requires description"))
|
|
161
|
-
}
|
|
162
|
-
return {
|
|
163
|
-
body,
|
|
164
|
-
frontmatter: {
|
|
165
|
-
name,
|
|
166
|
-
description: parsed.description,
|
|
167
|
-
...(parsed.whenToUse === undefined ? {} : { whenToUse: parsed.whenToUse }),
|
|
168
|
-
...(parsed.allowedTools === undefined ? {} : { allowedTools: parsed.allowedTools }),
|
|
169
|
-
...(parsed.disableModelInvocation === undefined
|
|
170
|
-
? {}
|
|
171
|
-
: { disableModelInvocation: parsed.disableModelInvocation }),
|
|
172
|
-
...(parsed.userInvocable === undefined ? {} : { userInvocable: parsed.userInvocable }),
|
|
173
|
-
...(parsed.contextFork === undefined ? {} : { contextFork: parsed.contextFork }),
|
|
174
|
-
...(parsed.agent === undefined ? {} : { agent: parsed.agent }),
|
|
175
|
-
...(parsed.model === undefined ? {} : { model: parsed.model }),
|
|
176
|
-
...(parsed.paths === undefined ? {} : { paths: parsed.paths }),
|
|
177
|
-
},
|
|
178
|
-
}
|
|
179
|
-
})
|
|
180
|
-
|
|
181
23
|
const readHeader = (
|
|
182
24
|
fs: FileSystem.FileSystem,
|
|
183
25
|
source: string,
|
|
@@ -202,29 +44,16 @@ const loadSkill = (
|
|
|
202
44
|
Effect.gen(function* () {
|
|
203
45
|
const header = yield* readHeader(fs, file, frontmatterMaxBytes)
|
|
204
46
|
const [headerBlock] = yield* splitDocument(file, header)
|
|
205
|
-
const
|
|
206
|
-
const
|
|
207
|
-
if (parsed.description === undefined || parsed.description.length === 0) {
|
|
208
|
-
return yield* Effect.fail(sourceError(file, "SKILL.md frontmatter requires description"))
|
|
209
|
-
}
|
|
210
|
-
const frontmatter: SkillSource.Frontmatter = {
|
|
211
|
-
name,
|
|
212
|
-
description: parsed.description,
|
|
213
|
-
...(parsed.whenToUse === undefined ? {} : { whenToUse: parsed.whenToUse }),
|
|
214
|
-
...(parsed.allowedTools === undefined ? {} : { allowedTools: parsed.allowedTools }),
|
|
215
|
-
...(parsed.disableModelInvocation === undefined ? {} : { disableModelInvocation: parsed.disableModelInvocation }),
|
|
216
|
-
...(parsed.userInvocable === undefined ? {} : { userInvocable: parsed.userInvocable }),
|
|
217
|
-
...(parsed.contextFork === undefined ? {} : { contextFork: parsed.contextFork }),
|
|
218
|
-
...(parsed.agent === undefined ? {} : { agent: parsed.agent }),
|
|
219
|
-
...(parsed.model === undefined ? {} : { model: parsed.model }),
|
|
220
|
-
...(parsed.paths === undefined ? {} : { paths: parsed.paths }),
|
|
221
|
-
}
|
|
47
|
+
const directoryName = path.basename(path.dirname(relativeFile))
|
|
48
|
+
const frontmatter = yield* parseFrontmatter(file, headerBlock, directoryName)
|
|
222
49
|
return {
|
|
223
50
|
frontmatter,
|
|
224
51
|
listing: SkillSource.makeListing(frontmatter, descriptionCap),
|
|
225
52
|
body: fs.readFileString(file).pipe(
|
|
226
53
|
Effect.mapError((error) => mapPlatformError(file, error)),
|
|
227
|
-
Effect.flatMap((content) =>
|
|
54
|
+
Effect.flatMap((content) =>
|
|
55
|
+
parseDocument(file, content, directoryName).pipe(Effect.map((document) => document.body)),
|
|
56
|
+
),
|
|
228
57
|
),
|
|
229
58
|
tools: [],
|
|
230
59
|
}
|
|
@@ -254,29 +83,30 @@ const discoverRoot = (
|
|
|
254
83
|
return skills
|
|
255
84
|
})
|
|
256
85
|
|
|
257
|
-
/** @experimental Build a SkillSource from filesystem roots. */
|
|
86
|
+
/** @experimental Build a composable SkillSource from filesystem roots. */
|
|
87
|
+
export const make = (options: LoadOptions = {}): SkillSource.Source<FileSystem.FileSystem | Path.Path> =>
|
|
88
|
+
Effect.gen(function* () {
|
|
89
|
+
const fs = yield* FileSystem.FileSystem
|
|
90
|
+
const path = yield* Path.Path
|
|
91
|
+
const cwd = options.cwd === undefined ? "." : path.resolve(options.cwd)
|
|
92
|
+
const roots = options.roots ?? DEFAULT_ROOTS
|
|
93
|
+
const descriptionCap = options.descriptionCap ?? SkillSource.DESCRIPTION_CAP
|
|
94
|
+
const frontmatterMaxBytes = options.frontmatterMaxBytes ?? 64 * 1024
|
|
95
|
+
const byName = new Map<string, SkillSource.Skill>()
|
|
96
|
+
for (const root of roots) {
|
|
97
|
+
for (const skill of yield* discoverRoot(fs, path, cwd, root, descriptionCap, frontmatterMaxBytes)) {
|
|
98
|
+
byName.set(skill.frontmatter.name, skill)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const skills = [...byName.values()]
|
|
102
|
+
return {
|
|
103
|
+
all: Effect.succeed(skills),
|
|
104
|
+
get: (name) => Effect.succeed(byName.get(name)),
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
/** @experimental Build a SkillSource layer from filesystem roots. */
|
|
258
109
|
export const layer = (
|
|
259
110
|
options: LoadOptions = {},
|
|
260
111
|
): Layer.Layer<SkillSource.SkillSource, SkillSource.SkillSourceError, FileSystem.FileSystem | Path.Path> =>
|
|
261
|
-
Layer.effect(
|
|
262
|
-
SkillSource.SkillSource,
|
|
263
|
-
Effect.gen(function* () {
|
|
264
|
-
const fs = yield* FileSystem.FileSystem
|
|
265
|
-
const path = yield* Path.Path
|
|
266
|
-
const cwd = options.cwd === undefined ? "." : path.resolve(options.cwd)
|
|
267
|
-
const roots = options.roots ?? DEFAULT_ROOTS
|
|
268
|
-
const descriptionCap = options.descriptionCap ?? SkillSource.DESCRIPTION_CAP
|
|
269
|
-
const frontmatterMaxBytes = options.frontmatterMaxBytes ?? 64 * 1024
|
|
270
|
-
const byName = new Map<string, SkillSource.Skill>()
|
|
271
|
-
for (const root of roots) {
|
|
272
|
-
for (const skill of yield* discoverRoot(fs, path, cwd, root, descriptionCap, frontmatterMaxBytes)) {
|
|
273
|
-
byName.set(skill.frontmatter.name, skill)
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
const skills = [...byName.values()]
|
|
277
|
-
return SkillSource.SkillSource.of({
|
|
278
|
-
all: Effect.succeed(skills),
|
|
279
|
-
get: (name) => Effect.succeed(byName.get(name)),
|
|
280
|
-
})
|
|
281
|
-
}),
|
|
282
|
-
)
|
|
112
|
+
Layer.effect(SkillSource.SkillSource, make(options).pipe(Effect.map(SkillSource.SkillSource.of)))
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest"
|
|
2
|
+
import { Crypto, Effect, Encoding, Layer } from "effect"
|
|
3
|
+
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
|
4
|
+
import { SkillSource } from "@batonfx/core"
|
|
5
|
+
import { GitHubCatalog, HttpCatalog, S3Catalog } from "../src/index"
|
|
6
|
+
|
|
7
|
+
const digestBytes = new Uint8Array(32).fill(1)
|
|
8
|
+
const digest = Encoding.encodeHex(digestBytes)
|
|
9
|
+
|
|
10
|
+
const cryptoLayer = (bytes: Uint8Array = digestBytes) =>
|
|
11
|
+
Layer.succeed(
|
|
12
|
+
Crypto.Crypto,
|
|
13
|
+
Crypto.make({
|
|
14
|
+
randomBytes: (size) => new Uint8Array(size),
|
|
15
|
+
digest: () => Effect.succeed(bytes),
|
|
16
|
+
}),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
const httpLayer = (
|
|
20
|
+
responses: Readonly<
|
|
21
|
+
Record<string, { readonly body: string | Uint8Array | ReadableStream<Uint8Array>; readonly status?: number }>
|
|
22
|
+
>,
|
|
23
|
+
requests: Array<{ readonly url: string; readonly accept: string | undefined }>,
|
|
24
|
+
) =>
|
|
25
|
+
Layer.succeed(
|
|
26
|
+
HttpClient.HttpClient,
|
|
27
|
+
HttpClient.make((request, url) => {
|
|
28
|
+
const target = url.toString()
|
|
29
|
+
requests.push({ url: target, accept: request.headers.accept })
|
|
30
|
+
const response = responses[target]
|
|
31
|
+
return Effect.succeed(
|
|
32
|
+
HttpClientResponse.fromWeb(
|
|
33
|
+
request,
|
|
34
|
+
new Response(response?.body ?? "missing", {
|
|
35
|
+
status: response?.status ?? (response === undefined ? 404 : 200),
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
}),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const document = `---
|
|
43
|
+
name: remote
|
|
44
|
+
description: Remote review skill
|
|
45
|
+
allowed-tools: read grep
|
|
46
|
+
---
|
|
47
|
+
# Remote body
|
|
48
|
+
Review carefully.
|
|
49
|
+
`
|
|
50
|
+
|
|
51
|
+
const manifest = (skillPath: string = "remote/SKILL.md", sha256: string = digest) =>
|
|
52
|
+
JSON.stringify({
|
|
53
|
+
version: 1,
|
|
54
|
+
skills: [
|
|
55
|
+
{
|
|
56
|
+
name: "remote",
|
|
57
|
+
description: "Remote review skill",
|
|
58
|
+
allowedTools: ["read", "grep"],
|
|
59
|
+
skillPath,
|
|
60
|
+
sha256,
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe("hosted skill catalogs", () => {
|
|
66
|
+
it.effect("loads HTTP metadata once and keeps verified SKILL.md bodies lazy", () => {
|
|
67
|
+
const requests: Array<{ readonly url: string; readonly accept: string | undefined }> = []
|
|
68
|
+
const manifestUrl = "https://skills.example/catalog/skills.json"
|
|
69
|
+
const bodyUrl = "https://skills.example/catalog/remote/SKILL.md"
|
|
70
|
+
return Effect.gen(function* () {
|
|
71
|
+
const source = yield* HttpCatalog.make({ manifestUrl, source: "company-skills" })
|
|
72
|
+
const all = yield* source.all
|
|
73
|
+
|
|
74
|
+
expect(all.map((skill) => skill.frontmatter.name)).toEqual(["remote"])
|
|
75
|
+
expect(requests.map((request) => request.url)).toEqual([manifestUrl])
|
|
76
|
+
|
|
77
|
+
const body = yield* all[0]!.body
|
|
78
|
+
|
|
79
|
+
expect(body).toContain("# Remote body")
|
|
80
|
+
expect(requests.map((request) => request.url)).toEqual([manifestUrl, bodyUrl])
|
|
81
|
+
}).pipe(
|
|
82
|
+
Effect.provide(
|
|
83
|
+
Layer.mergeAll(
|
|
84
|
+
cryptoLayer(),
|
|
85
|
+
httpLayer({ [manifestUrl]: { body: manifest() }, [bodyUrl]: { body: document } }, requests),
|
|
86
|
+
),
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it.effect("rejects unsafe hosted paths during source construction", () => {
|
|
92
|
+
const requests: Array<{ readonly url: string; readonly accept: string | undefined }> = []
|
|
93
|
+
const manifestUrl = "https://skills.example/catalog/skills.json"
|
|
94
|
+
return Effect.gen(function* () {
|
|
95
|
+
const failure = yield* Effect.flip(HttpCatalog.make({ manifestUrl, source: "unsafe-catalog" }))
|
|
96
|
+
|
|
97
|
+
expect(failure._tag).toBe("@batonfx/core/SkillSourceError")
|
|
98
|
+
expect(failure.source).toBe("unsafe-catalog")
|
|
99
|
+
}).pipe(
|
|
100
|
+
Effect.provide(
|
|
101
|
+
Layer.mergeAll(cryptoLayer(), httpLayer({ [manifestUrl]: { body: manifest("../escape/SKILL.md") } }, requests)),
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it.effect("fails digest mismatches on activation and allows a later retry", () => {
|
|
107
|
+
const requests: Array<{ readonly url: string; readonly accept: string | undefined }> = []
|
|
108
|
+
const manifestUrl = "https://skills.example/catalog/skills.json"
|
|
109
|
+
const bodyUrl = "https://skills.example/catalog/remote/SKILL.md"
|
|
110
|
+
return Effect.gen(function* () {
|
|
111
|
+
const source = yield* HttpCatalog.make({ manifestUrl, source: "retry-catalog" })
|
|
112
|
+
const skill = yield* source.get("remote")
|
|
113
|
+
const first = yield* Effect.exit(skill!.body)
|
|
114
|
+
const second = yield* Effect.exit(skill!.body)
|
|
115
|
+
|
|
116
|
+
expect(first._tag).toBe("Failure")
|
|
117
|
+
expect(second._tag).toBe("Failure")
|
|
118
|
+
expect(requests.filter((request) => request.url === bodyUrl)).toHaveLength(2)
|
|
119
|
+
}).pipe(
|
|
120
|
+
Effect.provide(
|
|
121
|
+
Layer.mergeAll(
|
|
122
|
+
cryptoLayer(new Uint8Array(32).fill(2)),
|
|
123
|
+
httpLayer({ [manifestUrl]: { body: manifest() }, [bodyUrl]: { body: document } }, requests),
|
|
124
|
+
),
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it.effect("rejects frontmatter drift after a verified body fetch", () => {
|
|
130
|
+
const requests: Array<{ readonly url: string; readonly accept: string | undefined }> = []
|
|
131
|
+
const manifestUrl = "https://skills.example/catalog/skills.json"
|
|
132
|
+
const bodyUrl = "https://skills.example/catalog/remote/SKILL.md"
|
|
133
|
+
const drifted = document.replace("Remote review skill", "Changed after publication")
|
|
134
|
+
return Effect.gen(function* () {
|
|
135
|
+
const source = yield* HttpCatalog.make({ manifestUrl, source: "drift-catalog" })
|
|
136
|
+
const skill = yield* source.get("remote")
|
|
137
|
+
const failure = yield* Effect.flip(skill!.body)
|
|
138
|
+
|
|
139
|
+
expect(failure._tag).toBe("@batonfx/core/SkillSourceError")
|
|
140
|
+
expect(failure.message).toContain("Frontmatter mismatch")
|
|
141
|
+
}).pipe(
|
|
142
|
+
Effect.provide(
|
|
143
|
+
Layer.mergeAll(
|
|
144
|
+
cryptoLayer(),
|
|
145
|
+
httpLayer({ [manifestUrl]: { body: manifest() }, [bodyUrl]: { body: drifted } }, requests),
|
|
146
|
+
),
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it.effect("rejects duplicate names and bounded manifest overflows", () => {
|
|
152
|
+
const requests: Array<{ readonly url: string; readonly accept: string | undefined }> = []
|
|
153
|
+
const duplicateUrl = "https://skills.example/duplicate.json"
|
|
154
|
+
const limitedUrl = "https://skills.example/limited.json"
|
|
155
|
+
const duplicate = JSON.stringify({
|
|
156
|
+
version: 1,
|
|
157
|
+
skills: [...JSON.parse(manifest()).skills, ...JSON.parse(manifest()).skills],
|
|
158
|
+
})
|
|
159
|
+
return Effect.gen(function* () {
|
|
160
|
+
const duplicateFailure = yield* Effect.flip(HttpCatalog.make({ manifestUrl: duplicateUrl }))
|
|
161
|
+
const limitedFailure = yield* Effect.flip(HttpCatalog.make({ manifestUrl: limitedUrl, manifestMaxBytes: 1 }))
|
|
162
|
+
|
|
163
|
+
expect(duplicateFailure.message).toContain("Duplicate hosted skill name")
|
|
164
|
+
expect(limitedFailure.message).toContain("exceeds 1 bytes")
|
|
165
|
+
}).pipe(
|
|
166
|
+
Effect.provide(
|
|
167
|
+
Layer.mergeAll(
|
|
168
|
+
cryptoLayer(),
|
|
169
|
+
httpLayer({ [duplicateUrl]: { body: duplicate }, [limitedUrl]: { body: manifest() } }, requests),
|
|
170
|
+
),
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it.effect("maps non-success HTTP responses to safe source errors", () => {
|
|
176
|
+
const requests: Array<{ readonly url: string; readonly accept: string | undefined }> = []
|
|
177
|
+
const manifestUrl = "https://user:password@skills.example/private/skills.json?signature=secret#fragment"
|
|
178
|
+
return Effect.gen(function* () {
|
|
179
|
+
const failure = yield* Effect.flip(HttpCatalog.make({ manifestUrl }))
|
|
180
|
+
|
|
181
|
+
expect(failure.source).toBe("https://skills.example/private/skills.json")
|
|
182
|
+
expect(failure.source).not.toContain("secret")
|
|
183
|
+
expect(failure.source).not.toContain("password")
|
|
184
|
+
}).pipe(
|
|
185
|
+
Effect.provide(
|
|
186
|
+
Layer.mergeAll(cryptoLayer(), httpLayer({ [manifestUrl]: { body: "forbidden", status: 403 } }, requests)),
|
|
187
|
+
),
|
|
188
|
+
)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it.effect("constructs S3 and immutable GitHub catalog endpoints", () => {
|
|
192
|
+
const requests: Array<{ readonly url: string; readonly accept: string | undefined }> = []
|
|
193
|
+
const commit = "a".repeat(40)
|
|
194
|
+
const s3Manifest = "https://company-skills.s3.us-west-2.amazonaws.com/support/skills.json"
|
|
195
|
+
const s3Body = "https://company-skills.s3.us-west-2.amazonaws.com/support/remote/SKILL.md"
|
|
196
|
+
const githubManifest = `https://api.github.com/repos/acme/agent-skills/contents/skills/skills.json?ref=${commit}`
|
|
197
|
+
const githubBody = `https://api.github.com/repos/acme/agent-skills/contents/skills/remote/SKILL.md?ref=${commit}`
|
|
198
|
+
return Effect.gen(function* () {
|
|
199
|
+
const s3 = yield* S3Catalog.make({ bucket: "company-skills", region: "us-west-2", prefix: "support" })
|
|
200
|
+
const github = yield* GitHubCatalog.make({
|
|
201
|
+
owner: "acme",
|
|
202
|
+
repo: "agent-skills",
|
|
203
|
+
ref: commit,
|
|
204
|
+
root: "skills",
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
yield* (yield* s3.get("remote"))!.body
|
|
208
|
+
yield* (yield* github.get("remote"))!.body
|
|
209
|
+
|
|
210
|
+
expect(requests.map((request) => request.url)).toEqual([s3Manifest, githubManifest, s3Body, githubBody])
|
|
211
|
+
expect(requests.filter((request) => request.url.startsWith("https://api.github.com"))).toSatisfy(
|
|
212
|
+
(items: ReadonlyArray<{ readonly accept: string | undefined }>) =>
|
|
213
|
+
items.every((request) => request.accept === "application/vnd.github.raw+json"),
|
|
214
|
+
)
|
|
215
|
+
}).pipe(
|
|
216
|
+
Effect.provide(
|
|
217
|
+
Layer.mergeAll(
|
|
218
|
+
cryptoLayer(),
|
|
219
|
+
httpLayer(
|
|
220
|
+
{
|
|
221
|
+
[s3Manifest]: { body: manifest() },
|
|
222
|
+
[s3Body]: { body: document },
|
|
223
|
+
[githubManifest]: { body: manifest() },
|
|
224
|
+
[githubBody]: { body: document },
|
|
225
|
+
},
|
|
226
|
+
requests,
|
|
227
|
+
),
|
|
228
|
+
),
|
|
229
|
+
),
|
|
230
|
+
)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it.effect("requires GitHub refs to be immutable commit ids", () =>
|
|
234
|
+
Effect.gen(function* () {
|
|
235
|
+
const failure = yield* Effect.flip(
|
|
236
|
+
GitHubCatalog.make({ owner: "acme", repo: "agent-skills", ref: "main", root: "skills" }),
|
|
237
|
+
)
|
|
238
|
+
expect(failure._tag).toBe("@batonfx/core/SkillSourceError")
|
|
239
|
+
}).pipe(Effect.provide(Layer.mergeAll(cryptoLayer(), httpLayer({}, [])))),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
it.effect("bounds raw streamed bytes before buffering and rejects invalid UTF-8", () => {
|
|
243
|
+
const requests: Array<{ readonly url: string; readonly accept: string | undefined }> = []
|
|
244
|
+
const limitedUrl = "https://skills.example/streamed.json"
|
|
245
|
+
const invalidUrl = "https://skills.example/invalid.json"
|
|
246
|
+
let pulls = 0
|
|
247
|
+
const streamed = new ReadableStream<Uint8Array>(
|
|
248
|
+
{
|
|
249
|
+
pull(controller) {
|
|
250
|
+
pulls += 1
|
|
251
|
+
controller.enqueue(new Uint8Array([123, 34, 120]))
|
|
252
|
+
if (pulls >= 3) controller.close()
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
{ highWaterMark: 0 },
|
|
256
|
+
)
|
|
257
|
+
return Effect.gen(function* () {
|
|
258
|
+
const limited = yield* Effect.flip(HttpCatalog.make({ manifestUrl: limitedUrl, manifestMaxBytes: 4 }))
|
|
259
|
+
const invalid = yield* Effect.flip(HttpCatalog.make({ manifestUrl: invalidUrl }))
|
|
260
|
+
|
|
261
|
+
expect(limited.message).toContain("exceeds 4 bytes")
|
|
262
|
+
expect(pulls).toBeLessThan(3)
|
|
263
|
+
expect(invalid.message).toContain("UTF-8")
|
|
264
|
+
}).pipe(
|
|
265
|
+
Effect.provide(
|
|
266
|
+
Layer.mergeAll(
|
|
267
|
+
cryptoLayer(),
|
|
268
|
+
httpLayer(
|
|
269
|
+
{
|
|
270
|
+
[limitedUrl]: { body: streamed },
|
|
271
|
+
[invalidUrl]: { body: new Uint8Array([0xff]) },
|
|
272
|
+
},
|
|
273
|
+
requests,
|
|
274
|
+
),
|
|
275
|
+
),
|
|
276
|
+
),
|
|
277
|
+
)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it.effect("hashes exact body bytes and does not inherit tools from record prototypes", () => {
|
|
281
|
+
const requests: Array<{ readonly url: string; readonly accept: string | undefined }> = []
|
|
282
|
+
const manifestUrl = "https://skills.example/catalog/skills.json"
|
|
283
|
+
const bodyUrl = "https://skills.example/catalog/constructor/SKILL.md"
|
|
284
|
+
const raw = new TextEncoder().encode(`---\nname: constructor\ndescription: Safe own-property lookup\n---\nbody`)
|
|
285
|
+
let hashed: Uint8Array | undefined
|
|
286
|
+
const manifestBody = JSON.stringify({
|
|
287
|
+
version: 1,
|
|
288
|
+
skills: [
|
|
289
|
+
{
|
|
290
|
+
name: "constructor",
|
|
291
|
+
description: "Safe own-property lookup",
|
|
292
|
+
skillPath: "constructor/SKILL.md",
|
|
293
|
+
sha256: digest,
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
})
|
|
297
|
+
const recordingCrypto = Layer.succeed(
|
|
298
|
+
Crypto.Crypto,
|
|
299
|
+
Crypto.make({
|
|
300
|
+
randomBytes: (size) => new Uint8Array(size),
|
|
301
|
+
digest: (_algorithm, bytes) => {
|
|
302
|
+
hashed = bytes
|
|
303
|
+
return Effect.succeed(digestBytes)
|
|
304
|
+
},
|
|
305
|
+
}),
|
|
306
|
+
)
|
|
307
|
+
return Effect.gen(function* () {
|
|
308
|
+
const source = yield* HttpCatalog.make({ manifestUrl, toolsBySkill: {} })
|
|
309
|
+
const skill = yield* source.get("constructor")
|
|
310
|
+
yield* skill!.body
|
|
311
|
+
|
|
312
|
+
expect([...hashed!]).toEqual([...raw])
|
|
313
|
+
expect(skill!.tools).toEqual([])
|
|
314
|
+
}).pipe(
|
|
315
|
+
Effect.provide(
|
|
316
|
+
Layer.mergeAll(
|
|
317
|
+
recordingCrypto,
|
|
318
|
+
httpLayer({ [manifestUrl]: { body: manifestBody }, [bodyUrl]: { body: raw } }, requests),
|
|
319
|
+
),
|
|
320
|
+
),
|
|
321
|
+
)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it.effect("rejects unsafe provider components and encoded hosted paths", () =>
|
|
325
|
+
Effect.gen(function* () {
|
|
326
|
+
const commit = "a".repeat(40)
|
|
327
|
+
const encodedPathUrl = "https://skills.example/catalog.json"
|
|
328
|
+
const failures = yield* Effect.all([
|
|
329
|
+
Effect.flip(GitHubCatalog.make({ owner: "..", repo: "skills", ref: commit })),
|
|
330
|
+
Effect.flip(GitHubCatalog.make({ owner: "acme", repo: "..", ref: commit })),
|
|
331
|
+
Effect.flip(
|
|
332
|
+
GitHubCatalog.make({
|
|
333
|
+
owner: "acme",
|
|
334
|
+
repo: "skills",
|
|
335
|
+
ref: commit,
|
|
336
|
+
apiBaseUrl: "https://user:secret@git.example/api?token=secret",
|
|
337
|
+
}),
|
|
338
|
+
),
|
|
339
|
+
Effect.flip(S3Catalog.make({ bucket: "company.skills", region: "us-west-2" })),
|
|
340
|
+
Effect.flip(S3Catalog.make({ bucket: "192.168.1.1", region: "us-west-2" })),
|
|
341
|
+
Effect.flip(HttpCatalog.make({ manifestUrl: encodedPathUrl })),
|
|
342
|
+
])
|
|
343
|
+
expect(failures).toHaveLength(6)
|
|
344
|
+
}).pipe(
|
|
345
|
+
Effect.provide(
|
|
346
|
+
Layer.mergeAll(
|
|
347
|
+
cryptoLayer(),
|
|
348
|
+
httpLayer(
|
|
349
|
+
{
|
|
350
|
+
"https://skills.example/catalog.json": {
|
|
351
|
+
body: manifest("remote%2fescape/SKILL.md"),
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
[],
|
|
355
|
+
),
|
|
356
|
+
),
|
|
357
|
+
),
|
|
358
|
+
),
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
it.effect("composes public filesystem-independent adapters with later sources winning", () => {
|
|
362
|
+
const requests: Array<{ readonly url: string; readonly accept: string | undefined }> = []
|
|
363
|
+
const httpUrl = "https://skills.example/catalog.json"
|
|
364
|
+
const s3Url = "https://company-skills.s3.us-west-2.amazonaws.com/skills.json"
|
|
365
|
+
const secondManifest = JSON.stringify({
|
|
366
|
+
version: 1,
|
|
367
|
+
skills: [
|
|
368
|
+
{
|
|
369
|
+
name: "remote",
|
|
370
|
+
description: "Later source wins",
|
|
371
|
+
skillPath: "remote/SKILL.md",
|
|
372
|
+
sha256: digest,
|
|
373
|
+
},
|
|
374
|
+
],
|
|
375
|
+
})
|
|
376
|
+
return Effect.gen(function* () {
|
|
377
|
+
const source = yield* SkillSource.SkillSource
|
|
378
|
+
const all = yield* source.all
|
|
379
|
+
const remote = yield* source.get("remote")
|
|
380
|
+
|
|
381
|
+
expect(all).toHaveLength(1)
|
|
382
|
+
expect(remote?.frontmatter.description).toBe("Later source wins")
|
|
383
|
+
}).pipe(
|
|
384
|
+
Effect.provide(
|
|
385
|
+
SkillSource.layer([
|
|
386
|
+
HttpCatalog.make({ manifestUrl: httpUrl }),
|
|
387
|
+
S3Catalog.make({ bucket: "company-skills", region: "us-west-2" }),
|
|
388
|
+
]),
|
|
389
|
+
),
|
|
390
|
+
Effect.provide(
|
|
391
|
+
Layer.mergeAll(
|
|
392
|
+
cryptoLayer(),
|
|
393
|
+
httpLayer({ [httpUrl]: { body: manifest() }, [s3Url]: { body: secondManifest } }, requests),
|
|
394
|
+
),
|
|
395
|
+
),
|
|
396
|
+
)
|
|
397
|
+
})
|
|
398
|
+
})
|