481 lines
15 KiB
TypeScript
481 lines
15 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
realpathSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { test, type TestContext } from "node:test";
|
|
import {
|
|
computeSkillPackageSha256,
|
|
createSkillPackageRegistry,
|
|
resolveActiveSkillPackage,
|
|
resolveExactSkillPackage,
|
|
resolveSkillPackageRuntimePath,
|
|
resolveSkillPackageVersion,
|
|
verifyAllActiveSkillPackages,
|
|
type SkillPackageIdentity,
|
|
} from "../src/lib/skill-package-registry.ts";
|
|
|
|
const sourceCommit = "0fd111d16b45796086a6c1d0945dbd3de6755d8a";
|
|
const projectRoot = fileURLToPath(new URL("../../", import.meta.url));
|
|
|
|
type RegistryEntry = SkillPackageIdentity | Record<string, unknown>;
|
|
|
|
function sha256(bytes: string | Buffer): string {
|
|
return createHash("sha256").update(bytes).digest("hex");
|
|
}
|
|
|
|
function fixture(t: TestContext): {
|
|
root: string;
|
|
registryPath: string;
|
|
writeSkill: (packagePath: string, contents: string) => string;
|
|
writeRegistry: (packages: RegistryEntry[]) => void;
|
|
} {
|
|
const root = mkdtempSync(join(tmpdir(), "skill-registry-test-"));
|
|
const registryPath = join(root, "skills", "skill-package-registry.json");
|
|
mkdirSync(dirname(registryPath), { recursive: true });
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
|
|
return {
|
|
root,
|
|
registryPath,
|
|
writeSkill(packagePath, contents) {
|
|
const directory = join(root, packagePath);
|
|
mkdirSync(directory, { recursive: true });
|
|
writeFileSync(join(directory, "SKILL.md"), contents);
|
|
return computeSkillPackageSha256(directory);
|
|
},
|
|
writeRegistry(packages) {
|
|
writeFileSync(
|
|
registryPath,
|
|
`${JSON.stringify({ schemaVersion: 1, packages }, null, 2)}\n`,
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
function identity(
|
|
overrides: Partial<SkillPackageIdentity> = {},
|
|
): SkillPackageIdentity {
|
|
return {
|
|
name: "example-skill",
|
|
version: "1.0.0",
|
|
sha256: "a".repeat(64),
|
|
sourceCommit,
|
|
packagePath: "skills/example-skill-1.0.0",
|
|
status: "active",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
test("checked-in registry verifies all three active packages and returns absolute directories", () => {
|
|
const packages = verifyAllActiveSkillPackages({ projectRoot });
|
|
assert.deepEqual(
|
|
packages.map(({ name, version, sha256 }) => ({ name, version, sha256 })),
|
|
[
|
|
{
|
|
name: "jyotish-vedic-astrology",
|
|
version: "6.9.14",
|
|
sha256: "d3d6d05c1da25bb684af3be591687f0321b9a3a69a9896454ea3e25b57b6071b",
|
|
},
|
|
{
|
|
name: "jyotish-birth-time-rectification",
|
|
version: "10.0.0",
|
|
sha256: "b66f243d266e12527b6934cc0c5925df654a9e7bcbaabea3bf244b2e1dfaf2e7",
|
|
},
|
|
{
|
|
name: "jyotish-personal-report",
|
|
version: "1.0.0",
|
|
sha256: "23149b9e1146b5a66b0762520b58eb750b67d95007144b959f0ff3d9ee2bf982",
|
|
},
|
|
],
|
|
);
|
|
for (const resolvedPackage of packages) {
|
|
assert.equal(isAbsolute(resolvedPackage.resolvedPath), true);
|
|
assert.equal(
|
|
resolvedPackage.resolvedPath,
|
|
resolve(projectRoot, resolvedPackage.packagePath),
|
|
);
|
|
}
|
|
assert.equal(packages[0]?.sourceCommit, null);
|
|
assert.equal(packages[1]?.sourceCommit, null);
|
|
assert.equal(packages[2]?.sourceCommit, null);
|
|
|
|
const active = resolveActiveSkillPackage("jyotish-vedic-astrology", {
|
|
projectRoot,
|
|
});
|
|
assert.equal(active.version, "6.9.14");
|
|
const runtimePath = resolveSkillPackageRuntimePath(active);
|
|
assert.equal(basename(runtimePath), active.name);
|
|
assert.equal(realpathSync(runtimePath), active.resolvedPath);
|
|
assert.notEqual(runtimePath, active.resolvedPath);
|
|
assert.deepEqual(
|
|
resolveExactSkillPackage(active.name, active.version, active.sha256, {
|
|
projectRoot,
|
|
}),
|
|
active,
|
|
);
|
|
});
|
|
|
|
test("exact resolution of an old deprecated package is independent of active switches", (t) => {
|
|
const f = fixture(t);
|
|
const oldHash = f.writeSkill("skills/example-1.0.0", "old immutable bytes\n");
|
|
const currentHash = f.writeSkill(
|
|
"skills/example-2.0.0",
|
|
"current immutable bytes\n",
|
|
);
|
|
const nextHash = f.writeSkill("skills/example-3.0.0", "next immutable bytes\n");
|
|
const oldIdentity = identity({
|
|
version: "1.0.0",
|
|
sha256: oldHash,
|
|
packagePath: "skills/example-1.0.0",
|
|
status: "deprecated",
|
|
});
|
|
const currentIdentity = identity({
|
|
version: "2.0.0",
|
|
sha256: currentHash,
|
|
packagePath: "skills/example-2.0.0",
|
|
});
|
|
const nextIdentity = identity({
|
|
version: "3.0.0",
|
|
sha256: nextHash,
|
|
packagePath: "skills/example-3.0.0",
|
|
status: "deprecated",
|
|
});
|
|
f.writeRegistry([oldIdentity, currentIdentity, nextIdentity]);
|
|
|
|
const registry = createSkillPackageRegistry({
|
|
projectRoot: f.root,
|
|
registryPath: f.registryPath,
|
|
});
|
|
assert.equal(registry.resolveActive("example-skill").version, "2.0.0");
|
|
assert.deepEqual(registry.resolveExact("example-skill", "1.0.0", oldHash), {
|
|
...oldIdentity,
|
|
resolvedPath: realpathSync(join(f.root, "skills/example-1.0.0")),
|
|
});
|
|
|
|
f.writeRegistry([
|
|
oldIdentity,
|
|
{ ...currentIdentity, status: "deprecated" },
|
|
{ ...nextIdentity, status: "active" },
|
|
]);
|
|
assert.equal(registry.resolveActive("example-skill").version, "3.0.0");
|
|
assert.equal(
|
|
registry.resolveExact("example-skill", "1.0.0", oldHash).sha256,
|
|
oldHash,
|
|
);
|
|
});
|
|
|
|
test("version resolution requires one non-blocked identity and verifies its bytes", (t) => {
|
|
const f = fixture(t);
|
|
const oldHash = f.writeSkill("skills/example-old", "old version\n");
|
|
const oldIdentity = identity({
|
|
sha256: oldHash,
|
|
packagePath: "skills/example-old",
|
|
status: "deprecated",
|
|
});
|
|
f.writeRegistry([oldIdentity]);
|
|
|
|
const registry = createSkillPackageRegistry({
|
|
projectRoot: f.root,
|
|
registryPath: f.registryPath,
|
|
});
|
|
assert.throws(() => registry.resolveActive("example-skill"), /No active/);
|
|
|
|
assert.deepEqual(
|
|
resolveSkillPackageVersion("example-skill", "1.0.0", {
|
|
projectRoot: f.root,
|
|
registryPath: f.registryPath,
|
|
}),
|
|
{
|
|
...oldIdentity,
|
|
resolvedPath: realpathSync(join(f.root, "skills/example-old")),
|
|
},
|
|
);
|
|
|
|
const alternateHash = f.writeSkill(
|
|
"skills/example-old-repacked",
|
|
"different bytes\n",
|
|
);
|
|
f.writeRegistry([
|
|
oldIdentity,
|
|
identity({
|
|
sha256: alternateHash,
|
|
packagePath: "skills/example-old-repacked",
|
|
status: "deprecated",
|
|
}),
|
|
]);
|
|
assert.throws(
|
|
() =>
|
|
resolveSkillPackageVersion("example-skill", "1.0.0", {
|
|
projectRoot: f.root,
|
|
registryPath: f.registryPath,
|
|
}),
|
|
/Expected one registry identity/,
|
|
);
|
|
|
|
f.writeRegistry([{ ...oldIdentity, status: "blocked" }]);
|
|
assert.throws(
|
|
() =>
|
|
resolveSkillPackageVersion("example-skill", "1.0.0", {
|
|
projectRoot: f.root,
|
|
registryPath: f.registryPath,
|
|
}),
|
|
/blocked/,
|
|
);
|
|
});
|
|
|
|
test("missing lookup identities fail closed", (t) => {
|
|
const f = fixture(t);
|
|
f.writeRegistry([]);
|
|
const options = { projectRoot: f.root, registryPath: f.registryPath };
|
|
const registry = createSkillPackageRegistry(options);
|
|
|
|
assert.throws(() => registry.resolveActive("example-skill"), /No active/);
|
|
assert.throws(
|
|
() => registry.resolveExact("example-skill", "1.0.0", "a".repeat(64)),
|
|
/No exact skill package/,
|
|
);
|
|
assert.throws(
|
|
() => resolveSkillPackageVersion("example-skill", "1.0.0", options),
|
|
/found 0/,
|
|
);
|
|
});
|
|
|
|
test("missing identity fields fail closed", (t) => {
|
|
const f = fixture(t);
|
|
f.writeSkill("skills/example-skill-1.0.0", "bytes\n");
|
|
const missingHash: Record<string, unknown> = { ...identity() };
|
|
delete missingHash.sha256;
|
|
f.writeRegistry([missingHash]);
|
|
|
|
assert.throws(
|
|
() =>
|
|
createSkillPackageRegistry({
|
|
projectRoot: f.root,
|
|
registryPath: f.registryPath,
|
|
}).resolveActive("example-skill"),
|
|
/missing sha256/,
|
|
);
|
|
});
|
|
|
|
test("hash mismatch fails closed after reading the complete package tree", (t) => {
|
|
const f = fixture(t);
|
|
const packagePath = "skills/example-skill-1.0.0";
|
|
const expectedHash = f.writeSkill(packagePath, "expected bytes\n");
|
|
mkdirSync(join(f.root, packagePath, "references"), { recursive: true });
|
|
writeFileSync(
|
|
join(f.root, packagePath, "references", "runtime-policy.md"),
|
|
"tampered reference bytes\n",
|
|
);
|
|
f.writeRegistry([identity({ sha256: expectedHash })]);
|
|
|
|
assert.throws(
|
|
() =>
|
|
createSkillPackageRegistry({
|
|
projectRoot: f.root,
|
|
registryPath: f.registryPath,
|
|
}).resolveActive("example-skill"),
|
|
/SHA-256 mismatch/,
|
|
);
|
|
});
|
|
|
|
test("package hash covers paths, executable bits, references, scripts, and assets", (t) => {
|
|
const f = fixture(t);
|
|
const packageDirectory = join(f.root, "skills", "example-skill-1.0.0");
|
|
mkdirSync(join(packageDirectory, "references"), { recursive: true });
|
|
mkdirSync(join(packageDirectory, "scripts"), { recursive: true });
|
|
mkdirSync(join(packageDirectory, "assets"), { recursive: true });
|
|
writeFileSync(join(packageDirectory, "SKILL.md"), "skill bytes\n");
|
|
writeFileSync(join(packageDirectory, "references", "guide.md"), "guide v1\n");
|
|
writeFileSync(join(packageDirectory, "scripts", "run.py"), "print('v1')\n");
|
|
writeFileSync(join(packageDirectory, "assets", "template.txt"), "asset v1\n");
|
|
|
|
const initial = computeSkillPackageSha256(packageDirectory);
|
|
writeFileSync(join(packageDirectory, "references", "guide.md"), "guide v2\n");
|
|
const changedReference = computeSkillPackageSha256(packageDirectory);
|
|
assert.notEqual(changedReference, initial);
|
|
|
|
writeFileSync(join(packageDirectory, "references", "guide.md"), "guide v1\n");
|
|
writeFileSync(join(packageDirectory, "assets", "template.txt"), "asset v2\n");
|
|
assert.notEqual(computeSkillPackageSha256(packageDirectory), initial);
|
|
});
|
|
|
|
test("path traversal and symlink escape fail closed", (t) => {
|
|
const f = fixture(t);
|
|
f.writeRegistry([
|
|
identity({
|
|
packagePath: "skills/../outside",
|
|
sha256: sha256("outside\n"),
|
|
}),
|
|
]);
|
|
assert.throws(
|
|
() =>
|
|
createSkillPackageRegistry({
|
|
projectRoot: f.root,
|
|
registryPath: f.registryPath,
|
|
}).resolveActive("example-skill"),
|
|
/unsafe packagePath/,
|
|
);
|
|
|
|
const outside = mkdtempSync(join(tmpdir(), "skill-registry-outside-"));
|
|
t.after(() => rmSync(outside, { recursive: true, force: true }));
|
|
writeFileSync(join(outside, "SKILL.md"), "outside\n");
|
|
const packageDirectory = join(f.root, "skills", "symlinked-skill");
|
|
mkdirSync(packageDirectory, { recursive: true });
|
|
symlinkSync(join(outside, "SKILL.md"), join(packageDirectory, "SKILL.md"));
|
|
f.writeRegistry([
|
|
identity({
|
|
packagePath: "skills/symlinked-skill",
|
|
sha256: sha256("outside\n"),
|
|
}),
|
|
]);
|
|
assert.throws(
|
|
() =>
|
|
createSkillPackageRegistry({
|
|
projectRoot: f.root,
|
|
registryPath: f.registryPath,
|
|
}).resolveActive("example-skill"),
|
|
/resolves outside the project root/,
|
|
);
|
|
});
|
|
|
|
test("symbolic links are rejected even when their target stays inside the project root", (t) => {
|
|
const f = fixture(t);
|
|
const packageDirectory = join(f.root, "skills", "symlinked-skill");
|
|
const sharedDirectory = join(f.root, "shared");
|
|
mkdirSync(packageDirectory, { recursive: true });
|
|
mkdirSync(sharedDirectory, { recursive: true });
|
|
writeFileSync(join(packageDirectory, "SKILL.md"), "skill bytes\n");
|
|
writeFileSync(join(sharedDirectory, "guide.md"), "shared bytes\n");
|
|
symlinkSync(sharedDirectory, join(packageDirectory, "references"));
|
|
f.writeRegistry([
|
|
identity({
|
|
packagePath: "skills/symlinked-skill",
|
|
sha256: "a".repeat(64),
|
|
}),
|
|
]);
|
|
|
|
assert.throws(
|
|
() =>
|
|
createSkillPackageRegistry({
|
|
projectRoot: f.root,
|
|
registryPath: f.registryPath,
|
|
}).resolveActive("example-skill"),
|
|
/must not be a symbolic link/,
|
|
);
|
|
});
|
|
|
|
test("blocked packages are rejected by active, exact, and version resolution", (t) => {
|
|
const f = fixture(t);
|
|
const hash = f.writeSkill("skills/example-skill-1.0.0", "blocked bytes\n");
|
|
f.writeRegistry([identity({ sha256: hash, status: "blocked" })]);
|
|
const registry = createSkillPackageRegistry({
|
|
projectRoot: f.root,
|
|
registryPath: f.registryPath,
|
|
});
|
|
|
|
assert.throws(() => registry.resolveActive("example-skill"), /No active/);
|
|
assert.throws(
|
|
() => registry.resolveExact("example-skill", "1.0.0", hash),
|
|
/blocked/,
|
|
);
|
|
assert.throws(
|
|
() => registry.resolveVersion("example-skill", "1.0.0"),
|
|
/blocked/,
|
|
);
|
|
});
|
|
|
|
test("duplicate active packages fail the entire registry closed", (t) => {
|
|
const f = fixture(t);
|
|
const firstHash = f.writeSkill("skills/example-1.0.0", "first\n");
|
|
const secondHash = f.writeSkill("skills/example-2.0.0", "second\n");
|
|
f.writeRegistry([
|
|
identity({
|
|
sha256: firstHash,
|
|
packagePath: "skills/example-1.0.0",
|
|
}),
|
|
identity({
|
|
version: "2.0.0",
|
|
sha256: secondHash,
|
|
packagePath: "skills/example-2.0.0",
|
|
}),
|
|
]);
|
|
|
|
const registry = createSkillPackageRegistry({
|
|
projectRoot: f.root,
|
|
registryPath: f.registryPath,
|
|
});
|
|
assert.throws(() => registry.resolveActive("example-skill"), /duplicate active/);
|
|
assert.throws(() => registry.verifyAllActive(), /duplicate active/);
|
|
});
|
|
|
|
test("nullable source commits are accepted while malformed non-null commits fail closed", (t) => {
|
|
const f = fixture(t);
|
|
const hash = f.writeSkill("skills/example-skill-1.0.0", "bytes\n");
|
|
f.writeRegistry([identity({ sha256: hash, sourceCommit: null })]);
|
|
|
|
const resolved = createSkillPackageRegistry({
|
|
projectRoot: f.root,
|
|
registryPath: f.registryPath,
|
|
}).resolveActive("example-skill");
|
|
assert.equal(resolved.sourceCommit, null);
|
|
|
|
f.writeRegistry([identity({ sha256: hash, sourceCommit: "not-a-commit" })]);
|
|
assert.throws(
|
|
() =>
|
|
createSkillPackageRegistry({
|
|
projectRoot: f.root,
|
|
registryPath: f.registryPath,
|
|
}).resolveActive("example-skill"),
|
|
/invalid sourceCommit/,
|
|
);
|
|
});
|
|
|
|
test("invalid hash, commit, and non-directory package paths fail closed", (t) => {
|
|
const f = fixture(t);
|
|
f.writeRegistry([identity({ sha256: "A".repeat(64) })]);
|
|
assert.throws(
|
|
() =>
|
|
createSkillPackageRegistry({
|
|
projectRoot: f.root,
|
|
registryPath: f.registryPath,
|
|
}).resolveActive("example-skill"),
|
|
/invalid sha256/,
|
|
);
|
|
|
|
f.writeRegistry([identity({ sourceCommit: "not-a-commit" })]);
|
|
assert.throws(
|
|
() =>
|
|
createSkillPackageRegistry({
|
|
projectRoot: f.root,
|
|
registryPath: f.registryPath,
|
|
}).resolveActive("example-skill"),
|
|
/invalid sourceCommit/,
|
|
);
|
|
|
|
const filePath = join(f.root, "skills", "not-a-directory");
|
|
writeFileSync(filePath, "file\n");
|
|
f.writeRegistry([
|
|
identity({
|
|
packagePath: "skills/not-a-directory",
|
|
sha256: sha256("file\n"),
|
|
}),
|
|
]);
|
|
assert.throws(
|
|
() =>
|
|
createSkillPackageRegistry({
|
|
projectRoot: f.root,
|
|
registryPath: f.registryPath,
|
|
}).resolveActive("example-skill"),
|
|
/must be a directory/,
|
|
);
|
|
});
|