feat(pi): verify bundled coding plugin artifact resources
This commit is contained in:
@@ -35,6 +35,9 @@ const EXTENSION_CONTRACT_MARKERS = Object.freeze([
|
||||
]);
|
||||
const PI_AI_PROVIDER_PREFIX = 'pi-runtime/node_modules/@earendil-works/pi-ai/dist/providers/';
|
||||
const PI_AI_PROVIDER_ASAR_PREFIX = 'app.asar/node_modules/@earendil-works/pi-ai/dist/providers/';
|
||||
export const BUNDLED_CODING_PLUGIN_RESOURCE_ROOT = 'resources/coding-plugins';
|
||||
export const CORE_CODING_SKILL_RESOURCE_ROOT = 'resources/coding-skills';
|
||||
const SDK_ASSET_PATH_PATTERN = /^skills\/[^/]+\/assets\/.+\.(?:js|ts)$/u;
|
||||
|
||||
async function pathExists(path) {
|
||||
try {
|
||||
@@ -45,6 +48,67 @@ async function pathExists(path) {
|
||||
}
|
||||
}
|
||||
|
||||
async function listResourceFiles(root) {
|
||||
const files = [];
|
||||
const visit = async (directory, prefix = '') => {
|
||||
if (!await pathExists(directory)) return;
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const path = join(directory, entry.name);
|
||||
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) await visit(path, relativePath);
|
||||
else if (entry.isFile()) files.push(relativePath);
|
||||
}
|
||||
};
|
||||
await visit(root);
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
function resolveBundledResource(root, baseDirectory, candidate, label) {
|
||||
if (typeof candidate !== 'string' || candidate.trim().length === 0) {
|
||||
throw new Error(`Bundled plugin ${label} is missing`);
|
||||
}
|
||||
const portableCandidate = candidate.replaceAll('\\', '/');
|
||||
if (portableCandidate.startsWith('/') || /^[A-Za-z]:\//u.test(portableCandidate)
|
||||
|| /^[A-Za-z][A-Za-z0-9+.-]*:/u.test(portableCandidate)) {
|
||||
throw new Error(`Bundled plugin ${label} must be relative`);
|
||||
}
|
||||
const resolved = resolve(baseDirectory, candidate);
|
||||
const relativePath = relative(root, resolved);
|
||||
if (!relativePath || relativePath === '..' || relativePath.startsWith(`..${sep}`)
|
||||
|| isAbsolute(relativePath)) {
|
||||
throw new Error(`Bundled plugin ${label} escapes its package root`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async function readJsonResource(path, label) {
|
||||
try {
|
||||
return JSON.parse(await readFile(path, 'utf8'));
|
||||
} catch (error) {
|
||||
throw new Error(`Bundled plugin ${label} is unreadable: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertMirroredResourceTree(sourceRoot, packagedRoot, label) {
|
||||
const [sourceFiles, packagedFiles] = await Promise.all([
|
||||
listResourceFiles(sourceRoot),
|
||||
listResourceFiles(packagedRoot),
|
||||
]);
|
||||
if (JSON.stringify(sourceFiles) !== JSON.stringify(packagedFiles)) {
|
||||
throw new Error(
|
||||
`Packaged ${label} files differ: expected ${sourceFiles.join(', ')}, got ${packagedFiles.join(', ')}`,
|
||||
);
|
||||
}
|
||||
for (const file of sourceFiles) {
|
||||
const [source, packaged] = await Promise.all([
|
||||
readFile(join(sourceRoot, file)),
|
||||
readFile(join(packagedRoot, file)),
|
||||
]);
|
||||
if (!source.equals(packaged)) throw new Error(`Packaged ${label} file differs: ${file}`);
|
||||
}
|
||||
return sourceFiles;
|
||||
}
|
||||
|
||||
function portable(path) {
|
||||
return path.split(sep).join('/');
|
||||
}
|
||||
@@ -264,6 +328,156 @@ async function sourceSkillIds(projectRoot) {
|
||||
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
|
||||
}
|
||||
|
||||
export async function verifyBundledCodingPluginResources({
|
||||
projectRoot,
|
||||
resourcesDirectory,
|
||||
appAsarContents,
|
||||
}) {
|
||||
const root = resolve(projectRoot);
|
||||
const sourcePluginRoot = join(root, BUNDLED_CODING_PLUGIN_RESOURCE_ROOT);
|
||||
const packagedPluginRoot = join(resourcesDirectory, BUNDLED_CODING_PLUGIN_RESOURCE_ROOT);
|
||||
if (!await pathExists(sourcePluginRoot)) {
|
||||
throw new Error(`Bundled coding plugin resources are missing: ${sourcePluginRoot}`);
|
||||
}
|
||||
if (!await pathExists(packagedPluginRoot)) {
|
||||
throw new Error(`Packaged coding plugin resources are missing: ${packagedPluginRoot}`);
|
||||
}
|
||||
|
||||
const [sourcePackages, packagedPackages] = await Promise.all([
|
||||
readdir(sourcePluginRoot, { withFileTypes: true }),
|
||||
readdir(packagedPluginRoot, { withFileTypes: true }),
|
||||
]);
|
||||
const sourcePackageNames = sourcePackages
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
const packagedPackageNames = packagedPackages
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
if (JSON.stringify(sourcePackageNames) !== JSON.stringify(packagedPackageNames)) {
|
||||
throw new Error(
|
||||
`Packaged coding plugin packages differ: expected ${sourcePackageNames.join(', ')}, `
|
||||
+ `got ${packagedPackageNames.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const packages = [];
|
||||
const catalogMarkers = [];
|
||||
for (const packageName of sourcePackageNames) {
|
||||
const sourcePackageRoot = join(sourcePluginRoot, packageName);
|
||||
const packagedPackageRoot = join(packagedPluginRoot, packageName);
|
||||
const packageFiles = await assertMirroredResourceTree(
|
||||
sourcePackageRoot,
|
||||
packagedPackageRoot,
|
||||
`coding plugin ${packageName}`,
|
||||
);
|
||||
const sourceManifestPath = join(sourcePackageRoot, 'plugin.json');
|
||||
const packagedManifestPath = join(packagedPackageRoot, 'plugin.json');
|
||||
const sourceManifest = await readJsonResource(sourceManifestPath, `${packageName} plugin.json`);
|
||||
const packagedManifest = await readJsonResource(packagedManifestPath, `${packageName} plugin.json`);
|
||||
const sourceCapabilityPath = resolveBundledResource(
|
||||
sourcePackageRoot,
|
||||
sourcePackageRoot,
|
||||
sourceManifest.extensions?.['com.makelore']?.capabilityManifest,
|
||||
`${packageName} capability manifest`,
|
||||
);
|
||||
const packagedCapabilityPath = resolveBundledResource(
|
||||
packagedPackageRoot,
|
||||
packagedPackageRoot,
|
||||
packagedManifest.extensions?.['com.makelore']?.capabilityManifest,
|
||||
`${packageName} capability manifest`,
|
||||
);
|
||||
const sourceCapability = await readJsonResource(sourceCapabilityPath, `${packageName} capability manifest`);
|
||||
const packagedCapability = await readJsonResource(packagedCapabilityPath, `${packageName} capability manifest`);
|
||||
if (packagedCapability.pluginId !== packagedManifest.name) {
|
||||
throw new Error(`Packaged coding plugin ${packageName} identity does not match its manifest`);
|
||||
}
|
||||
|
||||
const capabilityRelativePath = portable(relative(packagedPackageRoot, packagedCapabilityPath));
|
||||
if (!packageFiles.includes(capabilityRelativePath)) {
|
||||
throw new Error(`Packaged coding plugin capability manifest is not in its resource tree: ${packageName}`);
|
||||
}
|
||||
const skills = Array.isArray(packagedCapability.skills) ? packagedCapability.skills : [];
|
||||
if (skills.length === 0) throw new Error(`Packaged coding plugin has no Skill: ${packageName}`);
|
||||
const skillPaths = [];
|
||||
for (const [index, skill] of skills.entries()) {
|
||||
const skillPath = resolveBundledResource(
|
||||
packagedPackageRoot,
|
||||
dirname(packagedCapabilityPath),
|
||||
skill?.entry,
|
||||
`${packageName} Skill ${index}`,
|
||||
);
|
||||
const relativePath = portable(relative(packagedPackageRoot, skillPath));
|
||||
if (!packageFiles.includes(relativePath)) {
|
||||
throw new Error(`Packaged coding plugin Skill is missing: ${packageName}/${relativePath}`);
|
||||
}
|
||||
const content = await readFile(skillPath, 'utf8');
|
||||
if (content.trim().length === 0) throw new Error(`Packaged coding plugin Skill is empty: ${packageName}/${relativePath}`);
|
||||
skillPaths.push({ id: skill?.id, path: relativePath });
|
||||
}
|
||||
|
||||
const sdkAssetPaths = packageFiles.filter((file) => SDK_ASSET_PATH_PATTERN.test(file));
|
||||
if (sdkAssetPaths.length === 0) throw new Error(`Packaged coding plugin has no SDK assets: ${packageName}`);
|
||||
const adapterId = packagedCapability.adapterId;
|
||||
const tools = Array.isArray(packagedCapability.tools) ? packagedCapability.tools : [];
|
||||
const toolNames = tools.map((tool) => tool?.name);
|
||||
if (typeof adapterId !== 'string' || adapterId.length === 0 || tools.length === 0
|
||||
|| toolNames.some((name) => typeof name !== 'string' || name.length === 0)
|
||||
|| new Set(toolNames).size !== toolNames.length) {
|
||||
throw new Error(`Packaged coding plugin adapter/tool catalog is invalid: ${packageName}`);
|
||||
}
|
||||
catalogMarkers.push(packagedManifest.name, adapterId, ...toolNames);
|
||||
packages.push({
|
||||
id: packagedManifest.name,
|
||||
version: packagedManifest.version,
|
||||
manifestPath: portable(relative(resourcesDirectory, packagedManifestPath)),
|
||||
capabilityManifestPath: portable(relative(resourcesDirectory, packagedCapabilityPath)),
|
||||
files: packageFiles.map((file) => portable(join(BUNDLED_CODING_PLUGIN_RESOURCE_ROOT, packageName, file))),
|
||||
skills: skillPaths,
|
||||
sdkAssets: sdkAssetPaths.map((file) => portable(join(BUNDLED_CODING_PLUGIN_RESOURCE_ROOT, packageName, file))),
|
||||
adapterId,
|
||||
tools: toolNames,
|
||||
sourceManifest: sourceManifest.name,
|
||||
sourceCapability: sourceCapability.pluginId,
|
||||
});
|
||||
}
|
||||
|
||||
const asar = appAsarContents ?? await readFile(join(resourcesDirectory, 'app.asar'));
|
||||
const missingCatalogMarkers = [...new Set(catalogMarkers)].filter(
|
||||
(marker) => !asar.includes(Buffer.from(marker)),
|
||||
);
|
||||
if (missingCatalogMarkers.length > 0) {
|
||||
throw new Error(
|
||||
`Packaged app.asar does not contain the coding plugin adapter/tool catalog: ${missingCatalogMarkers.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const sourceCoreRoot = join(root, CORE_CODING_SKILL_RESOURCE_ROOT);
|
||||
const packagedCoreRoot = join(resourcesDirectory, CORE_CODING_SKILL_RESOURCE_ROOT);
|
||||
const coreFiles = await assertMirroredResourceTree(sourceCoreRoot, packagedCoreRoot, 'core coding resources');
|
||||
const expectedSkills = await sourceSkillIds(root);
|
||||
const actualSkills = await packagedSkillIds(resourcesDirectory);
|
||||
if (JSON.stringify(actualSkills) !== JSON.stringify(expectedSkills)) {
|
||||
throw new Error(`Packaged coding skills differ: expected ${expectedSkills}, got ${actualSkills}`);
|
||||
}
|
||||
return {
|
||||
root: BUNDLED_CODING_PLUGIN_RESOURCE_ROOT,
|
||||
packages,
|
||||
catalog: {
|
||||
adapters: [...new Set(packages.map(({ adapterId }) => adapterId))],
|
||||
tools: [...new Set(packages.flatMap(({ tools }) => tools))],
|
||||
packagedInAsar: true,
|
||||
},
|
||||
coreResources: {
|
||||
root: CORE_CODING_SKILL_RESOURCE_ROOT,
|
||||
files: coreFiles,
|
||||
skills: actualSkills,
|
||||
},
|
||||
result: 'pass',
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyPiProductArtifact({ projectRoot, executable }) {
|
||||
const root = resolve(projectRoot);
|
||||
const appExecutable = resolve(executable ?? defaultProductExecutable(root));
|
||||
@@ -299,6 +513,7 @@ export async function verifyPiProductArtifact({ projectRoot, executable }) {
|
||||
if (absoluteManifestValues.length > 0) {
|
||||
throw new Error(`Pi runtime manifest contains absolute paths: ${JSON.stringify(absoluteManifestValues)}`);
|
||||
}
|
||||
const appAsarContents = await readFile(appAsar);
|
||||
const physicalOpenCodePaths = await collectForbiddenResourcePaths(resourcesDirectory);
|
||||
const asarOpenCodePaths = collectForbiddenAsarPaths(appAsar);
|
||||
const openCodeResourcePaths = classifyOpenCodeResourcePaths([
|
||||
@@ -311,12 +526,12 @@ export async function verifyPiProductArtifact({ projectRoot, executable }) {
|
||||
);
|
||||
}
|
||||
|
||||
const expectedSkills = await sourceSkillIds(root);
|
||||
const actualSkills = await packagedSkillIds(resourcesDirectory);
|
||||
if (JSON.stringify(actualSkills) !== JSON.stringify(expectedSkills)) {
|
||||
throw new Error(`Packaged coding skills differ: expected ${expectedSkills}, got ${actualSkills}`);
|
||||
}
|
||||
const appAsarContents = await readFile(appAsar);
|
||||
const bundledPluginResources = await verifyBundledCodingPluginResources({
|
||||
projectRoot: root,
|
||||
resourcesDirectory,
|
||||
appAsarContents,
|
||||
});
|
||||
const actualSkills = bundledPluginResources.coreResources.skills;
|
||||
const missingExtensionMarkers = EXTENSION_CONTRACT_MARKERS.filter(
|
||||
(marker) => !appAsarContents.includes(Buffer.from(marker)),
|
||||
);
|
||||
@@ -366,6 +581,7 @@ export async function verifyPiProductArtifact({ projectRoot, executable }) {
|
||||
nodeEngine: manifest.runtime.nodeEngine,
|
||||
},
|
||||
packagedClosure,
|
||||
bundledPlugins: bundledPluginResources,
|
||||
extension: {
|
||||
contractMarkers: EXTENSION_CONTRACT_MARKERS,
|
||||
packaged: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
collectAbsoluteManifestValues,
|
||||
collectForbiddenAsarPaths,
|
||||
collectForbiddenResourcePaths,
|
||||
verifyBundledCodingPluginResources,
|
||||
defaultProductExecutable,
|
||||
validatePiArtifactMetadata,
|
||||
} from '../../scripts/lib/pi-product-artifact.mjs';
|
||||
@@ -52,6 +53,40 @@ function matchingMetadata() {
|
||||
};
|
||||
}
|
||||
|
||||
async function bundledResourceFixture() {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-bundled-plugin-'));
|
||||
roots.push(root);
|
||||
const projectRoot = path.join(root, 'project');
|
||||
const sourceResources = path.join(projectRoot, 'resources');
|
||||
const pluginRoot = path.join(sourceResources, 'coding-plugins', 'example');
|
||||
const capability = {
|
||||
pluginId: 'example.plugin',
|
||||
adapterId: 'example-adapter',
|
||||
skills: [{ id: 'example', entry: '../skills/example/SKILL.md', grants: [] }],
|
||||
tools: [{ name: 'example_read' }],
|
||||
};
|
||||
await mkdir(path.join(pluginRoot, 'com.makelore'), { recursive: true });
|
||||
await mkdir(path.join(pluginRoot, 'skills', 'example', 'assets'), { recursive: true });
|
||||
await mkdir(path.join(sourceResources, 'coding-skills', 'agent-browser'), { recursive: true });
|
||||
await writeFile(path.join(pluginRoot, 'plugin.json'), JSON.stringify({
|
||||
name: 'example.plugin',
|
||||
version: '1.0.0',
|
||||
extensions: { 'com.makelore': { capabilityManifest: './com.makelore/capability.json' } },
|
||||
}));
|
||||
await writeFile(path.join(pluginRoot, 'com.makelore', 'capability.json'), JSON.stringify(capability));
|
||||
await writeFile(path.join(pluginRoot, 'skills', 'example', 'SKILL.md'), '# Example Skill\n');
|
||||
await writeFile(path.join(pluginRoot, 'skills', 'example', 'assets', 'sdk.ts'), 'export {};\n');
|
||||
await writeFile(path.join(pluginRoot, 'skills', 'example', 'assets', 'sdk.js'), 'export {};\n');
|
||||
await writeFile(path.join(sourceResources, 'coding-skills', 'agent-browser', 'SKILL.md'), '# Browser\n');
|
||||
const resourcesDirectory = path.join(root, 'packaged');
|
||||
await cp(sourceResources, path.join(resourcesDirectory, 'resources'), { recursive: true });
|
||||
return {
|
||||
projectRoot,
|
||||
resourcesDirectory,
|
||||
appAsarContents: Buffer.from('example.plugin example-adapter example_read'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('final Pi product artifact verification', () => {
|
||||
it('requires the same pinned Pi version across package, lock, app, runtime, and manifest', () => {
|
||||
expect(validatePiArtifactMetadata(matchingMetadata())).toMatchObject({
|
||||
@@ -128,6 +163,49 @@ describe('final Pi product artifact verification', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('proves bundled plugin manifests, Skills, SDK assets, catalog, and core resources', async () => {
|
||||
const fixture = await bundledResourceFixture();
|
||||
await expect(verifyBundledCodingPluginResources(fixture)).resolves.toMatchObject({
|
||||
root: 'resources/coding-plugins',
|
||||
packages: [{
|
||||
id: 'example.plugin',
|
||||
manifestPath: 'resources/coding-plugins/example/plugin.json',
|
||||
capabilityManifestPath: 'resources/coding-plugins/example/com.makelore/capability.json',
|
||||
skills: [{ id: 'example', path: 'skills/example/SKILL.md' }],
|
||||
sdkAssets: expect.arrayContaining([
|
||||
'resources/coding-plugins/example/skills/example/assets/sdk.ts',
|
||||
'resources/coding-plugins/example/skills/example/assets/sdk.js',
|
||||
]),
|
||||
adapterId: 'example-adapter',
|
||||
tools: ['example_read'],
|
||||
}],
|
||||
catalog: { adapters: ['example-adapter'], tools: ['example_read'], packagedInAsar: true },
|
||||
coreResources: { root: 'resources/coding-skills', skills: ['agent-browser'] },
|
||||
result: 'pass',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a packaged plugin tree that drops an SDK asset or catalog marker', async () => {
|
||||
const fixture = await bundledResourceFixture();
|
||||
await rm(path.join(
|
||||
fixture.resourcesDirectory,
|
||||
'resources',
|
||||
'coding-plugins',
|
||||
'example',
|
||||
'skills',
|
||||
'example',
|
||||
'assets',
|
||||
'sdk.js',
|
||||
));
|
||||
await expect(verifyBundledCodingPluginResources(fixture)).rejects.toThrow('files differ');
|
||||
|
||||
const complete = await bundledResourceFixture();
|
||||
await expect(verifyBundledCodingPluginResources({
|
||||
...complete,
|
||||
appAsarContents: Buffer.from('example.plugin example-adapter'),
|
||||
})).rejects.toThrow('adapter/tool catalog');
|
||||
});
|
||||
|
||||
it('uses final unpacked-product paths and strictly parses verifier options', () => {
|
||||
expect(defaultProductExecutable('/repo', 'linux'))
|
||||
.toBe(path.resolve('/repo', 'release', 'linux-unpacked', 'niancode'));
|
||||
|
||||
Reference in New Issue
Block a user