From ee55704cd31ca5807dfa79851dd7d643c2cf89c2 Mon Sep 17 00:00:00 2001 From: Nikola Balic Date: Thu, 30 Jul 2026 16:57:52 +0200 Subject: [PATCH] feat(skills): publish complete skill archives with hardened generation Replace the per-file skill-md discovery entries with one complete tar.gz archive per skill, built from a single commit-pinned steel-dev/skills snapshot so the manifest and every supporting file come from the same commit. Archive bytes are deterministic (normalized tar headers and gzip metadata), digested with sha256, and the build fails closed on any catalog error. Hardening from code review: - Exclude hidden files/dirs from archives and cap uncompressed content at 5 MB per skill, so stray upstream files (.env.local, fixtures) can never be republished on docs.steel.dev - Fall back to the manifest key when a skill entry omits path, matching upstream's own `meta.path ?? name` resolution - Attribute YAML frontmatter parse failures to the offending skill - Exclude the whole RFC 8615 /.well-known namespace and archive extensions (.gz/.tar/.tgz) from markdown content negotiation - Serve index.json and tarballs with a short shared Cache-Control lifetime so a stale index cannot digest-mismatch fresh tarballs - Run agent-skills generation in CI so failures surface pre-deploy --- .env-example | 1 + .github/workflows/ci.yml | 5 + lib/markdown-negotiation.ts | 9 +- next.config.mjs | 26 ++ scripts/generate-agent-skills-index.ts | 357 ++++++++++++++++++++----- tests/agent-skills-index.test.ts | 269 ++++++++++++++++--- tests/markdown-negotiation.test.ts | 25 +- 7 files changed, 594 insertions(+), 98 deletions(-) diff --git a/.env-example b/.env-example index e5c98d23..74d81b88 100644 --- a/.env-example +++ b/.env-example @@ -1,5 +1,6 @@ NEXT_PUBLIC_GTM_ID= # Google Tag Manager container (e.g. GTM-XXXXXXX); loads GA4, Reo, LinkedIn, X, Clarity. Set only in production so forks/previews don't pollute analytics LLMS_BASE_URL= +GITHUB_TOKEN= # Read-only GitHub token for Agent Skills generation; required in shared build environments to avoid per-IP API limits GITHUB_APP_ID= GITHUB_APP_PRIVATE_KEY= # Base64 encoded GitHub App private key GITHUB_APP_INSTALLATION_ID= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 500c80ba..5f36b7af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,11 @@ jobs: env: LLMS_BASE_URL: https://docs.steel.dev + - name: Generate Agent Skills index + run: bun run generate-agent-skills + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Run link validation run: bun run validate-links diff --git a/lib/markdown-negotiation.ts b/lib/markdown-negotiation.ts index 75bc8bd0..9a79785d 100644 --- a/lib/markdown-negotiation.ts +++ b/lib/markdown-negotiation.ts @@ -31,8 +31,6 @@ export const MARKDOWN_USER_AGENT_SUBSTRINGS = [ const MARKDOWN_VARY_HEADERS = ['Accept', 'User-Agent']; const EXCLUDED_EXACT_PATHS = new Set([ - '/.well-known/api-catalog', - '/.well-known/llms.txt', '/AGENTS', '/AGENTS.md', '/favicon.ico', @@ -43,11 +41,14 @@ const EXCLUDED_EXACT_PATHS = new Set([ '/sitemap.xml', ]); -const EXCLUDED_PATH_PREFIXES = ['/_next', '/api', '/llms.mdx', '/og']; +// Everything under the RFC 8615 /.well-known namespace is already +// machine-readable, so none of it should ever be content-negotiated. +const EXCLUDED_PATH_PREFIXES = ['/.well-known', '/_next', '/api', '/llms.mdx', '/og']; const EXCLUDED_ASSET_EXTENSIONS = new Set([ '.avif', '.css', '.gif', + '.gz', '.ico', '.jpeg', '.jpg', @@ -58,6 +59,8 @@ const EXCLUDED_ASSET_EXTENSIONS = new Set([ '.pdf', '.png', '.svg', + '.tar', + '.tgz', '.ttf', '.txt', '.webp', diff --git a/next.config.mjs b/next.config.mjs index 36cbc579..f1f086f8 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -209,6 +209,32 @@ const config = { }, async headers() { return [ + { + // The index and its digest-pinned tarballs change together on deploy, + // so both get the same short lifetime: a client holding a stale + // index.json would otherwise fetch tarballs whose sha256 no longer + // matches and have to reject them as tampered. + source: "/.well-known/agent-skills/:name.tar.gz", + headers: [ + { + key: "Content-Type", + value: "application/gzip", + }, + { + key: "Cache-Control", + value: "public, max-age=300, must-revalidate", + }, + ], + }, + { + source: "/.well-known/agent-skills/index.json", + headers: [ + { + key: "Cache-Control", + value: "public, max-age=300, must-revalidate", + }, + ], + }, { // RFC 9727 requires the catalog to be served as a linkset. The file has // no extension, so static serving would otherwise guess a type for it. diff --git a/scripts/generate-agent-skills-index.ts b/scripts/generate-agent-skills-index.ts index e50751a7..96bf8e27 100644 --- a/scripts/generate-agent-skills-index.ts +++ b/scripts/generate-agent-skills-index.ts @@ -1,24 +1,32 @@ #!/usr/bin/env bun -// ABOUTME: Generates the Agent Skills discovery index at public/.well-known/agent-skills/index.json. -// ABOUTME: Reads the steel-dev/skills catalog, pins each SKILL.md to a commit and digests its bytes. +// ABOUTME: Generates the Agent Skills discovery index and complete skill archives. +// ABOUTME: Reads one commit-pinned steel-dev/skills snapshot and fails the build on errors. import { createHash } from 'node:crypto'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; +import { gzipSync } from 'node:zlib'; +import matter from 'gray-matter'; /** Discovery schema this index conforms to: cloudflare/agent-skills-discovery-rfc. */ export const AGENT_SKILLS_SCHEMA = 'https://schemas.agentskills.io/discovery/0.2.0/schema.json'; const SKILLS_REPO = 'steel-dev/skills'; -const OUTPUT_PATH = path.join(process.cwd(), 'public', '.well-known', 'agent-skills', 'index.json'); +const OUTPUT_DIR = path.join(process.cwd(), 'public', '.well-known', 'agent-skills'); +const OUTPUT_PATH = path.join(OUTPUT_DIR, 'index.json'); // Limits from the discovery schema, enforced here so a bad catalog entry fails // the build rather than publishing an index clients will reject. -const NAME_PATTERN = /^[a-z0-9-]{1,64}$/; +const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const COMMIT_PATTERN = /^[0-9a-f]{40}$/; +const MAX_NAME_LENGTH = 64; const MAX_DESCRIPTION_LENGTH = 1024; +// Ceiling on uncompressed content per skill archive. gzipSync buffers the whole +// archive in memory, so an unbounded upstream skill could bloat or OOM a deploy. +export const MAX_SKILL_ARCHIVE_CONTENT_BYTES = 5 * 1024 * 1024; + export type SkillArtifact = { name: string; - path: string; description: string; content: Buffer; }; @@ -27,18 +35,139 @@ export type AgentSkillsIndex = { $schema: string; skills: Array<{ name: string; - type: 'skill-md'; + type: 'archive'; description: string; url: string; digest: string; }>; }; -/** Builds the discovery index for skill artifacts read at a single commit. */ -export function buildAgentSkillsIndex( - commit: string, - artifacts: SkillArtifact[], -): AgentSkillsIndex { +type Manifest = { + skills: Record; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function validateSkillName(name: string): void { + if (name.length < 1 || name.length > MAX_NAME_LENGTH || !NAME_PATTERN.test(name)) { + throw new Error(`Skill name "${name}" is not a valid discovery name`); + } +} + +function validateDescription(name: string, description: unknown): asserts description is string { + if (typeof description !== 'string' || description.trim().length === 0) { + throw new Error(`Skill "${name}" must have a non-empty string description`); + } + + if (description.length > MAX_DESCRIPTION_LENGTH) { + throw new Error( + `Skill "${name}" has a description longer than ${MAX_DESCRIPTION_LENGTH} characters`, + ); + } +} + +function normalizeArchiveEntry(entryPath: string): string { + const unixPath = entryPath.replaceAll('\\', '/').replace(/^\.\/+/, ''); + const normalized = path.posix.normalize(unixPath); + + if ( + path.posix.isAbsolute(unixPath) || + normalized === '.' || + normalized === '..' || + normalized.startsWith('../') || + unixPath.split('/').includes('..') + ) { + throw new Error(`Repository archive contains unsafe path "${entryPath}"`); + } + + return normalized; +} + +function normalizeSkillPath(skillName: string, skillPath: unknown): string { + if (typeof skillPath !== 'string' || skillPath.trim().length === 0) { + throw new Error(`Skill "${skillName}" has an invalid manifest path`); + } + + const unixPath = skillPath.replaceAll('\\', '/'); + const segments = unixPath.split('/'); + const normalized = path.posix.normalize(unixPath); + + if ( + path.posix.isAbsolute(unixPath) || + normalized === '.' || + normalized === '..' || + normalized.startsWith('../') || + segments.some((segment) => segment === '' || segment === '.' || segment === '..') + ) { + throw new Error(`Skill "${skillName}" has unsafe manifest path "${skillPath}"`); + } + + return normalized; +} + +function parseManifest(source: string): Manifest { + let parsed: unknown; + + try { + parsed = JSON.parse(source); + } catch { + throw new Error('Repository manifest.json is not valid JSON'); + } + + if (!isRecord(parsed) || !isRecord(parsed.skills)) { + throw new Error('Repository manifest.json must contain a skills object'); + } + + if (Object.keys(parsed.skills).length === 0) { + throw new Error('No skills found in the catalog; refusing to publish an empty index'); + } + + return parsed as Manifest; +} + +function normalizeTarHeaders(tarBytes: Buffer): Buffer { + let offset = 0; + + while (offset + 512 <= tarBytes.length) { + const header = tarBytes.subarray(offset, offset + 512); + if (header.every((byte) => byte === 0)) break; + + const rawSize = header.subarray(124, 136).toString('ascii').replace(/\0.*$/, '').trim(); + const size = rawSize ? Number.parseInt(rawSize, 8) : 0; + if (!Number.isFinite(size)) { + throw new Error('Could not create skill archive: Bun produced an invalid tar size'); + } + + // Bun.Archive writes the current time into each tar header. Normalize it + // and recompute the checksum so a fixed upstream commit has a fixed digest. + header.write('00000000000\0', 136, 12, 'ascii'); + header.fill(0x20, 148, 156); + const checksum = header.reduce((sum, byte) => sum + byte, 0); + header.write(checksum.toString(8).padStart(6, '0'), 148, 6, 'ascii'); + header[154] = 0; + header[155] = 0x20; + + offset += 512 + Math.ceil(size / 512) * 512; + } + + return tarBytes; +} + +async function createDeterministicArchive(files: Record): Promise { + const tarBytes = normalizeTarHeaders(Buffer.from(await new Bun.Archive(files).bytes())); + const archiveBytes = gzipSync(tarBytes, { level: 9 }); + + // zlib already writes a zero timestamp. Normalize the OS marker too so + // archives produced on local macOS and Linux deploy builders stay identical. + archiveBytes.fill(0, 4, 8); + archiveBytes[9] = 0xff; + return archiveBytes; +} + +/** Builds the discovery index from complete archive artifacts. */ +export function buildAgentSkillsIndex(artifacts: SkillArtifact[]): AgentSkillsIndex { if (artifacts.length === 0) { throw new Error('No skills found in the catalog; refusing to publish an empty index'); } @@ -46,92 +175,194 @@ export function buildAgentSkillsIndex( return { $schema: AGENT_SKILLS_SCHEMA, skills: artifacts.map((artifact) => { - if (!NAME_PATTERN.test(artifact.name)) { - throw new Error(`Skill name "${artifact.name}" is not a valid discovery name`); - } - - if (artifact.description.length > MAX_DESCRIPTION_LENGTH) { - throw new Error( - `Skill "${artifact.name}" has a description longer than ${MAX_DESCRIPTION_LENGTH} characters`, - ); - } + validateSkillName(artifact.name); + validateDescription(artifact.name, artifact.description); return { name: artifact.name, - type: 'skill-md' as const, + type: 'archive' as const, description: artifact.description, - // Pinned to a commit so the URL and digest keep agreeing after an - // upstream edit; the next build re-pins to whatever main holds then. - url: `https://raw.githubusercontent.com/${SKILLS_REPO}/${commit}/${artifact.path}/SKILL.md`, + url: `/.well-known/agent-skills/${artifact.name}.tar.gz`, digest: `sha256:${createHash('sha256').update(artifact.content).digest('hex')}`, }; }), }; } -function githubHeaders(): Record { - const token = process.env.GITHUB_TOKEN; +/** + * Converts one GitHub repository snapshot into complete, root-layout skill archives. + * The manifest and every supporting file therefore come from the same commit. + */ +export async function buildSkillArtifactsFromRepositoryArchive( + repositoryBytes: Buffer, +): Promise { + const repositoryFiles = await new Bun.Archive(repositoryBytes).files(); + const normalizedFiles = new Map(); + + for (const [entryPath, file] of repositoryFiles) { + const normalizedPath = normalizeArchiveEntry(entryPath); + if (normalizedFiles.has(normalizedPath)) { + throw new Error(`Repository archive contains duplicate path "${normalizedPath}"`); + } + normalizedFiles.set(normalizedPath, file); + } + + const manifestCandidates = [...normalizedFiles.entries()].filter(([entryPath]) => { + const segments = entryPath.split('/'); + return segments.length === 2 && segments[1] === 'manifest.json'; + }); + + if (manifestCandidates.length !== 1) { + throw new Error( + `Repository archive must contain exactly one wrapper-level manifest.json; found ${manifestCandidates.length}`, + ); + } + + const [manifestPath, manifestFile] = manifestCandidates[0] as [string, File]; + const wrapper = manifestPath.slice(0, -'/manifest.json'.length); + const manifest = parseManifest(await manifestFile.text()); + const artifacts: SkillArtifact[] = []; + + for (const [skillName, manifestSkill] of Object.entries(manifest.skills)) { + validateSkillName(skillName); + if (!isRecord(manifestSkill)) { + throw new Error(`Skill "${skillName}" has an invalid manifest entry`); + } + + // Upstream treats path as optional and resolves `meta.path ?? name`; match + // that so a legal upstream manifest cannot fail the docs build. + const skillPath = normalizeSkillPath(skillName, manifestSkill.path ?? skillName); + const archivePrefix = `${wrapper}/${skillPath}/`; + // The upstream repository has no .gitignore, so hidden entries (.env.local, + // .DS_Store) may exist in a snapshot and must never be republished. + const skillFiles = [...normalizedFiles.entries()] + .filter(([entryPath]) => entryPath.startsWith(archivePrefix)) + .map(([entryPath, file]) => [entryPath.slice(archivePrefix.length), file] as const) + .filter(([entryPath]) => entryPath.length > 0) + .filter(([entryPath]) => !entryPath.split('/').some((segment) => segment.startsWith('.'))) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)); + const skillMarkdown = skillFiles.find(([entryPath]) => entryPath === 'SKILL.md')?.[1]; + + if (!skillMarkdown) { + throw new Error(`Skill "${skillName}" is missing SKILL.md at its archive root`); + } + + const contentBytes = skillFiles.reduce((total, [, file]) => total + file.size, 0); + if (contentBytes > MAX_SKILL_ARCHIVE_CONTENT_BYTES) { + throw new Error( + `Skill "${skillName}" content is ${contentBytes} bytes, over the ${MAX_SKILL_ARCHIVE_CONTENT_BYTES} byte archive limit`, + ); + } + + let metadata: Record; + try { + metadata = matter(await skillMarkdown.text()).data as Record; + } catch (error) { + throw new Error( + `Skill "${skillName}" has SKILL.md frontmatter that is not valid YAML: ${error instanceof Error ? error.message : String(error)}`, + ); + } + if (typeof metadata.name !== 'string' || metadata.name !== skillName) { + throw new Error(`Skill "${skillName}" frontmatter name must exactly match its manifest name`); + } + validateDescription(skillName, metadata.description); + + const archiveInput: Record = {}; + for (const [entryPath, file] of skillFiles) { + archiveInput[entryPath] = file; + } + + artifacts.push({ + name: skillName, + description: metadata.description, + content: await createDeterministicArchive(archiveInput), + }); + } + + return artifacts; +} + +function githubAuthorizationHeaders(): Record { + const token = process.env.GITHUB_TOKEN?.trim(); return token ? { Authorization: `Bearer ${token}` } : {}; } +function githubApiHeaders(): Record { + return { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + ...githubAuthorizationHeaders(), + }; +} + +function githubRequestError(action: string, response: Response): Error { + const remaining = response.headers.get('x-ratelimit-remaining'); + const reset = response.headers.get('x-ratelimit-reset'); + let guidance = ''; + + if (response.status === 401) { + guidance = '; check that GITHUB_TOKEN is valid'; + } else if (response.status === 403 && remaining === '0') { + const resetAt = + reset && Number.isFinite(Number(reset)) + ? new Date(Number(reset) * 1000).toISOString() + : 'the time reported by GitHub'; + guidance = `; GitHub API rate limit exhausted until ${resetAt}, set GITHUB_TOKEN`; + } + + return new Error(`${action}: GitHub returned ${response.status}${guidance}`); +} + async function resolveHeadCommit(): Promise { const response = await fetch(`https://api.github.com/repos/${SKILLS_REPO}/commits/main`, { - headers: { Accept: 'application/vnd.github+json', ...githubHeaders() }, + headers: githubApiHeaders(), }); if (!response.ok) { - throw new Error(`Could not resolve ${SKILLS_REPO}@main: ${response.status}`); + throw githubRequestError(`Could not resolve ${SKILLS_REPO}@main`, response); + } + + const payload = (await response.json()) as { sha?: unknown }; + if (typeof payload.sha !== 'string' || !COMMIT_PATTERN.test(payload.sha)) { + throw new Error(`Could not resolve ${SKILLS_REPO}@main: GitHub returned an invalid SHA`); } - return ((await response.json()) as { sha: string }).sha; + return payload.sha; } -async function fetchRaw(commit: string, filePath: string): Promise { - const url = `https://raw.githubusercontent.com/${SKILLS_REPO}/${commit}/${filePath}`; - const response = await fetch(url); +async function fetchRepositoryArchive(commit: string): Promise { + const url = `https://codeload.github.com/${SKILLS_REPO}/tar.gz/${commit}`; + const response = await fetch(url, { headers: githubAuthorizationHeaders() }); if (!response.ok) { - throw new Error(`Could not fetch ${url}: ${response.status}`); + throw githubRequestError(`Could not fetch ${SKILLS_REPO}@${commit}`, response); } return Buffer.from(await response.arrayBuffer()); } -type Manifest = { - skills: Record; -}; - -async function readCatalog(commit: string): Promise { - const manifest = JSON.parse( - (await fetchRaw(commit, 'manifest.json')).toString('utf8'), - ) as Manifest; - - return Promise.all( - Object.entries(manifest.skills).map(async ([name, skill]) => ({ - name, - path: skill.path, - description: skill.description, - content: await fetchRaw(commit, `${skill.path}/SKILL.md`), - })), - ); -} - -async function main() { +async function main(): Promise { const commit = await resolveHeadCommit(); - const index = buildAgentSkillsIndex(commit, await readCatalog(commit)); + const repositoryBytes = await fetchRepositoryArchive(commit); + const artifacts = await buildSkillArtifactsFromRepositoryArchive(repositoryBytes); + const index = buildAgentSkillsIndex(artifacts); - await fs.mkdir(path.dirname(OUTPUT_PATH), { recursive: true }); + // Build and validate everything before replacing the generated directory. + // Any later filesystem error remains fatal, so partial output cannot deploy. + await fs.rm(OUTPUT_DIR, { recursive: true, force: true }); + await fs.mkdir(OUTPUT_DIR, { recursive: true }); + await Promise.all( + artifacts.map((artifact) => + fs.writeFile(path.join(OUTPUT_DIR, `${artifact.name}.tar.gz`), artifact.content), + ), + ); await fs.writeFile(OUTPUT_PATH, `${JSON.stringify(index, null, 2)}\n`); - console.log(`✔️ Wrote ${index.skills.length} skills to ${OUTPUT_PATH} at ${commit.slice(0, 8)}`); + console.log( + `✔️ Wrote ${index.skills.length} skill archives to ${OUTPUT_DIR} at ${commit.slice(0, 8)}`, + ); } if (import.meta.main) { - try { - await main(); - } catch (error) { - // A docs deploy should not hinge on GitHub being reachable. Skipping leaves - // the index absent, which is honest, rather than stale or unverifiable. - console.warn(`⚠️ Skipped the Agent Skills index: ${(error as Error).message}`); - } + await main(); } diff --git a/tests/agent-skills-index.test.ts b/tests/agent-skills-index.test.ts index 16cb451a..e2d8fffa 100644 --- a/tests/agent-skills-index.test.ts +++ b/tests/agent-skills-index.test.ts @@ -1,70 +1,277 @@ -// ABOUTME: Tests for the Agent Skills discovery index builder, covering the schema -// ABOUTME: shape, commit-pinned artifact URLs and the SHA-256 digest of each SKILL.md. +// ABOUTME: Tests the Agent Skills discovery index and complete archive packaging. +// ABOUTME: Covers frontmatter metadata, archive layout, validation, and exact-byte digests. import { describe, expect, test } from 'bun:test'; import { createHash } from 'node:crypto'; import { AGENT_SKILLS_SCHEMA, buildAgentSkillsIndex, + buildSkillArtifactsFromRepositoryArchive, + MAX_SKILL_ARCHIVE_CONTENT_BYTES, type SkillArtifact, } from '../scripts/generate-agent-skills-index'; const COMMIT = '0123456789abcdef0123456789abcdef01234567'; +const WRAPPER = `skills-${COMMIT}`; +const FRONTMATTER_DESCRIPTION = + 'Use when a task needs a Steel cloud browser. Do not use for local-only HTTP requests.'; function artifact(overrides: Partial = {}): SkillArtifact { return { name: 'steel-browser', - path: 'steel-browser', - description: 'Skill for agent-driven web workflows using Steel cloud browsers.', - content: Buffer.from('---\nname: steel-browser\n---\n\nDrive a Steel browser.\n'), + description: FRONTMATTER_DESCRIPTION, + content: Buffer.from('exact archive bytes'), ...overrides, }; } +async function archiveBytes(files: Record): Promise { + const archive = new Bun.Archive(files, { compress: 'gzip', level: 9 }); + return Buffer.from(await archive.bytes()); +} + +function skillMarkdown(overrides: { name?: unknown; description?: unknown } = {}): string { + const name = overrides.name ?? 'steel-browser'; + const description = overrides.description ?? FRONTMATTER_DESCRIPTION; + + return `--- +name: ${JSON.stringify(name)} +description: ${JSON.stringify(description)} +--- + +# Steel Browser + +Read [the forms reference](references/forms.md) when a task needs forms. +`; +} + +async function repositoryArchive( + overrides: { + manifest?: unknown; + skillMd?: string | null; + extraFiles?: Record; + } = {}, +): Promise { + const manifest = + overrides.manifest ?? + ({ + skills: { + 'steel-browser': { + description: 'A deliberately different manifest blurb.', + path: 'steel-browser', + }, + }, + } satisfies Record); + const files: Record = { + [`${WRAPPER}/manifest.json`]: JSON.stringify(manifest), + [`${WRAPPER}/README.md`]: 'Repository readme', + [`${WRAPPER}/steel-browser/references/forms.md`]: '# Forms', + [`${WRAPPER}/steel-browser/scripts/run.sh`]: '#!/bin/sh\n', + ...overrides.extraFiles, + }; + + if (overrides.skillMd !== null) { + files[`${WRAPPER}/steel-browser/SKILL.md`] = overrides.skillMd ?? skillMarkdown(); + } + + return archiveBytes(files); +} + +describe('buildSkillArtifactsFromRepositoryArchive', () => { + test('packages the full skill directory at archive root and uses frontmatter metadata', async () => { + const [built] = await buildSkillArtifactsFromRepositoryArchive(await repositoryArchive()); + + expect(built?.name).toBe('steel-browser'); + expect(built?.description).toBe(FRONTMATTER_DESCRIPTION); + + const files = await new Bun.Archive(built?.content ?? Buffer.alloc(0)).files(); + expect([...files.keys()].sort()).toEqual(['SKILL.md', 'references/forms.md', 'scripts/run.sh']); + expect(await files.get('SKILL.md')?.text()).toContain('# Steel Browser'); + expect([...files.keys()].some((name) => name.startsWith(`${WRAPPER}/`))).toBe(false); + }); + + test('produces deterministic archive bytes for identical repository bytes', async () => { + const repository = await repositoryArchive(); + const [first] = await buildSkillArtifactsFromRepositoryArchive(repository); + await Bun.sleep(1100); + const [second] = await buildSkillArtifactsFromRepositoryArchive(repository); + + expect(first?.content.equals(second?.content ?? Buffer.alloc(0))).toBe(true); + }); + + test('rejects a missing or ambiguous wrapper-level manifest', async () => { + await expect( + buildSkillArtifactsFromRepositoryArchive( + await archiveBytes({ [`${WRAPPER}/README.md`]: 'No manifest' }), + ), + ).rejects.toThrow(/manifest/i); + + await expect( + buildSkillArtifactsFromRepositoryArchive( + await archiveBytes({ + 'first/manifest.json': JSON.stringify({ skills: {} }), + 'second/manifest.json': JSON.stringify({ skills: {} }), + }), + ), + ).rejects.toThrow(/manifest/i); + }); + + test('rejects an empty catalog or missing root SKILL.md', async () => { + await expect( + buildSkillArtifactsFromRepositoryArchive( + await repositoryArchive({ manifest: { skills: {} } }), + ), + ).rejects.toThrow(/empty|no skills/i); + + await expect( + buildSkillArtifactsFromRepositoryArchive(await repositoryArchive({ skillMd: null })), + ).rejects.toThrow(/SKILL\.md/i); + }); + + test.each([ + ['non-string', 42], + ['blank', ' '], + ['overlong', 'x'.repeat(1025)], + ])('rejects frontmatter description: %s', async (_label, description) => { + await expect( + buildSkillArtifactsFromRepositoryArchive( + await repositoryArchive({ skillMd: skillMarkdown({ description }) }), + ), + ).rejects.toThrow(/description/i); + }); + + test('rejects a frontmatter name that differs from the manifest key', async () => { + await expect( + buildSkillArtifactsFromRepositoryArchive( + await repositoryArchive({ + skillMd: skillMarkdown({ name: 'different-skill' }), + }), + ), + ).rejects.toThrow(/name/i); + }); + + test('excludes hidden files and directories from skill archives', async () => { + const [built] = await buildSkillArtifactsFromRepositoryArchive( + await repositoryArchive({ + extraFiles: { + [`${WRAPPER}/steel-browser/.env.local`]: 'STEEL_API_KEY=secret', + [`${WRAPPER}/steel-browser/.github/workflows/ci.yml`]: 'jobs: {}', + [`${WRAPPER}/steel-browser/references/.DS_Store`]: 'junk', + }, + }), + ); + + const files = await new Bun.Archive(built?.content ?? Buffer.alloc(0)).files(); + expect([...files.keys()].sort()).toEqual(['SKILL.md', 'references/forms.md', 'scripts/run.sh']); + }); + + test('rejects a skill whose content exceeds the archive size ceiling', async () => { + await expect( + buildSkillArtifactsFromRepositoryArchive( + await repositoryArchive({ + extraFiles: { + [`${WRAPPER}/steel-browser/fixtures/huge.bin`]: 'x'.repeat( + MAX_SKILL_ARCHIVE_CONTENT_BYTES + 1, + ), + }, + }), + ), + ).rejects.toThrow(/steel-browser.*bytes/i); + }); + + test('falls back to the manifest key when the manifest entry omits path', async () => { + const [built] = await buildSkillArtifactsFromRepositoryArchive( + await repositoryArchive({ manifest: { skills: { 'steel-browser': {} } } }), + ); + + expect(built?.name).toBe('steel-browser'); + const files = await new Bun.Archive(built?.content ?? Buffer.alloc(0)).files(); + expect([...files.keys()]).toContain('SKILL.md'); + }); + + test('names the skill when SKILL.md frontmatter is not valid YAML', async () => { + const skillMd = [ + '---', + 'name: steel-browser', + 'description: Use when: the agent needs a browser', + '---', + '', + '# Steel Browser', + '', + ].join('\n'); + + await expect( + buildSkillArtifactsFromRepositoryArchive(await repositoryArchive({ skillMd })), + ).rejects.toThrow(/steel-browser.*frontmatter/i); + }); + + test('rejects an unsafe manifest path', async () => { + await expect( + buildSkillArtifactsFromRepositoryArchive( + await repositoryArchive({ + manifest: { + skills: { + 'steel-browser': { + path: '../steel-browser', + }, + }, + }, + }), + ), + ).rejects.toThrow(/path/i); + }); +}); + describe('buildAgentSkillsIndex', () => { - test('emits the discovery schema and one entry per skill', () => { - const index = buildAgentSkillsIndex(COMMIT, [ - artifact(), - artifact({ name: 'steel-developer', path: 'steel-developer' }), - ]); + test('emits the discovery schema and one archive entry per skill', () => { + const index = buildAgentSkillsIndex([artifact(), artifact({ name: 'steel-developer' })]); expect(index.$schema).toBe(AGENT_SKILLS_SCHEMA); expect(index.skills).toHaveLength(2); expect(index.skills.map((skill) => skill.name)).toEqual(['steel-browser', 'steel-developer']); - expect(index.skills[0]?.type).toBe('skill-md'); + expect(index.skills[0]?.type).toBe('archive'); }); - test('pins each artifact URL to the commit it was read from', () => { - const [skill] = buildAgentSkillsIndex(COMMIT, [artifact()]).skills; + test('uses a same-origin tarball URL', () => { + const [skill] = buildAgentSkillsIndex([artifact()]).skills; - // Pinning to a commit rather than a branch keeps the URL and the digest - // agreeing forever, so a later upstream edit cannot invalidate this index. - expect(skill?.url).toBe( - `https://raw.githubusercontent.com/steel-dev/skills/${COMMIT}/steel-browser/SKILL.md`, - ); + expect(skill?.url).toBe('/.well-known/agent-skills/steel-browser.tar.gz'); }); - test('digests the exact bytes of the artifact', () => { - const content = Buffer.from('---\nname: steel-browser\n---\n\nExact bytes.\n'); - const [skill] = buildAgentSkillsIndex(COMMIT, [artifact({ content })]).skills; + test('digests the exact archive bytes', () => { + const content = Buffer.from('exact compressed archive bytes'); + const [skill] = buildAgentSkillsIndex([artifact({ content })]).skills; expect(skill?.digest).toBe(`sha256:${createHash('sha256').update(content).digest('hex')}`); expect(skill?.digest).toMatch(/^sha256:[0-9a-f]{64}$/); }); - test('rejects a name the discovery schema would not accept', () => { - expect(() => buildAgentSkillsIndex(COMMIT, [artifact({ name: 'Steel Browser' })])).toThrow( - /name/i, - ); + test.each([ + '-steel', + 'steel-', + 'steel--browser', + 'Steel-browser', + 'steel browser', + 'a'.repeat(65), + ])('rejects invalid discovery name %s', (name) => { + expect(() => buildAgentSkillsIndex([artifact({ name })])).toThrow(/name/i); }); - test('rejects a description longer than the schema allows', () => { - expect(() => - buildAgentSkillsIndex(COMMIT, [artifact({ description: 'x'.repeat(1025) })]), - ).toThrow(/description/i); - }); + test.each(['a', `${'a'.repeat(31)}-${'b'.repeat(32)}`])( + 'accepts valid boundary discovery name %s', + (name) => { + expect(buildAgentSkillsIndex([artifact({ name })]).skills[0]?.name).toBe(name); + }, + ); + + test.each(['', ' ', 'x'.repeat(1025)])( + 'rejects invalid discovery description', + (description) => { + expect(() => buildAgentSkillsIndex([artifact({ description })])).toThrow(/description/i); + }, + ); test('rejects an empty skill set rather than publishing an empty index', () => { - expect(() => buildAgentSkillsIndex(COMMIT, [])).toThrow(/skill/i); + expect(() => buildAgentSkillsIndex([])).toThrow(/skill/i); }); }); diff --git a/tests/markdown-negotiation.test.ts b/tests/markdown-negotiation.test.ts index aec495c6..22b0b8c2 100644 --- a/tests/markdown-negotiation.test.ts +++ b/tests/markdown-negotiation.test.ts @@ -1,7 +1,7 @@ // ABOUTME: Tests for resolveMarkdownPath, which maps .md-suffixed docs URLs // ABOUTME: to their canonical path so middleware can serve the markdown version. import { describe, expect, test } from 'bun:test'; -import { resolveMarkdownPath } from '../lib/markdown-negotiation'; +import { isNegotiableDocsPath, resolveMarkdownPath } from '../lib/markdown-negotiation'; describe('resolveMarkdownPath', () => { test('strips .md from a docs page path', () => { @@ -35,9 +35,32 @@ describe('resolveMarkdownPath', () => { test('returns null when the stripped path is under an excluded prefix', () => { expect(resolveMarkdownPath('/llms.mdx/overview.md')).toBeNull(); expect(resolveMarkdownPath('/api/search.md')).toBeNull(); + expect(resolveMarkdownPath('/.well-known/agent-skills/steel-browser.tar.gz.md')).toBeNull(); }); test('returns null when the stripped path is a static asset', () => { expect(resolveMarkdownPath('/images/logo.png.md')).toBeNull(); }); }); + +describe('isNegotiableDocsPath', () => { + test('excludes Agent Skills discovery artifacts from markdown rewrites', () => { + expect(isNegotiableDocsPath('/.well-known/agent-skills/index.json')).toBe(false); + expect(isNegotiableDocsPath('/.well-known/agent-skills/steel-browser.tar.gz')).toBe(false); + }); + + test('excludes the entire /.well-known namespace, including future artifacts', () => { + expect(isNegotiableDocsPath('/.well-known/api-catalog')).toBe(false); + expect(isNegotiableDocsPath('/.well-known/llms.txt')).toBe(false); + expect(isNegotiableDocsPath('/.well-known/agents')).toBe(false); + expect(isNegotiableDocsPath('/.well-known/security.txt')).toBe(false); + expect(isNegotiableDocsPath('/.well-known/mcp.json')).toBe(false); + }); + + test('excludes archive assets wherever they live', () => { + expect(isNegotiableDocsPath('/downloads/starter.tar.gz')).toBe(false); + expect(isNegotiableDocsPath('/downloads/starter.tgz')).toBe(false); + expect(isNegotiableDocsPath('/downloads/starter.tar')).toBe(false); + expect(isNegotiableDocsPath('/downloads/starter.zip')).toBe(false); + }); +});