1388 lines
56 KiB
TypeScript
1388 lines
56 KiB
TypeScript
import { Buffer } from 'node:buffer';
|
|
import { createHash, randomUUID } from 'node:crypto';
|
|
import {
|
|
lstat,
|
|
mkdir,
|
|
mkdtemp,
|
|
readFile,
|
|
rename,
|
|
rm,
|
|
stat,
|
|
writeFile,
|
|
} from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import AdmZip from 'adm-zip';
|
|
import { getDataDir } from '../utils/paths';
|
|
import { subscribeWorksSquareSession } from '../services/works-square-session';
|
|
import {
|
|
loadCodingPluginDefinition,
|
|
} from './manifest';
|
|
import type { CodingPluginDefinition } from '../../shared/coding-plugins';
|
|
import {
|
|
buildPluginReleaseDescriptor,
|
|
compareSemVer,
|
|
isMakeLoreVersionCompatible,
|
|
isValidSemVer,
|
|
type PluginReleaseDescriptor,
|
|
} from './release-descriptor';
|
|
import {
|
|
createPluginSignatureVerifier,
|
|
type PluginSignatureFailureCode,
|
|
type PluginSignatureVerifier,
|
|
} from './signature-verifier';
|
|
import {
|
|
AccountPluginCache,
|
|
type AccountBinding,
|
|
type MarketplaceLibrarySnapshot,
|
|
type MarketplaceResolveItem,
|
|
type MarketplaceResolveSnapshot,
|
|
} from './account-plugin-cache';
|
|
import type {
|
|
DownloadGrant,
|
|
DownloadRequest,
|
|
InstalledReleaseInput,
|
|
MarketplaceClient,
|
|
ResolveRequest,
|
|
} from './marketplace-client';
|
|
import type { PluginSigningKeyStore } from './trusted-keys';
|
|
|
|
const INDEX_SCHEMA_VERSION = 1;
|
|
const CURRENT_SELECTION_SCHEMA_VERSION = 1;
|
|
const DEFAULT_MAX_ARCHIVE_BYTES = 16 * 1024 * 1024;
|
|
const DEFAULT_MAX_EXTRACTED_BYTES = 32 * 1024 * 1024;
|
|
const DEFAULT_MAX_FILES = 256;
|
|
const DEFAULT_MAX_FILE_BYTES = 4 * 1024 * 1024;
|
|
const PLUGIN_MANIFEST_MAX_BYTES = 256 * 1024;
|
|
const PLUGIN_SKILL_MAX_BYTES = 256 * 1024;
|
|
const ORPHAN_ARCHIVE_FILE = '.makelore-release.zip';
|
|
const MAX_PLUGIN_ID = 128;
|
|
const MAX_RELEASE_ID = 128;
|
|
const MAX_VERSION = 128;
|
|
const MAX_CONTRACT_VERSION = 2 ** 31 - 1;
|
|
const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9.-]{0,127}$/u;
|
|
const RELEASE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
|
const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
|
|
const INDEX_ROOT_KEYS = new Set(['schema_version', 'releases']);
|
|
const INDEX_RELEASE_KEYS = new Set([
|
|
'plugin_id',
|
|
'release_id',
|
|
'version',
|
|
'package_schema_version',
|
|
'contract_version',
|
|
'runtime_kind',
|
|
'sha256',
|
|
'size_bytes',
|
|
'installed_at',
|
|
'channel',
|
|
'min_makelore_version',
|
|
'max_makelore_version',
|
|
]);
|
|
const CURRENT_SELECTION_ROOT_KEYS = new Set(['schema_version', 'current']);
|
|
const PACKAGE_MANIFEST_PATHS = new Set(['plugin.json', 'com.makelore/capability.json']);
|
|
const PACKAGE_TEXT_EXTENSIONS = new Set([
|
|
'.css', '.html', '.json', '.md', '.markdown', '.mustache', '.hbs', '.ini',
|
|
'.jinja', '.jinja2', '.template', '.txt', '.toml', '.tmpl', '.xml', '.yaml', '.yml',
|
|
]);
|
|
const PACKAGE_IMAGE_EXTENSIONS = new Set(['.jpeg', '.jpg', '.png', '.svg', '.webp']);
|
|
|
|
type UnknownRecord = Record<string, unknown>;
|
|
|
|
export type { DownloadGrant, MarketplaceResolveSnapshot } from './marketplace-client';
|
|
export type ResolveSnapshot = MarketplaceResolveSnapshot;
|
|
|
|
export interface InstalledReleaseRecord {
|
|
readonly pluginId: string;
|
|
readonly releaseId: string;
|
|
readonly version: string;
|
|
readonly packageSchemaVersion: number;
|
|
readonly contractVersion: number;
|
|
readonly runtimeKind: 'skill_only' | 'platform_hosted';
|
|
readonly sha256: string;
|
|
readonly sizeBytes: number;
|
|
readonly installedAt: string;
|
|
/** Channel is the latest explicit selection intent; verified client range is immutable. */
|
|
readonly channel?: 'stable' | 'beta';
|
|
readonly minMakeloreVersion?: string;
|
|
readonly maxMakeloreVersion?: string | null;
|
|
}
|
|
|
|
export interface InstalledRelease extends InstalledReleaseRecord {
|
|
readonly packageRoot: string;
|
|
readonly definition: CodingPluginDefinition;
|
|
readonly unavailableReason?: 'plugin_incompatible_client';
|
|
}
|
|
|
|
export type InstallationStatus = 'installed' | 'kept' | 'removed' | 'unavailable';
|
|
|
|
export interface InstallationSnapshot {
|
|
readonly status: InstallationStatus;
|
|
readonly pluginId: string;
|
|
readonly releaseId?: string;
|
|
readonly version?: string;
|
|
/** The immutable channel selected for this device package. */
|
|
readonly channel?: 'stable' | 'beta';
|
|
readonly packageRoot?: string;
|
|
readonly definition?: CodingPluginDefinition;
|
|
readonly reason?: string;
|
|
}
|
|
|
|
export interface ResolveInstallInput {
|
|
readonly pluginId: string;
|
|
readonly makeloreVersion: string;
|
|
readonly channel?: 'stable' | 'beta';
|
|
/** Beta packages are never selected by background/stable sync. */
|
|
readonly explicitBeta?: boolean;
|
|
readonly resolveRequestId?: string;
|
|
readonly resolveRequestDigest?: string;
|
|
readonly installed?: InstalledReleaseInput | null;
|
|
}
|
|
|
|
export interface MarketplacePackageClientPort {
|
|
resolve(input: ResolveRequest): Promise<MarketplaceResolveSnapshot>;
|
|
issueDownload(input: DownloadRequest): Promise<DownloadGrant>;
|
|
downloadContent(grant: DownloadGrant): Promise<Uint8Array>;
|
|
readLibrary?(): Promise<MarketplaceLibrarySnapshot>;
|
|
getCurrentAccountBinding?(): AccountBinding | null;
|
|
}
|
|
|
|
export interface PluginPackageStoreOptions {
|
|
/** A test-owned root may be supplied; production derives it from appData. */
|
|
readonly rootDir?: string;
|
|
readonly marketplace: MarketplacePackageClientPort | MarketplaceClient;
|
|
readonly accountCache?: AccountPluginCache;
|
|
readonly getAccountBinding?: () => AccountBinding | null;
|
|
readonly subscribeSession?: (listener: () => void) => () => void;
|
|
readonly clientVersion?: string;
|
|
readonly keyStore?: PluginSigningKeyStore | ReadonlyMap<string, Uint8Array | string>;
|
|
readonly signatureVerifier?: PluginSignatureVerifier;
|
|
readonly now?: () => number;
|
|
readonly maxArchiveBytes?: number;
|
|
readonly maxExtractedBytes?: number;
|
|
readonly maxFiles?: number;
|
|
readonly maxFileBytes?: number;
|
|
/** Replaced only by focused interruption tests; production is atomic. */
|
|
readonly writeIndex?: (filePath: string, bytes: Uint8Array) => Promise<void>;
|
|
readonly activeWorkerReleaseIds?: () => readonly string[];
|
|
}
|
|
|
|
export type PluginPackageStoreErrorCode =
|
|
| 'plugin_store_index_invalid'
|
|
| 'plugin_beta_selection_required'
|
|
| 'plugin_release_unavailable'
|
|
| 'plugin_account_changed'
|
|
| 'plugin_artifact_invalid'
|
|
| 'plugin_signature_invalid'
|
|
| 'plugin_incompatible_client'
|
|
| 'plugin_manifest_invalid'
|
|
| 'plugin_runtime_not_supported'
|
|
| 'plugin_release_conflict'
|
|
| 'plugin_release_not_ready'
|
|
| 'plugin_release_yanked'
|
|
| 'plugin_runtime_suspended'
|
|
| 'plugin_library_required'
|
|
| 'plugin_backend_unavailable'
|
|
| 'plugin_install_failed';
|
|
|
|
export class PluginPackageStoreError extends Error {
|
|
constructor(
|
|
readonly code: PluginPackageStoreErrorCode,
|
|
message: string = code,
|
|
) {
|
|
super(message);
|
|
this.name = 'PluginPackageStoreError';
|
|
}
|
|
}
|
|
|
|
interface IndexDocument {
|
|
readonly schema_version: typeof INDEX_SCHEMA_VERSION;
|
|
readonly releases: readonly InstalledReleaseRecord[];
|
|
}
|
|
|
|
interface CurrentSelectionDocument {
|
|
readonly schema_version: typeof CURRENT_SELECTION_SCHEMA_VERSION;
|
|
readonly current: Readonly<Record<string, string>>;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is UnknownRecord {
|
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function fail(code: PluginPackageStoreErrorCode, message: string = code): never {
|
|
throw new PluginPackageStoreError(code, message);
|
|
}
|
|
|
|
function assertExactKeys(value: UnknownRecord, allowed: ReadonlySet<string>, context: string): void {
|
|
for (const key of Object.keys(value)) {
|
|
if (!allowed.has(key)) fail('plugin_store_index_invalid', `unknown ${context} field: ${key.slice(0, 128)}`);
|
|
}
|
|
}
|
|
|
|
function boundedText(
|
|
value: unknown,
|
|
field: string,
|
|
maximum: number,
|
|
code: PluginPackageStoreErrorCode = 'plugin_store_index_invalid',
|
|
): string {
|
|
if (typeof value !== 'string' || value.length === 0 || value.length > maximum) {
|
|
fail(code, `invalid ${field}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function positiveInteger(value: unknown, field: string, maximum = Number.MAX_SAFE_INTEGER): number {
|
|
if (!Number.isSafeInteger(value) || (value as number) < 1 || (value as number) > maximum) {
|
|
fail('plugin_store_index_invalid', `invalid ${field}`);
|
|
}
|
|
return value as number;
|
|
}
|
|
|
|
function validPluginId(
|
|
value: unknown,
|
|
field = 'pluginId',
|
|
code: PluginPackageStoreErrorCode = 'plugin_release_unavailable',
|
|
): string {
|
|
const result = boundedText(value, field, MAX_PLUGIN_ID, code);
|
|
if (!PLUGIN_ID_PATTERN.test(result)) fail(code, `invalid ${field}`);
|
|
return result;
|
|
}
|
|
|
|
function validReleaseId(
|
|
value: unknown,
|
|
field = 'releaseId',
|
|
code: PluginPackageStoreErrorCode = 'plugin_release_unavailable',
|
|
): string {
|
|
const result = boundedText(value, field, MAX_RELEASE_ID, code);
|
|
if (!RELEASE_ID_PATTERN.test(result)) fail(code, `invalid ${field}`);
|
|
return result;
|
|
}
|
|
|
|
function validSha(
|
|
value: unknown,
|
|
field: string,
|
|
code: PluginPackageStoreErrorCode = 'plugin_artifact_invalid',
|
|
): string {
|
|
const result = boundedText(value, field, 64, code);
|
|
if (!SHA256_PATTERN.test(result)) fail(code, `invalid ${field}`);
|
|
return result;
|
|
}
|
|
|
|
function validVersion(value: unknown, field: string): string {
|
|
const result = boundedText(value, field, MAX_VERSION);
|
|
if (!isValidSemVer(result)) fail('plugin_store_index_invalid', `invalid ${field}`);
|
|
return result;
|
|
}
|
|
|
|
function assertPositiveOption(value: number | undefined, fallback: number, field: string): number {
|
|
const result = value ?? fallback;
|
|
if (!Number.isSafeInteger(result) || result < 1) throw new RangeError(`${field} must be a positive safe integer`);
|
|
return result;
|
|
}
|
|
|
|
function clone<T>(value: T): T {
|
|
return structuredClone(value);
|
|
}
|
|
|
|
function digest(bytes: Uint8Array): string {
|
|
return createHash('sha256').update(bytes).digest('hex');
|
|
}
|
|
|
|
function sameBinding(left: AccountBinding | null, right: AccountBinding): boolean {
|
|
return left !== null && left.accountKey === right.accountKey && left.epoch === right.epoch;
|
|
}
|
|
|
|
function toBuffer(bytes: Uint8Array): Buffer {
|
|
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
}
|
|
|
|
function isNotFound(error: unknown): boolean {
|
|
return isRecord(error) && error.code === 'ENOENT';
|
|
}
|
|
|
|
function isDirectoryPath(value: string): Promise<boolean> {
|
|
return stat(value).then((entry) => entry.isDirectory()).catch(() => false);
|
|
}
|
|
|
|
function parseIndexDocument(value: unknown): IndexDocument {
|
|
const root = isRecord(value) ? value : fail('plugin_store_index_invalid', 'index root must be an object');
|
|
assertExactKeys(root, INDEX_ROOT_KEYS, 'index');
|
|
if (root.schema_version !== INDEX_SCHEMA_VERSION) fail('plugin_store_index_invalid', 'unsupported index schema');
|
|
if (!Array.isArray(root.releases) || root.releases.length > DEFAULT_MAX_FILES) {
|
|
fail('plugin_store_index_invalid', 'invalid index releases');
|
|
}
|
|
const seen = new Set<string>();
|
|
const releases = root.releases.map((value, index) => {
|
|
const record = isRecord(value) ? value : fail('plugin_store_index_invalid', `invalid release ${index}`);
|
|
assertExactKeys(record, INDEX_RELEASE_KEYS, `release ${index}`);
|
|
if (record.package_schema_version !== 2) fail('plugin_store_index_invalid', `invalid release ${index} schema`);
|
|
const pluginId = validPluginId(record.plugin_id, `release ${index}.plugin_id`, 'plugin_store_index_invalid');
|
|
const releaseId = validReleaseId(record.release_id, `release ${index}.release_id`, 'plugin_store_index_invalid');
|
|
const key = `${pluginId}\u0000${releaseId}`;
|
|
if (seen.has(key)) fail('plugin_store_index_invalid', `duplicate release ${releaseId}`);
|
|
seen.add(key);
|
|
const runtimeKind = record.runtime_kind;
|
|
if (runtimeKind !== 'skill_only') {
|
|
fail('plugin_store_index_invalid', `invalid release ${index}.runtime_kind`);
|
|
}
|
|
const installedAt = boundedText(record.installed_at, `release ${index}.installed_at`, 80);
|
|
if (!Number.isFinite(Date.parse(installedAt))) fail('plugin_store_index_invalid', `invalid release ${index}.installed_at`);
|
|
const minMakeloreVersion = record.min_makelore_version === undefined
|
|
? undefined
|
|
: validVersion(record.min_makelore_version, `release ${index}.min_makelore_version`);
|
|
const maxMakeloreVersion = record.max_makelore_version === undefined || record.max_makelore_version === null
|
|
? record.max_makelore_version as string | null | undefined
|
|
: validVersion(record.max_makelore_version, `release ${index}.max_makelore_version`);
|
|
if (minMakeloreVersion && maxMakeloreVersion
|
|
&& compareSemVer(maxMakeloreVersion, minMakeloreVersion) < 0) {
|
|
fail('plugin_store_index_invalid', `invalid release ${index}.max_makelore_version`);
|
|
}
|
|
const channel = record.channel === undefined ? undefined : record.channel;
|
|
if (channel !== undefined && channel !== 'stable' && channel !== 'beta') {
|
|
fail('plugin_store_index_invalid', `invalid release ${index}.channel`);
|
|
}
|
|
return Object.freeze({
|
|
pluginId,
|
|
releaseId,
|
|
version: validVersion(record.version, `release ${index}.version`),
|
|
packageSchemaVersion: 2 as const,
|
|
contractVersion: positiveInteger(record.contract_version, `release ${index}.contract_version`, MAX_CONTRACT_VERSION),
|
|
runtimeKind,
|
|
sha256: validSha(record.sha256, `release ${index}.sha256`, 'plugin_store_index_invalid'),
|
|
sizeBytes: positiveInteger(record.size_bytes, `release ${index}.size_bytes`, DEFAULT_MAX_ARCHIVE_BYTES),
|
|
installedAt,
|
|
...(channel === undefined ? {} : { channel }),
|
|
...(minMakeloreVersion === undefined ? {} : { minMakeloreVersion }),
|
|
...(maxMakeloreVersion === undefined ? {} : { maxMakeloreVersion }),
|
|
});
|
|
});
|
|
return Object.freeze({ schema_version: INDEX_SCHEMA_VERSION, releases: Object.freeze(releases) });
|
|
}
|
|
|
|
function serializeIndex(document: IndexDocument): Uint8Array {
|
|
return Buffer.from(`${JSON.stringify({
|
|
schema_version: document.schema_version,
|
|
releases: document.releases.map((record) => ({
|
|
plugin_id: record.pluginId,
|
|
release_id: record.releaseId,
|
|
version: record.version,
|
|
package_schema_version: record.packageSchemaVersion,
|
|
contract_version: record.contractVersion,
|
|
runtime_kind: record.runtimeKind,
|
|
sha256: record.sha256,
|
|
size_bytes: record.sizeBytes,
|
|
installed_at: record.installedAt,
|
|
...(record.channel === undefined ? {} : { channel: record.channel }),
|
|
...(record.minMakeloreVersion === undefined ? {} : { min_makelore_version: record.minMakeloreVersion }),
|
|
...(record.maxMakeloreVersion === undefined ? {} : { max_makelore_version: record.maxMakeloreVersion }),
|
|
})),
|
|
})}\n`, 'utf8');
|
|
}
|
|
|
|
function parseCurrentSelectionDocument(value: unknown): CurrentSelectionDocument {
|
|
const root = isRecord(value) ? value : fail('plugin_store_index_invalid', 'current selection root must be an object');
|
|
assertExactKeys(root, CURRENT_SELECTION_ROOT_KEYS, 'current selection');
|
|
if (root.schema_version !== CURRENT_SELECTION_SCHEMA_VERSION) {
|
|
fail('plugin_store_index_invalid', 'unsupported current selection schema');
|
|
}
|
|
if (!isRecord(root.current) || Object.keys(root.current).length > DEFAULT_MAX_FILES) {
|
|
fail('plugin_store_index_invalid', 'invalid current selection');
|
|
}
|
|
const current: Record<string, string> = {};
|
|
for (const [pluginId, releaseId] of Object.entries(root.current)) {
|
|
const validId = validPluginId(pluginId, 'current plugin_id', 'plugin_store_index_invalid');
|
|
current[validId] = validReleaseId(releaseId, `current ${validId}.release_id`, 'plugin_store_index_invalid');
|
|
}
|
|
return Object.freeze({
|
|
schema_version: CURRENT_SELECTION_SCHEMA_VERSION,
|
|
current: Object.freeze(current),
|
|
});
|
|
}
|
|
|
|
function serializeCurrentSelection(document: CurrentSelectionDocument): Uint8Array {
|
|
return Buffer.from(`${JSON.stringify({
|
|
schema_version: document.schema_version,
|
|
current: document.current,
|
|
})}\n`, 'utf8');
|
|
}
|
|
|
|
async function parseJsonFile(filePath: string): Promise<unknown> {
|
|
const bytes = await readFile(filePath);
|
|
let source: string;
|
|
try {
|
|
source = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
} catch {
|
|
fail('plugin_store_index_invalid', 'index is not valid UTF-8');
|
|
}
|
|
try {
|
|
return JSON.parse(source) as unknown;
|
|
} catch {
|
|
fail('plugin_store_index_invalid', 'index is not valid JSON');
|
|
}
|
|
}
|
|
|
|
async function atomicWriteIndex(filePath: string, bytes: Uint8Array): Promise<void> {
|
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
const temporaryPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${randomUUID()}.tmp`);
|
|
try {
|
|
await writeFile(temporaryPath, toBuffer(bytes), { flag: 'wx' });
|
|
await rename(temporaryPath, filePath);
|
|
} finally {
|
|
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
function safeArchiveEntryName(entryName: string, isDirectory: boolean): string[] {
|
|
if (entryName.length === 0 || entryName.includes('\\') || entryName.startsWith('/')
|
|
|| /^[A-Za-z]:/u.test(entryName) || /^[A-Za-z][A-Za-z0-9+.-]*:/u.test(entryName)) {
|
|
fail('plugin_artifact_invalid', 'archive contains a non-canonical path');
|
|
}
|
|
const segments = entryName.split('/');
|
|
if (segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..')) {
|
|
fail('plugin_artifact_invalid', 'archive contains a path traversal');
|
|
}
|
|
if (isDirectory && !entryName.endsWith('/')) {
|
|
fail('plugin_artifact_invalid', 'archive directory path is not canonical');
|
|
}
|
|
if (!isDirectory && entryName.endsWith('/')) {
|
|
fail('plugin_artifact_invalid', 'archive file path is not canonical');
|
|
}
|
|
return isDirectory ? segments.slice(0, -1) : segments;
|
|
}
|
|
|
|
function entryIsSymlink(entry: AdmZip.IZipEntry): boolean {
|
|
const mode = (entry.attr >>> 16) & 0xffff;
|
|
return (mode & 0xf000) === 0xa000;
|
|
}
|
|
|
|
function assertUtf8(content: Uint8Array, filePath: string): void {
|
|
try {
|
|
new TextDecoder('utf-8', { fatal: true }).decode(content);
|
|
} catch {
|
|
fail('plugin_artifact_invalid', `${filePath} must be UTF-8`);
|
|
}
|
|
}
|
|
|
|
function validateArchiveFile(filePath: string, content: Uint8Array): void {
|
|
if (PACKAGE_MANIFEST_PATHS.has(filePath)) {
|
|
if (content.byteLength > PLUGIN_MANIFEST_MAX_BYTES) {
|
|
fail('plugin_artifact_invalid', `${filePath} exceeds its byte limit`);
|
|
}
|
|
assertUtf8(content, filePath);
|
|
return;
|
|
}
|
|
if (!filePath.startsWith('skills/')) {
|
|
fail('plugin_artifact_invalid', 'package files must be under a declared Skill');
|
|
}
|
|
const extension = path.posix.extname(filePath).toLowerCase();
|
|
if (!PACKAGE_TEXT_EXTENSIONS.has(extension) && !PACKAGE_IMAGE_EXTENSIONS.has(extension)) {
|
|
fail('plugin_artifact_invalid', 'archive asset extension is not supported');
|
|
}
|
|
if (filePath.endsWith('/SKILL.md') && content.byteLength > PLUGIN_SKILL_MAX_BYTES) {
|
|
fail('plugin_artifact_invalid', 'Skill exceeds its byte limit');
|
|
}
|
|
if (PACKAGE_TEXT_EXTENSIONS.has(extension)) assertUtf8(content, filePath);
|
|
}
|
|
|
|
function validateArchiveDefinition(
|
|
files: readonly string[],
|
|
definition: CodingPluginDefinition,
|
|
): void {
|
|
for (const manifestPath of PACKAGE_MANIFEST_PATHS) {
|
|
if (!files.includes(manifestPath)) fail('plugin_artifact_invalid', `archive is missing ${manifestPath}`);
|
|
}
|
|
const skillIds = new Set(definition.skills.map(({ id }) => id));
|
|
for (const filePath of files) {
|
|
if (PACKAGE_MANIFEST_PATHS.has(filePath)) continue;
|
|
const [, skillId] = filePath.split('/');
|
|
if (!skillId || !skillIds.has(skillId)) {
|
|
fail('plugin_artifact_invalid', 'archive file is outside a declared Skill');
|
|
}
|
|
}
|
|
for (const { entryPath } of definition.skills) {
|
|
if (!files.includes(entryPath)) fail('plugin_artifact_invalid', 'archive is missing a declared Skill entry');
|
|
}
|
|
}
|
|
|
|
async function extractArchive(
|
|
bytes: Uint8Array,
|
|
destination: string,
|
|
options: {
|
|
readonly maxExtractedBytes: number;
|
|
readonly maxFiles: number;
|
|
readonly maxFileBytes: number;
|
|
},
|
|
): Promise<readonly string[]> {
|
|
let archive: AdmZip;
|
|
try {
|
|
archive = new AdmZip(toBuffer(bytes));
|
|
} catch {
|
|
fail('plugin_artifact_invalid', 'artifact is not a readable ZIP archive');
|
|
}
|
|
const entries = archive.getEntries();
|
|
if (entries.length === 0 || entries.length > options.maxFiles) {
|
|
fail('plugin_artifact_invalid', 'artifact file count is outside its bound');
|
|
}
|
|
const seen = new Set<string>();
|
|
const files: string[] = [];
|
|
let extractedBytes = 0;
|
|
for (const entry of entries) {
|
|
if (entryIsSymlink(entry)) fail('plugin_artifact_invalid', 'archive symlinks are not supported');
|
|
const segments = safeArchiveEntryName(entry.entryName, entry.isDirectory);
|
|
const relative = segments.join('/');
|
|
if (seen.has(relative)) fail('plugin_artifact_invalid', 'archive contains duplicate paths');
|
|
seen.add(relative);
|
|
const target = path.resolve(destination, ...segments);
|
|
const relativeTarget = path.relative(destination, target);
|
|
if (!relativeTarget || relativeTarget === '..' || relativeTarget.startsWith(`..${path.sep}`) || path.isAbsolute(relativeTarget)) {
|
|
fail('plugin_artifact_invalid', 'archive path escapes its staging directory');
|
|
}
|
|
if (entry.isDirectory) {
|
|
await mkdir(target, { recursive: true });
|
|
continue;
|
|
}
|
|
const declaredSize = entry.header.size;
|
|
if (!Number.isSafeInteger(declaredSize) || declaredSize < 0 || declaredSize > options.maxFileBytes) {
|
|
fail('plugin_artifact_invalid', 'archive file exceeds its bound');
|
|
}
|
|
extractedBytes += declaredSize;
|
|
if (extractedBytes > options.maxExtractedBytes) fail('plugin_artifact_invalid', 'archive extraction exceeds its bound');
|
|
let content: Buffer;
|
|
try {
|
|
content = entry.getData();
|
|
} catch {
|
|
fail('plugin_artifact_invalid', 'archive entry could not be decompressed');
|
|
}
|
|
if (content.byteLength !== declaredSize || content.byteLength > options.maxFileBytes) {
|
|
fail('plugin_artifact_invalid', 'archive entry size is invalid');
|
|
}
|
|
validateArchiveFile(relative, content);
|
|
files.push(relative);
|
|
await mkdir(path.dirname(target), { recursive: true });
|
|
try {
|
|
await writeFile(target, content, { flag: 'wx' });
|
|
} catch {
|
|
fail('plugin_artifact_invalid', 'archive extraction could not create a file');
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
function mapVerificationFailure(code: PluginSignatureFailureCode): PluginPackageStoreErrorCode {
|
|
return code;
|
|
}
|
|
|
|
function validateGrantAgainstResolve(
|
|
item: MarketplaceResolveItem,
|
|
grant: DownloadGrant,
|
|
input: ResolveInstallInput,
|
|
now: number,
|
|
maxArchiveBytes: number,
|
|
): void {
|
|
if (!item.releaseId || !item.version || !item.sha256 || !item.sizeBytes || !item.releaseAdmissionId) {
|
|
fail('plugin_release_unavailable', 'resolve result is missing download admission metadata');
|
|
}
|
|
validReleaseId(item.releaseId);
|
|
validPluginId(grant.pluginId);
|
|
validReleaseId(grant.releaseId);
|
|
validReleaseId(grant.releaseAdmissionId);
|
|
validSha(item.sha256, 'resolve sha256');
|
|
if (!Number.isSafeInteger(item.sizeBytes) || item.sizeBytes < 1 || item.sizeBytes > maxArchiveBytes) {
|
|
fail('plugin_artifact_invalid', 'resolve artifact size is outside its bound');
|
|
}
|
|
if (!Number.isSafeInteger(grant.sizeBytes) || grant.sizeBytes < 1 || grant.sizeBytes > maxArchiveBytes) {
|
|
fail('plugin_artifact_invalid', 'download grant size is outside its bound');
|
|
}
|
|
if (item.pluginId !== input.pluginId || grant.pluginId !== input.pluginId
|
|
|| grant.releaseId !== item.releaseId || grant.version !== item.version
|
|
|| grant.sha256 !== item.sha256 || grant.sizeBytes !== item.sizeBytes
|
|
|| grant.releaseAdmissionId !== item.releaseAdmissionId) {
|
|
fail('plugin_release_unavailable', 'download grant does not match the resolve result');
|
|
}
|
|
if (grant.packageSchemaVersion !== 2) fail('plugin_artifact_invalid', 'downloaded Release must use package schema 2');
|
|
const expiresAt = typeof grant.expiresAt === 'string' ? Date.parse(grant.expiresAt) : Number.NaN;
|
|
if (!Number.isFinite(expiresAt) || expiresAt <= now) fail('plugin_release_unavailable', 'download admission has expired');
|
|
}
|
|
|
|
function toInstalledInput(record: InstalledReleaseRecord): InstalledReleaseInput {
|
|
return { pluginId: record.pluginId, releaseId: record.releaseId, sha256: record.sha256 };
|
|
}
|
|
|
|
/**
|
|
* Main-owned device package store. The on-disk index is intentionally
|
|
* account-free; account Library/admission state stays in AccountPluginCache.
|
|
*/
|
|
export class PluginPackageStore {
|
|
private readonly rootDir: string;
|
|
private readonly packagesDir: string;
|
|
private readonly indexPath: string;
|
|
private readonly currentSelectionPath: string;
|
|
private readonly marketplace: MarketplacePackageClientPort;
|
|
private readonly accountCache: AccountPluginCache;
|
|
private readonly getAccountBindingImpl: () => AccountBinding | null;
|
|
private readonly clientVersion: string;
|
|
private readonly signatureVerifier: PluginSignatureVerifier;
|
|
private readonly now: () => number;
|
|
private readonly maxArchiveBytes: number;
|
|
private readonly maxExtractedBytes: number;
|
|
private readonly maxFiles: number;
|
|
private readonly maxFileBytes: number;
|
|
private readonly writeIndex: (filePath: string, bytes: Uint8Array) => Promise<void>;
|
|
private readonly activeWorkerReleaseIdsImpl: (() => readonly string[]) | null;
|
|
private readonly activeWorkers = new Set<string>();
|
|
private readonly pendingExplicitCleanupPluginIds = new Set<string>();
|
|
private readonly unsubscribeSession: (() => void) | null;
|
|
private operation: Promise<void> = Promise.resolve();
|
|
|
|
constructor(options: PluginPackageStoreOptions) {
|
|
this.rootDir = path.resolve(options.rootDir ?? path.join(getDataDir(), 'coding-plugins'));
|
|
this.packagesDir = path.join(this.rootDir, 'packages');
|
|
this.indexPath = path.join(this.rootDir, 'index.json');
|
|
this.currentSelectionPath = path.join(this.rootDir, 'current.json');
|
|
this.marketplace = options.marketplace;
|
|
this.accountCache = options.accountCache ?? new AccountPluginCache();
|
|
this.getAccountBindingImpl = options.getAccountBinding
|
|
?? (() => this.marketplace.getCurrentAccountBinding?.() ?? null);
|
|
this.clientVersion = options.clientVersion ?? '1.0.0';
|
|
this.signatureVerifier = options.signatureVerifier ?? createPluginSignatureVerifier({
|
|
clientVersion: this.clientVersion,
|
|
keyStore: options.keyStore,
|
|
});
|
|
this.now = options.now ?? (() => Date.now());
|
|
this.maxArchiveBytes = assertPositiveOption(options.maxArchiveBytes, DEFAULT_MAX_ARCHIVE_BYTES, 'maxArchiveBytes');
|
|
this.maxExtractedBytes = assertPositiveOption(options.maxExtractedBytes, DEFAULT_MAX_EXTRACTED_BYTES, 'maxExtractedBytes');
|
|
this.maxFiles = assertPositiveOption(options.maxFiles, DEFAULT_MAX_FILES, 'maxFiles');
|
|
this.maxFileBytes = assertPositiveOption(options.maxFileBytes, DEFAULT_MAX_FILE_BYTES, 'maxFileBytes');
|
|
this.writeIndex = options.writeIndex ?? atomicWriteIndex;
|
|
this.activeWorkerReleaseIdsImpl = options.activeWorkerReleaseIds ?? null;
|
|
const subscribeSession = options.subscribeSession
|
|
?? ((listener: () => void) => subscribeWorksSquareSession(() => listener()));
|
|
this.unsubscribeSession = subscribeSession(() => this.accountCache.invalidateAll());
|
|
}
|
|
|
|
get indexFilePath(): string {
|
|
return this.indexPath;
|
|
}
|
|
|
|
get packageRoot(): string {
|
|
return this.rootDir;
|
|
}
|
|
|
|
dispose(): void {
|
|
this.unsubscribeSession?.();
|
|
this.accountCache.invalidateAll();
|
|
}
|
|
|
|
async syncLibrary(): Promise<MarketplaceLibrarySnapshot> {
|
|
const binding = this.requireBinding();
|
|
return this.withOperation(async () => {
|
|
this.assertBinding(binding);
|
|
if (!this.marketplace.readLibrary) fail('plugin_install_failed', 'Marketplace Library is unavailable');
|
|
const snapshot = await this.marketplace.readLibrary();
|
|
this.assertBinding(binding);
|
|
this.accountCache.setLibrary(binding, snapshot);
|
|
return clone(snapshot);
|
|
});
|
|
}
|
|
|
|
async resolveAndInstall(input: ResolveInstallInput): Promise<InstallationSnapshot> {
|
|
validPluginId(input.pluginId);
|
|
if (input.channel !== undefined && input.channel !== 'stable' && input.channel !== 'beta') {
|
|
fail('plugin_release_unavailable', 'invalid Marketplace channel');
|
|
}
|
|
if ((input.channel ?? 'stable') === 'beta' && input.explicitBeta !== true) {
|
|
fail('plugin_beta_selection_required');
|
|
}
|
|
const binding = this.requireBinding();
|
|
return this.withOperation(async () => {
|
|
const result = await this.resolveAndInstallLocked(input, binding);
|
|
this.pendingExplicitCleanupPluginIds.delete(input.pluginId);
|
|
return result;
|
|
});
|
|
}
|
|
|
|
async getInstalled(pluginId: string): Promise<InstalledRelease | null> {
|
|
const validated = validPluginId(pluginId);
|
|
const index = await this.readIndex();
|
|
const current = await this.readCurrentSelection();
|
|
return this.getInstalledFromIndex(index, validated, undefined, current);
|
|
}
|
|
|
|
async removeUnused(pluginId: string): Promise<InstallationSnapshot> {
|
|
const validated = validPluginId(pluginId);
|
|
return this.withOperation(() => this.removeUnusedLocked(validated, undefined, 'background'));
|
|
}
|
|
|
|
/** Remove a device package while leaving the account Library projection intact. */
|
|
async uninstall(pluginId: string): Promise<InstallationSnapshot> {
|
|
const validated = validPluginId(pluginId);
|
|
const binding = this.requireBinding();
|
|
return this.withOperation(async () => {
|
|
this.assertBinding(binding);
|
|
this.accountCache.invalidatePlugin(binding, validated);
|
|
this.assertBinding(binding);
|
|
const result = await this.removeUnusedLocked(validated, binding, 'explicit');
|
|
if (result.status === 'kept' && result.reason === 'active_worker_reference') {
|
|
this.pendingExplicitCleanupPluginIds.add(validated);
|
|
} else {
|
|
this.pendingExplicitCleanupPluginIds.delete(validated);
|
|
}
|
|
return result;
|
|
});
|
|
}
|
|
|
|
registerActiveWorker(releaseId: string): void {
|
|
this.activeWorkers.add(validReleaseId(releaseId));
|
|
}
|
|
|
|
async releaseActiveWorker(releaseId: string): Promise<void> {
|
|
this.activeWorkers.delete(validReleaseId(releaseId));
|
|
if (this.pendingExplicitCleanupPluginIds.size === 0) return;
|
|
await this.withOperation(async () => {
|
|
for (const pluginId of [...this.pendingExplicitCleanupPluginIds]) {
|
|
const result = await this.removeUnusedLocked(pluginId, undefined, 'explicit');
|
|
if (result.status === 'removed') this.pendingExplicitCleanupPluginIds.delete(pluginId);
|
|
}
|
|
});
|
|
}
|
|
|
|
async readInstalledIndex(): Promise<readonly InstalledReleaseRecord[]> {
|
|
const index = await this.readIndex();
|
|
return clone(index.releases);
|
|
}
|
|
|
|
private async resolveAndInstallLocked(
|
|
input: ResolveInstallInput,
|
|
binding: AccountBinding,
|
|
): Promise<InstallationSnapshot> {
|
|
const pluginId = validPluginId(input.pluginId);
|
|
if (input.channel !== undefined && input.channel !== 'stable' && input.channel !== 'beta') {
|
|
fail('plugin_release_unavailable', 'invalid Marketplace channel');
|
|
}
|
|
const channel = input.channel ?? 'stable';
|
|
if (channel === 'beta' && input.explicitBeta !== true) fail('plugin_beta_selection_required');
|
|
this.assertBinding(binding);
|
|
const index = await this.readIndex();
|
|
const current = await this.getInstalledFromIndex(
|
|
index,
|
|
pluginId,
|
|
undefined,
|
|
await this.readCurrentSelection(),
|
|
);
|
|
const installed = input.installed === undefined
|
|
? (current ? [toInstalledInput(current)] : [])
|
|
: input.installed === null ? [] : [input.installed];
|
|
const request: ResolveRequest = {
|
|
makeloreVersion: input.makeloreVersion,
|
|
channel,
|
|
installed,
|
|
resolveRequestId: input.resolveRequestId ?? `makelore-resolve-${randomUUID()}`,
|
|
resolveRequestDigest: input.resolveRequestDigest,
|
|
};
|
|
let resolved: MarketplaceResolveSnapshot;
|
|
try {
|
|
resolved = await this.marketplace.resolve(request);
|
|
} catch (error) {
|
|
throw this.mapMarketplaceError(error);
|
|
}
|
|
this.assertBinding(binding);
|
|
const item = resolved.items.find((candidate) => candidate.pluginId === pluginId);
|
|
if (!item) fail('plugin_release_unavailable', `Plugin ${pluginId} was not included in the resolve result`);
|
|
if (item.channel !== undefined && item.channel !== null && item.channel !== channel) {
|
|
fail('plugin_release_unavailable', 'resolve channel does not match the requested channel');
|
|
}
|
|
if (item.action === 'unavailable') {
|
|
const reason = item.reason;
|
|
if (reason === 'plugin_release_not_ready'
|
|
|| reason === 'plugin_release_yanked'
|
|
|| reason === 'plugin_runtime_suspended'
|
|
|| reason === 'plugin_library_required'
|
|
|| reason === 'plugin_incompatible_client'
|
|
|| reason === 'plugin_signature_invalid'
|
|
|| reason === 'plugin_artifact_invalid'
|
|
|| reason === 'plugin_backend_unavailable') {
|
|
fail(reason, reason);
|
|
}
|
|
fail('plugin_release_unavailable', reason ?? 'Plugin Release is unavailable');
|
|
}
|
|
if (item.action === 'keep') {
|
|
if (!current) fail('plugin_release_unavailable', 'resolve requested keep without an installed Release');
|
|
this.assertBinding(binding);
|
|
await this.persistSelectedChannel(index, pluginId, current.releaseId, channel, binding);
|
|
return {
|
|
status: 'kept',
|
|
pluginId,
|
|
releaseId: current.releaseId,
|
|
version: current.version,
|
|
channel,
|
|
packageRoot: current.packageRoot,
|
|
definition: current.definition,
|
|
};
|
|
}
|
|
if (resolved.stale) fail('plugin_release_unavailable', 'stale resolve data cannot install a Release');
|
|
if (item.action !== 'install' && item.action !== 'update') fail('plugin_release_unavailable', 'unsupported resolve action');
|
|
if (!item.releaseId || !item.releaseAdmissionId || !item.version || !item.sha256 || !item.sizeBytes) {
|
|
fail('plugin_release_unavailable', 'resolve result is missing Release metadata');
|
|
}
|
|
const releaseId = validReleaseId(item.releaseId);
|
|
const releaseAdmissionId = validReleaseId(item.releaseAdmissionId);
|
|
validSha(item.sha256, 'resolve sha256');
|
|
if (!Number.isSafeInteger(item.sizeBytes) || item.sizeBytes < 1 || item.sizeBytes > this.maxArchiveBytes) {
|
|
fail('plugin_artifact_invalid', 'resolve artifact size is outside its bound');
|
|
}
|
|
const existingRecord = index.releases.find((record) => record.pluginId === pluginId && record.releaseId === item.releaseId);
|
|
if (existingRecord && existingRecord.sha256 === item.sha256 && await isDirectoryPath(this.releaseDirectory(existingRecord))) {
|
|
const existing = await this.getInstalledFromIndex(index, pluginId, item.releaseId);
|
|
if (existing && !existing.unavailableReason) {
|
|
this.assertBinding(binding);
|
|
await this.persistSelectedChannel(index, pluginId, existing.releaseId, channel, binding);
|
|
await this.setCurrentSelection(
|
|
await this.readCurrentSelection(),
|
|
pluginId,
|
|
existing.releaseId,
|
|
);
|
|
return {
|
|
status: 'kept',
|
|
pluginId,
|
|
releaseId: existing.releaseId,
|
|
version: existing.version,
|
|
channel,
|
|
packageRoot: existing.packageRoot,
|
|
definition: existing.definition,
|
|
};
|
|
}
|
|
}
|
|
let grant: DownloadGrant;
|
|
try {
|
|
grant = await this.marketplace.issueDownload({ releaseId, releaseAdmissionId });
|
|
this.assertBinding(binding);
|
|
validateGrantAgainstResolve(item, grant, { ...input, pluginId }, this.now(), this.maxArchiveBytes);
|
|
} catch (error) {
|
|
if (error instanceof PluginPackageStoreError) throw error;
|
|
throw this.mapMarketplaceError(error);
|
|
}
|
|
const packageRoot = this.releaseDirectory({ pluginId, releaseId: grant.releaseId } as InstalledReleaseRecord);
|
|
if (await pathExists(packageRoot)) {
|
|
return this.recoverOrphanedRelease({
|
|
index,
|
|
currentSelection: await this.readCurrentSelection(),
|
|
packageRoot,
|
|
pluginId,
|
|
grant,
|
|
channel,
|
|
binding,
|
|
});
|
|
}
|
|
let artifact: Uint8Array;
|
|
try {
|
|
artifact = await this.marketplace.downloadContent(grant);
|
|
this.assertBinding(binding);
|
|
} catch (error) {
|
|
if (error instanceof PluginPackageStoreError) throw error;
|
|
throw this.mapMarketplaceError(error);
|
|
}
|
|
const descriptor = this.verifyArtifact(artifact, grant);
|
|
try {
|
|
await mkdir(this.rootDir, { recursive: true });
|
|
} catch {
|
|
fail('plugin_install_failed', 'package store is unavailable');
|
|
}
|
|
let staging: string;
|
|
try {
|
|
staging = await mkdtemp(path.join(this.rootDir, `.download-${randomUUID()}-`));
|
|
} catch {
|
|
fail('plugin_install_failed', 'package store is unavailable');
|
|
}
|
|
let moved = false;
|
|
try {
|
|
const archivePath = path.join(staging, 'package.zip');
|
|
const extractedPath = path.join(staging, 'package');
|
|
await writeFile(archivePath, toBuffer(artifact), { flag: 'wx' });
|
|
await mkdir(extractedPath, { recursive: true });
|
|
const archiveFiles = await extractArchive(artifact, extractedPath, {
|
|
maxExtractedBytes: this.maxExtractedBytes,
|
|
maxFiles: this.maxFiles,
|
|
maxFileBytes: this.maxFileBytes,
|
|
});
|
|
const definition = await this.loadDefinition(extractedPath, grant, descriptor);
|
|
validateArchiveDefinition(archiveFiles, definition);
|
|
this.assertBinding(binding);
|
|
await mkdir(path.dirname(packageRoot), { recursive: true });
|
|
if (await isDirectoryPath(packageRoot) || await pathExists(packageRoot)) {
|
|
fail('plugin_release_conflict', 'immutable Release directory already exists');
|
|
}
|
|
await writeFile(path.join(extractedPath, ORPHAN_ARCHIVE_FILE), toBuffer(artifact), { flag: 'wx' });
|
|
await rename(extractedPath, packageRoot);
|
|
moved = true;
|
|
const record = this.installedRecord(grant, channel);
|
|
const records = index.releases.filter((candidate) => !(candidate.pluginId === pluginId && candidate.releaseId === grant.releaseId));
|
|
this.assertBinding(binding);
|
|
try {
|
|
await this.writeIndex(this.indexPath, serializeIndex({ schema_version: INDEX_SCHEMA_VERSION, releases: [...records, record] }));
|
|
} catch {
|
|
throw new PluginPackageStoreError('plugin_install_failed', 'package index replacement failed');
|
|
}
|
|
this.assertBinding(binding);
|
|
await this.setCurrentSelection(await this.readCurrentSelection(), pluginId, record.releaseId);
|
|
this.assertBinding(binding);
|
|
return {
|
|
status: 'installed',
|
|
pluginId,
|
|
releaseId: record.releaseId,
|
|
version: record.version,
|
|
channel: record.channel,
|
|
packageRoot,
|
|
definition,
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof PluginPackageStoreError) throw error;
|
|
if (error instanceof Error && error.name === 'CodingPluginManifestError') {
|
|
throw new PluginPackageStoreError('plugin_manifest_invalid', 'package manifest is invalid');
|
|
}
|
|
throw new PluginPackageStoreError('plugin_install_failed', 'package installation failed');
|
|
} finally {
|
|
if (moved) {
|
|
await rm(path.join(staging, 'package.zip'), { force: true }).catch(() => undefined);
|
|
}
|
|
await rm(staging, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
private async recoverOrphanedRelease(input: {
|
|
readonly index: IndexDocument;
|
|
readonly currentSelection: CurrentSelectionDocument;
|
|
readonly packageRoot: string;
|
|
readonly pluginId: string;
|
|
readonly grant: DownloadGrant;
|
|
readonly channel: 'stable' | 'beta';
|
|
readonly binding: AccountBinding;
|
|
}): Promise<InstallationSnapshot> {
|
|
const existingRecord = input.index.releases.find((record) => (
|
|
record.pluginId === input.pluginId && record.releaseId === input.grant.releaseId
|
|
));
|
|
if (existingRecord) fail('plugin_release_conflict', 'immutable Release directory already exists');
|
|
let artifact: Uint8Array;
|
|
try {
|
|
artifact = await readFile(path.join(input.packageRoot, ORPHAN_ARCHIVE_FILE));
|
|
} catch {
|
|
fail('plugin_release_conflict', 'immutable Release directory cannot be recovered');
|
|
}
|
|
const descriptor = this.verifyArtifact(artifact, input.grant);
|
|
const definition = await this.loadDefinition(input.packageRoot, input.grant, descriptor);
|
|
this.assertBinding(input.binding);
|
|
const record = this.installedRecord(input.grant, input.channel);
|
|
this.assertBinding(input.binding);
|
|
try {
|
|
await this.writeIndex(this.indexPath, serializeIndex({
|
|
schema_version: INDEX_SCHEMA_VERSION,
|
|
releases: [...input.index.releases, record],
|
|
}));
|
|
} catch {
|
|
throw new PluginPackageStoreError('plugin_install_failed', 'package index replacement failed');
|
|
}
|
|
this.assertBinding(input.binding);
|
|
await this.setCurrentSelection(input.currentSelection, input.pluginId, record.releaseId);
|
|
this.assertBinding(input.binding);
|
|
return {
|
|
status: 'installed',
|
|
pluginId: input.pluginId,
|
|
releaseId: record.releaseId,
|
|
version: record.version,
|
|
channel: record.channel,
|
|
packageRoot: input.packageRoot,
|
|
definition,
|
|
};
|
|
}
|
|
|
|
private verifyArtifact(artifact: Uint8Array, grant: DownloadGrant): PluginReleaseDescriptor {
|
|
const expiresAt = typeof grant.expiresAt === 'string' ? Date.parse(grant.expiresAt) : Number.NaN;
|
|
if (!Number.isFinite(expiresAt) || expiresAt <= this.now()) {
|
|
fail('plugin_release_unavailable', 'download admission has expired');
|
|
}
|
|
if (!(artifact instanceof Uint8Array)) fail('plugin_artifact_invalid', 'downloaded artifact is not binary data');
|
|
if (artifact.byteLength !== grant.sizeBytes || artifact.byteLength > this.maxArchiveBytes || digest(artifact) !== grant.sha256) {
|
|
fail('plugin_artifact_invalid', 'downloaded artifact does not match its grant');
|
|
}
|
|
const descriptor = this.buildDescriptor(grant);
|
|
if (!isMakeLoreVersionCompatible(
|
|
this.clientVersion,
|
|
descriptor.minMakeloreVersion,
|
|
descriptor.maxMakeloreVersion,
|
|
)) {
|
|
fail('plugin_incompatible_client', 'Release is incompatible with this MakeLore client');
|
|
}
|
|
const verification = this.signatureVerifier.verify({
|
|
keyId: grant.signingKeyId,
|
|
signature: grant.descriptorSignature,
|
|
descriptor,
|
|
artifact,
|
|
});
|
|
if (!verification.ok) fail(mapVerificationFailure(verification.code), verification.message);
|
|
return descriptor;
|
|
}
|
|
|
|
private installedRecord(grant: DownloadGrant, channel: 'stable' | 'beta'): InstalledReleaseRecord {
|
|
return Object.freeze({
|
|
pluginId: grant.pluginId,
|
|
releaseId: grant.releaseId,
|
|
version: grant.version,
|
|
packageSchemaVersion: grant.packageSchemaVersion,
|
|
contractVersion: grant.contractVersion,
|
|
runtimeKind: 'skill_only',
|
|
sha256: grant.sha256,
|
|
sizeBytes: grant.sizeBytes,
|
|
installedAt: new Date(this.now()).toISOString(),
|
|
channel,
|
|
minMakeloreVersion: grant.minMakeloreVersion,
|
|
maxMakeloreVersion: grant.maxMakeloreVersion,
|
|
});
|
|
}
|
|
|
|
private async persistSelectedChannel(
|
|
index: IndexDocument,
|
|
pluginId: string,
|
|
releaseId: string,
|
|
channel: 'stable' | 'beta',
|
|
binding: AccountBinding,
|
|
): Promise<void> {
|
|
const record = index.releases.find((candidate) => (
|
|
candidate.pluginId === pluginId && candidate.releaseId === releaseId
|
|
));
|
|
if (!record) fail('plugin_store_index_invalid', 'selected Release is missing from the package index');
|
|
if (record.channel === channel) return;
|
|
this.assertBinding(binding);
|
|
const releases = index.releases.map((candidate) => (
|
|
candidate === record ? Object.freeze({ ...candidate, channel }) : candidate
|
|
));
|
|
try {
|
|
await this.writeIndex(this.indexPath, serializeIndex({
|
|
schema_version: INDEX_SCHEMA_VERSION,
|
|
releases,
|
|
}));
|
|
} catch {
|
|
throw new PluginPackageStoreError('plugin_install_failed', 'package channel update failed');
|
|
}
|
|
this.assertBinding(binding);
|
|
}
|
|
|
|
private async removeUnusedLocked(
|
|
validated: string,
|
|
binding?: AccountBinding,
|
|
mode: 'background' | 'explicit' = 'background',
|
|
): Promise<InstallationSnapshot> {
|
|
if (binding) this.assertBinding(binding);
|
|
const index = await this.readIndex();
|
|
const currentSelection = await this.readCurrentSelection();
|
|
const records = index.releases.filter((record) => record.pluginId === validated);
|
|
if (records.length === 0) {
|
|
if (currentSelection.current[validated] !== undefined) {
|
|
if (binding) this.assertBinding(binding);
|
|
const current = { ...currentSelection.current };
|
|
delete current[validated];
|
|
await this.writeCurrentSelection({
|
|
schema_version: CURRENT_SELECTION_SCHEMA_VERSION,
|
|
current: Object.freeze(current),
|
|
});
|
|
}
|
|
return { status: 'removed', pluginId: validated, reason: 'none' };
|
|
}
|
|
const selected = currentSelection.current[validated]
|
|
? records.find((record) => record.releaseId === currentSelection.current[validated])
|
|
: undefined;
|
|
const protectedIds = new Set([
|
|
...this.accountCache.referencedReleaseIds(),
|
|
...(this.activeWorkerReleaseIds() ?? []),
|
|
...this.activeWorkers,
|
|
]);
|
|
let removable: InstalledReleaseRecord[];
|
|
if (mode === 'explicit') {
|
|
removable = records.filter((record) => !protectedIds.has(record.releaseId));
|
|
} else {
|
|
if (!selected) {
|
|
return {
|
|
status: 'kept',
|
|
pluginId: validated,
|
|
reason: 'current_selection_missing',
|
|
};
|
|
}
|
|
removable = records.length === 1 && !protectedIds.has(selected.releaseId)
|
|
? records
|
|
: records.filter((record) => (
|
|
record.releaseId !== selected.releaseId && !protectedIds.has(record.releaseId)
|
|
));
|
|
if (removable.length === 0) {
|
|
return {
|
|
status: 'kept',
|
|
pluginId: validated,
|
|
releaseId: selected.releaseId,
|
|
version: selected.version,
|
|
...(selected.channel === undefined ? {} : { channel: selected.channel }),
|
|
};
|
|
}
|
|
}
|
|
const remaining = index.releases.filter((record) => !removable.includes(record));
|
|
if (binding) this.assertBinding(binding);
|
|
if (removable.length > 0) {
|
|
try {
|
|
await this.writeIndex(this.indexPath, serializeIndex({ schema_version: INDEX_SCHEMA_VERSION, releases: remaining }));
|
|
} catch {
|
|
throw new PluginPackageStoreError('plugin_install_failed', 'package index cleanup failed');
|
|
}
|
|
}
|
|
const nextCurrent = { ...currentSelection.current };
|
|
if (mode === 'explicit') {
|
|
delete nextCurrent[validated];
|
|
} else if (nextCurrent[validated] && !remaining.some((record) => (
|
|
record.pluginId === validated && record.releaseId === nextCurrent[validated]
|
|
))) delete nextCurrent[validated];
|
|
await this.writeCurrentSelection({
|
|
schema_version: CURRENT_SELECTION_SCHEMA_VERSION,
|
|
current: Object.freeze(nextCurrent),
|
|
});
|
|
if (binding) this.assertBinding(binding);
|
|
await Promise.all(removable.map(async (record) => {
|
|
await rm(this.releaseDirectory(record), { recursive: true, force: true });
|
|
}));
|
|
if (binding) this.assertBinding(binding);
|
|
const currentAfter = nextCurrent[validated]
|
|
? remaining.find((record) => record.pluginId === validated && record.releaseId === nextCurrent[validated])
|
|
: undefined;
|
|
const selectedAfter = selected && remaining.some((record) => (
|
|
record.pluginId === validated && record.releaseId === selected.releaseId
|
|
)) ? selected : undefined;
|
|
if (mode === 'explicit' && !currentAfter
|
|
&& remaining.some((record) => record.pluginId === validated && protectedIds.has(record.releaseId))) {
|
|
return { status: 'kept', pluginId: validated, reason: 'active_worker_reference' };
|
|
}
|
|
const reportedAfter = currentAfter ?? selectedAfter ?? remaining.find((record) => record.pluginId === validated);
|
|
if (reportedAfter) {
|
|
return {
|
|
status: 'kept',
|
|
pluginId: validated,
|
|
releaseId: reportedAfter.releaseId,
|
|
version: reportedAfter.version,
|
|
...(reportedAfter.channel === undefined ? {} : { channel: reportedAfter.channel }),
|
|
};
|
|
}
|
|
return {
|
|
status: 'removed',
|
|
pluginId: validated,
|
|
...(selected ? { releaseId: selected.releaseId, version: selected.version } : { reason: 'none' }),
|
|
};
|
|
}
|
|
|
|
private buildDescriptor(grant: DownloadGrant): PluginReleaseDescriptor {
|
|
try {
|
|
return buildPluginReleaseDescriptor({
|
|
pluginId: grant.pluginId,
|
|
version: grant.version,
|
|
packageSchemaVersion: grant.packageSchemaVersion,
|
|
contractVersion: grant.contractVersion,
|
|
minMakeloreVersion: grant.minMakeloreVersion,
|
|
maxMakeloreVersion: grant.maxMakeloreVersion,
|
|
artifact: { sha256: grant.sha256, sizeBytes: grant.sizeBytes },
|
|
});
|
|
} catch (error) {
|
|
fail('plugin_artifact_invalid', error instanceof Error ? error.message : 'invalid Release descriptor');
|
|
}
|
|
}
|
|
|
|
private async loadDefinition(
|
|
packageRoot: string,
|
|
grant: DownloadGrant,
|
|
descriptor: PluginReleaseDescriptor,
|
|
): Promise<CodingPluginDefinition> {
|
|
let definition: CodingPluginDefinition;
|
|
try {
|
|
definition = await loadCodingPluginDefinition(packageRoot, {
|
|
runtimeKind: 'skill_only',
|
|
acquisitionMode: 'user_acquired',
|
|
releaseId: grant.releaseId,
|
|
provenance: { source: 'marketplace', packageRoot },
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof PluginPackageStoreError) throw error;
|
|
throw new PluginPackageStoreError('plugin_manifest_invalid', 'package manifest is invalid');
|
|
}
|
|
if (definition.id !== descriptor.pluginId || definition.version !== descriptor.version
|
|
|| definition.contractVersion !== descriptor.contractVersion || definition.releaseId !== grant.releaseId
|
|
|| definition.runtimeKind !== 'skill_only' || definition.acquisitionMode !== 'user_acquired') {
|
|
fail('plugin_manifest_invalid', 'package definition does not match the signed Release');
|
|
}
|
|
return definition;
|
|
}
|
|
|
|
private requireBinding(): AccountBinding {
|
|
const binding = this.getAccountBindingImpl();
|
|
if (!binding || typeof binding.accountKey !== 'string' || binding.accountKey.length === 0
|
|
|| binding.accountKey.length > 512 || !Number.isSafeInteger(binding.epoch) || binding.epoch < 0) {
|
|
fail('plugin_account_changed', 'Marketplace account session is unavailable');
|
|
}
|
|
return { accountKey: binding.accountKey, epoch: binding.epoch };
|
|
}
|
|
|
|
private assertBinding(binding: AccountBinding): void {
|
|
if (!sameBinding(this.getAccountBindingImpl(), binding)) fail('plugin_account_changed', 'Marketplace account changed during package operation');
|
|
}
|
|
|
|
private activeWorkerReleaseIds(): readonly string[] {
|
|
return this.activeWorkerReleaseIdsImpl?.() ?? [];
|
|
}
|
|
|
|
private async readIndex(): Promise<IndexDocument> {
|
|
try {
|
|
return parseIndexDocument(await parseJsonFile(this.indexPath));
|
|
} catch (error) {
|
|
if (isNotFound(error)) return { schema_version: INDEX_SCHEMA_VERSION, releases: [] };
|
|
if (error instanceof PluginPackageStoreError) throw error;
|
|
throw new PluginPackageStoreError('plugin_store_index_invalid', 'package index is unavailable');
|
|
}
|
|
}
|
|
|
|
private async readCurrentSelection(): Promise<CurrentSelectionDocument> {
|
|
try {
|
|
return parseCurrentSelectionDocument(await parseJsonFile(this.currentSelectionPath));
|
|
} catch (error) {
|
|
if (isNotFound(error)) {
|
|
return { schema_version: CURRENT_SELECTION_SCHEMA_VERSION, current: Object.freeze({}) };
|
|
}
|
|
if (error instanceof PluginPackageStoreError) throw error;
|
|
throw new PluginPackageStoreError('plugin_store_index_invalid', 'current package selection is unavailable');
|
|
}
|
|
}
|
|
|
|
private async writeCurrentSelection(document: CurrentSelectionDocument): Promise<void> {
|
|
try {
|
|
await atomicWriteIndex(this.currentSelectionPath, serializeCurrentSelection(document));
|
|
} catch {
|
|
throw new PluginPackageStoreError('plugin_install_failed', 'package current selection replacement failed');
|
|
}
|
|
}
|
|
|
|
private async setCurrentSelection(
|
|
currentSelection: CurrentSelectionDocument,
|
|
pluginId: string,
|
|
releaseId: string,
|
|
): Promise<void> {
|
|
if (currentSelection.current[pluginId] === releaseId) return;
|
|
const current = { ...currentSelection.current, [pluginId]: releaseId };
|
|
await this.writeCurrentSelection({
|
|
schema_version: CURRENT_SELECTION_SCHEMA_VERSION,
|
|
current: Object.freeze(current),
|
|
});
|
|
}
|
|
|
|
private releaseDirectory(record: Pick<InstalledReleaseRecord, 'pluginId' | 'releaseId'>): string {
|
|
return path.join(this.packagesDir, record.pluginId, record.releaseId);
|
|
}
|
|
|
|
private async getInstalledFromIndex(
|
|
index: IndexDocument,
|
|
pluginId: string,
|
|
releaseId?: string,
|
|
currentSelection?: CurrentSelectionDocument,
|
|
): Promise<InstalledRelease | null> {
|
|
const pointedReleaseId = releaseId === undefined ? currentSelection?.current[pluginId] : undefined;
|
|
const records = index.releases
|
|
.map((record, index) => ({ record, index }))
|
|
.filter(({ record }) => record.pluginId === pluginId && (releaseId === undefined || record.releaseId === releaseId))
|
|
.sort((left, right) => right.record.installedAt.localeCompare(left.record.installedAt) || right.index - left.index)
|
|
.map(({ record }) => record);
|
|
const selected = releaseId !== undefined
|
|
? records
|
|
: pointedReleaseId === undefined
|
|
? []
|
|
: records.filter((record) => record.releaseId === pointedReleaseId);
|
|
for (const record of selected) {
|
|
const packageRoot = this.releaseDirectory(record);
|
|
if (!await isDirectoryPath(packageRoot)) continue;
|
|
try {
|
|
const definition = await loadCodingPluginDefinition(packageRoot, {
|
|
runtimeKind: record.runtimeKind,
|
|
acquisitionMode: 'user_acquired',
|
|
releaseId: record.releaseId,
|
|
provenance: { source: 'marketplace', packageRoot },
|
|
});
|
|
if (definition.id !== record.pluginId || definition.version !== record.version
|
|
|| definition.contractVersion !== record.contractVersion || definition.runtimeKind !== record.runtimeKind) {
|
|
throw new PluginPackageStoreError('plugin_manifest_invalid', 'installed package does not match its immutable index record');
|
|
}
|
|
const compatible = record.minMakeloreVersion !== undefined
|
|
&& isMakeLoreVersionCompatible(
|
|
this.clientVersion,
|
|
record.minMakeloreVersion,
|
|
record.maxMakeloreVersion ?? null,
|
|
);
|
|
return {
|
|
...record,
|
|
packageRoot,
|
|
definition,
|
|
...(compatible ? {} : { unavailableReason: 'plugin_incompatible_client' as const }),
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof PluginPackageStoreError) throw error;
|
|
throw new PluginPackageStoreError('plugin_manifest_invalid', 'installed package manifest is invalid');
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private mapMarketplaceError(error: unknown): PluginPackageStoreError {
|
|
if (error instanceof PluginPackageStoreError) return error;
|
|
if (isRecord(error) && error.code === 'marketplace_account_changed') {
|
|
return new PluginPackageStoreError('plugin_account_changed', 'Marketplace account changed during package operation');
|
|
}
|
|
if (isRecord(error) && error.code === 'marketplace_beta_selection_required') {
|
|
return new PluginPackageStoreError('plugin_beta_selection_required');
|
|
}
|
|
if (isRecord(error) && (
|
|
error.code === 'plugin_release_yanked'
|
|
|| error.code === 'plugin_release_not_ready'
|
|
|| error.code === 'plugin_runtime_suspended'
|
|
|| error.code === 'plugin_library_required'
|
|
|| error.code === 'plugin_backend_unavailable'
|
|
|| error.code === 'plugin_incompatible_client'
|
|
|| error.code === 'plugin_signature_invalid'
|
|
|| error.code === 'plugin_artifact_invalid'
|
|
)) {
|
|
return new PluginPackageStoreError(
|
|
error.code,
|
|
typeof error.message === 'string' ? error.message : error.code,
|
|
);
|
|
}
|
|
if (isRecord(error) && error.code === 'plugin_auth_required') {
|
|
return new PluginPackageStoreError('plugin_account_changed', 'Marketplace authentication is required');
|
|
}
|
|
return new PluginPackageStoreError('plugin_install_failed', 'Marketplace package operation failed');
|
|
}
|
|
|
|
private async withOperation<T>(operation: () => Promise<T>): Promise<T> {
|
|
let release!: () => void;
|
|
const previous = this.operation;
|
|
this.operation = new Promise<void>((resolve) => { release = resolve; });
|
|
await previous;
|
|
try {
|
|
return await operation();
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
}
|
|
|
|
async function pathExists(value: string): Promise<boolean> {
|
|
try {
|
|
await lstat(value);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function readPluginPackageIndex(filePath: string): Promise<readonly InstalledReleaseRecord[]> {
|
|
try {
|
|
const value = parseIndexDocument(await parseJsonFile(filePath));
|
|
return clone(value.releases);
|
|
} catch (error) {
|
|
if (error instanceof PluginPackageStoreError) throw error;
|
|
throw new PluginPackageStoreError('plugin_store_index_invalid', 'package index is unavailable');
|
|
}
|
|
}
|