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,
|
||||
|
||||
Reference in New Issue
Block a user