diff --git a/scripts/lib/pi-product-artifact.mjs b/scripts/lib/pi-product-artifact.mjs index 8aa92b2..fcaeac2 100644 --- a/scripts/lib/pi-product-artifact.mjs +++ b/scripts/lib/pi-product-artifact.mjs @@ -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, diff --git a/tests/unit/pi-product-artifact.test.ts b/tests/unit/pi-product-artifact.test.ts index 58b3bf7..2350dbf 100644 --- a/tests/unit/pi-product-artifact.test.ts +++ b/tests/unit/pi-product-artifact.test.ts @@ -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'));