Files
Jyotisha/frontend/src/lib/skill-package-registry.ts
T

723 lines
21 KiB
TypeScript

import { createHash } from "node:crypto";
import {
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
realpathSync,
statSync,
symlinkSync,
} from "node:fs";
import {
basename,
isAbsolute,
join,
relative,
resolve,
sep,
win32,
} from "node:path";
import { tmpdir } from "node:os";
export type SkillPackageStatus = "active" | "deprecated" | "blocked";
export interface SkillPackageIdentity {
name: string;
version: string;
sha256: string;
sourceCommit: string | null;
packagePath: string;
status: SkillPackageStatus;
}
export type ResolvedSkillPackageIdentity = SkillPackageIdentity & {
resolvedPath: string;
};
export interface SkillPackageRegistryOptions {
projectRoot?: string;
registryPath?: string;
}
interface SkillPackageRegistryDocument {
schemaVersion?: number;
packages: SkillPackageIdentity[];
}
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
const GIT_COMMIT_PATTERN = /^[0-9a-f]{40}$/;
const NAME_PATTERN = /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/;
const VERSION_PATTERN =
/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
const STATUS_VALUES = new Set<SkillPackageStatus>([
"active",
"deprecated",
"blocked",
]);
const IDENTITY_KEYS = new Set([
"name",
"version",
"sha256",
"sourceCommit",
"packagePath",
"status",
]);
const DEFAULT_REGISTRY_PATH = "skills/skill-package-registry.json";
const SOURCE_TREE_PROJECT_ROOT = process.env.JYOTISHA_PROJECT_ROOT
?? (existsSync(resolve(process.cwd(), DEFAULT_REGISTRY_PATH))
? process.cwd()
: resolve(process.cwd(), ".."));
const PACKAGE_HASH_DOMAIN = "jyotisha-skill-package-v1\0";
const RUNTIME_SKILL_ROOT = mkdtempSync(join(tmpdir(), "jyotisha-skill-runtime-"));
export class SkillPackageRegistryError extends Error {
constructor(message: string) {
super(message);
this.name = "SkillPackageRegistryError";
}
}
function fail(message: string): never {
throw new SkillPackageRegistryError(message);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function assertString(
value: unknown,
field: keyof SkillPackageIdentity,
index: number,
): string {
if (typeof value !== "string" || value.length === 0) {
return fail(`Registry package ${index} has invalid or missing ${field}`);
}
return value;
}
function validatePackagePath(packagePath: string, index: number): void {
if (
packagePath.includes("\0") ||
packagePath.includes("\\") ||
isAbsolute(packagePath) ||
win32.isAbsolute(packagePath)
) {
fail(`Registry package ${index} has an unsafe packagePath`);
}
const segments = packagePath.split("/");
if (
segments.length === 0 ||
segments.some(
(segment) => segment === "" || segment === "." || segment === "..",
)
) {
fail(`Registry package ${index} has an unsafe packagePath`);
}
}
function parseIdentity(value: unknown, index: number): SkillPackageIdentity {
if (!isRecord(value)) {
return fail(`Registry package ${index} must be an object`);
}
const unknownKeys = Object.keys(value).filter((key) => !IDENTITY_KEYS.has(key));
if (unknownKeys.length > 0) {
fail(
`Registry package ${index} contains unknown fields: ${unknownKeys.join(", ")}`,
);
}
const name = assertString(value.name, "name", index);
const version = assertString(value.version, "version", index);
const sha256 = assertString(value.sha256, "sha256", index);
const sourceCommit = value.sourceCommit === null
? null
: assertString(value.sourceCommit, "sourceCommit", index);
const packagePath = assertString(value.packagePath, "packagePath", index);
const status = assertString(value.status, "status", index);
if (!NAME_PATTERN.test(name)) {
fail(`Registry package ${index} has an invalid name`);
}
if (!VERSION_PATTERN.test(version)) {
fail(`Registry package ${index} has an invalid version`);
}
if (!SHA256_PATTERN.test(sha256)) {
fail(`Registry package ${index} has an invalid sha256`);
}
if (sourceCommit !== null && !GIT_COMMIT_PATTERN.test(sourceCommit)) {
fail(`Registry package ${index} has an invalid sourceCommit`);
}
validatePackagePath(packagePath, index);
if (!STATUS_VALUES.has(status as SkillPackageStatus)) {
fail(`Registry package ${index} has an invalid status`);
}
return Object.freeze({
name,
version,
sha256,
sourceCommit,
packagePath,
status: status as SkillPackageStatus,
});
}
function parseRegistry(contents: string): SkillPackageRegistryDocument {
let value: unknown;
try {
value = JSON.parse(contents);
} catch (error) {
fail(
`Skill package registry is not valid JSON: ${
error instanceof Error ? error.message : "unknown parse error"
}`,
);
}
if (!isRecord(value) || !Array.isArray(value.packages)) {
return fail("Skill package registry must contain a packages array");
}
if (value.schemaVersion !== undefined && value.schemaVersion !== 1) {
fail("Skill package registry has an unsupported schemaVersion");
}
const packages = value.packages.map(parseIdentity);
const exactIdentities = new Set<string>();
const activeNames = new Set<string>();
for (const identity of packages) {
const exactKey = `${identity.name}\0${identity.version}\0${identity.sha256}`;
if (exactIdentities.has(exactKey)) {
fail(
`Registry contains duplicate identity ${identity.name}@${identity.version}#${identity.sha256}`,
);
}
exactIdentities.add(exactKey);
if (identity.status === "active") {
if (activeNames.has(identity.name)) {
fail(`Registry contains duplicate active package ${identity.name}`);
}
activeNames.add(identity.name);
}
}
return { schemaVersion: value.schemaVersion as number | undefined, packages };
}
function isInside(rootPath: string, candidatePath: string): boolean {
const relativePath = relative(rootPath, candidatePath);
return (
relativePath === "" ||
(!relativePath.startsWith(`..${sep}`) &&
relativePath !== ".." &&
!isAbsolute(relativePath))
);
}
function resolveExistingPath(pathValue: string, label: string): string {
try {
return realpathSync(pathValue);
} catch (error) {
return fail(
`${label} does not exist or cannot be resolved: ${
error instanceof Error ? error.message : "unknown filesystem error"
}`,
);
}
}
function assertDirectory(pathValue: string, label: string): void {
let stats;
try {
stats = statSync(pathValue);
} catch (error) {
fail(
`${label} cannot be inspected: ${
error instanceof Error ? error.message : "unknown filesystem error"
}`,
);
}
if (!stats.isDirectory()) {
fail(`${label} must be a directory`);
}
}
function assertRegularFile(pathValue: string, label: string): void {
let stats;
try {
stats = statSync(pathValue);
} catch (error) {
fail(
`${label} cannot be inspected: ${
error instanceof Error ? error.message : "unknown filesystem error"
}`,
);
}
if (!stats.isFile()) {
fail(`${label} must be a regular file`);
}
}
function comparePathNames(left: string, right: string): number {
if (left === right) return 0;
return left < right ? -1 : 1;
}
function updateLengthPrefixed(
hash: ReturnType<typeof createHash>,
label: string,
value: string | Buffer,
): void {
const bytes = typeof value === "string" ? Buffer.from(value, "utf8") : value;
hash.update(`${label}:${bytes.length}\0`, "utf8");
hash.update(bytes);
hash.update("\0", "utf8");
}
/**
* Hash the complete immutable package tree, not just SKILL.md. Paths,
* executable bits, and file bytes are framed deterministically so references,
* scripts, and assets are part of the runtime identity.
*/
export function computeSkillPackageSha256(packageDirectory: string): string {
const resolvedDirectory = resolveExistingPath(
resolve(packageDirectory),
"Skill package directory",
);
assertDirectory(resolvedDirectory, "Skill package directory");
const hash = createHash("sha256");
hash.update(PACKAGE_HASH_DOMAIN, "utf8");
let fileCount = 0;
const visit = (directory: string, relativeDirectory: string): void => {
let entries;
try {
entries = readdirSync(directory, { withFileTypes: true });
} catch (error) {
fail(
`Skill package directory cannot be read: ${
error instanceof Error ? error.message : "unknown filesystem error"
}`,
);
}
entries.sort((left, right) => comparePathNames(left.name, right.name));
for (const entry of entries) {
const absolutePath = resolve(directory, entry.name);
const relativePath = relativeDirectory
? `${relativeDirectory}/${entry.name}`
: entry.name;
let stats;
try {
stats = lstatSync(absolutePath);
} catch (error) {
fail(
`Skill package entry ${relativePath} cannot be inspected: ${
error instanceof Error ? error.message : "unknown filesystem error"
}`,
);
}
if (stats.isSymbolicLink()) {
fail(`Skill package entry ${relativePath} must not be a symbolic link`);
}
if (stats.isDirectory()) {
visit(absolutePath, relativePath);
continue;
}
if (!stats.isFile()) {
fail(`Skill package entry ${relativePath} must be a regular file`);
}
let bytes: Buffer;
try {
bytes = readFileSync(absolutePath);
} catch (error) {
fail(
`Skill package entry ${relativePath} cannot be read: ${
error instanceof Error ? error.message : "unknown filesystem error"
}`,
);
}
updateLengthPrefixed(hash, "path", relativePath);
updateLengthPrefixed(hash, "executable", stats.mode & 0o111 ? "1" : "0");
updateLengthPrefixed(hash, "bytes", bytes);
fileCount += 1;
}
};
visit(resolvedDirectory, "");
if (fileCount === 0) {
fail("Skill package directory must contain at least one regular file");
}
return hash.digest("hex");
}
function normalizeLookupName(name: string): string {
if (!NAME_PATTERN.test(name)) {
fail("Skill package name is invalid");
}
return name;
}
function normalizeLookupVersion(version: string): string {
if (!VERSION_PATTERN.test(version)) {
fail("Skill package version is invalid");
}
return version;
}
function normalizeLookupHash(sha256: string): string {
if (!SHA256_PATTERN.test(sha256)) {
fail("Skill package sha256 is invalid");
}
return sha256;
}
export class SkillPackageRegistry {
readonly projectRoot: string;
readonly registryPath: string;
constructor(options: SkillPackageRegistryOptions = {}) {
const requestedProjectRoot = resolve(
options.projectRoot ?? process.env.JYOTISHA_PROJECT_ROOT ?? SOURCE_TREE_PROJECT_ROOT,
);
this.projectRoot = resolveExistingPath(
requestedProjectRoot,
"Skill registry project root",
);
assertDirectory(this.projectRoot, "Skill registry project root");
const requestedRegistryPath = options.registryPath
? isAbsolute(options.registryPath)
? resolve(options.registryPath)
: resolve(this.projectRoot, options.registryPath)
: resolve(this.projectRoot, DEFAULT_REGISTRY_PATH);
const registryPath = resolveExistingPath(
requestedRegistryPath,
"Skill package registry",
);
if (!isInside(this.projectRoot, registryPath)) {
fail("Skill package registry escapes the project root");
}
assertRegularFile(registryPath, "Skill package registry");
this.registryPath = registryPath;
}
resolveActiveSkillPackage(name: string): ResolvedSkillPackageIdentity {
return this.resolveActive(name);
}
resolveActive(name: string): ResolvedSkillPackageIdentity {
const normalizedName = normalizeLookupName(name);
const registry = this.loadRegistry();
const identity = registry.packages.find(
(candidate) =>
candidate.name === normalizedName && candidate.status === "active",
);
if (!identity) {
return fail(`No active skill package found for ${normalizedName}`);
}
return this.verifyIdentity(identity);
}
resolveExactSkillPackage(
name: string,
version: string,
sha256: string,
): ResolvedSkillPackageIdentity {
return this.resolveExact(name, version, sha256);
}
resolveExact(
name: string,
version: string,
sha256: string,
): ResolvedSkillPackageIdentity {
const normalizedName = normalizeLookupName(name);
const normalizedVersion = normalizeLookupVersion(version);
const normalizedHash = normalizeLookupHash(sha256);
const registry = this.loadRegistry();
const identity = registry.packages.find(
(candidate) =>
candidate.name === normalizedName &&
candidate.version === normalizedVersion &&
candidate.sha256 === normalizedHash,
);
if (!identity) {
return fail(
`No exact skill package found for ${normalizedName}@${normalizedVersion}#${normalizedHash}`,
);
}
if (identity.status === "blocked") {
return fail(
`Skill package ${normalizedName}@${normalizedVersion} is blocked`,
);
}
return this.verifyIdentity(identity);
}
resolveSkillPackageVersion(
name: string,
version: string,
): ResolvedSkillPackageIdentity {
return this.resolveVersion(name, version);
}
resolveVersion(name: string, version: string): ResolvedSkillPackageIdentity {
const normalizedName = normalizeLookupName(name);
const normalizedVersion = normalizeLookupVersion(version);
const registry = this.loadRegistry();
const matches = registry.packages.filter(
(candidate) =>
candidate.name === normalizedName &&
candidate.version === normalizedVersion,
);
if (matches.length !== 1) {
return fail(
`Expected one registry identity for ${normalizedName}@${normalizedVersion}, found ${matches.length}`,
);
}
const [identity] = matches;
if (identity.status === "blocked") {
return fail(
`Skill package ${normalizedName}@${normalizedVersion} is blocked`,
);
}
return this.verifyIdentity(identity);
}
verifyAllActiveSkillPackages(): readonly ResolvedSkillPackageIdentity[] {
return this.verifyAllActive();
}
verifyAllActive(): readonly ResolvedSkillPackageIdentity[] {
const registry = this.loadRegistry();
return Object.freeze(
registry.packages
.filter((identity) => identity.status === "active")
.map((identity) => this.verifyIdentity(identity)),
);
}
private loadRegistry(): SkillPackageRegistryDocument {
const currentRegistryPath = resolveExistingPath(
this.registryPath,
"Skill package registry",
);
if (!isInside(this.projectRoot, currentRegistryPath)) {
return fail("Skill package registry escapes the project root");
}
assertRegularFile(currentRegistryPath, "Skill package registry");
let contents: string;
try {
contents = readFileSync(currentRegistryPath, "utf8");
} catch (error) {
return fail(
`Skill package registry cannot be read: ${
error instanceof Error ? error.message : "unknown filesystem error"
}`,
);
}
return parseRegistry(contents);
}
private verifyIdentity(
identity: SkillPackageIdentity,
): ResolvedSkillPackageIdentity {
const requestedPackagePath = resolve(
this.projectRoot,
identity.packagePath,
);
if (!isInside(this.projectRoot, requestedPackagePath)) {
return fail(
`Skill package ${identity.name}@${identity.version} escapes the project root`,
);
}
const packageDirectory = resolveExistingPath(
requestedPackagePath,
`Skill package directory ${identity.packagePath}`,
);
if (!isInside(this.projectRoot, packageDirectory)) {
return fail(
`Skill package ${identity.name}@${identity.version} resolves outside the project root`,
);
}
assertDirectory(
packageDirectory,
`Skill package directory ${identity.packagePath}`,
);
if (packageDirectory !== requestedPackagePath) {
return fail(
`Skill package ${identity.name}@${identity.version} path must not contain symbolic links`,
);
}
const requestedSkillPath = resolve(packageDirectory, "SKILL.md");
const skillPath = resolveExistingPath(
requestedSkillPath,
`SKILL.md for ${identity.name}@${identity.version}`,
);
if (!isInside(this.projectRoot, skillPath)) {
return fail(
`SKILL.md for ${identity.name}@${identity.version} resolves outside the project root`,
);
}
assertRegularFile(
skillPath,
`SKILL.md for ${identity.name}@${identity.version}`,
);
const actualHash = computeSkillPackageSha256(packageDirectory);
if (actualHash !== identity.sha256) {
return fail(
`SHA-256 mismatch for ${identity.name}@${identity.version}: expected ${identity.sha256}, got ${actualHash}`,
);
}
return Object.freeze({ ...identity, resolvedPath: packageDirectory });
}
}
/**
* Return a Mastra-compatible read-only alias for a verified package.
*
* Mastra validates that the configured skill directory basename matches the
* frontmatter name. Versioned registry packages intentionally live under a
* numeric directory (for example, `versions/6.9.14`), so loading that
* directory directly fails validation. The alias keeps the verified package
* bytes and identity unchanged while exposing the canonical skill name at the
* loader boundary.
*/
export function resolveSkillPackageRuntimePath(
skillPackage: ResolvedSkillPackageIdentity,
): string {
const packageDirectory = resolveExistingPath(
skillPackage.resolvedPath,
`Skill package directory ${skillPackage.packagePath}`,
);
assertDirectory(
packageDirectory,
`Skill package directory ${skillPackage.packagePath}`,
);
const actualHash = computeSkillPackageSha256(packageDirectory);
if (actualHash !== skillPackage.sha256) {
return fail(
`SHA-256 mismatch before runtime loading for ${skillPackage.name}@${skillPackage.version}: expected ${skillPackage.sha256}, got ${actualHash}`,
);
}
const runtimeParent = join(RUNTIME_SKILL_ROOT, skillPackage.sha256);
mkdirSync(runtimeParent, { recursive: true });
const runtimePath = join(runtimeParent, skillPackage.name);
if (basename(runtimePath) !== skillPackage.name) {
return fail(
`Runtime skill path must end with the canonical skill name ${skillPackage.name}`,
);
}
try {
const runtimeEntry = lstatSync(runtimePath);
if (!runtimeEntry.isSymbolicLink()) {
return fail(`Runtime skill path is not a symbolic-link alias: ${runtimePath}`);
}
const linkedTarget = resolveExistingPath(
runtimePath,
`Runtime skill alias for ${skillPackage.name}@${skillPackage.version}`,
);
if (linkedTarget !== packageDirectory) {
return fail(
`Runtime skill alias for ${skillPackage.name} points to an unexpected package`,
);
}
} catch (error) {
if (error instanceof SkillPackageRegistryError) throw error;
try {
symlinkSync(packageDirectory, runtimePath, "dir");
} catch (linkError) {
return fail(
`Runtime skill alias for ${skillPackage.name}@${skillPackage.version} could not be created: ${
linkError instanceof Error ? linkError.message : "unknown filesystem error"
}`,
);
}
}
return runtimePath;
}
export function createSkillPackageRegistry(
options: SkillPackageRegistryOptions = {},
): SkillPackageRegistry {
return new SkillPackageRegistry(options);
}
export function resolveActiveSkillPackage(
name: string,
options?: SkillPackageRegistryOptions,
): ResolvedSkillPackageIdentity {
return createSkillPackageRegistry(options).resolveActiveSkillPackage(name);
}
export function resolveActive(
name: string,
options?: SkillPackageRegistryOptions,
): ResolvedSkillPackageIdentity {
return createSkillPackageRegistry(options).resolveActive(name);
}
export function resolveExactSkillPackage(
name: string,
version: string,
sha256: string,
options?: SkillPackageRegistryOptions,
): ResolvedSkillPackageIdentity {
return createSkillPackageRegistry(options).resolveExactSkillPackage(
name,
version,
sha256,
);
}
export function resolveExact(
name: string,
version: string,
sha256: string,
options?: SkillPackageRegistryOptions,
): ResolvedSkillPackageIdentity {
return createSkillPackageRegistry(options).resolveExact(name, version, sha256);
}
export function resolveSkillPackageVersion(
name: string,
version: string,
options?: SkillPackageRegistryOptions,
): ResolvedSkillPackageIdentity {
return createSkillPackageRegistry(options).resolveSkillPackageVersion(
name,
version,
);
}
export function verifyAllActiveSkillPackages(
options?: SkillPackageRegistryOptions,
): readonly ResolvedSkillPackageIdentity[] {
return createSkillPackageRegistry(options).verifyAllActiveSkillPackages();
}
export function verifyAllActive(
options?: SkillPackageRegistryOptions,
): readonly ResolvedSkillPackageIdentity[] {
return createSkillPackageRegistry(options).verifyAllActive();
}