feat(coding): add marketplace package trust primitives

This commit is contained in:
2026-08-28 12:57:50 +08:00
parent 4d8b1fcec0
commit 898e2b7bdd
7 changed files with 1386 additions and 16 deletions

View File

@@ -0,0 +1,148 @@
# Task: Marketplace MLM-01 package selection and signature trust
## Identity
- Task ID: 20260828-plugin-mlm01-5c8d1e4f
- Mode: Feature
- Branch: codex/20260828-plugin-mlm01-5c8d1e4f-mlm01-package-trust
- Worktree: D:\Datas\OthersProjects\makelore-plugin-marketplace-client-mlm01-5c8d1e4f
- Base commit: 4d8b1fcec0a751d2935effc7816c7e59f568ec65
- Owner: codex-mlm01
- Status: Ready for Integration
## Scope
- Implement ticket MLM-01 from the exact Marketplace Release A client frontier:
schema-v2 declarative package parsing, bounded hosted/skill-only definitions,
fixed release-descriptor serialization, Ed25519 verification, strict SemVer
client-range checks, and the code-owned production trust-store seam.
- Own only `shared/coding-plugins.ts`,
`electron/coding-plugins/manifest.ts`, the new descriptor/signature/trusted-key
modules, focused fixtures/tests for those modules, and this task record.
- Do not touch marketplace/network clients, Package Store, account/project
services, Pi/Host/Renderer consumers, server/Operations code, hosted runtime,
billing, or Release B tickets.
## Project Context Loaded
- Concurrent Task Gate passed after `check_project_docs.py`; `task_context.py
start` and `status --json` match this task ID, owner, isolated worktree,
branch, mode, and exact base.
- Planning Gate passed after reading `AGENTS.md`, the mandatory project-memory
startup set, relevant architecture/domain/evidence/reflection/commitment/stale
records, the Marketplace implementation spec sections 1-6, 10.1, 13.2-13.3,
the full ticket graph MLM-01, the accepted Marketplace design sections 6,
11.2-11.5, 14, and 18.3, and peer task records for the coordinator and prior
P0 package/policy/runtime work.
- Current integrated memory is a historical snapshot and predates Marketplace;
the exact implementation spec, ticket graph, design authority, and coordinator
frontier are authoritative. The prior P0 parser is the compatibility baseline,
not a source branch to merge or modify.
- Other active local owners are the Marketplace client coordinator and historical
P0/review tasks in separate worktrees. Their records identify no semantic
conflict with this isolated parser/trust scope. The coordinator owns only
integration/documentation and later Marketplace tickets; MLM-02 owns the
network/Package Store files after this handoff.
## Intent And Constraints
- Preserve schema-1 bundled compatibility exactly, including code-owned adapter,
settings, and preview privilege allowlists; schema-2 packages cannot name any
such privilege and cannot narrow arbitrary strings into code-owned IDs.
- Accept only the closed schema-2 vocabulary: `skill_only` with empty tools and
grants, or declarative `platform_hosted` with protocol
`makelore-hosted.v1`, namespaced permissions, closed bounded input/output
schemas, and `projectWriteLease=false`. Parsing does not execute package files
or implement hosted runtime.
- Reject unknown object fields/components/paths, malformed or unbounded JSON
Schema, duplicate tool/capability mappings, unsupported local execution,
invalid identifiers, and schema-v2 adapter/settings/preview claims with stable
validation failures.
- Build the exact compact, fixed-order release descriptor bytes from the closed
shape and verify descriptor identity, artifact size/SHA-256, Ed25519 signature,
known key ID, and MakeLore compatibility range. No generic canonicalizer is
introduced.
- Production trust is code-owned and fail-closed when the official public key is
absent. No environment/runtime public-key override and no production private
key are allowed. Ephemeral keys may be injected only through test/service
dependencies.
- Keep parsed definitions immutable and distinguish trusted Package Store
provenance from manifest self-assertion. Do not add network, install, index,
account, project, registry, Pi, Host, Renderer, billing, or Release B behavior.
## Implementation Plan
1. Inspect the exact base parser, shared contracts, package-resource path helpers,
and existing test conventions; identify compatibility seams without editing.
2. Add red focused tests for schema-v2 golden/invalid cases, bounded schema
validation, immutable provenance, descriptor byte order/identity and digest,
compatibility ranges, key lookup/fail-closed trust, and Ed25519 failure modes.
3. Implement the smallest cohesive shared definitions and parser/descriptor/
signature/trusted-key modules within the ownership boundary.
4. Run the owned focused suite, relevant existing manifest/package regressions,
typecheck, scoped/full lint, and diff/doc gates; investigate only failures
attributable to this ticket.
5. Update this record with the actual outcome and verification ledger, run
task-aware drift, complete `task_context.py`, and return one clean source
commit whose sole parent is the exact dispatch base.
## Outcome
- Implemented MLM-01 from the exact `4d8b1fcec0a751d2935effc7816c7e59f568ec65`
frontier. Schema 1 remains bundled/Data Service compatible while schema 2 now
parses immutable `skill_only` and declarative `platform_hosted` definitions,
including the closed bounded JSON Schema subset, hosted permission namespace,
tool/operation uniqueness, and trusted package metadata.
- Added the fixed compact release descriptor serializer/parser, strict SemVer
range comparison, Ed25519 descriptor verifier, and a code-owned fail-closed
signing-key loader. Injected key stores remain an explicit test/service seam;
no environment key override or production private key was added.
- The official MakeLore Ed25519 public key has not been supplied. Production
signing activation therefore remains an explicit hold represented by the empty
code-owned trust table and `PLUGIN_SIGNING_KEY_ACTIVATION_HOLD`; injected-key
verification is covered and is not presented as production activation.
- No network, Package Store, account/project, registry, Pi/Host, Renderer,
billing, hosted runtime, server, Operations, or Release B code was changed.
## Verification
- Before each check, the concrete failure and follow-up action were defined:
- `check_project_docs.py`: detect malformed/missing project-docs concurrency
assets; PASS before planning and implementation.
- `task_context.py status --json`: detect owner/base/worktree/branch drift;
PASS for this task and exact dispatch base.
- Initial focused red run: detect missing contract modules; it failed as
expected before implementation, then became green after the modules landed.
- `vitest run tests/unit/coding-plugin-marketplace-contract.test.ts`: detect
schema/descriptor/trust regressions; PASS, 19 tests.
- Existing manifest/Data Service/Pi resource suite: detect schema-1 and
packaged-resource regressions; PASS, 4 files/46 tests.
- Registry/adapter/Pi extension host/bundle/tool suite: detect shared contract
projection regressions; PASS, 5 files/39 tests.
- `pnpm run typecheck`: detect TypeScript contract/import errors; PASS.
- Scoped ESLint: one empty-interface error was fixed in the task-owned
manifest options type; rerun PASS with no errors.
- `pnpm run lint:check`: detect repository lint errors; PASS with five
pre-existing React warnings and zero errors.
- `pnpm run build:vite`: detect renderer/Electron build regressions from the
shared contract changes; PASS with only the repository's existing chunk and
dynamic-import warnings.
- `pnpm test`: detect cross-repository client unit regressions; PASS, 202 test
files/1713 passed/2 skipped, followed by pressure suite 1/1.
- `git diff --check`: detect whitespace/boundary corruption; PASS.
- Final task-aware `check_doc_drift.py` and `task_context.py complete` are the
remaining handoff gates and will be run before the source commit.
## Follow-ups
- Supply the official platform Ed25519 public key through the code-owned release
resource before production activation; keep the deployment private key only in
the Works Square deployment secret.
- MLM-02 may consume these primitives after this single source commit is merged
into the client coordinator frontier; its Package Store must compare requested
Release metadata before atomically installing bytes.
## Promotion Candidates
- None. This feature task did not modify canonical project memory or accepted
decisions.

View File

@@ -1,6 +1,9 @@
import { readFileSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import {
isValidSemVer,
} from './release-descriptor';
import {
AGENT_PLUGINS_SCHEMA_URL,
BUNDLED_CODING_PLUGIN_ADAPTER_IDS,
@@ -13,6 +16,10 @@ import {
DATA_SERVICE_TOOL_NAMES,
type AgentPluginsRootManifest,
type CodingPluginDefinition,
type CodingPluginAcquisitionMode,
type CodingPluginExecutionMode,
type CodingPluginPackageProvenance,
type CodingPluginRuntimeKind,
type CodingPluginSkillDefinition,
type CodingPluginToolDefinition,
type PluginToolMutation,
@@ -33,7 +40,6 @@ const SKILL_ID_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/u;
const CAPABILITY_ID_PATTERN = /^[a-z][a-z0-9.-]{0,63}$/u;
const OPERATION_ID_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/u;
const TOOL_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9._:-]{0,63}$/u;
const SEMVER_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-]+)*)?$/u;
const MAX_DISPLAY_TEXT = 256;
const UNSUPPORTED_COMPONENT_KEYS = new Set([
@@ -103,6 +109,64 @@ const TOOL_KEYS = new Set([
]);
const SURFACE_KEYS = new Set(['projectSettings', 'previewRuntime']);
const V2_CAPABILITY_KEYS = new Set([
'schemaVersion',
'pluginId',
'contractVersion',
'scope',
'runtime',
'skills',
'tools',
]);
const V2_RUNTIME_KEYS = new Set(['kind', 'protocol']);
const V2_SKILL_KEYS = new Set(['id', 'entry', 'grants']);
const V2_TOOL_KEYS = new Set([
'name',
'label',
'description',
'capabilityId',
'operation',
'roles',
'mutation',
'projectWriteLease',
'permissions',
'executionMode',
'inputSchema',
'outputSchema',
]);
const HOSTED_PERMISSION_PATTERN = /^hosted\.[a-z][a-z0-9._-]{0,127}$/u;
const V2_PROPERTY_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/u;
const V2_SCHEMA_TYPES = new Set(['array', 'boolean', 'integer', 'number', 'object', 'string']);
const V2_SCHEMA_KEYS = new Set([
'type',
'additionalProperties',
'required',
'properties',
'items',
'minLength',
'maxLength',
'minimum',
'maximum',
'maxItems',
'const',
]);
export interface CodingPluginManifestParseOptions {
packageRoot?: string;
rootManifestPath?: string;
capabilityManifestPath?: string;
/** Metadata supplied by the trusted bundle/package-store owner. */
runtimeKind?: CodingPluginRuntimeKind;
acquisitionMode?: CodingPluginAcquisitionMode;
releaseId?: string | null;
provenance?: CodingPluginPackageProvenance;
}
export type CodingPluginLoadOptions = Pick<
CodingPluginManifestParseOptions,
'runtimeKind' | 'acquisitionMode' | 'releaseId' | 'provenance'
>;
type UnknownRecord = Record<string, unknown>;
export class CodingPluginManifestError extends Error {
@@ -248,6 +312,41 @@ function normalizeCandidatePath(
return relative.split(path.sep).join('/');
}
function normalizeV2CandidatePath(
packageRoot: string,
baseDirectory: string,
candidate: unknown,
filePath: string,
field: string,
): string {
if (typeof candidate !== 'string') fail(filePath, field, 'expected a relative path');
const portable = candidate.replaceAll('\\', '/');
if (portable !== candidate || portable.split('/').some((segment) => segment === '')) {
fail(filePath, field, 'path must use canonical relative separators');
}
if (portable.split('/').some((segment) => segment === '.')) {
fail(filePath, field, 'path must not contain dot segments');
}
return normalizeCandidatePath(packageRoot, baseDirectory, candidate, filePath, field);
}
function normalizeCapabilityManifestPath(
packageRoot: string,
candidate: unknown,
filePath: string,
field: string,
): string {
if (typeof candidate !== 'string') fail(filePath, field, 'expected a relative path');
const portable = candidate.replaceAll('\\', '/');
const segments = portable.split('/');
if (portable !== candidate || segments.some((segment, index) => (
segment === '' || segment === '..' || (segment === '.' && index !== 0)
))) {
fail(filePath, field, 'path must use the canonical capability manifest location');
}
return normalizeCandidatePath(packageRoot, packageRoot, candidate, filePath, field);
}
function validateSchemaNode(value: unknown, filePath: string, field: string, root = false): void {
const schema = assertRecord(value, filePath, field);
const allowed = root
@@ -305,6 +404,112 @@ function validateSchemaNode(value: unknown, filePath: string, field: string, roo
}
}
function finiteNumber(value: unknown, filePath: string, field: string): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
fail(filePath, field, 'must be a finite number');
}
return value;
}
function boundedInteger(value: unknown, filePath: string, field: string): number {
if (!Number.isSafeInteger(value) || (value as number) < 0) {
fail(filePath, field, 'must be a non-negative safe integer');
}
return value as number;
}
/**
* Schema-v2 deliberately accepts a small data-only subset. This validator is
* separate from the legacy schema-1 validator because the bundled Data Service
* contract predates the v2 requirement that strings, arrays, and numbers carry
* explicit bounds.
*/
function validateV2SchemaNode(
value: unknown,
filePath: string,
field: string,
depth = 1,
): void {
if (depth > 4) fail(filePath, field, 'schema exceeds the maximum depth of 4');
const schema = assertRecord(value, filePath, field);
assertExactKeys(schema, V2_SCHEMA_KEYS, filePath, field);
if (typeof schema.type !== 'string' || !V2_SCHEMA_TYPES.has(schema.type)) {
fail(filePath, `${field}.type`, 'unsupported bounded schema type');
}
const type = schema.type;
if ('const' in schema && (type !== 'boolean' || typeof schema.const !== 'boolean')) {
fail(filePath, `${field}.const`, 'only boolean constants are supported');
}
if ('additionalProperties' in schema) {
if (type !== 'object' || schema.additionalProperties !== false) {
fail(filePath, `${field}.additionalProperties`, 'only false is supported on object schemas');
}
}
if ('required' in schema && !Array.isArray(schema.required)) {
fail(filePath, `${field}.required`, 'must be an array');
}
if (type === 'object') {
if (schema.additionalProperties !== false) {
fail(filePath, `${field}.additionalProperties`, 'object schemas must close additional properties');
}
if (!('required' in schema)) {
fail(filePath, `${field}.required`, 'object schemas must declare required properties');
}
const properties = assertRecord(schema.properties, filePath, `${field}.properties`);
if (Object.keys(properties).length > 64) {
fail(filePath, `${field}.properties`, 'object schemas may contain at most 64 properties');
}
for (const [name, child] of Object.entries(properties)) {
if (!V2_PROPERTY_NAME_PATTERN.test(name)) {
fail(filePath, `${field}.properties.${name}`, 'property name is invalid');
}
validateV2SchemaNode(child, filePath, `${field}.properties.${name}`, depth + 1);
}
const required = schema.required === undefined
? []
: uniqueStrings(schema.required, filePath, `${field}.required`, V2_PROPERTY_NAME_PATTERN);
for (const name of required) {
if (!(name in properties)) fail(filePath, `${field}.required`, `required property is not declared: ${name}`);
}
for (const key of ['items', 'minLength', 'maxLength', 'minimum', 'maximum', 'maxItems']) {
if (key in schema) fail(filePath, `${field}.${key}`, `${key} is not valid for an object schema`);
}
return;
}
if (type === 'array') {
if (!('items' in schema)) fail(filePath, `${field}.items`, 'array schemas must declare items');
boundedInteger(schema.maxItems, filePath, `${field}.maxItems`);
validateV2SchemaNode(schema.items, filePath, `${field}.items`, depth + 1);
for (const key of ['additionalProperties', 'required', 'properties', 'minLength', 'maxLength', 'minimum', 'maximum']) {
if (key in schema) fail(filePath, `${field}.${key}`, `${key} is not valid for an array schema`);
}
return;
}
if ('items' in schema || 'properties' in schema || 'required' in schema
|| 'additionalProperties' in schema || 'maxItems' in schema) {
fail(filePath, field, 'nested schema keywords do not match the scalar type');
}
if (type === 'string') {
const maxLength = boundedInteger(schema.maxLength, filePath, `${field}.maxLength`);
if ('minLength' in schema) {
const minLength = boundedInteger(schema.minLength, filePath, `${field}.minLength`);
if (minLength > maxLength) fail(filePath, field, 'minLength cannot exceed maxLength');
}
for (const key of ['minimum', 'maximum']) {
if (key in schema) fail(filePath, `${field}.${key}`, `${key} is not valid for a string schema`);
}
return;
}
if (type === 'integer' || type === 'number') {
const minimum = finiteNumber(schema.minimum, filePath, `${field}.minimum`);
const maximum = finiteNumber(schema.maximum, filePath, `${field}.maximum`);
if (minimum > maximum) fail(filePath, field, 'minimum cannot exceed maximum');
for (const key of ['minLength', 'maxLength']) {
if (key in schema) fail(filePath, `${field}.${key}`, `${key} is not valid for a numeric schema`);
}
}
}
function validateDestructiveConfirmation(
schema: UnknownRecord,
filePath: string,
@@ -331,7 +536,7 @@ export function parseAgentPluginsRootManifest(
}
const name = stableId(root.name, filePath, 'name', /^[a-z][a-z0-9.-]{0,127}$/u);
const version = text(root.version, filePath, 'version', 128);
if (!SEMVER_PATTERN.test(version)) fail(filePath, 'version', 'must be a valid package semver');
if (!isValidSemVer(version)) fail(filePath, 'version', 'must be a valid package semver');
const description = text(root.description, filePath, 'description');
const author = assertRecord(root.author, filePath, 'author');
assertExactKeys(author, AUTHOR_KEYS, filePath, 'author');
@@ -466,14 +671,241 @@ function validateDataServiceDefinition(
}
}
function trustedMetadata(
options: CodingPluginManifestParseOptions,
declaredRuntimeKind: CodingPluginRuntimeKind,
packageRoot: string,
schemaVersion: 1 | 2,
filePath: string,
): Pick<CodingPluginDefinition, 'runtimeKind' | 'acquisitionMode' | 'releaseId' | 'provenance'> {
const runtimeKind = options.runtimeKind ?? declaredRuntimeKind;
if (runtimeKind !== declaredRuntimeKind) {
fail(filePath, 'trusted metadata.runtimeKind', 'does not match the package runtime declaration');
}
const expectedAcquisition: CodingPluginAcquisitionMode = schemaVersion === 1
? 'system_included'
: 'user_acquired';
const acquisitionMode = options.acquisitionMode ?? expectedAcquisition;
if (acquisitionMode !== expectedAcquisition) {
fail(filePath, 'trusted metadata.acquisitionMode', `must equal ${expectedAcquisition} for schema ${schemaVersion}`);
}
const releaseId = options.releaseId ?? null;
if (releaseId !== null && (typeof releaseId !== 'string' || releaseId.length === 0 || releaseId.length > 128)) {
fail(filePath, 'trusted metadata.releaseId', 'must be null or a bounded non-empty string');
}
if (schemaVersion === 1 && releaseId !== null) {
fail(filePath, 'trusted metadata.releaseId', 'bundled schema-1 definitions cannot carry a Release ID');
}
const defaultProvenance: CodingPluginPackageProvenance = {
source: schemaVersion === 1 ? 'bundled' : 'marketplace',
packageRoot: schemaVersion === 1 ? path.basename(packageRoot) : path.resolve(packageRoot),
};
const provenance = options.provenance ?? defaultProvenance;
if (provenance.source !== defaultProvenance.source
|| typeof provenance.packageRoot !== 'string'
|| provenance.packageRoot.length === 0
|| provenance.packageRoot.length > 1024) {
fail(filePath, 'trusted metadata.provenance', 'is not valid for this package source');
}
return {
runtimeKind,
acquisitionMode,
releaseId,
provenance: {
source: provenance.source,
packageRoot: provenance.packageRoot,
},
};
}
function parseV2Skill(
value: unknown,
index: number,
packageRoot: string,
capabilityDirectory: string,
filePath: string,
): CodingPluginSkillDefinition {
const skill = assertRecord(value, filePath, `skills[${index}]`);
assertExactKeys(skill, V2_SKILL_KEYS, filePath, `skills[${index}]`);
const id = stableId(skill.id, filePath, `skills[${index}].id`, SKILL_ID_PATTERN);
const entryPath = normalizeV2CandidatePath(
packageRoot,
capabilityDirectory,
skill.entry,
filePath,
`skills[${index}].entry`,
);
const grants = uniqueStrings(
skill.grants,
filePath,
`skills[${index}].grants`,
CAPABILITY_ID_PATTERN,
);
return { id, entryPath, grants };
}
function hostedPermissionMatchesPlugin(permission: string, pluginId: string): boolean {
if (!HOSTED_PERMISSION_PATTERN.test(permission)) return false;
const suffix = pluginId.startsWith('makelore.') ? pluginId.slice('makelore.'.length) : pluginId;
return permission.startsWith(`hosted.${suffix}.`);
}
function parseV2Tool(
value: unknown,
index: number,
pluginId: string,
filePath: string,
): CodingPluginToolDefinition {
const tool = assertRecord(value, filePath, `tools[${index}]`);
assertExactKeys(tool, V2_TOOL_KEYS, filePath, `tools[${index}]`);
const name = stableId(tool.name, filePath, `tools[${index}].name`, TOOL_NAME_PATTERN);
const label = text(tool.label, filePath, `tools[${index}].label`);
const description = text(tool.description, filePath, `tools[${index}].description`);
const capabilityId = stableId(tool.capabilityId, filePath, `tools[${index}].capabilityId`, CAPABILITY_ID_PATTERN);
const operation = stableId(tool.operation, filePath, `tools[${index}].operation`, OPERATION_ID_PATTERN);
const roles = uniqueStrings(tool.roles, filePath, `tools[${index}].roles`, /^[a-z]+$/u);
if (roles.length !== 1 || roles[0] !== 'parent') {
fail(filePath, `tools[${index}].roles`, 'distributed tools must be exactly parent-only');
}
const mutation = tool.mutation;
if (mutation !== 'read' && mutation !== 'write' && mutation !== 'destructive') {
fail(filePath, `tools[${index}].mutation`, 'unknown tool mutation');
}
const projectWriteLease = bool(tool.projectWriteLease, filePath, `tools[${index}].projectWriteLease`);
if (projectWriteLease) fail(filePath, `tools[${index}].projectWriteLease`, 'distributed tools cannot request a project write lease');
const permissions = uniqueStrings(
tool.permissions,
filePath,
`tools[${index}].permissions`,
/^[a-z][a-z0-9._-]{0,127}$/u,
);
if (permissions.some((permission) => !hostedPermissionMatchesPlugin(permission, pluginId))) {
fail(filePath, `tools[${index}].permissions`, 'permission is outside the plugin hosted namespace');
}
if (tool.executionMode !== 'synchronous' && tool.executionMode !== 'accepted') {
fail(filePath, `tools[${index}].executionMode`, 'must be synchronous or accepted');
}
validateV2SchemaNode(tool.inputSchema, filePath, `tools[${index}].inputSchema`);
validateV2SchemaNode(tool.outputSchema, filePath, `tools[${index}].outputSchema`);
const inputSchema = tool.inputSchema as UnknownRecord;
const outputSchema = tool.outputSchema as UnknownRecord;
if (inputSchema.type !== 'object' || inputSchema.additionalProperties !== false) {
fail(filePath, `tools[${index}].inputSchema`, 'tool input schema must be a closed object');
}
if (outputSchema.type !== 'object' || outputSchema.additionalProperties !== false) {
fail(filePath, `tools[${index}].outputSchema`, 'tool output schema must be a closed object');
}
if (mutation === 'destructive') validateDestructiveConfirmation(inputSchema, filePath, `tools[${index}].inputSchema`);
return {
name,
label,
description,
capabilityId,
operation,
roles: ['parent'],
mutation: mutation as PluginToolMutation,
projectWriteLease: false,
permissions,
executionMode: tool.executionMode as CodingPluginExecutionMode,
inputSchema: freezeDeep(structuredClone(inputSchema)),
outputSchema: freezeDeep(structuredClone(outputSchema)),
};
}
function parseV2CodingPluginManifest(
root: AgentPluginsRootManifest,
capability: UnknownRecord,
packageRoot: string,
capabilityManifestPath: string,
options: CodingPluginManifestParseOptions,
): CodingPluginDefinition {
assertExactKeys(capability, V2_CAPABILITY_KEYS, capabilityManifestPath, 'root');
rejectUnsupportedComponents(capability, capabilityManifestPath, 'root');
if (capability.schemaVersion !== 2) fail(capabilityManifestPath, 'schemaVersion', 'must equal 2');
const pluginId = stableId(capability.pluginId, capabilityManifestPath, 'pluginId', /^[a-z][a-z0-9.-]{0,127}$/u);
if (pluginId !== root.name) fail(capabilityManifestPath, 'pluginId', 'must match plugin.json name');
const contractVersion = positiveInteger(capability.contractVersion, capabilityManifestPath, 'contractVersion');
if (capability.scope !== 'project') fail(capabilityManifestPath, 'scope', 'distributed plugins must be project-scoped');
const runtime = assertRecord(capability.runtime, capabilityManifestPath, 'runtime');
assertExactKeys(runtime, V2_RUNTIME_KEYS, capabilityManifestPath, 'runtime');
const runtimeKind = runtime.kind;
if (runtimeKind !== 'skill_only' && runtimeKind !== 'platform_hosted') {
fail(capabilityManifestPath, 'runtime.kind', 'must be skill_only or platform_hosted');
}
if (runtimeKind === 'skill_only') {
if ('protocol' in runtime) fail(capabilityManifestPath, 'runtime.protocol', 'skill_only must not declare a hosted protocol');
} else if (runtime.protocol !== 'makelore-hosted.v1') {
fail(capabilityManifestPath, 'runtime.protocol', 'must equal makelore-hosted.v1');
}
const skillsValue = capability.skills;
if (!Array.isArray(skillsValue) || skillsValue.length === 0) {
fail(capabilityManifestPath, 'skills', 'must contain at least one Skill');
}
const capabilityDirectory = path.dirname(capabilityManifestPath);
const skills = skillsValue.map((value, index) => parseV2Skill(
value,
index,
packageRoot,
capabilityDirectory,
capabilityManifestPath,
));
if (new Set(skills.map((skill) => skill.id)).size !== skills.length) {
fail(capabilityManifestPath, 'skills', 'duplicate Skill identifier');
}
if (new Set(skills.map((skill) => skill.entryPath)).size !== skills.length) {
fail(capabilityManifestPath, 'skills', 'duplicate Skill entry path');
}
if (!Array.isArray(capability.tools)) fail(capabilityManifestPath, 'tools', 'must be an array');
if (runtimeKind === 'skill_only' && capability.tools.length !== 0) {
fail(capabilityManifestPath, 'tools', 'skill_only packages must declare an empty tools array');
}
if (runtimeKind === 'platform_hosted' && capability.tools.length === 0) {
fail(capabilityManifestPath, 'tools', 'platform_hosted packages must declare at least one tool');
}
const tools = capability.tools.map((value, index) => parseV2Tool(
value,
index,
pluginId,
capabilityManifestPath,
));
if (new Set(tools.map((tool) => tool.name)).size !== tools.length) {
fail(capabilityManifestPath, 'tools.name', 'duplicate tool identifier');
}
const mappingKeys = tools.map((tool) => `${tool.capabilityId}\u0000${tool.operation}`);
if (new Set(mappingKeys).size !== mappingKeys.length) {
fail(capabilityManifestPath, 'tools', 'duplicate capability operation mapping');
}
const grants = new Set(skills.flatMap((skill) => skill.grants));
for (const tool of tools) {
if (!grants.has(tool.capabilityId)) {
fail(capabilityManifestPath, `tools.${tool.name}.capabilityId`, 'tool capability has no Skill grant');
}
}
if (runtimeKind === 'skill_only' && skills.some((skill) => skill.grants.length > 0)) {
fail(capabilityManifestPath, 'skills', 'skill_only Skills must declare empty grants');
}
const metadata = trustedMetadata(options, runtimeKind, packageRoot, 2, capabilityManifestPath);
return freezeDeep({
id: pluginId,
version: root.version,
contractVersion,
displayName: root.name,
description: root.description,
...metadata,
scope: 'project' as const,
adapterId: '',
requiresBackend: runtimeKind === 'platform_hosted',
skills,
tools,
operations: tools.map(({ capabilityId, operation, name }) => ({ capabilityId, operation, toolName: name })),
surfaces: {},
});
}
export function parseCodingPluginManifest(
rootValue: unknown,
capabilityValue: unknown,
options: {
packageRoot?: string;
rootManifestPath?: string;
capabilityManifestPath?: string;
} = {},
options: CodingPluginManifestParseOptions = {},
): CodingPluginDefinition {
const packageRoot = path.resolve(options.packageRoot ?? path.dirname(options.capabilityManifestPath ?? '.'));
const rootManifestPath = path.resolve(
@@ -488,10 +920,32 @@ export function parseCodingPluginManifest(
|| capabilityRelativePath.startsWith(`..${path.sep}`) || path.isAbsolute(capabilityRelativePath)) {
fail(capabilityManifestPath, 'root', 'capability manifest must be inside the package root');
}
const declaredCapabilityPath = normalizeCapabilityManifestPath(
packageRoot,
root.extensions['com.makelore'].capabilityManifest,
rootManifestPath,
'extensions.com.makelore.capabilityManifest',
);
if (declaredCapabilityPath !== CAPABILITY_MANIFEST_RELATIVE_PATH) {
fail(
rootManifestPath,
'extensions.com.makelore.capabilityManifest',
`must point to ${CAPABILITY_MANIFEST_RELATIVE_PATH}`,
);
}
const capability = assertRecord(capabilityValue, capabilityManifestPath, 'root');
if (capability.schemaVersion === 2) {
return parseV2CodingPluginManifest(
root,
capability,
packageRoot,
capabilityManifestPath,
options,
);
}
if (capability.schemaVersion !== 1) fail(capabilityManifestPath, 'schemaVersion', 'must equal 1 or 2');
assertExactKeys(capability, CAPABILITY_KEYS, capabilityManifestPath, 'root');
rejectUnsupportedComponents(capability, capabilityManifestPath, 'root');
if (capability.schemaVersion !== 1) fail(capabilityManifestPath, 'schemaVersion', 'must equal 1');
const pluginId = stableId(capability.pluginId, capabilityManifestPath, 'pluginId', /^[a-z][a-z0-9.-]{0,127}$/u);
if (pluginId !== root.name) fail(capabilityManifestPath, 'pluginId', 'must match plugin.json name');
const contractVersion = positiveInteger(capability.contractVersion, capabilityManifestPath, 'contractVersion');
@@ -560,6 +1014,7 @@ export function parseCodingPluginManifest(
contractVersion,
displayName,
description,
...trustedMetadata(options, 'bundled_typed', packageRoot, 1, capabilityManifestPath),
scope: 'project' as const,
adapterId,
requiresBackend,
@@ -582,13 +1037,15 @@ function readJson(source: string, filePath: string): unknown {
}
}
export async function loadCodingPluginDefinition(packageRoot: string): Promise<CodingPluginDefinition> {
export async function loadCodingPluginDefinition(
packageRoot: string,
options: CodingPluginLoadOptions = {},
): Promise<CodingPluginDefinition> {
const root = path.resolve(packageRoot);
const pluginManifestPath = path.join(root, PACKAGE_MANIFEST_FILE);
const rootValue = readJson(await readFile(pluginManifestPath, 'utf8'), pluginManifestPath);
const rootManifest = parseAgentPluginsRootManifest(rootValue, pluginManifestPath);
const capabilityManifest = normalizeCandidatePath(
root,
const capabilityManifest = normalizeCapabilityManifestPath(
root,
rootManifest.extensions['com.makelore'].capabilityManifest,
pluginManifestPath,
@@ -603,6 +1060,7 @@ export async function loadCodingPluginDefinition(packageRoot: string): Promise<C
packageRoot: root,
rootManifestPath: pluginManifestPath,
capabilityManifestPath,
...options,
});
for (const skill of definition.skills) {
try {
@@ -618,13 +1076,15 @@ export async function loadCodingPluginDefinition(packageRoot: string): Promise<C
return definition;
}
export function loadCodingPluginDefinitionSync(packageRoot: string): CodingPluginDefinition {
export function loadCodingPluginDefinitionSync(
packageRoot: string,
options: CodingPluginLoadOptions = {},
): CodingPluginDefinition {
const root = path.resolve(packageRoot);
const pluginManifestPath = path.join(root, PACKAGE_MANIFEST_FILE);
const rootValue = readJson(readFileSync(pluginManifestPath, 'utf8'), pluginManifestPath);
const rootManifest = parseAgentPluginsRootManifest(rootValue, pluginManifestPath);
const capabilityManifest = normalizeCandidatePath(
root,
const capabilityManifest = normalizeCapabilityManifestPath(
root,
rootManifest.extensions['com.makelore'].capabilityManifest,
pluginManifestPath,
@@ -639,6 +1099,7 @@ export function loadCodingPluginDefinitionSync(packageRoot: string): CodingPlugi
packageRoot: root,
rootManifestPath: pluginManifestPath,
capabilityManifestPath,
...options,
});
for (const skill of definition.skills) {
try {
@@ -690,8 +1151,13 @@ export async function loadBundledCodingPluginDefinitions(
): Promise<readonly CodingPluginDefinition[]> {
const roots = resolveBundledCodingPluginRootPaths(resourcesRoot);
const definitions = await Promise.all(roots.map(async (root, index) => {
const definition = await loadCodingPluginDefinition(root);
const expectedRoot = BUNDLED_CODING_PLUGIN_ROOTS[index];
const definition = await loadCodingPluginDefinition(root, {
runtimeKind: 'bundled_typed',
acquisitionMode: 'system_included',
releaseId: null,
provenance: { source: 'bundled', packageRoot: expectedRoot },
});
if (definition.id !== `makelore.${expectedRoot}`) {
throw new CodingPluginManifestError(
path.join(root, PACKAGE_MANIFEST_FILE),
@@ -710,8 +1176,13 @@ export function loadBundledCodingPluginDefinitionsSync(
): readonly CodingPluginDefinition[] {
const roots = resolveBundledCodingPluginRootPaths(resourcesRoot);
const definitions = roots.map((root, index) => {
const definition = loadCodingPluginDefinitionSync(root);
const expectedRoot = BUNDLED_CODING_PLUGIN_ROOTS[index];
const definition = loadCodingPluginDefinitionSync(root, {
runtimeKind: 'bundled_typed',
acquisitionMode: 'system_included',
releaseId: null,
provenance: { source: 'bundled', packageRoot: expectedRoot },
});
if (definition.id !== `makelore.${expectedRoot}`) {
throw new CodingPluginManifestError(
path.join(root, PACKAGE_MANIFEST_FILE),

View File

@@ -0,0 +1,252 @@
import { Buffer } from 'node:buffer';
export const PLUGIN_RELEASE_DESCRIPTOR_SCHEMA = 'makelore-plugin-release.v1' as const;
const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9.-]{0,127}$/u;
const SEMVER_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-]+)*)?$/u;
const DIGEST_PATTERN = /^[a-f0-9]{64}$/u;
const DESCRIPTOR_INPUT_KEYS = [
'pluginId',
'version',
'packageSchemaVersion',
'contractVersion',
'minMakeloreVersion',
'maxMakeloreVersion',
'artifact',
] as const;
export type PluginReleaseDescriptorInput = {
readonly pluginId: string;
readonly version: string;
readonly packageSchemaVersion: number;
readonly contractVersion: number;
readonly minMakeloreVersion: string;
readonly maxMakeloreVersion: string | null;
readonly artifact: {
readonly sha256: string;
readonly sizeBytes: number;
};
};
export interface PluginReleaseDescriptor extends PluginReleaseDescriptorInput {
readonly schema: typeof PLUGIN_RELEASE_DESCRIPTOR_SCHEMA;
}
export class PluginReleaseDescriptorError extends Error {
readonly code = 'plugin_descriptor_invalid' as const;
constructor(readonly field: string, message: string) {
super(`Invalid plugin release descriptor (${field}): ${message}`);
this.name = 'PluginReleaseDescriptorError';
}
}
interface ParsedSemVer {
major: number;
minor: number;
patch: number;
prerelease: readonly (number | string)[];
}
function fail(field: string, message: string): never {
throw new PluginReleaseDescriptorError(field, message);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function assertDescriptorInputKeys(value: Record<string, unknown>, includeSchema: boolean): void {
const allowed = new Set<string>([
...DESCRIPTOR_INPUT_KEYS,
...(includeSchema ? ['schema'] : []),
]);
for (const key of Object.keys(value)) {
if (!allowed.has(key)) fail(`root.${key}`, 'unknown field');
}
}
function parseSemVer(value: unknown, field: string): ParsedSemVer {
if (typeof value !== 'string' || value.length > 128) fail(field, 'must be a SemVer string');
const match = SEMVER_PATTERN.exec(value);
if (!match) fail(field, 'must be a valid SemVer');
const prerelease = (match[4] ?? '').split('.').filter(Boolean).map((item) => {
if (/^\d+$/u.test(item)) {
if (item.length > 1 && item.startsWith('0')) fail(field, 'numeric prerelease identifiers cannot have leading zeroes');
return Number(item);
}
return item;
});
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
prerelease,
};
}
export function isValidSemVer(value: unknown): value is string {
try {
parseSemVer(value, 'version');
return true;
} catch {
return false;
}
}
export function compareSemVer(left: string, right: string): -1 | 0 | 1 {
const a = parseSemVer(left, 'left');
const b = parseSemVer(right, 'right');
for (const key of ['major', 'minor', 'patch'] as const) {
if (a[key] < b[key]) return -1;
if (a[key] > b[key]) return 1;
}
if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0;
if (a.prerelease.length === 0) return 1;
if (b.prerelease.length === 0) return -1;
const length = Math.max(a.prerelease.length, b.prerelease.length);
for (let index = 0; index < length; index += 1) {
const leftPart = a.prerelease[index];
const rightPart = b.prerelease[index];
if (leftPart === undefined) return -1;
if (rightPart === undefined) return 1;
if (leftPart === rightPart) continue;
if (typeof leftPart === 'number' && typeof rightPart === 'string') return -1;
if (typeof leftPart === 'string' && typeof rightPart === 'number') return 1;
return leftPart < rightPart ? -1 : 1;
}
return 0;
}
export function isMakeLoreVersionCompatible(
clientVersion: string,
minimumVersion: string,
maximumVersion: string | null,
): boolean {
if (!isValidSemVer(clientVersion) || !isValidSemVer(minimumVersion)
|| (maximumVersion !== null && !isValidSemVer(maximumVersion))) return false;
if (compareSemVer(clientVersion, minimumVersion) < 0) return false;
return maximumVersion === null || compareSemVer(clientVersion, maximumVersion) <= 0;
}
function positiveInteger(value: unknown, field: string, allowZero = false): number {
if (!Number.isSafeInteger(value) || (value as number) < (allowZero ? 0 : 1)) {
fail(field, allowZero ? 'must be a non-negative safe integer' : 'must be a positive safe integer');
}
return value as number;
}
function descriptorFromInput(input: PluginReleaseDescriptorInput): PluginReleaseDescriptor {
if (!isRecord(input)) fail('root', 'must be an object');
assertDescriptorInputKeys(input, 'schema' in input);
if ('schema' in input && input.schema !== PLUGIN_RELEASE_DESCRIPTOR_SCHEMA) {
fail('schema', `must equal ${PLUGIN_RELEASE_DESCRIPTOR_SCHEMA}`);
}
if (typeof input.pluginId !== 'string' || !PLUGIN_ID_PATTERN.test(input.pluginId)) {
fail('plugin_id', 'must be a stable Plugin ID');
}
parseSemVer(input.version, 'version');
if (input.packageSchemaVersion !== 2) fail('package_schema_version', 'must equal 2 for an artifact Release');
positiveInteger(input.contractVersion, 'contract_version');
parseSemVer(input.minMakeloreVersion, 'min_makelore_version');
if (input.maxMakeloreVersion !== null) {
parseSemVer(input.maxMakeloreVersion, 'max_makelore_version');
if (compareSemVer(input.maxMakeloreVersion, input.minMakeloreVersion) < 0) {
fail('max_makelore_version', 'must not be lower than min_makelore_version');
}
}
if (!isRecord(input.artifact)) fail('artifact', 'must be an object');
if (typeof input.artifact.sha256 !== 'string' || !DIGEST_PATTERN.test(input.artifact.sha256)) {
fail('artifact.sha256', 'must be 64 lowercase hexadecimal characters');
}
positiveInteger(input.artifact.sizeBytes, 'artifact.size_bytes', true);
return Object.freeze({
schema: PLUGIN_RELEASE_DESCRIPTOR_SCHEMA,
pluginId: input.pluginId,
version: input.version,
packageSchemaVersion: input.packageSchemaVersion,
contractVersion: input.contractVersion,
minMakeloreVersion: input.minMakeloreVersion,
maxMakeloreVersion: input.maxMakeloreVersion,
artifact: Object.freeze({
sha256: input.artifact.sha256,
sizeBytes: input.artifact.sizeBytes,
}),
});
}
export function buildPluginReleaseDescriptor(input: PluginReleaseDescriptorInput): PluginReleaseDescriptor {
return descriptorFromInput(input);
}
export const createPluginReleaseDescriptor = buildPluginReleaseDescriptor;
export function serializePluginReleaseDescriptor(descriptor: PluginReleaseDescriptor): Buffer {
if (!isRecord(descriptor) || descriptor.schema !== PLUGIN_RELEASE_DESCRIPTOR_SCHEMA) {
fail('schema', `must equal ${PLUGIN_RELEASE_DESCRIPTOR_SCHEMA}`);
}
const valid = descriptorFromInput(descriptor);
return Buffer.from(JSON.stringify({
schema: valid.schema,
plugin_id: valid.pluginId,
version: valid.version,
package_schema_version: valid.packageSchemaVersion,
contract_version: valid.contractVersion,
min_makelore_version: valid.minMakeloreVersion,
max_makelore_version: valid.maxMakeloreVersion,
artifact: {
sha256: valid.artifact.sha256,
size_bytes: valid.artifact.sizeBytes,
},
}), 'utf8');
}
function exactKeys(value: Record<string, unknown>, keys: readonly string[], field: string): void {
const allowed = new Set(keys);
for (const key of Object.keys(value)) {
if (!allowed.has(key)) fail(`${field}.${key}`, 'unknown field');
}
for (const key of keys) {
if (!(key in value)) fail(`${field}.${key}`, 'required field is missing');
}
}
export function parsePluginReleaseDescriptor(input: Uint8Array | string): PluginReleaseDescriptor {
const bytes = typeof input === 'string' ? Buffer.from(input, 'utf8') : Buffer.from(input);
if (bytes.length === 0 || bytes.length > 16_384) fail('root', 'descriptor size is outside the supported bound');
const source = bytes.toString('utf8');
let value: unknown;
try {
value = JSON.parse(source) as unknown;
} catch {
fail('root', 'must be valid UTF-8 JSON');
}
const root = isRecord(value) ? value : fail('root', 'must be an object');
exactKeys(root, [
'schema', 'plugin_id', 'version', 'package_schema_version', 'contract_version',
'min_makelore_version', 'max_makelore_version', 'artifact',
], 'root');
if (root.schema !== PLUGIN_RELEASE_DESCRIPTOR_SCHEMA) {
fail('schema', `must equal ${PLUGIN_RELEASE_DESCRIPTOR_SCHEMA}`);
}
const artifact = isRecord(root.artifact) ? root.artifact : fail('artifact', 'must be an object');
exactKeys(artifact, ['sha256', 'size_bytes'], 'artifact');
const descriptor = descriptorFromInput({
pluginId: root.plugin_id as string,
version: root.version as string,
packageSchemaVersion: root.package_schema_version as number,
contractVersion: root.contract_version as number,
minMakeloreVersion: root.min_makelore_version as string,
maxMakeloreVersion: root.max_makelore_version as string | null,
artifact: {
sha256: artifact.sha256 as string,
sizeBytes: artifact.size_bytes as number,
},
});
const canonical = serializePluginReleaseDescriptor(descriptor);
if (!Buffer.from(canonical).equals(bytes)) fail('root', 'descriptor must use the canonical compact byte representation');
return descriptor;
}
export const parseReleaseDescriptor = parsePluginReleaseDescriptor;
export const serializeReleaseDescriptor = serializePluginReleaseDescriptor;

View File

@@ -0,0 +1,155 @@
import { Buffer } from 'node:buffer';
import { createHash, createPublicKey, verify } from 'node:crypto';
import type { KeyObject } from 'node:crypto';
import {
isMakeLoreVersionCompatible,
parsePluginReleaseDescriptor,
serializePluginReleaseDescriptor,
type PluginReleaseDescriptor,
} from './release-descriptor';
import {
createCodeOwnedPluginTrustStore,
type PluginSigningKeyStore,
} from './trusted-keys';
export type PluginSignatureFailureCode =
| 'plugin_signature_invalid'
| 'plugin_artifact_invalid'
| 'plugin_incompatible_client';
export type PluginSignatureVerificationResult =
| { readonly ok: true }
| { readonly ok: false; readonly code: PluginSignatureFailureCode; readonly message: string };
export interface PluginSignatureVerifierOptions {
readonly keyStore?: PluginSigningKeyStore | ReadonlyMap<string, Uint8Array | string>;
readonly clientVersion: string;
}
export interface PluginSignatureVerifier {
verify(input: {
readonly keyId: string;
readonly signature: string | Uint8Array;
readonly descriptor: PluginReleaseDescriptor;
readonly artifact: Uint8Array;
}): PluginSignatureVerificationResult;
}
function failure(code: PluginSignatureFailureCode, message: string): PluginSignatureVerificationResult {
return { ok: false, code, message };
}
function keyFromStore(
keyStore: PluginSigningKeyStore | ReadonlyMap<string, Uint8Array | string>,
keyId: string,
): Uint8Array | string | null {
const key = keyStore.get(keyId);
return key === undefined ? null : key;
}
function publicKey(value: Uint8Array | string): KeyObject | null {
try {
if (typeof value === 'string' && !value.includes('PUBLIC KEY')) return null;
const key = typeof value === 'string'
? createPublicKey(value)
: createPublicKey({ key: Buffer.from(value), format: 'der', type: 'spki' });
return key.asymmetricKeyType === 'ed25519' ? key : null;
} catch {
return null;
}
}
function signatureBytes(value: string | Uint8Array): Buffer | null {
try {
if (typeof value === 'string'
&& (value.length !== 86 || !/^[A-Za-z0-9_-]+$/u.test(value))) return null;
const bytes = typeof value === 'string' ? Buffer.from(value, 'base64url') : Buffer.from(value);
return bytes.length === 64 ? bytes : null;
} catch {
return null;
}
}
function artifactDigest(artifact: Uint8Array): string {
return createHash('sha256').update(artifact).digest('hex');
}
export function createPluginSignatureVerifier(
options: PluginSignatureVerifierOptions,
): PluginSignatureVerifier {
const keyStore = options.keyStore ?? createCodeOwnedPluginTrustStore();
return {
verify(input) {
if (!(input.artifact instanceof Uint8Array)) {
return failure('plugin_artifact_invalid', 'artifact bytes are invalid');
}
let descriptorBytes: Uint8Array;
try {
descriptorBytes = serializePluginReleaseDescriptor(input.descriptor);
} catch {
return failure('plugin_signature_invalid', 'descriptor is invalid');
}
if (input.artifact.byteLength !== input.descriptor.artifact.sizeBytes
|| artifactDigest(input.artifact) !== input.descriptor.artifact.sha256) {
return failure('plugin_artifact_invalid', 'artifact bytes do not match the descriptor');
}
if (!isCompatibleClient(options.clientVersion, input.descriptor)) {
return failure('plugin_incompatible_client', 'client version is outside the Release range');
}
const encodedKey = keyFromStore(keyStore, input.keyId);
if (encodedKey === null) return failure('plugin_signature_invalid', 'signing key is not trusted');
const key = publicKey(encodedKey);
const signature = signatureBytes(input.signature);
if (!key || !signature || !verify(null, descriptorBytes, key, signature)) {
return failure('plugin_signature_invalid', 'descriptor signature is invalid');
}
return { ok: true };
},
};
}
function isCompatibleClient(clientVersion: string, descriptor: PluginReleaseDescriptor): boolean {
// Kept local to avoid making the verifier infer a range from untrusted fields.
return isMakeLoreVersionCompatible(
clientVersion,
descriptor.minMakeloreVersion,
descriptor.maxMakeloreVersion,
);
}
export interface VerifyPluginReleaseSignatureInput {
readonly verifier?: PluginSignatureVerifier;
readonly keyId: string;
readonly signature: string | Uint8Array;
readonly descriptor: PluginReleaseDescriptor | Uint8Array | string;
readonly artifact: Uint8Array;
readonly clientVersion?: string;
readonly keyStore?: PluginSigningKeyStore | ReadonlyMap<string, Uint8Array | string>;
}
export function verifyPluginReleaseSignature(
input: VerifyPluginReleaseSignatureInput,
): PluginSignatureVerificationResult {
const descriptor = input.descriptor instanceof Uint8Array || typeof input.descriptor === 'string'
? (() => {
try {
return parsePluginReleaseDescriptor(input.descriptor as Uint8Array | string);
} catch {
return null;
}
})()
: input.descriptor;
if (!descriptor) return failure('plugin_signature_invalid', 'descriptor is invalid');
const verifier = input.verifier ?? createPluginSignatureVerifier({
keyStore: input.keyStore,
clientVersion: input.clientVersion ?? '',
});
return verifier.verify({
keyId: input.keyId,
signature: input.signature,
descriptor,
artifact: input.artifact,
});
}
export const verifyReleaseSignature = verifyPluginReleaseSignature;

View File

@@ -0,0 +1,42 @@
import { Buffer } from 'node:buffer';
/**
* The production signing-key resource is deliberately code-owned. The empty
* table records the current activation hold until the platform supplies an
* official key; there is no environment or runtime override.
*/
export const CODE_OWNED_PLUGIN_SIGNING_KEYS = Object.freeze(
{} as Readonly<Record<string, string>>,
);
export const PLUGIN_SIGNING_KEY_ACTIVATION_HOLD = true as const;
export interface PluginSigningKeyStore {
get(keyId: string): Uint8Array | string | null;
}
function decodeKey(value: string): Uint8Array | null {
const trimmed = value.trim();
if (trimmed.length === 0) return null;
if (trimmed.includes('BEGIN PUBLIC KEY')) return Buffer.from(trimmed, 'utf8');
try {
const bytes = Buffer.from(trimmed, 'base64');
return bytes.length > 0 ? bytes : null;
} catch {
return null;
}
}
export function loadCodeOwnedPluginSigningKey(keyId: string): Uint8Array | null {
if (typeof keyId !== 'string' || keyId.length === 0) return null;
const encoded = CODE_OWNED_PLUGIN_SIGNING_KEYS[keyId];
return encoded === undefined ? null : decodeKey(encoded);
}
export function createCodeOwnedPluginTrustStore(): PluginSigningKeyStore {
return Object.freeze({
get: loadCodeOwnedPluginSigningKey,
});
}
export const getCodeOwnedPluginSigningKey = loadCodeOwnedPluginSigningKey;

View File

@@ -12,6 +12,23 @@ export type PluginBillingMode =
export type PluginToolMutation = 'read' | 'write' | 'destructive';
export type CodingPluginRuntimeKind =
| 'bundled_typed'
| 'skill_only'
| 'platform_hosted';
export type CodingPluginAcquisitionMode =
| 'system_included'
| 'user_acquired';
export type CodingPluginExecutionMode = 'synchronous' | 'accepted';
export interface CodingPluginPackageProvenance {
readonly source: 'bundled' | 'marketplace';
/** A trusted Package Store root, never a manifest-provided value. */
readonly packageRoot: string;
}
export interface CodingPluginSkillDefinition {
id: string;
entryPath: string;
@@ -29,6 +46,8 @@ export interface CodingPluginToolDefinition {
projectWriteLease: boolean;
permissions: readonly string[];
inputSchema: Readonly<Record<string, unknown>>;
outputSchema?: Readonly<Record<string, unknown>>;
executionMode?: CodingPluginExecutionMode;
}
export interface CodingPluginOperationDefinition {
@@ -43,6 +62,10 @@ export interface CodingPluginDefinition {
contractVersion: number;
displayName: string;
description: string;
runtimeKind: CodingPluginRuntimeKind;
acquisitionMode: CodingPluginAcquisitionMode;
releaseId: string | null;
provenance: CodingPluginPackageProvenance;
scope: 'project';
adapterId: string;
requiresBackend: boolean;
@@ -404,6 +427,13 @@ export const DATA_SERVICE_PLUGIN_DEFINITION = freezeDeep({
contractVersion: 1,
displayName: '开发数据服务',
description: '为当前项目提供受控的开发期 JSON 数据存储。',
runtimeKind: 'bundled_typed',
acquisitionMode: 'system_included',
releaseId: null,
provenance: {
source: 'bundled',
packageRoot: 'data-service',
},
scope: 'project',
adapterId: DATA_SERVICE_ADAPTER_ID,
requiresBackend: true,

View File

@@ -0,0 +1,272 @@
// @vitest-environment node
import { createHash, generateKeyPairSync, sign } from 'node:crypto';
import { describe, expect, it } from 'vitest';
import {
CodingPluginManifestError,
parseCodingPluginManifest,
} from '../../electron/coding-plugins/manifest';
import {
buildPluginReleaseDescriptor,
isMakeLoreVersionCompatible,
parsePluginReleaseDescriptor,
serializePluginReleaseDescriptor,
} from '../../electron/coding-plugins/release-descriptor';
import {
createPluginSignatureVerifier,
verifyPluginReleaseSignature,
} from '../../electron/coding-plugins/signature-verifier';
import {
CODE_OWNED_PLUGIN_SIGNING_KEYS,
createCodeOwnedPluginTrustStore,
} from '../../electron/coding-plugins/trusted-keys';
const PACKAGE_ROOT = '/trusted/plugin';
const ROOT = {
$schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json',
name: 'makelore.example',
version: '1.2.0',
description: 'A declarative example Skill.',
author: { name: 'MakeLore' },
extensions: { 'com.makelore': { capabilityManifest: './com.makelore/capability.json' } },
};
const SKILL_ONLY_CAPABILITY = {
schemaVersion: 2,
pluginId: 'makelore.example',
contractVersion: 1,
scope: 'project',
runtime: { kind: 'skill_only' },
skills: [{ id: 'example-skill', entry: '../skills/example-skill/SKILL.md', grants: [] }],
tools: [],
};
const HOSTED_CAPABILITY = {
schemaVersion: 2,
pluginId: 'makelore.example',
contractVersion: 1,
scope: 'project',
runtime: { kind: 'platform_hosted', protocol: 'makelore-hosted.v1' },
skills: [{ id: 'example-skill', entry: '../skills/example-skill/SKILL.md', grants: ['example.generate'] }],
tools: [{
name: 'example_generate',
label: 'Generate example',
description: 'Generate one example.',
capabilityId: 'example.generate',
operation: 'generate',
roles: ['parent'],
mutation: 'write',
projectWriteLease: false,
permissions: ['hosted.example.generate'],
executionMode: 'synchronous',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['prompt'],
properties: { prompt: { type: 'string', minLength: 1, maxLength: 4000 } },
},
outputSchema: {
type: 'object',
additionalProperties: false,
required: ['text'],
properties: { text: { type: 'string', maxLength: 20000 } },
},
}],
};
function parse(capability: Record<string, unknown>) {
return parseCodingPluginManifest(ROOT, capability, {
packageRoot: PACKAGE_ROOT,
rootManifestPath: `${PACKAGE_ROOT}/plugin.json`,
capabilityManifestPath: `${PACKAGE_ROOT}/com.makelore/capability.json`,
runtimeKind: capability === SKILL_ONLY_CAPABILITY ? 'skill_only' : 'platform_hosted',
acquisitionMode: 'user_acquired',
releaseId: 'release-1',
provenance: { source: 'marketplace', packageRoot: PACKAGE_ROOT },
});
}
describe('Marketplace Release A package contract', () => {
it('parses immutable skill-only schema 2 with trusted provenance', () => {
const definition = parse(SKILL_ONLY_CAPABILITY);
expect(definition).toMatchObject({
id: 'makelore.example',
version: '1.2.0',
runtimeKind: 'skill_only',
acquisitionMode: 'user_acquired',
releaseId: 'release-1',
provenance: { source: 'marketplace', packageRoot: PACKAGE_ROOT },
skills: [{ id: 'example-skill', grants: [] }],
tools: [],
operations: [],
});
expect(Object.isFrozen(definition)).toBe(true);
expect(Object.isFrozen(definition.skills)).toBe(true);
expect(Object.isFrozen(definition.provenance)).toBe(true);
});
it('parses declarative hosted schema 2 and preserves closed schemas', () => {
const definition = parse(HOSTED_CAPABILITY);
expect(definition).toMatchObject({
runtimeKind: 'platform_hosted',
acquisitionMode: 'user_acquired',
requiresBackend: true,
tools: [{
executionMode: 'synchronous',
outputSchema: HOSTED_CAPABILITY.tools[0]?.outputSchema,
projectWriteLease: false,
}],
operations: [{ capabilityId: 'example.generate', operation: 'generate', toolName: 'example_generate' }],
});
expect(Object.isFrozen(definition.tools[0]?.outputSchema)).toBe(true);
});
it('requires the exact capability manifest path even for direct parsing', () => {
const root = structuredClone(ROOT) as Record<string, unknown>;
const extensions = root.extensions as Record<string, unknown>;
const makeLore = extensions['com.makelore'] as Record<string, unknown>;
makeLore.capabilityManifest = './com.makelore/alternate.json';
expect(() => parseCodingPluginManifest(root, SKILL_ONLY_CAPABILITY, {
packageRoot: PACKAGE_ROOT,
rootManifestPath: `${PACKAGE_ROOT}/plugin.json`,
capabilityManifestPath: `${PACKAGE_ROOT}/com.makelore/capability.json`,
})).toThrow(CodingPluginManifestError);
});
it.each([
['unknown capability field', (value: Record<string, unknown>) => { value.unknown = true; }],
['unknown runtime field', (value: Record<string, unknown>) => {
(value.runtime as Record<string, unknown>).unknown = true;
}],
['schema-2 adapter privilege', (value: Record<string, unknown>) => { value.adapterId = 'data-service'; }],
['schema-2 local surface privilege', (value: Record<string, unknown>) => { value.surfaces = {}; }],
['skill-only grants', (value: Record<string, unknown>) => {
(value.skills as Array<Record<string, unknown>>)[0]!.grants = ['example.generate'];
}],
['skill-only tools', (value: Record<string, unknown>) => {
value.tools = [{ ...HOSTED_CAPABILITY.tools[0] }];
}],
['hosted local permission', (value: Record<string, unknown>) => {
(value.tools as Array<Record<string, unknown>>)[0]!.permissions = ['project.data.read'];
}],
['hosted foreign permission namespace', (value: Record<string, unknown>) => {
(value.tools as Array<Record<string, unknown>>)[0]!.permissions = ['hosted.makelore.example.generate'];
}],
['hosted project lease', (value: Record<string, unknown>) => {
(value.tools as Array<Record<string, unknown>>)[0]!.projectWriteLease = true;
}],
['unbounded schema', (value: Record<string, unknown>) => {
const tool = (value.tools as Array<Record<string, unknown>>)[0]!;
(tool.inputSchema as Record<string, unknown>).properties = {
prompt: { type: 'string' },
};
}],
['implicit required schema', (value: Record<string, unknown>) => {
const tool = (value.tools as Array<Record<string, unknown>>)[0]!;
delete (tool.inputSchema as Record<string, unknown>).required;
}],
])('rejects %s', (_label, mutate) => {
const source = structuredClone(
_label === 'skill-only grants' || _label === 'skill-only tools'
? SKILL_ONLY_CAPABILITY
: HOSTED_CAPABILITY,
) as Record<string, unknown>;
mutate(source);
expect(() => parse(source)).toThrow(CodingPluginManifestError);
});
it('keeps the bundled schema-1 definition byte-compatible', async () => {
const { readFile } = await import('node:fs/promises');
const path = await import('node:path');
const packageRoot = path.resolve('resources/coding-plugins/data-service');
const root = JSON.parse(await readFile(path.join(packageRoot, 'plugin.json'), 'utf8')) as Record<string, unknown>;
const capability = JSON.parse(await readFile(path.join(packageRoot, 'com.makelore/capability.json'), 'utf8')) as Record<string, unknown>;
const definition = parseCodingPluginManifest(root, capability, {
packageRoot,
capabilityManifestPath: path.join(packageRoot, 'com.makelore/capability.json'),
});
expect(definition.id).toBe('makelore.data-service');
expect(definition.runtimeKind).toBe('bundled_typed');
expect(definition.acquisitionMode).toBe('system_included');
expect(definition.tools).toHaveLength(10);
expect(definition.operations).toHaveLength(14);
});
it('serializes and parses the fixed descriptor bytes in field order', () => {
const descriptor = buildPluginReleaseDescriptor({
pluginId: 'makelore.example',
version: '1.2.0',
packageSchemaVersion: 2,
contractVersion: 1,
minMakeloreVersion: '1.0.0',
maxMakeloreVersion: null,
artifact: { sha256: 'a'.repeat(64), sizeBytes: 1234 },
});
const bytes = serializePluginReleaseDescriptor(descriptor);
expect(Buffer.from(bytes).toString('utf8')).toBe(
'{"schema":"makelore-plugin-release.v1","plugin_id":"makelore.example","version":"1.2.0","package_schema_version":2,"contract_version":1,"min_makelore_version":"1.0.0","max_makelore_version":null,"artifact":{"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size_bytes":1234}}',
);
expect(parsePluginReleaseDescriptor(bytes)).toEqual(descriptor);
expect(() => parsePluginReleaseDescriptor(
Buffer.from(Buffer.from(bytes).toString('utf8').replace('"schema":"makelore-plugin-release.v1"', '"schema":"other.v1"')),
)).toThrow();
});
it('uses inclusive strict SemVer client bounds', () => {
expect(isMakeLoreVersionCompatible('1.0.0', '1.0.0', null)).toBe(true);
expect(isMakeLoreVersionCompatible('2.0.0', '1.0.0', '2.0.0')).toBe(true);
expect(isMakeLoreVersionCompatible('2.0.1', '1.0.0', '2.0.0')).toBe(false);
expect(isMakeLoreVersionCompatible('1.0.0-alpha.1', '1.0.0', null)).toBe(false);
});
it('verifies Ed25519 signatures only through the injected test key dependency', () => {
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
const artifact = Buffer.from('artifact-bytes');
const descriptor = buildPluginReleaseDescriptor({
pluginId: 'makelore.example', version: '1.2.0', packageSchemaVersion: 2,
contractVersion: 1, minMakeloreVersion: '1.0.0', maxMakeloreVersion: null,
artifact: { sha256: createHash('sha256').update(artifact).digest('hex'), sizeBytes: artifact.length },
});
const descriptorBytes = serializePluginReleaseDescriptor(descriptor);
const signature = sign(null, descriptorBytes, privateKey).toString('base64url');
const verifier = createPluginSignatureVerifier({
keyStore: new Map([['test-key', publicKey.export({ type: 'spki', format: 'der' })]]),
clientVersion: '1.0.0',
});
expect(verifyPluginReleaseSignature({
verifier, keyId: 'test-key', signature, descriptor, artifact,
})).toEqual({ ok: true });
expect(verifyPluginReleaseSignature({
verifier, keyId: 'missing-key', signature, descriptor, artifact,
})).toMatchObject({ ok: false, code: 'plugin_signature_invalid' });
expect(createCodeOwnedPluginTrustStore().get('missing-key')).toBeNull();
expect(Object.keys(CODE_OWNED_PLUGIN_SIGNING_KEYS)).toHaveLength(0);
});
it('rejects bad signature, digest, descriptor identity, and incompatible client', () => {
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
const artifact = Buffer.from('artifact-bytes');
const descriptor = buildPluginReleaseDescriptor({
pluginId: 'makelore.example', version: '1.2.0', packageSchemaVersion: 2,
contractVersion: 1, minMakeloreVersion: '2.0.0', maxMakeloreVersion: null,
artifact: { sha256: createHash('sha256').update(artifact).digest('hex'), sizeBytes: artifact.length },
});
const signature = sign(null, serializePluginReleaseDescriptor(descriptor), privateKey).toString('base64url');
const verifier = createPluginSignatureVerifier({
keyStore: new Map([['test-key', publicKey.export({ type: 'spki', format: 'der' })]]),
clientVersion: '1.0.0',
});
expect(verifyPluginReleaseSignature({ verifier, keyId: 'test-key', signature, descriptor, artifact }))
.toMatchObject({ ok: false, code: 'plugin_incompatible_client' });
const compatible = createPluginSignatureVerifier({
keyStore: new Map([['test-key', publicKey.export({ type: 'spki', format: 'der' })]]),
clientVersion: '2.0.0',
});
expect(verifyPluginReleaseSignature({
verifier: compatible, keyId: 'test-key', signature: `${signature.slice(0, -2)}aa`, descriptor, artifact,
})).toMatchObject({ ok: false, code: 'plugin_signature_invalid' });
expect(verifyPluginReleaseSignature({
verifier: compatible, keyId: 'test-key', signature, descriptor, artifact: Buffer.from('other'),
})).toMatchObject({ ok: false, code: 'plugin_artifact_invalid' });
});
});