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); + }); +});