import { createHash, randomUUID } from "node:crypto"; import { cp, lstat, mkdir, readFile, readdir, rename, rm, writeFile, } from "node:fs/promises"; import { join, relative, resolve } from "node:path"; import type { RoleSkillEntry } from "./models.js"; const MAX_SKILL_FILES = 512; const MAX_SKILL_BYTES = 16 * 1024 * 1024; const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/; const DIGEST_PATTERN = /^[a-f0-9]{64}$/; const RUNTIME_PLUGIN_NAME = "cph-runtime"; export interface ImportedSkillContent { readonly name: string; readonly description: string | undefined; readonly contentDigest: string; } export interface RunSkillPlugin { readonly root: string; readonly skillIds: readonly string[]; cleanup(): Promise; } export async function importSkillDirectory(input: { readonly sourceDir: string; readonly storeRoot: string; }): Promise { const source = await inspectSkillDirectory(input.sourceDir); const versionsRoot = join(input.storeRoot, "versions"); await mkdir(versionsRoot, { recursive: true, mode: 0o750 }); const destination = join(versionsRoot, source.contentDigest); try { const existing = await inspectSkillDirectory(destination); if (existing.contentDigest !== source.contentDigest || existing.name !== source.name) { throw new Error(`stored skill content digest mismatch: ${source.name}`); } return source; } catch (error) { if (!isMissingPath(error)) throw error; } const temporary = join(versionsRoot, `.tmp-${randomUUID()}`); try { await cp(input.sourceDir, temporary, { recursive: true, force: false, errorOnExist: true }); const copied = await inspectSkillDirectory(temporary); if (copied.contentDigest !== source.contentDigest || copied.name !== source.name) { throw new Error(`skill changed while importing: ${source.name}`); } await rename(temporary, destination); } catch (error) { await rm(temporary, { recursive: true, force: true }); if (isDestinationExists(error)) { const existing = await inspectSkillDirectory(destination); if (existing.contentDigest === source.contentDigest && existing.name === source.name) return source; } throw error; } return source; } export async function prepareRunSkillPlugin(input: { readonly storeRoot: string; readonly runId: string; readonly skills: readonly RoleSkillEntry[]; }): Promise { if (input.skills.length === 0) return null; const names = new Set(); for (const skill of input.skills) { requireSkillName(skill.name); if (!DIGEST_PATTERN.test(skill.contentDigest)) { throw new Error(`skill ${skill.name} has invalid content digest`); } if (names.has(skill.name)) throw new Error(`duplicate role skill: ${skill.name}`); names.add(skill.name); } const runtimeRoot = join(input.storeRoot, "runtime"); await mkdir(runtimeRoot, { recursive: true, mode: 0o750 }); const pluginRoot = join(runtimeRoot, `run-${randomUUID()}`); try { await mkdir(join(pluginRoot, ".claude-plugin"), { recursive: true, mode: 0o750 }); await mkdir(join(pluginRoot, "skills"), { recursive: true, mode: 0o750 }); await writeFile( join(pluginRoot, ".claude-plugin", "plugin.json"), `${JSON.stringify({ name: RUNTIME_PLUGIN_NAME, description: `Runtime skill snapshot for ${input.runId}`, version: "1", }, null, 2)}\n`, { mode: 0o640 }, ); for (const skill of input.skills) { const sourceDir = join(input.storeRoot, "versions", skill.contentDigest); const stored = await inspectSkillDirectory(sourceDir); if (stored.contentDigest !== skill.contentDigest) { throw new Error(`skill ${skill.name} content digest mismatch`); } if (stored.name !== skill.name) { throw new Error(`skill name mismatch: expected ${skill.name}, got ${stored.name}`); } await cp(sourceDir, join(pluginRoot, "skills", skill.name), { recursive: true, force: false, errorOnExist: true, }); } } catch (error) { await rm(pluginRoot, { recursive: true, force: true }); throw error; } return { root: pluginRoot, skillIds: input.skills.map((skill) => `${RUNTIME_PLUGIN_NAME}:${skill.name}`), async cleanup() { await rm(pluginRoot, { recursive: true, force: true }); }, }; } export function readSkillStoreRoot(env: Readonly> = process.env): string { const configured = env["HUB_SKILL_STORE_ROOT"]?.trim(); if (configured !== undefined && configured !== "") return resolve(configured); const stateRoot = env["XDG_STATE_HOME"]?.trim(); if (stateRoot === undefined || stateRoot === "") { throw new Error("HUB_SKILL_STORE_ROOT or XDG_STATE_HOME is required"); } return resolve(stateRoot, "skills"); } export async function verifyStoredSkill(input: { readonly storeRoot: string; readonly name: string; readonly contentDigest: string; }): Promise { requireSkillName(input.name); if (!DIGEST_PATTERN.test(input.contentDigest)) { throw new Error(`skill ${input.name} has invalid content digest`); } const stored = await inspectSkillDirectory(join(input.storeRoot, "versions", input.contentDigest)); if (stored.name !== input.name || stored.contentDigest !== input.contentDigest) { throw new Error(`stored skill verification failed: ${input.name}`); } } async function inspectSkillDirectory(directory: string): Promise { const root = resolve(directory); const rootStat = await lstat(root); if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) { throw new Error(`skill root must be a real directory: ${root}`); } const files: Array<{ readonly path: string; readonly bytes: Buffer }> = []; await walk(root, root, files); if (files.length > MAX_SKILL_FILES) throw new Error(`skill has too many files: ${files.length}`); const totalBytes = files.reduce((sum, file) => sum + file.bytes.byteLength, 0); if (totalBytes > MAX_SKILL_BYTES) throw new Error(`skill is too large: ${totalBytes} bytes`); const manifest = files.find((file) => file.path === "SKILL.md"); if (manifest === undefined) throw new Error(`skill manifest missing: ${join(root, "SKILL.md")}`); const frontmatter = manifest.bytes.toString("utf8"); const name = /^name:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(frontmatter)?.[1]?.trim(); if (name === undefined) throw new Error("skill manifest name missing"); requireSkillName(name); const description = /^description:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(frontmatter)?.[1]?.trim(); const hash = createHash("sha256"); for (const file of files.sort((left, right) => left.path.localeCompare(right.path))) { hash.update(`${Buffer.byteLength(file.path)}:`); hash.update(file.path); hash.update(`${file.bytes.byteLength}:`); hash.update(file.bytes); } return { name, description, contentDigest: hash.digest("hex") }; } async function walk( root: string, directory: string, files: Array<{ readonly path: string; readonly bytes: Buffer }>, ): Promise { const entries = await readdir(directory, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(directory, entry.name); if (entry.isSymbolicLink()) throw new Error(`skill symlink is forbidden: ${fullPath}`); if (entry.isDirectory()) { await walk(root, fullPath, files); continue; } if (!entry.isFile()) throw new Error(`skill contains unsupported filesystem entry: ${fullPath}`); const relativePath = relative(root, fullPath); files.push({ path: relativePath, bytes: await readFile(fullPath) }); if (files.length > MAX_SKILL_FILES) throw new Error(`skill has too many files: ${files.length}`); } } function requireSkillName(name: string): void { if (!SKILL_NAME_PATTERN.test(name)) throw new Error(`invalid skill name: ${name}`); } function isMissingPath(error: unknown): boolean { return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"; } function isDestinationExists(error: unknown): boolean { return typeof error === "object" && error !== null && "code" in error && (error.code === "EEXIST" || error.code === "ENOTEMPTY"); }