feat: add marketplace client package store
This commit is contained in:
198
electron/coding-plugins/account-plugin-cache.ts
Normal file
198
electron/coding-plugins/account-plugin-cache.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Session/account-scoped Marketplace projections.
|
||||
*
|
||||
* The package index is deliberately device-scoped and contains immutable
|
||||
* release facts only. Library rows and release admissions live here instead,
|
||||
* keyed by the opaque Main-owned account binding and its session epoch.
|
||||
*/
|
||||
|
||||
export interface AccountBinding {
|
||||
readonly accountKey: string;
|
||||
readonly epoch: number;
|
||||
}
|
||||
|
||||
export type MarketplaceChannel = 'stable' | 'beta';
|
||||
export type MarketplaceResolveAction = 'keep' | 'install' | 'update' | 'unavailable';
|
||||
|
||||
export interface MarketplaceLibraryEntry {
|
||||
readonly pluginId: string;
|
||||
readonly title: string;
|
||||
readonly summary: string;
|
||||
readonly category: string;
|
||||
readonly acquisition: 'free' | 'system_included';
|
||||
readonly acquisitionMode: 'system_included' | 'user_acquired';
|
||||
readonly catalogStatus: 'active' | 'retired';
|
||||
readonly runtimeStatus: 'enabled' | 'suspended';
|
||||
readonly acquiredAt: string | null;
|
||||
readonly removedAt: string | null;
|
||||
readonly stableVersion: string | null;
|
||||
readonly betaVersion: string | null;
|
||||
}
|
||||
|
||||
export interface MarketplaceLibrarySnapshot {
|
||||
readonly items: readonly MarketplaceLibraryEntry[];
|
||||
readonly total: number;
|
||||
readonly stale: boolean;
|
||||
readonly fetchedAt: number;
|
||||
}
|
||||
|
||||
export interface MarketplaceResolveItem {
|
||||
readonly pluginId: string;
|
||||
readonly action: MarketplaceResolveAction;
|
||||
readonly releaseId?: string | null;
|
||||
readonly version?: string | null;
|
||||
readonly sha256?: string | null;
|
||||
readonly sizeBytes?: number | null;
|
||||
readonly releaseAdmissionId?: string | null;
|
||||
readonly expiresAt?: string | null;
|
||||
readonly channel?: MarketplaceChannel | null;
|
||||
readonly reason?: string | null;
|
||||
}
|
||||
|
||||
export interface MarketplaceResolveSnapshot {
|
||||
readonly resolveRequestId: string;
|
||||
readonly resolveRequestDigest: string;
|
||||
readonly items: readonly MarketplaceResolveItem[];
|
||||
readonly catalogGeneration: number;
|
||||
readonly etag: string | null;
|
||||
readonly stale: boolean;
|
||||
}
|
||||
|
||||
export interface AccountPluginCacheRecord {
|
||||
readonly binding: AccountBinding;
|
||||
readonly library: MarketplaceLibrarySnapshot | null;
|
||||
readonly resolves: ReadonlyMap<string, MarketplaceResolveSnapshot>;
|
||||
}
|
||||
|
||||
function assertBinding(binding: AccountBinding): void {
|
||||
if (typeof binding.accountKey !== 'string' || binding.accountKey.length === 0
|
||||
|| binding.accountKey.length > 512 || !Number.isSafeInteger(binding.epoch)
|
||||
|| binding.epoch < 0) {
|
||||
throw new TypeError('Marketplace account binding is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function bindingId(binding: AccountBinding): string {
|
||||
assertBinding(binding);
|
||||
return `${binding.accountKey}\u0000${binding.epoch}`;
|
||||
}
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory account cache. Keeping this cache in Main memory prevents
|
||||
* admissions and Library state from entering the shared package index or a
|
||||
* project file. A session change invalidates every account snapshot.
|
||||
*/
|
||||
export class AccountPluginCache {
|
||||
private readonly records = new Map<string, {
|
||||
binding: AccountBinding;
|
||||
library: MarketplaceLibrarySnapshot | null;
|
||||
resolves: Map<string, MarketplaceResolveSnapshot>;
|
||||
}>();
|
||||
|
||||
getLibrary(binding: AccountBinding): MarketplaceLibrarySnapshot | null {
|
||||
const record = this.records.get(bindingId(binding));
|
||||
return record?.library ? clone(record.library) : null;
|
||||
}
|
||||
|
||||
setLibrary(binding: AccountBinding, snapshot: MarketplaceLibrarySnapshot): void {
|
||||
const key = bindingId(binding);
|
||||
const record = this.records.get(key) ?? {
|
||||
binding: { accountKey: binding.accountKey, epoch: binding.epoch },
|
||||
library: null,
|
||||
resolves: new Map<string, MarketplaceResolveSnapshot>(),
|
||||
};
|
||||
record.library = clone(snapshot);
|
||||
this.records.set(key, record);
|
||||
}
|
||||
|
||||
markLibraryStale(binding: AccountBinding): MarketplaceLibrarySnapshot | null {
|
||||
const key = bindingId(binding);
|
||||
const record = this.records.get(key);
|
||||
if (!record?.library) return null;
|
||||
record.library = { ...record.library, stale: true };
|
||||
return clone(record.library);
|
||||
}
|
||||
|
||||
getResolve(binding: AccountBinding, logicalKey: string): MarketplaceResolveSnapshot | null {
|
||||
if (typeof logicalKey !== 'string' || logicalKey.length === 0) return null;
|
||||
const record = this.records.get(bindingId(binding));
|
||||
const snapshot = record?.resolves.get(logicalKey);
|
||||
return snapshot ? clone(snapshot) : null;
|
||||
}
|
||||
|
||||
setResolve(
|
||||
binding: AccountBinding,
|
||||
logicalKey: string,
|
||||
snapshot: MarketplaceResolveSnapshot,
|
||||
): void {
|
||||
if (typeof logicalKey !== 'string' || logicalKey.length === 0 || logicalKey.length > 512) {
|
||||
throw new TypeError('Marketplace resolve cache key is invalid');
|
||||
}
|
||||
const key = bindingId(binding);
|
||||
const record = this.records.get(key) ?? {
|
||||
binding: { accountKey: binding.accountKey, epoch: binding.epoch },
|
||||
library: null,
|
||||
resolves: new Map<string, MarketplaceResolveSnapshot>(),
|
||||
};
|
||||
record.resolves.set(logicalKey, clone(snapshot));
|
||||
this.records.set(key, record);
|
||||
}
|
||||
|
||||
markResolveStale(binding: AccountBinding, logicalKey: string): MarketplaceResolveSnapshot | null {
|
||||
const key = bindingId(binding);
|
||||
const record = this.records.get(key);
|
||||
const snapshot = record?.resolves.get(logicalKey);
|
||||
if (!record || !snapshot) return null;
|
||||
const stale = { ...snapshot, stale: true };
|
||||
record.resolves.set(logicalKey, stale);
|
||||
return clone(stale);
|
||||
}
|
||||
|
||||
getRecord(binding: AccountBinding): AccountPluginCacheRecord | null {
|
||||
const record = this.records.get(bindingId(binding));
|
||||
if (!record) return null;
|
||||
return {
|
||||
binding: { ...record.binding },
|
||||
library: record.library ? clone(record.library) : null,
|
||||
resolves: new Map([...record.resolves.entries()].map(([key, value]) => [key, clone(value)])),
|
||||
};
|
||||
}
|
||||
|
||||
/** Release IDs retained by all live account snapshots. */
|
||||
referencedReleaseIds(): ReadonlySet<string> {
|
||||
const result = new Set<string>();
|
||||
for (const record of this.records.values()) {
|
||||
for (const snapshot of record.resolves.values()) {
|
||||
for (const item of snapshot.items) {
|
||||
if (item.releaseId) result.add(item.releaseId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
clearAccount(binding: AccountBinding): void {
|
||||
assertBinding(binding);
|
||||
for (const [key, record] of this.records.entries()) {
|
||||
if (record.binding.accountKey === binding.accountKey) this.records.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
invalidateAll(): void {
|
||||
this.records.clear();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.invalidateAll();
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.records.size;
|
||||
}
|
||||
}
|
||||
|
||||
export const accountBindingKey = bindingId;
|
||||
1140
electron/coding-plugins/marketplace-client.ts
Normal file
1140
electron/coding-plugins/marketplace-client.ts
Normal file
File diff suppressed because it is too large
Load Diff
932
electron/coding-plugins/package-store.ts
Normal file
932
electron/coding-plugins/package-store.ts
Normal file
@@ -0,0 +1,932 @@
|
||||
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,
|
||||
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 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 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',
|
||||
]);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface InstalledRelease extends InstalledReleaseRecord {
|
||||
readonly packageRoot: string;
|
||||
readonly definition: CodingPluginDefinition;
|
||||
}
|
||||
|
||||
export type InstallationStatus = 'installed' | 'kept' | 'removed' | 'unavailable';
|
||||
|
||||
export interface InstallationSnapshot {
|
||||
readonly status: InstallationStatus;
|
||||
readonly pluginId: string;
|
||||
readonly releaseId?: string;
|
||||
readonly version?: string;
|
||||
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_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[];
|
||||
}
|
||||
|
||||
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`);
|
||||
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,
|
||||
});
|
||||
});
|
||||
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,
|
||||
})),
|
||||
})}\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;
|
||||
}
|
||||
|
||||
async function extractArchive(
|
||||
bytes: Uint8Array,
|
||||
destination: string,
|
||||
options: {
|
||||
readonly maxExtractedBytes: number;
|
||||
readonly maxFiles: number;
|
||||
readonly maxFileBytes: number;
|
||||
},
|
||||
): Promise<void> {
|
||||
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>();
|
||||
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');
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 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 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.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> {
|
||||
return this.withOperation(async () => {
|
||||
const binding = this.requireBinding();
|
||||
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> {
|
||||
return this.withOperation(() => this.resolveAndInstallLocked(input));
|
||||
}
|
||||
|
||||
async getInstalled(pluginId: string): Promise<InstalledRelease | null> {
|
||||
const validated = validPluginId(pluginId);
|
||||
const index = await this.readIndex();
|
||||
return this.getInstalledFromIndex(index, validated);
|
||||
}
|
||||
|
||||
async removeUnused(pluginId: string): Promise<InstallationSnapshot> {
|
||||
const validated = validPluginId(pluginId);
|
||||
return this.withOperation(async () => {
|
||||
const index = await this.readIndex();
|
||||
const records = index.releases.filter((record) => record.pluginId === validated);
|
||||
if (records.length === 0) return { status: 'removed', pluginId: validated, reason: 'none' };
|
||||
const latest = records
|
||||
.map((record, index) => ({ record, index }))
|
||||
.sort((left, right) => right.record.installedAt.localeCompare(left.record.installedAt) || right.index - left.index)[0]
|
||||
?.record;
|
||||
if (!latest) return { status: 'removed', pluginId: validated, reason: 'none' };
|
||||
const protectedIds = new Set([
|
||||
...this.accountCache.referencedReleaseIds(),
|
||||
...(this.activeWorkerReleaseIds() ?? []),
|
||||
...this.activeWorkers,
|
||||
latest.releaseId,
|
||||
]);
|
||||
const removable = records.filter((record) => !protectedIds.has(record.releaseId));
|
||||
if (removable.length === 0) return { status: 'kept', pluginId: validated, releaseId: latest.releaseId, version: latest.version };
|
||||
const remaining = index.releases.filter((record) => !removable.includes(record));
|
||||
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');
|
||||
}
|
||||
await Promise.all(removable.map(async (record) => {
|
||||
await rm(this.releaseDirectory(record), { recursive: true, force: true });
|
||||
}));
|
||||
return { status: 'removed', pluginId: validated, releaseId: latest.releaseId, version: latest.version };
|
||||
});
|
||||
}
|
||||
|
||||
registerActiveWorker(releaseId: string): void {
|
||||
this.activeWorkers.add(validReleaseId(releaseId));
|
||||
}
|
||||
|
||||
releaseActiveWorker(releaseId: string): void {
|
||||
this.activeWorkers.delete(validReleaseId(releaseId));
|
||||
}
|
||||
|
||||
async readInstalledIndex(): Promise<readonly InstalledReleaseRecord[]> {
|
||||
const index = await this.readIndex();
|
||||
return clone(index.releases);
|
||||
}
|
||||
|
||||
private async resolveAndInstallLocked(input: ResolveInstallInput): 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');
|
||||
const binding = this.requireBinding();
|
||||
const index = await this.readIndex();
|
||||
const current = await this.getInstalledFromIndex(index, pluginId);
|
||||
const installed = input.installed === undefined
|
||||
? (current ? [toInstalledInput(current)] : [])
|
||||
: input.installed === null ? [] : [input.installed];
|
||||
const request: ResolveRequest = {
|
||||
makeloreVersion: input.makeloreVersion,
|
||||
channel,
|
||||
installed,
|
||||
resolveRequestId: input.resolveRequestId,
|
||||
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') fail('plugin_release_unavailable', item.reason ?? 'Plugin Release is unavailable');
|
||||
if (item.action === 'keep') {
|
||||
if (!current) fail('plugin_release_unavailable', 'resolve requested keep without an installed Release');
|
||||
return {
|
||||
status: 'kept',
|
||||
pluginId,
|
||||
releaseId: current.releaseId,
|
||||
version: current.version,
|
||||
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) {
|
||||
return {
|
||||
status: 'kept',
|
||||
pluginId,
|
||||
releaseId: existing.releaseId,
|
||||
version: existing.version,
|
||||
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);
|
||||
}
|
||||
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 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);
|
||||
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 });
|
||||
await extractArchive(artifact, extractedPath, {
|
||||
maxExtractedBytes: this.maxExtractedBytes,
|
||||
maxFiles: this.maxFiles,
|
||||
maxFileBytes: this.maxFileBytes,
|
||||
});
|
||||
const definition = await this.loadDefinition(extractedPath, grant, descriptor);
|
||||
this.assertBinding(binding);
|
||||
const packageRoot = this.releaseDirectory({ pluginId, releaseId: grant.releaseId } as InstalledReleaseRecord);
|
||||
await mkdir(path.dirname(packageRoot), { recursive: true });
|
||||
if (await isDirectoryPath(packageRoot) || await pathExists(packageRoot)) {
|
||||
fail('plugin_release_conflict', 'immutable Release directory already exists');
|
||||
}
|
||||
await rename(extractedPath, packageRoot);
|
||||
moved = true;
|
||||
const record: InstalledReleaseRecord = Object.freeze({
|
||||
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(),
|
||||
});
|
||||
const records = index.releases.filter((candidate) => !(candidate.pluginId === pluginId && candidate.releaseId === grant.releaseId));
|
||||
try {
|
||||
await this.writeIndex(this.indexPath, serializeIndex({ schema_version: INDEX_SCHEMA_VERSION, releases: [...records, record] }));
|
||||
} catch {
|
||||
await rm(packageRoot, { recursive: true, force: true }).catch(() => undefined);
|
||||
moved = false;
|
||||
throw new PluginPackageStoreError('plugin_install_failed', 'package index replacement failed');
|
||||
}
|
||||
return {
|
||||
status: 'installed',
|
||||
pluginId,
|
||||
releaseId: record.releaseId,
|
||||
version: record.version,
|
||||
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 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 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,
|
||||
): Promise<InstalledRelease | null> {
|
||||
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);
|
||||
for (const record of records) {
|
||||
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');
|
||||
}
|
||||
return { ...record, packageRoot, definition };
|
||||
} 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');
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user