Files
curriculum-project-hub/hub/src/agent/skillStore.ts
T

218 lines
8.1 KiB
TypeScript

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<void>;
}
export async function importSkillDirectory(input: {
readonly sourceDir: string;
readonly storeRoot: string;
}): Promise<ImportedSkillContent> {
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<RunSkillPlugin | null> {
if (input.skills.length === 0) return null;
const names = new Set<string>();
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<Record<string, string | undefined>> = 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<void> {
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<ImportedSkillContent> {
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<void> {
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");
}